diff --git a/docs/FASES_IMPLEMENTADAS.md b/docs/FASES_IMPLEMENTADAS.md index 7f1bfca..5f3352b 100644 --- a/docs/FASES_IMPLEMENTADAS.md +++ b/docs/FASES_IMPLEMENTADAS.md @@ -1,9 +1,9 @@ # Nexus POS — Resumen de Fases Implementadas **Fecha:** 2026-06-15 -**Versión DB:** v4.3 -**Tests:** 93/93 pasando (pytest: 61 consola + 20 Facturapi; POS requieren PostgreSQL) -**Commit:** `6aff32f` (HEAD + cambios sin commitear) +**Versión DB:** v4.4 +**Tests:** 92/92 pasando (pytest: 61 consola + 20 Facturapi + 11 Taller; POS requieren PostgreSQL) +**Commit:** `d678872` (HEAD + cambios sin commitear) --- @@ -202,6 +202,7 @@ METABASE_URL=http://localhost:3000 | — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` | | — | **Migración CFDI de Horux a Facturapi** | 2026-06-14 | `8796cad` | | — | **Setup/estado masivo de organizaciones Facturapi** | 2026-06-15 | — | +| — | **Módulo de Taller (Workshop Lite)** | 2026-06-15 | — | ## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global @@ -292,6 +293,32 @@ python3 scripts/check_facturapi_tenants.py --- +## FASE 9: Módulo de Taller (Workshop Lite) + +**Commit:** (en progreso) +**Migración DB:** `v4.4_workshop.sql` + +| Feature | Archivos | Capacidades | +|---------|----------|-------------| +| **Migración DB** | `v4.4_workshop.sql` | `service_orders.sale_id`, tabla `service_catalog`, columna `reserved_quantity`, tipos `SO_RESERVE`/`SO_RELEASE` en `inventory_operations` | +| **Reserva de inventario** | `service_order_engine.py` | `reserve_item()` y `release_item()` para apartar/liberar refacciones del stock de la sucursal | +| **Conversión a venta** | `service_order_engine.py` | `convert_to_sale()` crea una venta en `sales` con refacciones + mano de obra, descuenta inventario y guarda `sale_id` | +| **Catálogo de servicios** | `service_order_engine.py`, `service_order_bp.py` | Conceptos reutilizables de mano de obra (ej. "Cambio de aceite") | +| **Endpoints taller** | `service_order_bp.py` | `POST /:id/items/:item_id/reserve`, `POST /:id/convert-to-sale`, `PUT /:id/assign-mechanic`, CRUD `/service-catalog` | +| **Interfaz Kanban** | `workshop.html`, `workshop.js`, `workshop.css` | Vista por columnas, tarjetas de orden, modal de detalle, cambio de estado, agregar refacciones/mano de obra | +| **Navegación** | `sidebar.js`, plantillas inline | Entrada "Taller" en el menú de gestión | +| **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo | + +### Flujo de uso + +1. El paquetero crea la orden desde `/pos/workshop` (cliente, vehículo, mecánico, falla). +2. El mecánico diagnostica y agrega refacciones y mano deobra. +3. Se reservan las refacciones del inventario de la sucursal. +4. Cuando el vehículo está listo, se convierte la orden en venta. +5. Desde facturación se timbra el CFDI de la venta generada. + +--- + ## Mejoras Pendientes (Roadmap Actualizado) ### 🔴 Crítico — Deuda Técnica diff --git a/pos/app.py b/pos/app.py index 2005966..46961dd 100644 --- a/pos/app.py +++ b/pos/app.py @@ -1,6 +1,7 @@ from flask import Flask from json_provider import OrjsonProvider + def create_app(): app = Flask(__name__) app.json = OrjsonProvider(app) @@ -124,7 +125,7 @@ def create_app(): def health(): return {'status': 'ok'} - from flask import render_template, send_from_directory, jsonify, g + from flask import g, jsonify, render_template, send_from_directory @app.route('/favicon.ico') def favicon(): @@ -181,6 +182,10 @@ def create_app(): def pos_fleet(): return render_template('fleet.html') + @app.route('/pos/workshop') + def pos_workshop(): + return render_template('workshop.html') + @app.route('/pos/quotations') def pos_quotations(): return render_template('quotations.html') diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py index 9472ee6..d30be9f 100644 --- a/pos/blueprints/service_order_bp.py +++ b/pos/blueprints/service_order_bp.py @@ -3,15 +3,31 @@ Prefix: /pos/api/service-orders """ -from flask import Blueprint, request, jsonify, g +from flask import Blueprint, g, jsonify, request from middleware import require_auth -from tenant_db import get_tenant_conn from services.service_order_engine import ( - create_service_order, get_service_order, list_service_orders, - update_status, add_item, update_item, remove_item, - add_labor, update_labor, remove_labor, - update_service_order, get_kanban_summary, + add_item, + add_labor, + assign_mechanic, + convert_to_sale, + create_service_catalog_item, + create_service_order, + delete_service_catalog_item, + 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') @@ -202,3 +218,154 @@ def kanban_summary(): return jsonify(summary) finally: conn.close() + + +# ─── Inventory reservation ──────────────────────── + + +@service_order_bp.route('//items//reserve', methods=['POST']) +@require_auth() +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_auth() +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() + + +# ─── Mechanic assignment ────────────────────────── + + +@service_order_bp.route('//assign-mechanic', methods=['PUT']) +@require_auth() +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 catalog (reusable labor) ───────────── + + +@service_order_bp.route('/service-catalog', methods=['GET']) +@require_auth() +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_auth() +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_auth() +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_auth() +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() diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 33934d6..14863f4 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -50,6 +50,7 @@ MIGRATIONS = { "v4.1": "v4.1_global_invoice.sql", "v4.2": "v4.2_meli_sync_queue.sql", "v4.3": "v4.3_facturapi.sql", + "v4.4": "v4.4_workshop.sql", } diff --git a/pos/migrations/v4.4_workshop.sql b/pos/migrations/v4.4_workshop.sql new file mode 100644 index 0000000..1efe5fd --- /dev/null +++ b/pos/migrations/v4.4_workshop.sql @@ -0,0 +1,66 @@ +-- v4.4 Workshop Lite +-- Extends service orders with inventory reservation, sale linking and a labor catalog. + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 1. SERVICE_ORDERS: link to the sale generated from the order +-- ═════════════════════════════════════════════════════════════════════════════ +ALTER TABLE service_orders + ADD COLUMN IF NOT EXISTS sale_id INTEGER REFERENCES sales(id); + +COMMENT ON COLUMN service_orders.sale_id IS 'Sale/invoice generated from this service order'; + +CREATE INDEX IF NOT EXISTS idx_service_orders_sale_id ON service_orders(sale_id); + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 2. SERVICE_CATALOG: reusable labor/work concepts for mechanics +-- ═════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS service_catalog ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + suggested_hours NUMERIC(6,2) DEFAULT 0, + suggested_rate NUMERIC(12,2) DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_service_catalog_tenant ON service_catalog(tenant_id); +CREATE INDEX IF NOT EXISTS idx_service_catalog_active ON service_catalog(is_active); + +COMMENT ON TABLE service_catalog IS 'Reusable labor concepts for workshop service orders'; + +-- Trigger to auto-update updated_at on service_catalog +CREATE OR REPLACE FUNCTION update_service_catalog_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_service_catalog_updated_at ON service_catalog; +CREATE TRIGGER trg_service_catalog_updated_at + BEFORE UPDATE ON service_catalog + FOR EACH ROW + EXECUTE FUNCTION update_service_catalog_updated_at(); + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 3. SERVICE_ORDER_ITEMS: track reserved quantity separately +-- ═════════════════════════════════════════════════════════════════════════════ +ALTER TABLE service_order_items + ADD COLUMN IF NOT EXISTS reserved_quantity NUMERIC(10,2) DEFAULT 0; + +COMMENT ON COLUMN service_order_items.reserved_quantity IS 'Quantity currently reserved from inventory'; + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 4. INVENTORY_OPERATIONS: new operation types for service orders +-- ═════════════════════════════════════════════════════════════════════════════ +-- operation_type is VARCHAR(20) without a constraint, so no ALTER is needed. +-- New types used by the workshop module: +-- SO_RESERVE : negative quantity, reserves stock when item is added to SO +-- SO_RELEASE : positive quantity, releases a previous reservation +-- SO_CONSUME : negative quantity, final deduction when SO is converted to sale +COMMENT ON COLUMN inventory_operations.operation_type IS + 'SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE, SO_RESERVE, SO_RELEASE, SO_CONSUME'; diff --git a/pos/services/service_order_engine.py b/pos/services/service_order_engine.py index 4884cab..a443580 100644 --- a/pos/services/service_order_engine.py +++ b/pos/services/service_order_engine.py @@ -3,8 +3,11 @@ States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered """ +import contextlib from datetime import datetime +from services import inventory_engine + VALID_TRANSITIONS = { 'received': ['diagnosis', 'cancelled'], 'diagnosis': ['waiting_parts', 'repair', 'cancelled'], @@ -30,10 +33,8 @@ def _generate_order_number(conn): row = cur.fetchone() last_num = 0 if row and row[0]: - try: + with contextlib.suppress(ValueError): last_num = int(row[0].split('-')[-1]) - except ValueError: - pass new_num = last_num + 1 cur.close() return f"{prefix}{new_num:04d}" @@ -422,7 +423,7 @@ def get_kanban_summary(conn, branch_id=None): GROUP BY status """, params) - summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'} + summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'} for r in cur.fetchall(): summary[r[0]] = r[1] @@ -438,3 +439,413 @@ def get_kanban_summary(conn, branch_id=None): cur.close() summary['overdue'] = overdue return summary + + +# ─── Workshop inventory integration ───────────────────────────────────────── + + +def reserve_item(conn, so_item_id, branch_id, employee_id=None): + """Reserve inventory for a service order item. + + Records a negative SO_RESERVE operation and updates reserved_quantity. + Raises ValueError if stock is insufficient. + """ + cur = conn.cursor() + cur.execute( + """ + SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status, + so.order_number + FROM service_order_items soi + JOIN service_orders so ON so.id = soi.service_order_id + WHERE soi.id = %s + """, + (so_item_id,), + ) + row = cur.fetchone() + if not row: + cur.close() + raise ValueError("Service order item not found") + + so_id, inventory_id, quantity, status, order_number = row + if status == "cancelled": + cur.close() + raise ValueError("Cannot reserve a cancelled item") + if not inventory_id: + cur.close() + raise ValueError("Item has no inventory linked") + + qty = int(quantity) + available = inventory_engine.get_stock(conn, inventory_id, branch_id) + if available < qty: + cur.close() + raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}") + + inventory_engine.record_operation( + conn, + inventory_id, + branch_id, + "SO_RESERVE", + -qty, + reference_id=so_id, + reference_type="service_order_item", + notes=f"Reserva orden {order_number}", + employee_id=employee_id, + ) + + cur.execute( + "UPDATE service_order_items SET reserved_quantity = %s WHERE id = %s", + (qty, so_item_id), + ) + conn.commit() + cur.close() + return {"reserved": qty} + + +def release_item(conn, so_item_id, employee_id=None): + """Release a previous reservation for a service order item. + + Records a positive SO_RELEASE operation and resets reserved_quantity. + """ + cur = conn.cursor() + cur.execute( + """ + SELECT soi.service_order_id, soi.inventory_id, soi.reserved_quantity, + so.branch_id, so.order_number + FROM service_order_items soi + JOIN service_orders so ON so.id = soi.service_order_id + WHERE soi.id = %s + """, + (so_item_id,), + ) + row = cur.fetchone() + if not row: + cur.close() + raise ValueError("Service order item not found") + + so_id, inventory_id, reserved_qty, branch_id, order_number = row + if not inventory_id or not reserved_qty: + cur.close() + return {"released": 0} + + qty = int(reserved_qty) + inventory_engine.record_operation( + conn, + inventory_id, + branch_id, + "SO_RELEASE", + qty, + reference_id=so_id, + reference_type="service_order_item", + notes=f"Liberacion reserva orden {order_number}", + employee_id=employee_id, + ) + + cur.execute( + "UPDATE service_order_items SET reserved_quantity = 0 WHERE id = %s", + (so_item_id,), + ) + conn.commit() + cur.close() + return {"released": qty} + + +def _consume_item_inventory(conn, so_item, sale_id, order_number, branch_id, employee_id=None): + """Release reservation and record final SALE for a service order item.""" + inventory_id = so_item.get("inventory_id") + reserved_qty = so_item.get("reserved_quantity", 0) + qty = int(so_item.get("quantity", 0)) + if not inventory_id or qty <= 0: + return + + if reserved_qty: + inventory_engine.record_operation( + conn, + inventory_id, + branch_id, + "SO_RELEASE", + int(reserved_qty), + reference_id=so_item.get("service_order_id"), + reference_type="service_order", + notes=f"Liberacion para venta orden {order_number}", + employee_id=employee_id, + ) + + inventory_engine.record_operation( + conn, + inventory_id, + branch_id, + "SALE", + -qty, + reference_id=sale_id, + reference_type="sale", + notes=f"Venta desde orden {order_number}", + employee_id=employee_id, + ) + + +def convert_to_sale(conn, so_id, sale_data, employee_id=None): + """Convert a service order into a POS sale. + + sale_data keys: + payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto' + sale_type: 'cash' | 'credit' | 'mixed' + register_id: int (optional) + amount_paid: float (optional) + payment_details: list (optional) + notes: str (optional) + + Returns dict with sale_id, total, items_count. + """ + cur = conn.cursor() + so = get_service_order(conn, so_id) + if not so: + cur.close() + raise ValueError("Service order not found") + if so["status"] == "cancelled": + cur.close() + raise ValueError("Cannot convert a cancelled service order") + if so.get("sale_id"): + cur.close() + raise ValueError("Service order already converted to sale") + + branch_id = so["branch_id"] + customer_id = so["customer_id"] + + # Build sale items from SO parts and labor + sale_items = [] + for item in so.get("items", []): + if item.get("status") == "cancelled": + continue + qty = int(item.get("quantity", 1)) + unit_price = float(item.get("unit_price") or 0) + unit_cost = float(item.get("unit_cost") or 0) + sale_items.append( + { + "inventory_id": item.get("inventory_id"), + "part_number": item.get("part_number") or "PART", + "name": item.get("name") or "Refaccion", + "quantity": qty, + "unit_price": unit_price, + "unit_cost": unit_cost, + "tax_rate": 0.16, + } + ) + + for labor in so.get("labor", []): + if labor.get("status") == "cancelled": + continue + sale_items.append( + { + "inventory_id": None, + "part_number": "SERV", + "name": labor.get("description") or "Mano de obra", + "quantity": 1, + "unit_price": float(labor.get("total_cost") or 0), + "unit_cost": 0, + "tax_rate": 0.16, + } + ) + + if not sale_items: + cur.close() + raise ValueError("No items or labor to invoice") + + # Calculate totals + subtotal = 0.0 + tax_total = 0.0 + for item in sale_items: + item_subtotal = item["quantity"] * item["unit_price"] + item_tax = item_subtotal * item["tax_rate"] + item["subtotal"] = item_subtotal + item["tax_amount"] = item_tax + subtotal += item_subtotal + tax_total += item_tax + + total = subtotal + tax_total + + payment_method = sale_data.get("payment_method", "efectivo") + sale_type = sale_data.get("sale_type", "cash") + register_id = sale_data.get("register_id") + amount_paid = float(sale_data.get("amount_paid", total if sale_type == "cash" else 0)) + change_given = max(amount_paid - total, 0) if sale_type == "cash" and payment_method == "efectivo" else 0 + notes = sale_data.get("notes") or f"Orden de servicio {so['order_number']}" + + metodo_pago_sat = "PPD" if sale_type == "credit" else "PUE" + forma_pago_map = {"efectivo": "01", "transferencia": "03", "tarjeta": "04", "mixto": "99"} + forma_pago_sat = forma_pago_map.get(payment_method, "99") + + cur.execute( + """ + INSERT INTO sales + (branch_id, customer_id, employee_id, register_id, sale_type, + payment_method, subtotal, discount_total, tax_total, total, + amount_paid, change_given, metodo_pago_sat, forma_pago_sat, + status, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s) + RETURNING id, created_at + """, + ( + branch_id, + customer_id, + employee_id, + register_id, + sale_type, + payment_method, + subtotal, + 0, + tax_total, + total, + amount_paid, + change_given, + metodo_pago_sat, + forma_pago_sat, + notes, + ), + ) + sale_id, _created_at = cur.fetchone() + + # Insert sale_items + for item in sale_items: + cur.execute( + """ + INSERT INTO sale_items + (sale_id, inventory_id, part_number, name, quantity, + unit_price, unit_cost, tax_rate, tax_amount, subtotal) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + sale_id, + item["inventory_id"], + item["part_number"], + item["name"], + item["quantity"], + item["unit_price"], + item["unit_cost"], + item["tax_rate"], + item["tax_amount"], + item["subtotal"], + ), + ) + + # Consume inventory for parts + for item in so.get("items", []): + if item.get("status") == "cancelled": + continue + _consume_item_inventory( + conn, item, sale_id, so["order_number"], branch_id, employee_id + ) + + # Link order to sale + cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id)) + + conn.commit() + cur.close() + return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)} + + +def assign_mechanic(conn, so_id, employee_id): + """Assign a mechanic/technician to a service order.""" + cur = conn.cursor() + cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,)) + if not cur.fetchone(): + cur.close() + raise ValueError("Service order not found") + + cur.execute( + "UPDATE service_orders SET employee_id = %s WHERE id = %s", + (employee_id, so_id), + ) + conn.commit() + cur.close() + return {"employee_id": employee_id} + + +# ─── Service catalog (reusable labor concepts) ─────────────────────────────── + + +def list_service_catalog(conn, active_only=True): + """List reusable labor/service concepts.""" + cur = conn.cursor() + where = "WHERE is_active = true" if active_only else "" + cur.execute( + f""" + SELECT id, tenant_id, name, description, suggested_hours, suggested_rate, + is_active, created_at, updated_at + FROM service_catalog + {where} + ORDER BY name + """ + ) + items = [] + for r in cur.fetchall(): + items.append( + { + "id": r[0], + "tenant_id": r[1], + "name": r[2], + "description": r[3], + "suggested_hours": float(r[4]) if r[4] else 0, + "suggested_rate": float(r[5]) if r[5] else 0, + "is_active": r[6], + "created_at": str(r[7]) if r[7] else None, + "updated_at": str(r[8]) if r[8] else None, + } + ) + cur.close() + return items + + +def create_service_catalog_item(conn, tenant_id, data): + """Create a reusable labor concept.""" + cur = conn.cursor() + cur.execute( + """ + INSERT INTO service_catalog + (tenant_id, name, description, suggested_hours, suggested_rate, is_active) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + tenant_id, + data.get("name"), + data.get("description"), + data.get("suggested_hours", 0), + data.get("suggested_rate", 0), + data.get("is_active", True), + ), + ) + item_id = cur.fetchone()[0] + conn.commit() + cur.close() + return {"id": item_id} + + +def update_service_catalog_item(conn, item_id, data): + """Update a reusable labor concept.""" + cur = conn.cursor() + allowed = ["name", "description", "suggested_hours", "suggested_rate", "is_active"] + 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_catalog SET {', '.join(sets)} WHERE id = %s", vals) + conn.commit() + cur.close() + return True + + +def delete_service_catalog_item(conn, item_id): + """Soft-delete a reusable labor concept by setting is_active = false.""" + cur = conn.cursor() + cur.execute( + "UPDATE service_catalog SET is_active = false WHERE id = %s", (item_id,) + ) + conn.commit() + cur.close() + return True diff --git a/pos/static/css/workshop.css b/pos/static/css/workshop.css new file mode 100644 index 0000000..d408358 --- /dev/null +++ b/pos/static/css/workshop.css @@ -0,0 +1,221 @@ +:root { + --kanban-column-width: 280px; + --kanban-card-bg: var(--glass-bg-strong); + --kanban-card-border: var(--glass-border); +} + +.page-header__subtitle { + color: var(--color-text-muted); + font-size: var(--text-sm); + margin-top: var(--space-1); +} + +.kanban-board { + display: flex; + gap: var(--space-4); + overflow-x: auto; + padding-bottom: var(--space-4); + min-height: 60vh; +} + +.kanban-column { + flex: 0 0 var(--kanban-column-width); + display: flex; + flex-direction: column; + max-height: 75vh; +} + +.kanban-column__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + background: var(--glass-bg-strong); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md) var(--radius-md) 0 0; + font-family: var(--font-heading); + font-size: var(--text-sm); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: var(--tracking-wide); +} + +.kanban-column__count { + background: var(--color-primary); + color: var(--color-bg); + font-size: var(--text-xs); + padding: 2px 8px; + border-radius: var(--radius-full); +} + +.kanban-column__body { + flex: 1; + overflow-y: auto; + background: rgba(0, 0, 0, 0.15); + border: 1px solid var(--glass-border); + border-top: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.kanban-card { + background: var(--kanban-card-bg); + border: 1px solid var(--kanban-card-border); + border-radius: var(--radius-md); + padding: var(--space-3); + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.kanban-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.kanban-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-2); +} + +.kanban-card__id { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--color-primary); + font-weight: var(--font-weight-bold); +} + +.kanban-card__priority { + font-size: var(--text-xs); + padding: 2px 6px; + border-radius: var(--radius-sm); + text-transform: uppercase; + font-weight: var(--font-weight-bold); +} + +.kanban-card__priority--urgent { background: var(--color-error); color: #fff; } +.kanban-card__priority--high { background: var(--color-warn); color: #000; } +.kanban-card__priority--normal { background: var(--color-info); color: #fff; } + +.kanban-card__customer { + font-weight: var(--font-weight-bold); + margin-bottom: var(--space-1); +} + +.kanban-card__vehicle { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-2); +} + +.kanban-card__meta { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.kanban-card__mechanic { + display: flex; + align-items: center; + gap: var(--space-1); +} + +/* Detail modal */ +.so-detail { + display: grid; + gap: var(--space-4); +} + +.so-detail__section { + background: var(--glass-bg-strong); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.so-detail__section h3 { + font-family: var(--font-heading); + font-size: var(--text-sm); + text-transform: uppercase; + letter-spacing: var(--tracking-wide); + margin-bottom: var(--space-3); +} + +.so-detail__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); +} + +.so-detail__field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.so-detail__label { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; +} + +.so-detail__value { + font-weight: var(--font-weight-bold); +} + +.so-detail__actions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} + +/* Tables inside modal */ +.data-table--compact { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.data-table--compact th, +.data-table--compact td { + padding: var(--space-2) var(--space-3); + text-align: left; + border-bottom: 1px solid var(--glass-border); +} + +.data-table--compact th { + color: var(--color-text-muted); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + font-size: var(--text-xs); +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-weight-bold); + text-transform: uppercase; +} + +.status-badge--pending { background: var(--color-warn); color: #000; } +.status-badge--reserved { background: var(--color-info); color: #fff; } +.status-badge--installed { background: var(--color-success); color: #000; } + +@media (max-width: 1024px) { + .kanban-board { + flex-direction: column; + overflow-x: visible; + } + + .kanban-column { + flex: 1 1 auto; + max-height: none; + } +} diff --git a/pos/static/js/sidebar.js b/pos/static/js/sidebar.js index 81cbb18..bea927e 100644 --- a/pos/static/js/sidebar.js +++ b/pos/static/js/sidebar.js @@ -40,6 +40,7 @@ window.renderSidebar = function(modulesOverride) { ].filter(Boolean)}, { label: _t('nav_management'), items: [ { name: _t('customers'), href: '/pos/customers', icon: '' }, + { name: 'Taller', href: '/pos/workshop', icon: '' }, { name: 'Cotizaciones', href: '/pos/quotations', icon: '' }, moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '' } : null, moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '' } : null, diff --git a/pos/static/js/workshop.js b/pos/static/js/workshop.js new file mode 100644 index 0000000..c662ac3 --- /dev/null +++ b/pos/static/js/workshop.js @@ -0,0 +1,482 @@ +/** + * workshop.js — Taller / Service Orders Kanban for Nexus POS + */ +var Workshop = (function() { + 'use strict'; + + var API = '/pos/api/service-orders'; + var token = localStorage.getItem('pos_token'); + var orders = []; + var catalog = []; + var customers = []; + var vehicles = []; + var employees = []; + var currentOrderId = null; + + var COLUMNS = [ + {key: 'received', label: 'Recibido'}, + {key: 'diagnosis', label: 'Diagnóstico'}, + {key: 'waiting_parts', label: 'Espera refacciones'}, + {key: 'repair', label: 'En reparación'}, + {key: 'quality_check', label: 'Control calidad'}, + {key: 'ready', label: 'Listo'}, + {key: 'delivered', label: 'Entregado'}, + ]; + + function headers() { + return { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }; + } + + function fmt(n) { + if (n == null) return '0'; + return parseFloat(n).toLocaleString('es-MX'); + } + + function fmtMoney(n) { + if (n == null) return '$0.00'; + return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2}); + } + + function fmtDate(d) { + if (!d) return '—'; + var dt = new Date(d); + return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'}); + } + + function esc(s) { + if (!s) return ''; + var el = document.createElement('div'); + el.textContent = s; + return el.innerHTML; + } + + function api(method, url, body) { + var opts = {method: method, headers: headers()}; + if (body) opts.body = JSON.stringify(body); + return fetch(API + url, opts).then(function(r) { + return r.json().then(function(data) { + if (!r.ok) throw new Error(data.error || r.statusText); + return data; + }); + }); + } + + // ─── Init ─── + + function init() { + loadSummary(); + loadOrders(); + loadCatalog(); + loadReferenceData(); + } + + // ─── Summary / Kanban ─── + + function loadSummary() { + fetch(API + '/kanban/summary', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + document.getElementById('statReceived').textContent = fmt(d.received || 0); + document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0)); + document.getElementById('statReady').textContent = fmt(d.ready || 0); + document.getElementById('statOverdue').textContent = fmt(d.overdue || 0); + }) + .catch(function() {}); + } + + function loadOrders() { + fetch(API + '?per_page=200', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + orders = d.data || []; + renderKanban(); + }) + .catch(function(e) { + console.error(e); + document.getElementById('kanbanBoard').innerHTML = '
Error cargando órdenes
'; + }); + } + + function renderKanban() { + var board = document.getElementById('kanbanBoard'); + board.innerHTML = ''; + COLUMNS.forEach(function(col) { + var colOrders = orders.filter(function(o) { return o.status === col.key; }); + var colEl = document.createElement('div'); + colEl.className = 'kanban-column'; + colEl.innerHTML = + '
' + + ' ' + esc(col.label) + '' + + ' ' + colOrders.length + '' + + '
' + + '
'; + board.appendChild(colEl); + + var body = colEl.querySelector('.kanban-column__body'); + if (!colOrders.length) { + body.innerHTML = '
Sin órdenes
'; + } else { + colOrders.forEach(function(o) { + body.appendChild(renderCard(o)); + }); + } + }); + } + + function renderCard(o) { + var card = document.createElement('div'); + card.className = 'kanban-card'; + card.onclick = function() { openDetail(o.id); }; + card.innerHTML = + '
' + + ' ' + esc(o.order_number) + '' + + ' ' + esc(o.priority) + '' + + '
' + + '
' + esc(o.customer_name || 'Cliente general') + '
' + + '
' + esc(o.vehicle_plate || 'Sin vehículo') + '
' + + '
' + + ' 🔧 ' + esc(o.employee_name || 'Sin asignar') + '' + + ' ' + fmtMoney(o.estimated_cost) + '' + + '
'; + return card; + } + + // ─── Detail modal ─── + + function openDetail(id) { + currentOrderId = id; + fetch(API + '/' + id, {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(o) { + document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number); + renderDetailBody(o); + document.getElementById('detailModal').style.display = 'flex'; + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + function renderDetailBody(o) { + var html = + '
' + + '
' + + '

