Files
Autoparts-DB/pos/services/service_order_engine.py
consultoria-as f42910f4f6
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
2026-07-02 12:51:56 +00:00

1150 lines
39 KiB
Python

"""Service Order Engine: workshop Kanban management.
States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered
"""
import contextlib
from datetime import datetime
from services import inventory_engine
# Rached workshop statuses (applies to all tenants).
ORDER_STATUSES = [
'por_revisar',
'en_revision',
'revisada',
'cotizada',
'por_autorizar',
'autorizada',
'autorizacion_parcial',
'en_reparacion',
'reparada',
'por_entregar',
'entregado',
'por_enviar',
'enviado',
'por_facturar',
'facturada',
'por_recolectar',
'cancelada',
]
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
# Allow moving from any non-terminal status to any other non-terminal status,
# plus cancellation. Terminal statuses cannot change.
VALID_TRANSITIONS = {
status: [s for s in ORDER_STATUSES if s != status]
for status in ORDER_STATUSES
}
for terminal in TERMINAL_STATUSES:
VALID_TRANSITIONS[terminal] = []
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
_LEGACY_STATUSES = {
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
}
for legacy in _LEGACY_STATUSES:
VALID_TRANSITIONS.setdefault(legacy, [])
def _generate_order_number(conn):
"""Generate DDMMYYYY-N order number (daily sequential).
Uses a per-day advisory transaction lock to avoid duplicate order
numbers when multiple workers create orders concurrently.
"""
cur = conn.cursor()
today = datetime.utcnow().strftime('%d%m%Y')
prefix = f"{today}-"
# Serialize order creation per day within the current transaction.
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (prefix,))
cur.execute("""
SELECT order_number FROM service_orders
WHERE order_number LIKE %s
ORDER BY order_number DESC LIMIT 1
""", (f"{prefix}%",))
row = cur.fetchone()
last_num = 0
if row and row[0]:
with contextlib.suppress(ValueError):
last_num = int(row[0].split('-')[-1])
new_num = last_num + 1
cur.close()
return f"{prefix}{new_num}"
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
def _normalize_delivery(data):
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
dm = data.get('delivery_method')
if dm not in _VALID_DELIVERY_METHODS:
data['delivery_method'] = None
if data.get('delivery_method') not in ('delivery', 'courier'):
data['courier_id'] = None
def create_service_order(conn, data):
"""Create a new service order.
data: {
customer_id, vehicle_id, branch_id, priority,
reception_notes, estimated_cost, estimated_completion,
employee_id, mileage_in, fuel_level, created_by,
delivery_method, courier_id, is_direct, requires_invoice
}
"""
_normalize_delivery(data)
cur = conn.cursor()
order_number = _generate_order_number(conn)
cur.execute("""
INSERT INTO service_orders
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
priority, reception_notes, estimated_cost, estimated_completion,
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
delivery_method, courier_id, is_direct, requires_invoice,
workshop_name, customer_address, customer_phone, vehicle_description)
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
data.get('vehicle_id'), order_number,
data.get('priority', 'normal'), data.get('reception_notes'),
data.get('estimated_cost'), data.get('estimated_completion'),
data.get('employee_id'), data.get('mechanic_name'),
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
data.get('delivery_method'), data.get('courier_id'),
data.get('is_direct', False), data.get('requires_invoice', False),
data.get('workshop_name'), data.get('customer_address'),
data.get('customer_phone'), data.get('vehicle_description'),
))
so_id = cur.fetchone()[0]
# Log initial status
cur.execute("""
INSERT INTO service_order_status_history
(service_order_id, new_status, changed_by, notes)
VALUES (%s, 'por_revisar', %s, 'Orden creada')
""", (so_id, data.get('created_by')))
conn.commit()
cur.close()
return {'service_order_id': so_id, 'order_number': order_number}
def get_service_order(conn, so_id):
cur = conn.cursor()
cur.execute("""
SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name, c.phone as customer_phone,
c.address as customer_address, c.price_tier as customer_price_tier,
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
so.reception_notes, so.diagnosis_notes, so.repair_notes,
so.delivery_notes, so.estimated_cost, so.final_cost,
so.estimated_completion, so.actual_completion, so.delivered_at,
so.mileage_in, so.mileage_out, so.fuel_level,
so.employee_id, e.name as employee_name,
so.mechanic_name,
so.created_by, creator.name as created_by_name,
so.created_at, so.updated_at,
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
so.requires_invoice, so.sale_id,
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
LEFT JOIN employees e ON so.employee_id = e.id
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
WHERE so.id = %s AND so.is_deleted = false
""", (so_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
so = {
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
'customer_address': row[7], 'customer_price_tier': row[8],
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
'reception_notes': row[17], 'diagnosis_notes': row[18],
'repair_notes': row[19], 'delivery_notes': row[20],
'estimated_cost': float(row[21]) if row[21] else None,
'final_cost': float(row[22]) if row[22] else None,
'estimated_completion': str(row[23]) if row[23] else None,
'actual_completion': str(row[24]) if row[24] else None,
'delivered_at': str(row[25]) if row[25] else None,
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
'employee_id': row[29], 'employee_name': row[30],
'mechanic_name': row[31],
'created_by': row[32], 'created_by_name': row[33],
'created_at': str(row[34]), 'updated_at': str(row[35]),
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
'is_direct': bool(row[39]) if row[39] is not None else False,
'requires_invoice': bool(row[40]) if row[40] is not None else False,
'sale_id': row[41],
'workshop_name': row[42], 'customer_address': row[43],
'customer_phone': row[44], 'vehicle_description': row[45],
}
# Items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
mechanic_id, observations
FROM service_order_items
WHERE service_order_id = %s
ORDER BY id
""", (so_id,))
so['items'] = []
total_parts = 0.0
for r in cur.fetchall():
qty = float(r[4]) if r[4] else 0
price = float(r[6]) if r[6] else 0
so['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': qty,
'unit_cost': float(r[5]) if r[5] else None,
'unit_price': price,
'status': r[7], 'notes': r[8],
'mechanic_id': r[9], 'observations': r[10],
})
total_parts += qty * price
# Labor
cur.execute("""
SELECT id, description, hours, hourly_rate, total_cost, employee_id, status
FROM service_order_labor
WHERE service_order_id = %s
ORDER BY id
""", (so_id,))
so['labor'] = []
total_labor = 0.0
for r in cur.fetchall():
total = float(r[4]) if r[4] else 0
so['labor'].append({
'id': r[0], 'description': r[1],
'hours': float(r[2]) if r[2] else 0,
'hourly_rate': float(r[3]) if r[3] else 0,
'total_cost': total,
'employee_id': r[5], 'status': r[6],
})
total_labor += total
so['total_parts'] = round(total_parts, 2)
so['total_labor'] = round(total_labor, 2)
so['total'] = round(total_parts + total_labor, 2)
# Status history
cur.execute("""
SELECT h.id, h.old_status, h.new_status, h.changed_by, e.name as changed_by_name,
h.notes, h.created_at
FROM service_order_status_history h
LEFT JOIN employees e ON h.changed_by = e.id
WHERE h.service_order_id = %s
ORDER BY h.created_at
""", (so_id,))
so['status_history'] = []
for r in cur.fetchall():
so['status_history'].append({
'id': r[0], 'old_status': r[1], 'new_status': r[2],
'changed_by': r[3], 'changed_by_name': r[4],
'notes': r[5], 'created_at': str(r[6]),
})
cur.close()
return so
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
priority=None, employee_id=None, delivery_method=None,
is_direct=None, q=None, page=1, per_page=50):
cur = conn.cursor()
where_clauses = []
params = []
if status:
where_clauses.append("so.status = %s")
params.append(status)
if branch_id:
where_clauses.append("so.branch_id = %s")
params.append(branch_id)
if customer_id:
where_clauses.append("so.customer_id = %s")
params.append(customer_id)
if priority:
where_clauses.append("so.priority = %s")
params.append(priority)
if employee_id:
where_clauses.append("so.employee_id = %s")
params.append(employee_id)
if delivery_method:
where_clauses.append("so.delivery_method = %s")
params.append(delivery_method)
if is_direct is not None:
where_clauses.append("so.is_direct = %s")
params.append(is_direct)
if q:
where_clauses.append(
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
)
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
where_clauses.append("so.is_deleted = false")
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT count(*) FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
WHERE {where}
""", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name,
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
so.branch_id, b.name as branch_name,
so.estimated_cost, so.final_cost,
so.delivery_method, co.name as courier_name, so.is_direct,
so.requires_invoice, so.sale_id, so.created_at,
creator.name as created_by_name,
so.employee_id, mech.name as employee_name,
so.mechanic_name,
so.workshop_name, so.vehicle_description
FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN employees mech ON so.employee_id = mech.id
WHERE {where}
ORDER BY
CASE so.priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
WHEN 'low' THEN 4
END,
so.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
orders = []
for r in cur.fetchall():
estimated = float(r[12]) if r[12] else 0
final = float(r[13]) if r[13] else None
orders.append({
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
'customer_id': r[4], 'customer_name': r[5],
'vehicle_id': r[6], 'vehicle_plate': r[7], 'vehicle_make': r[8], 'vehicle_model': r[9],
'branch_id': r[10], 'branch_name': r[11],
'estimated_cost': estimated,
'final_cost': final,
'delivery_method': r[14], 'courier_name': r[15],
'is_direct': bool(r[16]) if r[16] is not None else False,
'requires_invoice': bool(r[17]) if r[17] is not None else False,
'sale_id': r[18], 'created_at': str(r[19]),
'created_by_name': r[20],
'employee_id': r[21], 'employee_name': r[22],
'mechanic_name': r[23],
'workshop_name': r[24], 'vehicle_description': r[25],
'total': round(final or estimated, 2),
'paid': 0.0, # to be computed if needed
})
cur.close()
total_pages = (total + per_page - 1) // per_page
return {
'data': orders,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
}
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
"""Update service order status with validation."""
cur = conn.cursor()
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Service order not found")
old_status = row[0]
if new_status not in VALID_TRANSITIONS.get(old_status, []):
cur.close()
raise ValueError(f"Invalid transition: {old_status} -> {new_status}")
# Update status
extra_sets = []
extra_vals = []
if new_status == 'reparada':
extra_sets.append("actual_completion = NOW()")
if new_status == 'entregado':
extra_sets.append("delivered_at = NOW()")
extra_sets.append("delivered_by = %s")
extra_vals.append(changed_by)
set_clause = ", ".join(["status = %s"] + extra_sets)
cur.execute(f"""
UPDATE service_orders
SET {set_clause}
WHERE id = %s
""", [new_status] + extra_vals + [so_id])
# Log history
cur.execute("""
INSERT INTO service_order_status_history
(service_order_id, old_status, new_status, changed_by, notes)
VALUES (%s, %s, %s, %s, %s)
""", (so_id, old_status, new_status, changed_by, notes))
conn.commit()
cur.close()
return {'old_status': old_status, 'new_status': new_status}
def add_item(conn, so_id, item_data):
"""Add a part/item to the service order."""
cur = conn.cursor()
cur.execute("""
INSERT INTO service_order_items
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
mechanic_id, observations)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
item_data.get('name'), item_data.get('quantity', 1),
item_data.get('unit_cost'), item_data.get('unit_price'),
item_data.get('status', 'pending'), item_data.get('notes'),
item_data.get('mechanic_id'), item_data.get('observations'),
))
item_id = cur.fetchone()[0]
conn.commit()
cur.close()
return item_id
def update_item(conn, item_id, data):
cur = conn.cursor()
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
sets = []
vals = []
for field in allowed:
if field in data:
sets.append(f"{field} = %s")
vals.append(data[field])
if not sets:
cur.close()
return False
vals.append(item_id)
cur.execute(f"UPDATE service_order_items SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close()
return True
def remove_item(conn, item_id):
cur = conn.cursor()
cur.execute("DELETE FROM service_order_items WHERE id = %s", (item_id,))
conn.commit()
cur.close()
return True
def add_labor(conn, so_id, labor_data):
cur = conn.cursor()
total_cost = labor_data.get('hours', 0) * labor_data.get('hourly_rate', 0)
cur.execute("""
INSERT INTO service_order_labor
(service_order_id, description, hours, hourly_rate, total_cost, employee_id, status)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
so_id, labor_data['description'], labor_data.get('hours', 0),
labor_data.get('hourly_rate', 0), total_cost,
labor_data.get('employee_id'), labor_data.get('status', 'pending'),
))
labor_id = cur.fetchone()[0]
conn.commit()
cur.close()
return labor_id
def update_labor(conn, labor_id, data):
cur = conn.cursor()
allowed = ['description', 'hours', 'hourly_rate', 'employee_id', 'status']
sets = []
vals = []
for field in allowed:
if field in data:
sets.append(f"{field} = %s")
vals.append(data[field])
if not sets:
cur.close()
return False
# Recalculate total_cost if hours or rate changed
cur.execute("SELECT hours, hourly_rate FROM service_order_labor WHERE id = %s", (labor_id,))
row = cur.fetchone()
hours = data.get('hours', row[0]) if row else data.get('hours', 0)
rate = data.get('hourly_rate', row[1]) if row else data.get('hourly_rate', 0)
sets.append("total_cost = %s")
vals.append((hours or 0) * (rate or 0))
vals.append(labor_id)
cur.execute(f"UPDATE service_order_labor SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close()
return True
def remove_labor(conn, labor_id):
cur = conn.cursor()
cur.execute("DELETE FROM service_order_labor WHERE id = %s", (labor_id,))
conn.commit()
cur.close()
return True
def update_service_order(conn, so_id, data):
"""Update general service order fields."""
_normalize_delivery(data)
cur = conn.cursor()
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
'reception_notes', 'diagnosis_notes', 'repair_notes',
'delivery_notes', 'estimated_cost', 'estimated_completion',
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
sets = []
vals = []
for field in allowed:
if field in data:
sets.append(f"{field} = %s")
vals.append(data[field])
if not sets:
cur.close()
return False
vals.append(so_id)
cur.execute(f"UPDATE service_orders SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close()
return True
def get_kanban_summary(conn, branch_id=None):
"""Get counts per status for Kanban board."""
cur = conn.cursor()
params = []
branch_filter = ""
if branch_id:
branch_filter = "AND branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT status, COUNT(*) as cnt
FROM service_orders
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
GROUP BY status
""", params)
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
for r in cur.fetchall():
summary[r[0]] = r[1]
# Overdue orders (estimated_completion passed and not delivered/invoiced)
cur.execute(f"""
SELECT count(*) FROM service_orders
WHERE estimated_completion < NOW()
AND status NOT IN ('entregado', 'facturada', 'cancelada')
AND is_deleted = false
{branch_filter}
""", params)
overdue = cur.fetchone()[0]
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, so.branch_id
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, branch_id = 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 convert_to_remission(conn, so_id, sale_data, employee_id=None):
"""Convert a service order into a counter remission note (pending payment).
sale_data keys:
register_id: int (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")
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")
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
register_id = sale_data.get("register_id")
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
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, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
RETURNING id, created_at
""",
(
branch_id,
customer_id,
employee_id,
register_id,
subtotal,
0,
tax_total,
total,
0,
0,
notes,
),
)
sale_id, _created_at = cur.fetchone()
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"],
),
)
# Reserve inventory for parts
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
inventory_id = item.get("inventory_id")
qty = int(item.get("quantity", 0))
if inventory_id and qty > 0:
inventory_engine.record_reservation(
conn,
inventory_id,
branch_id,
qty,
sale_id=sale_id,
cost_at_time=float(item.get("unit_cost") or 0),
notes=f"Reserva nota de remision orden {so['order_number']}"
)
# Link order to sale (the remission note)
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 AND is_deleted = false", (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}
def delete_service_order(conn, so_id):
"""Soft-delete a service order."""
cur = conn.cursor()
cur.execute(
"UPDATE service_orders SET is_deleted = true WHERE id = %s AND is_deleted = false",
(so_id,),
)
deleted = cur.rowcount
conn.commit()
cur.close()
if deleted == 0:
raise ValueError("Service order not found")
return {"deleted": True}
# ─── 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