"""Service Order Blueprint: workshop Kanban management. Prefix: /pos/api/service-orders """ import json from functools import wraps from flask import Blueprint, g, jsonify, request from middleware import require_auth from services.service_order_engine import ( add_item, add_labor, assign_mechanic, convert_to_remission, convert_to_sale, create_service_catalog_item, create_service_order, delete_service_catalog_item, delete_service_order, get_kanban_summary, get_service_order, list_service_catalog, list_service_orders, release_item, remove_item, remove_labor, reserve_item, update_item, update_labor, update_service_catalog_item, update_service_order, update_status, ) from tenant_db import get_tenant_conn service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders') # Roles allowed to view or edit service orders. # Counter (mostrador) creates/edits; workshop/mechanic only view restricted info. _WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'} _WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'} # Statuses that mechanics are not allowed to see (single shared mechanic account). _MECHANIC_HIDDEN_STATUSES = { 'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial', 'por_facturar', 'facturada' } # Statuses visible to workshop/mechanic accounts (taller only works on authorized orders). _TALLER_ALLOWED_STATUSES = {'autorizada'} def _can_view_workshop(): return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions def _can_edit_workshop(): return g.employee_role in _WORKSHOP_EDIT_ROLES def _is_restricted_workshop_viewer(): return g.employee_role in ('workshop', 'mechanic') def _redact_order_for_mechanic(order): """Remove customer/commercial data from the order payload for mechanics.""" if not _is_restricted_workshop_viewer(): return order sensitive = { 'customer_id', 'customer_name', 'customer_phone', 'customer_address', 'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make', 'vehicle_model', 'delivery_method', 'courier_id', 'courier_name', 'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost', 'final_cost', 'total_parts', 'total_labor', 'total', } def redact_value(v): if isinstance(v, str): return '—' if isinstance(v, (int, float)): return 0 return None redacted = {} for k, v in order.items(): if k in sensitive: redacted[k] = redact_value(v) elif k == 'items' and isinstance(v, list): redacted[k] = [_redact_item_for_mechanic(it) for it in v] else: redacted[k] = v return redacted def _redact_item_for_mechanic(item): """Hide mechanic name from item rows for mechanics.""" item = dict(item) for k in ('mechanic_id', 'unit_cost', 'unit_price'): if k in item: item[k] = None if not isinstance(item[k], (int, float)) else 0 return item def require_workshop_view(f): @wraps(f) @require_auth() def decorated(*args, **kwargs): if not _can_view_workshop(): return jsonify({'error': 'Missing permissions: workshop.view'}), 403 return f(*args, **kwargs) return decorated def require_workshop_edit(f): @wraps(f) @require_auth() def decorated(*args, **kwargs): if not _can_edit_workshop(): return jsonify({'error': 'Missing permissions: workshop.edit'}), 403 return f(*args, **kwargs) return decorated @service_order_bp.route('', methods=['GET']) @require_workshop_view def list_orders(): status = request.args.get('status') priority = request.args.get('priority') customer_id = request.args.get('customer_id', type=int) employee_id = request.args.get('employee_id', type=int) delivery_method = request.args.get('delivery_method') is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None q = request.args.get('q') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 200) conn = get_tenant_conn(g.tenant_id) try: result = list_service_orders( conn, status=status, branch_id=g.branch_id, customer_id=customer_id, priority=priority, employee_id=employee_id, delivery_method=delivery_method, is_direct=is_direct, q=q, page=page, per_page=per_page ) if _is_restricted_workshop_viewer(): result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])] # Workshop/mechanic accounts only see orders ready to be worked on. result['data'] = [ o for o in result.get('data', []) if o.get('status') in _TALLER_ALLOWED_STATUSES ] return jsonify(result) finally: conn.close() @service_order_bp.route('/from-pos', methods=['POST']) @require_auth('pos.sell') def create_order_from_pos(): """Create a service order directly from the POS cart (quotation).""" data = request.get_json() or {} items = data.get('items', []) estimated_cost = sum( (it.get('unit_price') or 0) * (it.get('quantity') or 1) for it in items ) conn = get_tenant_conn(g.tenant_id) try: result = create_service_order(conn, { 'tenant_id': g.tenant_id, 'branch_id': data.get('branch_id', g.branch_id), 'customer_id': data.get('customer_id'), 'vehicle_id': data.get('vehicle_id'), 'priority': data.get('priority', 'normal'), 'reception_notes': data.get('reception_notes'), 'estimated_cost': estimated_cost or None, 'estimated_completion': data.get('estimated_completion'), 'employee_id': data.get('employee_id'), 'mileage_in': data.get('mileage_in'), 'created_by': getattr(g, 'employee_id', None), 'delivery_method': data.get('delivery_method'), 'courier_id': data.get('courier_id'), 'is_direct': data.get('is_direct', False), 'requires_invoice': data.get('requires_invoice', False), 'workshop_name': data.get('workshop_name'), 'customer_address': data.get('customer_address'), 'customer_phone': data.get('customer_phone'), 'vehicle_description': data.get('vehicle_description'), }) so_id = result['service_order_id'] for item in items: add_item(conn, so_id, item) order = get_service_order(conn, so_id) return jsonify(order), 201 finally: conn.close() @service_order_bp.route('', methods=['POST']) @require_workshop_edit def create_order(): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: result = create_service_order(conn, { 'tenant_id': g.tenant_id, 'branch_id': data.get('branch_id', g.branch_id), 'customer_id': data.get('customer_id'), 'vehicle_id': data.get('vehicle_id'), 'priority': data.get('priority', 'normal'), 'reception_notes': data.get('reception_notes'), 'estimated_cost': data.get('estimated_cost'), 'estimated_completion': data.get('estimated_completion'), 'employee_id': data.get('employee_id'), 'mileage_in': data.get('mileage_in'), 'fuel_level': data.get('fuel_level'), 'created_by': getattr(g, 'employee_id', None), 'delivery_method': data.get('delivery_method'), 'courier_id': data.get('courier_id'), 'is_direct': data.get('is_direct', False), 'requires_invoice': data.get('requires_invoice', False), 'workshop_name': data.get('workshop_name'), 'customer_address': data.get('customer_address'), 'customer_phone': data.get('customer_phone'), 'vehicle_description': data.get('vehicle_description'), }) return jsonify(result), 201 finally: conn.close() @service_order_bp.route('/', methods=['GET']) @require_workshop_view def get_order(so_id): conn = get_tenant_conn(g.tenant_id) try: order = get_service_order(conn, so_id) if not order: return jsonify({'error': 'Service order not found'}), 404 # Workshop/mechanic accounts can only view orders that are ready to be worked on. if _is_restricted_workshop_viewer() and order.get('status') not in _TALLER_ALLOWED_STATUSES: return jsonify({'error': 'No tienes acceso a esta orden'}), 403 return jsonify(_redact_order_for_mechanic(order)) finally: conn.close() @service_order_bp.route('/', methods=['PUT']) @require_workshop_view def update_order(so_id): data = request.get_json() or {} # Full edit remains for owner/admin/counter/cashier. # Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name. if g.employee_role == 'mechanic': allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'} if not data or any(k not in allowed for k in data.keys()): return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403 elif not _can_edit_workshop(): return jsonify({'error': 'Missing permissions: workshop.edit'}), 403 conn = get_tenant_conn(g.tenant_id) try: ok = update_service_order(conn, so_id, data) if not ok: return jsonify({'error': 'No fields to update'}), 400 return jsonify({'message': 'Service order updated'}) finally: conn.close() @service_order_bp.route('/', methods=['DELETE']) @require_workshop_edit def delete_order(so_id): """Soft-delete a service order. Restricted to owner/admin.""" if g.employee_role not in ('owner', 'admin'): return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403 conn = get_tenant_conn(g.tenant_id) try: delete_service_order(conn, so_id) return jsonify({'message': 'Orden eliminada'}) except ValueError as e: return jsonify({'error': str(e)}), 404 finally: conn.close() @service_order_bp.route('//status', methods=['PUT']) @require_workshop_view def change_status(so_id): if not (_can_edit_workshop() or g.employee_role == 'mechanic'): return jsonify({'error': 'Missing permissions: workshop.edit'}), 403 data = request.get_json() or {} new_status = data.get('status') if not new_status: return jsonify({'error': 'status is required'}), 400 # Mechanics cannot move orders into commercial/closed statuses. if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES: return jsonify({'error': 'No puedes cambiar a este estatus'}), 403 conn = get_tenant_conn(g.tenant_id) try: result = update_status( conn, so_id, new_status, changed_by=getattr(g, 'employee_id', None), notes=data.get('notes'), ) return jsonify(result) except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() # ─── Items (Parts) ───────────────────────────── @service_order_bp.route('//items', methods=['POST']) @require_workshop_edit def add_order_item(so_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: item_id = add_item(conn, so_id, data) return jsonify({'id': item_id, 'message': 'Item added'}), 201 finally: conn.close() @service_order_bp.route('/items/', methods=['PUT']) @require_workshop_edit def update_order_item(item_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: ok = update_item(conn, item_id, data) if not ok: return jsonify({'error': 'No fields to update'}), 400 return jsonify({'message': 'Item updated'}) finally: conn.close() @service_order_bp.route('/items/', methods=['DELETE']) @require_workshop_edit def delete_order_item(item_id): conn = get_tenant_conn(g.tenant_id) try: remove_item(conn, item_id) return jsonify({'message': 'Item removed'}) finally: conn.close() # ─── Labor ───────────────────────────── @service_order_bp.route('//labor', methods=['POST']) @require_workshop_edit def add_order_labor(so_id): data = request.get_json() or {} if not data.get('description'): return jsonify({'error': 'description is required'}), 400 conn = get_tenant_conn(g.tenant_id) try: labor_id = add_labor(conn, so_id, data) return jsonify({'id': labor_id, 'message': 'Labor added'}), 201 finally: conn.close() @service_order_bp.route('/labor/', methods=['PUT']) @require_workshop_edit def update_order_labor(labor_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: ok = update_labor(conn, labor_id, data) if not ok: return jsonify({'error': 'No fields to update'}), 400 return jsonify({'message': 'Labor updated'}) finally: conn.close() @service_order_bp.route('/labor/', methods=['DELETE']) @require_workshop_edit def delete_order_labor(labor_id): conn = get_tenant_conn(g.tenant_id) try: remove_labor(conn, labor_id) return jsonify({'message': 'Labor removed'}) finally: conn.close() # ─── Kanban Summary ───────────────────────────── @service_order_bp.route('/kanban/summary', methods=['GET']) @require_workshop_view def kanban_summary(): conn = get_tenant_conn(g.tenant_id) try: summary = get_kanban_summary(conn, branch_id=g.branch_id) return jsonify(summary) finally: conn.close() # ─── Inventory reservation ──────────────────────── @service_order_bp.route('//items//reserve', methods=['POST']) @require_workshop_edit def reserve_order_item(so_id, item_id): """Reserve inventory for a service order item.""" conn = get_tenant_conn(g.tenant_id) try: result = reserve_item(conn, item_id, branch_id=g.branch_id, employee_id=g.employee_id) return jsonify(result) except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() @service_order_bp.route('//items//release', methods=['POST']) @require_workshop_edit def release_order_item(so_id, item_id): """Release a previous inventory reservation.""" conn = get_tenant_conn(g.tenant_id) try: result = release_item(conn, item_id, employee_id=g.employee_id) return jsonify(result) except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() # ─── Convert to sale ────────────────────────────── @service_order_bp.route('//convert-to-sale', methods=['POST']) @require_auth('pos.sell') def convert_order_to_sale(so_id): """Convert a service order into a POS sale. Body: { payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto', sale_type: 'cash' | 'credit' | 'mixed', register_id: int (optional), amount_paid: float (optional), payment_details: [...] (optional), notes: str (optional) } """ data = request.get_json() or {} sale_payload = { 'payment_method': data.get('payment_method', 'efectivo'), 'sale_type': data.get('sale_type', 'cash'), 'register_id': data.get('register_id'), 'amount_paid': data.get('amount_paid'), 'payment_details': data.get('payment_details', []), 'notes': data.get('notes'), } conn = get_tenant_conn(g.tenant_id) try: result = convert_to_sale( conn, so_id, sale_payload, employee_id=g.employee_id ) return jsonify(result), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() # ─── Convert to remission note ──────────────────── @service_order_bp.route('//convert-to-remission', methods=['POST']) @require_auth('pos.remission') def convert_order_to_remission(so_id): """Convert a service order into a counter remission note (pending payment). Body: { register_id: int (optional), notes: str (optional) } """ data = request.get_json() or {} sale_payload = { 'register_id': data.get('register_id'), 'notes': data.get('notes'), } conn = get_tenant_conn(g.tenant_id) try: result = convert_to_remission( conn, so_id, sale_payload, employee_id=g.employee_id ) return jsonify(result), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() # ─── Mechanic assignment ────────────────────────── @service_order_bp.route('//assign-mechanic', methods=['PUT']) @require_workshop_edit def assign_mechanic_endpoint(so_id): """Assign a mechanic/technician to a service order.""" data = request.get_json() or {} employee_id = data.get('employee_id') if not employee_id: return jsonify({'error': 'employee_id is required'}), 400 conn = get_tenant_conn(g.tenant_id) try: result = assign_mechanic(conn, so_id, employee_id) return jsonify(result) except ValueError as e: return jsonify({'error': str(e)}), 400 finally: conn.close() @service_order_bp.route('/customers', methods=['POST']) @require_workshop_edit def create_customer_for_workshop(): """Create a customer directly from the workshop flow.""" data = request.get_json() or {} if not data.get('name'): return jsonify({'error': 'El nombre es obligatorio'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" INSERT INTO customers (branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( data.get('branch_id', g.branch_id), data['name'], data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'), data.get('cp'), data.get('email'), data.get('phone'), data.get('address'), data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0), json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None )) customer_id = cur.fetchone()[0] conn.commit() cur.close(); conn.close() return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201 except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 @service_order_bp.route('/vehicles', methods=['POST']) @require_workshop_edit def create_vehicle_for_workshop(): """Create a fleet vehicle directly from the workshop flow. Vehicles must be assigned to a customer. Brand and model are required; plate is optional and VIN is not used. """ data = request.get_json() or {} if not data.get('make') or not data.get('model'): return jsonify({'error': 'Marca y modelo son obligatorios'}), 400 if not data.get('customer_id'): return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],)) cust = cur.fetchone() if not cust: cur.close(); conn.close() return jsonify({'error': 'Cliente no encontrado'}), 404 cur.execute(""" INSERT INTO fleet_vehicles (branch_id, customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'), data.get('make'), data.get('model'), data.get('year'), data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'), data.get('color'), cust[0], data.get('notes'), )) vehicle_id = cur.fetchone()[0] conn.commit() cur.close(); conn.close() return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201 except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 @service_order_bp.route('/inventory-search', methods=['GET']) @require_workshop_view def inventory_search(): """Search active inventory for attaching parts to a service order. Does not require inventory.view so workshop users can pick parts. """ q = request.args.get('q', '').strip() if not q: return jsonify({'data': []}) branch_id = request.args.get('branch_id', g.branch_id) conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() like = f'%{q}%' if branch_id: cur.execute(""" SELECT i.id, i.part_number, i.name, i.brand, i.unit, COALESCE((SELECT stock FROM inventory_stock WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock, i.cost, i.price_1 FROM inventory i WHERE i.is_active = true AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s) ORDER BY i.name LIMIT 20 """, (branch_id, like, like, like)) else: cur.execute(""" SELECT i.id, i.part_number, i.name, i.brand, i.unit, COALESCE((SELECT stock FROM inventory_stock_summary WHERE inventory_id = i.id), 0) AS stock, i.cost, i.price_1 FROM inventory i WHERE i.is_active = true AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s) ORDER BY i.name LIMIT 20 """, (like, like, like)) items = [] for r in cur.fetchall(): items.append({ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4], 'stock': float(r[5]) if r[5] else 0, 'cost': float(r[6]) if r[6] else 0, 'price_1': float(r[7]) if r[7] else 0, }) cur.close() return jsonify({'data': items}) finally: conn.close() # ─── Service catalog (reusable labor) ───────────── @service_order_bp.route('/service-catalog', methods=['GET']) @require_workshop_view def list_catalog(): """List reusable labor/service concepts.""" active_only = request.args.get('active_only', 'true').lower() != 'false' conn = get_tenant_conn(g.tenant_id) try: items = list_service_catalog(conn, active_only=active_only) return jsonify({'data': items}) finally: conn.close() @service_order_bp.route('/service-catalog', methods=['POST']) @require_workshop_edit def create_catalog_item(): """Create a reusable labor concept.""" data = request.get_json() or {} if not data.get('name'): return jsonify({'error': 'name is required'}), 400 conn = get_tenant_conn(g.tenant_id) try: result = create_service_catalog_item(conn, g.tenant_id, data) return jsonify(result), 201 finally: conn.close() @service_order_bp.route('/service-catalog/', methods=['PUT']) @require_workshop_edit def update_catalog_item(item_id): """Update a reusable labor concept.""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: ok = update_service_catalog_item(conn, item_id, data) if not ok: return jsonify({'error': 'No fields to update'}), 400 return jsonify({'message': 'Catalog item updated'}) finally: conn.close() @service_order_bp.route('/service-catalog/', methods=['DELETE']) @require_workshop_edit def delete_catalog_item(item_id): """Soft-delete a reusable labor concept.""" conn = get_tenant_conn(g.tenant_id) try: delete_service_catalog_item(conn, item_id) return jsonify({'message': 'Catalog item deactivated'}) finally: conn.close() # ─── Thermal printing ───────────────────────────── @service_order_bp.route('//print', methods=['POST']) @require_workshop_edit def print_service_order_ticket(so_id): """Generate a printable ticket for a service order. Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80} - escpos_raw: returns raw ESC/POS bytes (application/octet-stream) - browser: returns the data dict as JSON for browser-side rendering """ from flask import Response from services.thermal_printer import generate_service_order_ticket body = request.get_json(silent=True) or {} printer_type = body.get('printer_type', 'escpos_raw') width = int(body.get('width', 80)) conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() order = get_service_order(conn, so_id) if not order: cur.close() conn.close() return jsonify({'error': 'Service order not found'}), 404 # Fetch business info from config business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''} try: cur.execute( "SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')" ) for rw in cur.fetchall(): if rw[0] == 'business_name': business_info['name'] = rw[1] else: business_info[rw[0]] = rw[1] except Exception: pass cur.close() conn.close() if printer_type == 'browser': return jsonify(order) raw = generate_service_order_ticket(order, business_info, width=width) return Response( raw, mimetype='application/octet-stream', headers={ 'Content-Disposition': f'attachment; filename=orden_{order.get("order_number", so_id)}.bin' }, )