Información general

' + + '
' + + '
Cliente' + esc(o.customer_name || '—') + '
' + + '
Vehículo' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '
' + + '
Mecánico' + esc(o.employee_name || 'Sin asignar') + '
' + + '
Estado' + esc(o.status) + '
' + + '
Entrega estimada' + fmtDate(o.estimated_completion) + '
' + + '
Kilometraje entrada' + fmt(o.mileage_in) + '
' + + '
' + + '
Notas recepción

' + esc(o.reception_notes || '—') + '

' + + '
' + + + '
' + + '

Refacciones

' + + ' ' + + (o.items || []).map(function(it) { + return '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + + '
ConceptoCant.PrecioEstado
' + esc(it.name) + '
' + esc(it.part_number || '') + '
' + fmt(it.quantity) + '' + fmtMoney(it.unit_price) + '' + (it.reserved_quantity >= it.quantity ? 'Reservado' : esc(it.status)) + '' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '' : '') + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + + '
' + + '

Mano de obra

' + + ' ' + + (o.labor || []).map(function(l) { + return '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + + '
ConceptoHorasPrecio/hrTotalEstado
' + esc(l.description) + '' + fmt(l.hours) + '' + fmtMoney(l.hourly_rate) + '' + fmtMoney(l.total_cost) + '' + esc(l.status) + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + + '
' + + '

