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:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
66
pos/migrations/v4.4_workshop.sql
Normal file
66
pos/migrations/v4.4_workshop.sql
Normal 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';
|
||||
@@ -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
221
pos/static/css/workshop.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
482
pos/static/js/workshop.js
Normal 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);
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
189
pos/templates/workshop.html
Normal 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()">×</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()">×</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()">×</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>
|
||||
238
pos/tests/test_service_order_integration.py
Normal file
238
pos/tests/test_service_order_integration.py
Normal 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
|
||||
Reference in New Issue
Block a user