feat(pos/workshop): add lightweight workshop/taller module

- Add DB migration v4.4_workshop.sql (sale_id, service_catalog,
  reserved_quantity, SO_RESERVE/SO_RELEASE operation types).
- Extend service_order_engine with inventory reservation, release,
  convert-to-sale, mechanic assignment, and service catalog CRUD.
- Extend service_order_bp with /reserve, /convert-to-sale,
  /assign-mechanic, and /service-catalog endpoints.
- Create workshop Kanban UI: workshop.html, workshop.js, workshop.css.
- Add /pos/workshop route and sidebar navigation (sidebar.js + inline
  templates).
- Add 11 unit tests with mocked cursors.
- Update FASES_IMPLEMENTADAS.md with FASE 9 documentation.

Tests: 92 passing (61 console + 20 Facturapi + 11 workshop).
This commit is contained in:
2026-06-15 05:34:35 +00:00
parent d67887284d
commit ce66212223
15 changed files with 1842 additions and 14 deletions

View File

@@ -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

View File

@@ -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')

View File

@@ -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('/<int:so_id>/items/<int:item_id>/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('/<int:so_id>/items/<int:item_id>/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('/<int:so_id>/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('/<int:so_id>/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/<int:item_id>', 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/<int:item_id>', 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()

View File

@@ -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",
}

View File

@@ -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';

View File

@@ -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

221
pos/static/css/workshop.css Normal file
View File

@@ -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;
}
}

View File

@@ -40,6 +40,7 @@ window.renderSidebar = function(modulesOverride) {
].filter(Boolean)},
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,

482
pos/static/js/workshop.js Normal file
View File

@@ -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 = '<div class="empty-state"><div class="empty-state__text">Error cargando órdenes</div></div>';
});
}
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 =
'<div class="kanban-column__header">' +
' <span>' + esc(col.label) + '</span>' +
' <span class="kanban-column__count">' + colOrders.length + '</span>' +
'</div>' +
'<div class="kanban-column__body" id="col-' + col.key + '"></div>';
board.appendChild(colEl);
var body = colEl.querySelector('.kanban-column__body');
if (!colOrders.length) {
body.innerHTML = '<div class="empty-state__text" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">Sin órdenes</div>';
} 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 =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority kanban-card__priority--' + esc(o.priority) + '">' + esc(o.priority) + '</span>' +
'</div>' +
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__meta">' +
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
'</div>';
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 =
'<div class="so-detail">' +
' <div class="so-detail__section">' +
' <h3>Información general</h3>' +
' <div class="so-detail__grid">' +
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Estado</span><span class="so-detail__value">' + esc(o.status) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
' </div>' +
' <div style="margin-top:var(--space-3);"><span class="so-detail__label">Notas recepción</span><p>' + esc(o.reception_notes || '—') + '</p></div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Refacciones</h3>' +
' <table class="data-table--compact"><thead><tr><th>Concepto</th><th>Cant.</th><th>Precio</th><th>Estado</th><th></th></tr></thead><tbody>' +
(o.items || []).map(function(it) {
return '<tr>' +
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
'<td>' + fmt(it.quantity) + '</td>' +
'<td>' + fmtMoney(it.unit_price) + '</td>' +
'<td><span class="status-badge status-badge--' + (it.reserved_quantity >= it.quantity ? 'reserved' : it.status) + '">' + (it.reserved_quantity >= it.quantity ? 'Reservado' : esc(it.status)) + '</span></td>' +
'<td>' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '<button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button>' : '') + '</td>' +
'</tr>';
}).join('') +
'</tbody></table>' +
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/numero" style="flex:1;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
' </div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Mano de obra</h3>' +
' <table class="data-table--compact"><thead><tr><th>Concepto</th><th>Horas</th><th>Precio/hr</th><th>Total</th><th>Estado</th></tr></thead><tbody>' +
(o.labor || []).map(function(l) {
return '<tr>' +
'<td>' + esc(l.description) + '</td>' +
'<td>' + fmt(l.hours) + '</td>' +
'<td>' + fmtMoney(l.hourly_rate) + '</td>' +
'<td>' + fmtMoney(l.total_cost) + '</td>' +
'<td><span class="status-badge status-badge--' + l.status + '">' + esc(l.status) + '</span></td>' +
'</tr>';
}).join('') +
'</tbody></table>' +
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;" />' +
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
' <input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
' </div>' +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Cambiar estado</h3>' +
' <div class="so-detail__actions">' +
' <select class="form-input" id="statusSelect" style="width:auto;">' +
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
' </select>' +
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
' </div>' +
' </div>' +
'</div>';
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 =
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
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 = '<tr><td colspan="5" style="text-align:center;">Sin conceptos</td></tr>';
return;
}
body.innerHTML = catalog.map(function(c) {
return '<tr>' +
'<td>' + esc(c.name) + (c.description ? '<br><small>' + esc(c.description) + '</small>' : '') + '</td>' +
'<td>' + fmt(c.suggested_hours) + '</td>' +
'<td>' + fmtMoney(c.suggested_rate) + '</td>' +
'<td>' + fmtMoney(c.suggested_hours * c.suggested_rate) + '</td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Workshop.deleteCatalogItem(' + c.id + ')">Desactivar</button></td>' +
'</tr>';
}).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' ? '' : '<option value="">—</option>';
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);

