"""Service Order Engine: workshop Kanban management. States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered """ from datetime import datetime VALID_TRANSITIONS = { 'received': ['diagnosis', 'cancelled'], 'diagnosis': ['waiting_parts', 'repair', 'cancelled'], 'waiting_parts': ['repair', 'cancelled'], 'repair': ['quality_check', 'cancelled'], 'quality_check': ['ready', 'repair', 'cancelled'], 'ready': ['delivered', 'cancelled'], 'delivered': [], 'cancelled': [], } def _generate_order_number(conn): """Generate SO-YYYY-NNNN order number.""" cur = conn.cursor() year = datetime.utcnow().year prefix = f"SO-{year}-" cur.execute(""" SELECT order_number FROM service_orders WHERE order_number LIKE %s ORDER BY order_number DESC LIMIT 1 """, (f"{prefix}%",)) row = cur.fetchone() last_num = 0 if row and row[0]: try: last_num = int(row[0].split('-')[-1]) except ValueError: pass new_num = last_num + 1 cur.close() return f"{prefix}{new_num:04d}" def create_service_order(conn, data): """Create a new service order. data: { customer_id, vehicle_id, branch_id, priority, reception_notes, estimated_cost, estimated_completion, employee_id, mileage_in, fuel_level, created_by } """ cur = conn.cursor() order_number = _generate_order_number(conn) cur.execute(""" INSERT INTO service_orders (tenant_id, branch_id, customer_id, vehicle_id, order_number, status, priority, reception_notes, estimated_cost, estimated_completion, employee_id, mileage_in, fuel_level, created_by) VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'), data.get('vehicle_id'), order_number, data.get('priority', 'normal'), data.get('reception_notes'), data.get('estimated_cost'), data.get('estimated_completion'), data.get('employee_id'), data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'), )) so_id = cur.fetchone()[0] # Log initial status cur.execute(""" INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes) VALUES (%s, 'received', %s, 'Orden creada') """, (so_id, data.get('created_by'))) conn.commit() cur.close() return {'service_order_id': so_id, 'order_number': order_number} def get_service_order(conn, so_id): cur = conn.cursor() cur.execute(""" SELECT so.id, so.order_number, so.status, so.priority, so.customer_id, c.name as customer_name, c.phone as customer_phone, so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model, so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes, so.delivery_notes, so.estimated_cost, so.final_cost, so.estimated_completion, so.actual_completion, so.delivered_at, so.mileage_in, so.mileage_out, so.fuel_level, so.employee_id, e.name as employee_name, so.created_by, so.created_at, so.updated_at FROM service_orders so LEFT JOIN customers c ON so.customer_id = c.id LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id LEFT JOIN employees e ON so.employee_id = e.id WHERE so.id = %s """, (so_id,)) row = cur.fetchone() if not row: cur.close() return None so = { 'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3], 'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6], 'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10], 'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13], 'repair_notes': row[14], 'delivery_notes': row[15], 'estimated_cost': float(row[16]) if row[16] else None, 'final_cost': float(row[17]) if row[17] else None, 'estimated_completion': str(row[18]) if row[18] else None, 'actual_completion': str(row[19]) if row[19] else None, 'delivered_at': str(row[20]) if row[20] else None, 'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23], 'employee_id': row[24], 'employee_name': row[25], 'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(row[28]), } # Items cur.execute(""" SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes FROM service_order_items WHERE service_order_id = %s ORDER BY id """, (so_id,)) so['items'] = [] for r in cur.fetchall(): so['items'].append({ 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3], 'quantity': float(r[4]) if r[4] else 0, 'unit_cost': float(r[5]) if r[5] else None, 'unit_price': float(r[6]) if r[6] else None, 'status': r[7], 'notes': r[8], }) # Labor cur.execute(""" SELECT id, description, hours, hourly_rate, total_cost, employee_id, status FROM service_order_labor WHERE service_order_id = %s ORDER BY id """, (so_id,)) so['labor'] = [] for r in cur.fetchall(): so['labor'].append({ 'id': r[0], 'description': r[1], 'hours': float(r[2]) if r[2] else 0, 'hourly_rate': float(r[3]) if r[3] else 0, 'total_cost': float(r[4]) if r[4] else 0, 'employee_id': r[5], 'status': r[6], }) # Status history cur.execute(""" SELECT id, old_status, new_status, changed_by, notes, created_at FROM service_order_status_history WHERE service_order_id = %s ORDER BY created_at """, (so_id,)) so['status_history'] = [] for r in cur.fetchall(): so['status_history'].append({ 'id': r[0], 'old_status': r[1], 'new_status': r[2], 'changed_by': r[3], 'notes': r[4], 'created_at': str(r[5]), }) cur.close() return so def list_service_orders(conn, status=None, branch_id=None, customer_id=None, priority=None, employee_id=None, page=1, per_page=50): cur = conn.cursor() where_clauses = [] params = [] if status: where_clauses.append("so.status = %s") params.append(status) if branch_id: where_clauses.append("so.branch_id = %s") params.append(branch_id) if customer_id: where_clauses.append("so.customer_id = %s") params.append(customer_id) if priority: where_clauses.append("so.priority = %s") params.append(priority) if employee_id: where_clauses.append("so.employee_id = %s") params.append(employee_id) where = " AND ".join(where_clauses) if where_clauses else "true" cur.execute(f""" SELECT count(*) FROM service_orders so WHERE {where} """, params) total = cur.fetchone()[0] cur.execute(f""" SELECT so.id, so.order_number, so.status, so.priority, so.customer_id, c.name as customer_name, so.vehicle_id, fv.plate as vehicle_plate, so.estimated_cost, so.estimated_completion, so.created_at FROM service_orders so LEFT JOIN customers c ON so.customer_id = c.id LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id WHERE {where} ORDER BY CASE so.priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 WHEN 'low' THEN 4 END, so.created_at DESC LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) orders = [] for r in cur.fetchall(): orders.append({ 'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3], 'customer_id': r[4], 'customer_name': r[5], 'vehicle_id': r[6], 'vehicle_plate': r[7], 'estimated_cost': float(r[8]) if r[8] else None, 'estimated_completion': str(r[9]) if r[9] else None, 'created_at': str(r[10]), }) cur.close() total_pages = (total + per_page - 1) // per_page return { 'data': orders, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} } def update_status(conn, so_id, new_status, changed_by=None, notes=None): """Update service order status with validation.""" cur = conn.cursor() cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,)) row = cur.fetchone() if not row: cur.close() raise ValueError("Service order not found") old_status = row[0] if new_status not in VALID_TRANSITIONS.get(old_status, []): cur.close() raise ValueError(f"Invalid transition: {old_status} -> {new_status}") # Update status extra_sets = [] extra_vals = [] if new_status == 'ready': extra_sets.append("actual_completion = NOW()") if new_status == 'delivered': extra_sets.append("delivered_at = NOW()") extra_sets.append("delivered_by = %s") extra_vals.append(changed_by) set_clause = ", ".join(["status = %s"] + extra_sets) cur.execute(f""" UPDATE service_orders SET {set_clause} WHERE id = %s """, [new_status] + extra_vals + [so_id]) # Log history cur.execute(""" INSERT INTO service_order_status_history (service_order_id, old_status, new_status, changed_by, notes) VALUES (%s, %s, %s, %s, %s) """, (so_id, old_status, new_status, changed_by, notes)) conn.commit() cur.close() return {'old_status': old_status, 'new_status': new_status} def add_item(conn, so_id, item_data): """Add a part/item to the service order.""" cur = conn.cursor() cur.execute(""" INSERT INTO service_order_items (service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( so_id, item_data.get('inventory_id'), item_data.get('part_number'), item_data.get('name'), item_data.get('quantity', 1), item_data.get('unit_cost'), item_data.get('unit_price'), item_data.get('status', 'pending'), item_data.get('notes'), )) item_id = cur.fetchone()[0] conn.commit() cur.close() return item_id def update_item(conn, item_id, data): cur = conn.cursor() allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes'] sets = [] vals = [] for field in allowed: if field in data: sets.append(f"{field} = %s") vals.append(data[field]) if not sets: cur.close() return False vals.append(item_id) cur.execute(f"UPDATE service_order_items SET {', '.join(sets)} WHERE id = %s", vals) conn.commit() cur.close() return True def remove_item(conn, item_id): cur = conn.cursor() cur.execute("DELETE FROM service_order_items WHERE id = %s", (item_id,)) conn.commit() cur.close() return True def add_labor(conn, so_id, labor_data): cur = conn.cursor() total_cost = labor_data.get('hours', 0) * labor_data.get('hourly_rate', 0) cur.execute(""" INSERT INTO service_order_labor (service_order_id, description, hours, hourly_rate, total_cost, employee_id, status) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( so_id, labor_data['description'], labor_data.get('hours', 0), labor_data.get('hourly_rate', 0), total_cost, labor_data.get('employee_id'), labor_data.get('status', 'pending'), )) labor_id = cur.fetchone()[0] conn.commit() cur.close() return labor_id def update_labor(conn, labor_id, data): cur = conn.cursor() allowed = ['description', 'hours', 'hourly_rate', 'employee_id', 'status'] sets = [] vals = [] for field in allowed: if field in data: sets.append(f"{field} = %s") vals.append(data[field]) if not sets: cur.close() return False # Recalculate total_cost if hours or rate changed cur.execute("SELECT hours, hourly_rate FROM service_order_labor WHERE id = %s", (labor_id,)) row = cur.fetchone() hours = data.get('hours', row[0]) if row else data.get('hours', 0) rate = data.get('hourly_rate', row[1]) if row else data.get('hourly_rate', 0) sets.append("total_cost = %s") vals.append((hours or 0) * (rate or 0)) vals.append(labor_id) cur.execute(f"UPDATE service_order_labor SET {', '.join(sets)} WHERE id = %s", vals) conn.commit() cur.close() return True def remove_labor(conn, labor_id): cur = conn.cursor() cur.execute("DELETE FROM service_order_labor WHERE id = %s", (labor_id,)) conn.commit() cur.close() return True def update_service_order(conn, so_id, data): """Update general service order fields.""" cur = conn.cursor() allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes', 'delivery_notes', 'estimated_cost', 'estimated_completion', 'employee_id', 'mileage_out', 'fuel_level', 'final_cost'] sets = [] vals = [] for field in allowed: if field in data: sets.append(f"{field} = %s") vals.append(data[field]) if not sets: cur.close() return False vals.append(so_id) cur.execute(f"UPDATE service_orders SET {', '.join(sets)} WHERE id = %s", vals) conn.commit() cur.close() return True def get_kanban_summary(conn, branch_id=None): """Get counts per status for Kanban board.""" cur = conn.cursor() params = [] branch_filter = "" if branch_id: branch_filter = "AND branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT status, COUNT(*) as cnt FROM service_orders WHERE status != 'cancelled' {branch_filter} GROUP BY status """, params) summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'} for r in cur.fetchall(): summary[r[0]] = r[1] # Overdue orders (estimated_completion passed and not ready/delivered) cur.execute(f""" SELECT count(*) FROM service_orders WHERE estimated_completion < NOW() AND status NOT IN ('ready', 'delivered', 'cancelled') {branch_filter} """, params) overdue = cur.fetchone()[0] cur.close() summary['overdue'] = overdue return summary