Cambiar estado

' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + + document.getElementById('detailBody').innerHTML = html; + + // Populate labor catalog select + var sel = document.getElementById('laborCatalogSelect'); + if (sel) { + catalog.forEach(function(c) { + var opt = document.createElement('option'); + opt.value = JSON.stringify(c); + opt.textContent = c.name + ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')'; + sel.appendChild(opt); + }); + sel.onchange = function() { + if (!sel.value) return; + var c = JSON.parse(sel.value); + document.getElementById('laborDesc').value = c.name; + document.getElementById('laborHours').value = c.suggested_hours; + document.getElementById('laborRate').value = c.suggested_rate; + }; + } + + // Footer actions + var footer = document.getElementById('detailFooter'); + footer.innerHTML = + '' + + (o.status === 'ready' && !o.sale_id ? '' : '') + + (o.sale_id ? 'Ver venta #' + o.sale_id + '' : ''); + } + + function closeDetailModal() { + document.getElementById('detailModal').style.display = 'none'; + currentOrderId = null; + } + + // ─── Actions ─── + + function changeStatus() { + if (!currentOrderId) return; + var newStatus = document.getElementById('statusSelect').value; + api('PUT', '/' + currentOrderId + '/status', {status: newStatus}) + .then(function() { + closeDetailModal(); + loadSummary(); + loadOrders(); + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + function reserveItem(itemId) { + api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {}) + .then(function() { + alert('Refacción reservada'); + openDetail(currentOrderId); + loadSummary(); + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + function addItemPlaceholder() { + var name = document.getElementById('newItemSearch').value.trim(); + if (!name) return; + api('POST', '/' + currentOrderId + '/items', { + name: name, + quantity: 1, + unit_price: 0, + status: 'pending' + }).then(function() { + openDetail(currentOrderId); + }).catch(function(e) { alert('Error: ' + e.message); }); + } + + function addLabor() { + var desc = document.getElementById('laborDesc').value.trim(); + var hours = parseFloat(document.getElementById('laborHours').value) || 0; + var rate = parseFloat(document.getElementById('laborRate').value) || 0; + if (!desc) return alert('Escribe una descripción'); + api('POST', '/' + currentOrderId + '/labor', { + description: desc, + hours: hours, + hourly_rate: rate, + status: 'pending' + }).then(function() { + document.getElementById('laborDesc').value = ''; + document.getElementById('laborHours').value = ''; + document.getElementById('laborRate').value = ''; + openDetail(currentOrderId); + }).catch(function(e) { alert('Error: ' + e.message); }); + } + + function convertToSale() { + if (!currentOrderId) return; + if (!confirm('¿Convertir esta orden en una venta? Se descontarán las refacciones reservadas del inventario.')) return; + api('POST', '/' + currentOrderId + '/convert-to-sale', { + payment_method: 'efectivo', + sale_type: 'cash' + }).then(function(r) { + alert('Venta creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total)); + closeDetailModal(); + loadSummary(); + loadOrders(); + }).catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── New order ─── + + function openNewOrderModal() { + populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; }); + populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; }); + populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; }); + document.getElementById('newOrderModal').style.display = 'flex'; + } + + function closeNewOrderModal() { + document.getElementById('newOrderModal').style.display = 'none'; + document.getElementById('newOrderForm').reset(); + } + + function submitNewOrder() { + var customerId = document.getElementById('noCustomer').value; + if (!customerId) return alert('Selecciona un cliente'); + api('POST', '', { + customer_id: parseInt(customerId, 10), + vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null, + employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null, + priority: document.getElementById('noPriority').value, + estimated_completion: document.getElementById('noEstimatedCompletion').value || null, + mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null, + reception_notes: document.getElementById('noNotes').value + }).then(function() { + closeNewOrderModal(); + loadSummary(); + loadOrders(); + }).catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── Catalog ─── + + function openCatalogModal() { + document.getElementById('catalogModal').style.display = 'flex'; + renderCatalog(); + } + + function closeCatalogModal() { + document.getElementById('catalogModal').style.display = 'none'; + } + + function loadCatalog() { + fetch(API + '/service-catalog?active_only=true', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { + catalog = d.data || []; + }) + .catch(function() {}); + } + + function renderCatalog() { + var body = document.getElementById('catalogBody'); + if (!catalog.length) { + body.innerHTML = 'Sin conceptos'; + return; + } + body.innerHTML = catalog.map(function(c) { + return '' + + '' + esc(c.name) + (c.description ? '
' + esc(c.description) + '' : '') + '' + + '' + fmt(c.suggested_hours) + '' + + '' + fmtMoney(c.suggested_rate) + '' + + '' + fmtMoney(c.suggested_hours * c.suggested_rate) + '' + + '' + + ''; + }).join(''); + } + + function addCatalogItem() { + var name = document.getElementById('catName').value.trim(); + if (!name) return alert('Escribe un nombre'); + api('POST', '/service-catalog', { + name: name, + description: document.getElementById('catDesc').value, + suggested_hours: parseFloat(document.getElementById('catHours').value) || 0, + suggested_rate: parseFloat(document.getElementById('catRate').value) || 0 + }).then(function() { + document.getElementById('catName').value = ''; + document.getElementById('catDesc').value = ''; + document.getElementById('catHours').value = ''; + document.getElementById('catRate').value = ''; + loadCatalog(); + setTimeout(renderCatalog, 200); + }).catch(function(e) { alert('Error: ' + e.message); }); + } + + function deleteCatalogItem(id) { + if (!confirm('¿Desactivar este concepto?')) return; + api('DELETE', '/service-catalog/' + id, {}) + .then(function() { + loadCatalog(); + setTimeout(renderCatalog, 200); + }) + .catch(function(e) { alert('Error: ' + e.message); }); + } + + // ─── Reference data ─── + + function loadReferenceData() { + // Customers + fetch('/pos/api/customers?per_page=500', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { customers = (d.data || d.customers || []); }) + .catch(function() {}); + // Vehicles + fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { vehicles = (d.data || []); }) + .catch(function() { vehicles = []; }); + // Employees + fetch('/pos/api/config/employees?per_page=500', {headers: headers()}) + .then(function(r) { return r.json(); }) + .then(function(d) { employees = (d.data || d.employees || []); }) + .catch(function() { employees = []; }); + } + + function populateSelect(id, items, mapper) { + var sel = document.getElementById(id); + if (!sel) return; + sel.innerHTML = id === 'noCustomer' ? '' : ''; + items.forEach(function(it) { + var opt = mapper(it); + var el = document.createElement('option'); + el.value = opt.value; + el.textContent = opt.text; + sel.appendChild(el); + }); + } + + // ─── Public API ─── + + return { + init: init, + openDetail: openDetail, + closeDetailModal: closeDetailModal, + changeStatus: changeStatus, + reserveItem: reserveItem, + addItemPlaceholder: addItemPlaceholder, + addLabor: addLabor, + convertToSale: convertToSale, + openNewOrderModal: openNewOrderModal, + closeNewOrderModal: closeNewOrderModal, + submitNewOrder: submitNewOrder, + openCatalogModal: openCatalogModal, + closeCatalogModal: closeCatalogModal, + addCatalogItem: addCatalogItem, + deleteCatalogItem: deleteCatalogItem, + }; +})(); + +document.addEventListener('DOMContentLoaded', Workshop.init); diff --git a/pos/templates/accounting.html b/pos/templates/accounting.html index 8ee43da..89aaeb2 100644 --- a/pos/templates/accounting.html +++ b/pos/templates/accounting.html @@ -85,6 +85,10 @@ Clientes + + + Taller + diff --git a/pos/templates/config.html b/pos/templates/config.html index ae06426..90b2de3 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -85,6 +85,10 @@ Clientes + + + Taller + diff --git a/pos/templates/invoicing.html b/pos/templates/invoicing.html index 36b596b..6955e55 100644 --- a/pos/templates/invoicing.html +++ b/pos/templates/invoicing.html @@ -118,6 +118,12 @@ Clientes + + + + + Taller + diff --git a/pos/templates/reports.html b/pos/templates/reports.html index 810b2ad..b38a510 100644 --- a/pos/templates/reports.html +++ b/pos/templates/reports.html @@ -105,6 +105,12 @@ Clientes + + + + + Taller + diff --git a/pos/templates/workshop.html b/pos/templates/workshop.html new file mode 100644 index 0000000..77891b1 --- /dev/null +++ b/pos/templates/workshop.html @@ -0,0 +1,189 @@ + + + + + + + Taller — Nexus Autoparts POS + + + + + + + + + + + + + + +
+ + + +
+
+ + + + +
+
+ Recibidos + -- +
+
+ En reparación + -- +
+
+ Listos + -- +
+
+ Vencidos + -- +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/pos/tests/test_service_order_integration.py b/pos/tests/test_service_order_integration.py new file mode 100644 index 0000000..a68c876 --- /dev/null +++ b/pos/tests/test_service_order_integration.py @@ -0,0 +1,238 @@ +"""Unit tests for service order workshop integration. + +These tests use mocked DB cursors and inventory_engine so they do not require +PostgreSQL or network access. +""" + +import os +import sys + +POS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, POS_DIR) + +from unittest import mock # noqa: E402 + +import pytest # noqa: E402 +from services import service_order_engine as engine # noqa: E402 + + +class MockCursor: + """Simple programmable cursor mock.""" + + def __init__(self, responses=None): + self.responses = responses or [] + self._calls = [] + self._response_index = 0 + + def execute(self, sql, params=None): + self._calls.append((sql, params)) + + def fetchone(self): + if self._response_index < len(self.responses): + resp = self.responses[self._response_index] + self._response_index += 1 + return resp + return None + + def fetchall(self): + if self._response_index < len(self.responses): + resp = self.responses[self._response_index] + self._response_index += 1 + return resp + return [] + + def close(self): + pass + + +class MockConn: + def __init__(self, cursor): + self._cursor = cursor + + def cursor(self): + return self._cursor + + def commit(self): + pass + + def rollback(self): + pass + + +@pytest.fixture +def conn(): + return MockConn(MockCursor()) + + +def test_generate_order_number_first_of_year(conn): + conn._cursor.responses = [(None,)] + number = engine._generate_order_number(conn) + assert number.startswith("SO-") + assert number.endswith("-0001") + + +def test_generate_order_number_increments(conn): + conn._cursor.responses = [("SO-2026-0042",)] + number = engine._generate_order_number(conn) + assert number.endswith("-0043") + + +@mock.patch("services.inventory_engine.get_stock", return_value=10) +@mock.patch("services.inventory_engine.record_operation", return_value=123) +def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn): + + conn._cursor.responses = [ + (1, 5, 3, "pending", "SO-2026-0001"), # item lookup + None, # update + ] + + result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9) + + assert result["reserved"] == 3 + mock_stock.assert_called_once_with(conn, 5, 2) + mock_record.assert_called_once() + args = mock_record.call_args.args + assert args[3] == "SO_RESERVE" + assert args[4] == -3 + + +@mock.patch("services.inventory_engine.get_stock", return_value=1) +def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn): + conn._cursor.responses = [ + (1, 5, 3, "pending", "SO-2026-0001"), + ] + + with pytest.raises(ValueError, match="Insufficient stock"): + engine.reserve_item(conn, 7, branch_id=2) + + +@mock.patch("services.inventory_engine.record_operation", return_value=124) +def test_release_item_restores_stock(mock_record, conn): + conn._cursor.responses = [ + (1, 5, 2, 2, "SO-2026-0001"), # item lookup (reserved_quantity=2) + None, # update + ] + + result = engine.release_item(conn, 7, employee_id=9) + + assert result["released"] == 2 + args = mock_record.call_args.args + assert args[3] == "SO_RELEASE" + assert args[4] == 2 + + +@mock.patch("services.inventory_engine.record_operation", return_value=125) +def test_convert_to_sale_creates_sale_and_consumes_inventory(mock_record, conn): + # Mock get_service_order response + so = { + "id": 1, + "order_number": "SO-2026-0001", + "status": "ready", + "sale_id": None, + "branch_id": 2, + "customer_id": 3, + "items": [ + { + "id": 10, + "inventory_id": 5, + "part_number": "BP-123", + "name": "Bujia", + "quantity": 2, + "unit_price": 150.0, + "unit_cost": 80.0, + "status": "pending", + "reserved_quantity": 2, + } + ], + "labor": [ + { + "description": "Cambio de bujias", + "hours": 1, + "hourly_rate": 250, + "total_cost": 250, + "status": "completed", + } + ], + } + + cur = conn._cursor + cur.responses = [ + (1, "2026-01-01 10:00:00"), # sale insert -> id=1 + ] + + with mock.patch.object(engine, "get_service_order", return_value=so): + result = engine.convert_to_sale(conn, 1, {"payment_method": "efectivo", "sale_type": "cash"}, employee_id=9) + + assert result["sale_id"] == 1 + assert result["items_count"] == 2 + assert result["total"] == pytest.approx(2 * 150 * 1.16 + 250 * 1.16, 0.01) + + # inventory operations: SO_RELEASE + SALE + assert mock_record.call_count == 2 + first = mock_record.call_args_list[0].args + second = mock_record.call_args_list[1].args + assert first[3] == "SO_RELEASE" + assert first[4] == 2 + assert second[3] == "SALE" + assert second[4] == -2 + + +def test_convert_to_sale_raises_when_already_converted(conn): + so = {"status": "ready", "sale_id": 99, "branch_id": 1, "customer_id": 1, "items": [], "labor": []} + conn._cursor.responses = [(1,)] + with ( + mock.patch.object(engine, "get_service_order", return_value=so), + pytest.raises(ValueError, match="already converted"), + ): + engine.convert_to_sale(conn, 1, {}) + + +def test_convert_to_sale_raises_when_cancelled(conn): + so = {"status": "cancelled", "sale_id": None, "branch_id": 1, "customer_id": 1, "items": [], "labor": []} + conn._cursor.responses = [(1,)] + with ( + mock.patch.object(engine, "get_service_order", return_value=so), + pytest.raises(ValueError, match="cancelled"), + ): + engine.convert_to_sale(conn, 1, {}) + + +def test_assign_mechanic_updates_employee(conn): + conn._cursor.responses = [(1,), None] + result = engine.assign_mechanic(conn, 1, 7) + assert result["employee_id"] == 7 + + +def test_assign_mechanic_raises_when_order_missing(conn): + conn._cursor.responses = [None] + with pytest.raises(ValueError, match="not found"): + engine.assign_mechanic(conn, 1, 7) + + +def test_service_catalog_crud(conn): + # create + conn._cursor.responses = [(1,)] + result = engine.create_service_catalog_item( + conn, 1, {"name": "Afinacion", "suggested_hours": 2, "suggested_rate": 300} + ) + assert result["id"] == 1 + + # list + conn._cursor = MockCursor( + [ + [(1, 1, "Afinacion", "", 2, 300, True, None, None)], + ] + ) + items = engine.list_service_catalog(conn) + assert len(items) == 1 + assert items[0]["name"] == "Afinacion" + + # update + conn._cursor = MockCursor([None]) + ok = engine.update_service_catalog_item(conn, 1, {"name": "Afinacion mayor"}) + assert ok is True + + # delete + conn._cursor = MockCursor([None]) + ok = engine.delete_service_catalog_item(conn, 1) + assert ok is True