View File

@@ -85,6 +85,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing">

View File

@@ -85,6 +85,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing">

View File

@@ -118,6 +118,12 @@
</svg>
<span>Clientes</span>
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span>Taller</span>
</a>
<a class="nav-item is-active" href="/pos/invoicing" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -105,6 +105,12 @@
</svg>
<span>Clientes</span>
</a>
<a href="/pos/workshop" class="nav-item">
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4.7a.8.8 0 0 0 0 1.1l1.2 1.2a.8.8 0 0 0 1.1 0l2.8-2.8a4.5 4.5 0 0 1-6 6l-5.2 5.2a1.6 1.6 0 0 1-2.2-2.2l5.2-5.2a4.5 4.5 0 0 1 6-6L11 4.7z"/>
</svg>
<span>Taller</span>
</a>
<a href="/pos/invoicing" class="nav-item">
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">

189
pos/templates/workshop.html Normal file
View File

@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Taller — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/workshop.css">
</head>
<body>
<div class="page-shell">
<!-- Sidebar injected by sidebar.js -->
<aside id="sidebar"></aside>
<div class="main-area pos-main-offset">
<div id="offlineBanner"></div>
<div class="page-header">
<div>
<h1>Taller</h1>
<p class="page-header__subtitle">Órdenes de servicio y control de reparaciones</p>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">Catálogo de servicios</button>
<button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()">+ Nueva orden</button>
</div>
</div>
<!-- Summary cards -->
<div class="stats-row" id="statsRow">
<div class="stat-card">
<span class="stat-card__label">Recibidos</span>
<span class="stat-card__value" id="statReceived">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">En reparación</span>
<span class="stat-card__value" id="statRepair">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Listos</span>
<span class="stat-card__value" id="statReady">--</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Vencidos</span>
<span class="stat-card__value stat-card__value--danger" id="statOverdue">--</span>
</div>
</div>
<!-- Kanban board -->
<div class="kanban-board" id="kanbanBoard">
<!-- Columns injected by JS -->
</div>
</div>
</div>
<!-- Detail modal -->
<div class="modal-overlay" id="detailModal" style="display:none;">
<div class="modal modal--lg">
<div class="modal__header">
<h2 class="modal__title" id="detailTitle">Orden #0000</h2>
<button class="modal__close" onclick="Workshop.closeDetailModal()">&times;</button>
</div>
<div class="modal__body" id="detailBody">
<!-- Injected by JS -->
</div>
<div class="modal__footer" id="detailFooter">
<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>
</div>
</div>
</div>
<!-- New order modal -->
<div class="modal-overlay" id="newOrderModal" style="display:none;">
<div class="modal">
<div class="modal__header">
<h2 class="modal__title">Nueva orden de servicio</h2>
<button class="modal__close" onclick="Workshop.closeNewOrderModal()">&times;</button>
</div>
<div class="modal__body">
<form id="newOrderForm" class="form-grid form-grid--2">
<div class="form-field">
<label class="form-label" for="noCustomer">Cliente</label>
<select class="form-input" id="noCustomer" required></select>
</div>
<div class="form-field">
<label class="form-label" for="noVehicle">Vehículo</label>
<select class="form-input" id="noVehicle"></select>
</div>
<div class="form-field">
<label class="form-label" for="noMechanic">Mecánico asignado</label>
<select class="form-input" id="noMechanic"></select>
</div>
<div class="form-field">
<label class="form-label" for="noPriority">Prioridad</label>
<select class="form-input" id="noPriority">
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
</div>
<div class="form-field">
<label class="form-label" for="noMileage">Kilometraje</label>
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noNotes">Notas de recepción</label>
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewOrderModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.submitNewOrder()">Crear orden</button>
</div>
</div>
</div>
<!-- Catalog modal -->
<div class="modal-overlay" id="catalogModal" style="display:none;">
<div class="modal modal--lg">
<div class="modal__header">
<h2 class="modal__title">Catálogo de servicios</h2>
<button class="modal__close" onclick="Workshop.closeCatalogModal()">&times;</button>
</div>
<div class="modal__body">
<div class="form-grid form-grid--4" id="catalogForm">
<div class="form-field form-field--span2">
<input class="form-input" id="catName" placeholder="Nombre del servicio" />
</div>
<div class="form-field">
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
</div>
<div class="form-field">
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
</div>
<div class="form-field form-field--span3">
<input class="form-input" id="catDesc" placeholder="Descripción" />
</div>
<div class="form-field">
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
</div>
</div>
<div class="vs-container" style="margin-top:var(--space-4);max-height:40vh;overflow-y:auto;">
<table class="data-table">
<thead>
<tr>
<th>Servicio</th>
<th>Horas</th>
<th>Precio/hora</th>
<th>Total sugerido</th>
<th></th>
</tr>
</thead>
<tbody id="catalogBody">
<tr><td colspan="5" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/workshop.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -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