fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
This commit is contained in:
@@ -46,18 +46,22 @@ def _get_account_id(cur, code):
|
||||
|
||||
def _get_account_ids(cur, codes):
|
||||
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||
result = {}
|
||||
for code in codes:
|
||||
result[code] = _get_account_id(cur, code)
|
||||
return result
|
||||
cur.execute(
|
||||
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
|
||||
(list(codes),)
|
||||
)
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
missing = set(codes) - set(rows)
|
||||
if missing:
|
||||
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
|
||||
return rows
|
||||
|
||||
|
||||
def get_next_entry_number(conn):
|
||||
"""Get the next sequential journal entry number.
|
||||
|
||||
Uses a simple MAX+1 approach. For high-concurrency environments this
|
||||
could be replaced with a sequence, but for single-tenant refaccionarias
|
||||
the transaction-level lock from the INSERT is sufficient.
|
||||
Uses a transaction-level advisory lock to prevent duplicate numbers
|
||||
when multiple journal entries are created concurrently.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection to tenant DB
|
||||
@@ -66,6 +70,7 @@ def get_next_entry_number(conn):
|
||||
int: next entry number (starts at 1)
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
|
||||
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
||||
number = cur.fetchone()[0]
|
||||
cur.close()
|
||||
|
||||
@@ -29,8 +29,13 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||
|
||||
|
||||
def _generate_provisional_folio(conn):
|
||||
"""Generate a provisional folio like PRE-00001."""
|
||||
"""Generate a provisional folio like PRE-00001.
|
||||
|
||||
Uses a transaction-level advisory lock to avoid duplicate provisional
|
||||
folios when multiple CFDIs are enqueued concurrently.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||
seq = cur.fetchone()[0]
|
||||
cur.close()
|
||||
@@ -101,6 +106,7 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 50
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
@@ -364,7 +370,7 @@ def get_queue_status(conn, filters=None):
|
||||
params.append(int(filters["sale_id"]))
|
||||
|
||||
if filters.get("type"):
|
||||
where_clauses.append("q.type = %s")
|
||||
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
||||
params.append(filters["type"])
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -376,8 +382,12 @@ def get_queue_status(conn, filters=None):
|
||||
f"""
|
||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||
q.retry_count, q.provisional_folio, q.error_message,
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
|
||||
c.name as customer_name, c.rfc,
|
||||
s.subtotal, s.tax_total, s.total, s.payment_method
|
||||
FROM cfdi_queue q
|
||||
LEFT JOIN sales s ON q.sale_id = s.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE {where}
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@@ -401,6 +411,12 @@ def get_queue_status(conn, filters=None):
|
||||
"created_at": str(r[9]) if r[9] else None,
|
||||
"stamped_at": str(r[10]) if r[10] else None,
|
||||
"external_id": r[11],
|
||||
"customer_name": r[12],
|
||||
"rfc": r[13],
|
||||
"subtotal": float(r[14]) if r[14] else 0,
|
||||
"tax_total": float(r[15]) if r[15] else 0,
|
||||
"total": float(r[16]) if r[16] else 0,
|
||||
"payment_method": r[17],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -58,19 +58,18 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
# Load sale details with items
|
||||
sales = []
|
||||
for sale_id in sale_ids:
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
# Load sale details with items in two bulk queries (O(1) round-trips)
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
FROM sales
|
||||
WHERE id = ANY(%s)
|
||||
ORDER BY created_at ASC
|
||||
""", (sale_ids,))
|
||||
|
||||
sales = {}
|
||||
for row in cur.fetchall():
|
||||
sale = {
|
||||
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||
'employee_id': row[3], 'sale_type': row[4],
|
||||
@@ -85,33 +84,37 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
'created_at': str(row[13]),
|
||||
'items': [],
|
||||
}
|
||||
sales[row[0]] = sale
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||
subtotal, clave_prod_serv, clave_unidad
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
cur.execute("""
|
||||
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||
subtotal, clave_prod_serv, clave_unidad
|
||||
FROM sale_items
|
||||
WHERE sale_id = ANY(%s)
|
||||
ORDER BY sale_id, id
|
||||
""", (sale_ids,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
sale['items'].append({
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||
'name': r[3], 'quantity': r[4],
|
||||
'unit_price': float(r[5]) if r[5] else 0,
|
||||
'unit_cost': float(r[6]) if r[6] else 0,
|
||||
'discount_pct': float(r[7]) if r[7] else 0,
|
||||
'discount_amount': float(r[8]) if r[8] else 0,
|
||||
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||
'tax_amount': float(r[10]) if r[10] else 0,
|
||||
'subtotal': float(r[11]) if r[11] else 0,
|
||||
'clave_prod_serv': r[12] or '25174800',
|
||||
'clave_unidad': r[13] or 'H87',
|
||||
})
|
||||
|
||||
sales.append(sale)
|
||||
for r in cur.fetchall():
|
||||
sale = sales.get(r[1])
|
||||
if not sale:
|
||||
continue
|
||||
sale['items'].append({
|
||||
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
|
||||
'name': r[4], 'quantity': r[5],
|
||||
'unit_price': float(r[6]) if r[6] else 0,
|
||||
'unit_cost': float(r[7]) if r[7] else 0,
|
||||
'discount_pct': float(r[8]) if r[8] else 0,
|
||||
'discount_amount': float(r[9]) if r[9] else 0,
|
||||
'tax_rate': float(r[10]) if r[10] else 0.16,
|
||||
'tax_amount': float(r[11]) if r[11] else 0,
|
||||
'subtotal': float(r[12]) if r[12] else 0,
|
||||
'clave_prod_serv': r[13] or '25174800',
|
||||
'clave_unidad': r[14] or 'H87',
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return sales
|
||||
return list(sales.values())
|
||||
|
||||
|
||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ Tax: 16% IVA per item (from item.tax_rate field).
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import threading
|
||||
from flask import g
|
||||
from services.audit import log_action
|
||||
from services.inventory_engine import (
|
||||
@@ -223,9 +224,9 @@ def process_sale(conn, sale_data):
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Validate register is open
|
||||
# Validate register is open and lock it to prevent concurrent close/sale races
|
||||
if register_id:
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
||||
reg = cur.fetchone()
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
@@ -249,6 +250,17 @@ def process_sale(conn, sale_data):
|
||||
# Batch stock check
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
|
||||
# Lock per-branch stock rows and refresh stock map to prevent overselling
|
||||
# on concurrent sales of the same items.
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock
|
||||
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
||||
FOR UPDATE
|
||||
""", (branch_id, inv_ids))
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
# Validate and enrich items
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
|
||||
@@ -21,10 +21,16 @@ VALID_TRANSITIONS = {
|
||||
|
||||
|
||||
def _generate_order_number(conn):
|
||||
"""Generate SO-YYYY-NNNN order number."""
|
||||
"""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()
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"SO-{year}-"
|
||||
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
|
||||
@@ -37,7 +43,7 @@ def _generate_order_number(conn):
|
||||
last_num = int(row[0].split('-')[-1])
|
||||
new_num = last_num + 1
|
||||
cur.close()
|
||||
return f"{prefix}{new_num:04d}"
|
||||
return f"{prefix}{new_num}"
|
||||
|
||||
|
||||
def create_service_order(conn, data):
|
||||
@@ -46,7 +52,8 @@ def create_service_order(conn, data):
|
||||
data: {
|
||||
customer_id, vehicle_id, branch_id, priority,
|
||||
reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
@@ -56,8 +63,10 @@ def create_service_order(conn, data):
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||
priority, reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %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'),
|
||||
@@ -66,6 +75,7 @@ def create_service_order(conn, data):
|
||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||
data.get('employee_id'), 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),
|
||||
))
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
@@ -86,17 +96,25 @@ def get_service_order(conn, so_id):
|
||||
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,
|
||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||
so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||
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.created_by, so.created_at, so.updated_at
|
||||
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.sale_id
|
||||
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
|
||||
""", (so_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -107,17 +125,23 @@ def get_service_order(conn, so_id):
|
||||
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],
|
||||
'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10],
|
||||
'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13],
|
||||
'repair_notes': row[14], 'delivery_notes': row[15],
|
||||
'estimated_cost': float(row[16]) if row[16] else None,
|
||||
'final_cost': float(row[17]) if row[17] else None,
|
||||
'estimated_completion': str(row[18]) if row[18] else None,
|
||||
'actual_completion': str(row[19]) if row[19] else None,
|
||||
'delivered_at': str(row[20]) if row[20] else None,
|
||||
'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23],
|
||||
'employee_id': row[24], 'employee_name': row[25],
|
||||
'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(row[28]),
|
||||
'customer_address': row[7],
|
||||
'vehicle_id': row[8], 'vehicle_plate': row[9], 'vehicle_make': row[10], 'vehicle_model': row[11],
|
||||
'branch_id': row[12], 'branch_name': row[13], 'branch_address': row[14], 'branch_phone': row[15],
|
||||
'reception_notes': row[16], 'diagnosis_notes': row[17],
|
||||
'repair_notes': row[18], 'delivery_notes': row[19],
|
||||
'estimated_cost': float(row[20]) if row[20] else None,
|
||||
'final_cost': float(row[21]) if row[21] else None,
|
||||
'estimated_completion': str(row[22]) if row[22] else None,
|
||||
'actual_completion': str(row[23]) if row[23] else None,
|
||||
'delivered_at': str(row[24]) if row[24] else None,
|
||||
'mileage_in': row[25], 'mileage_out': row[26], 'fuel_level': row[27],
|
||||
'employee_id': row[28], 'employee_name': row[29],
|
||||
'created_by': row[30], 'created_by_name': row[31],
|
||||
'created_at': str(row[32]), 'updated_at': str(row[33]),
|
||||
'delivery_method': row[34], 'courier_id': row[35], 'courier_name': row[36],
|
||||
'is_direct': bool(row[37]) if row[37] is not None else False,
|
||||
'sale_id': row[38],
|
||||
}
|
||||
|
||||
# Items
|
||||
@@ -128,14 +152,18 @@ def get_service_order(conn, so_id):
|
||||
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': float(r[4]) if r[4] else 0,
|
||||
'quantity': qty,
|
||||
'unit_cost': float(r[5]) if r[5] else None,
|
||||
'unit_price': float(r[6]) if r[6] else None,
|
||||
'unit_price': price,
|
||||
'status': r[7], 'notes': r[8],
|
||||
})
|
||||
total_parts += qty * price
|
||||
|
||||
# Labor
|
||||
cur.execute("""
|
||||
@@ -145,27 +173,37 @@ def get_service_order(conn, so_id):
|
||||
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': float(r[4]) if r[4] 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 id, old_status, new_status, changed_by, notes, created_at
|
||||
FROM service_order_status_history
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY created_at
|
||||
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], 'notes': r[4], 'created_at': str(r[5]),
|
||||
'changed_by': r[3], 'changed_by_name': r[4],
|
||||
'notes': r[5], 'created_at': str(r[6]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -173,7 +211,8 @@ def get_service_order(conn, so_id):
|
||||
|
||||
|
||||
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
priority=None, employee_id=None, page=1, per_page=50):
|
||||
priority=None, employee_id=None, delivery_method=None,
|
||||
is_direct=None, q=None, page=1, per_page=50):
|
||||
cur = conn.cursor()
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -193,22 +232,41 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
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)")
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders so WHERE {where}
|
||||
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,
|
||||
so.estimated_cost, so.estimated_completion, so.created_at
|
||||
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.sale_id, so.created_at,
|
||||
creator.name as created_by_name
|
||||
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
|
||||
WHERE {where}
|
||||
ORDER BY
|
||||
CASE so.priority
|
||||
@@ -223,13 +281,21 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
|
||||
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],
|
||||
'estimated_cost': float(r[8]) if r[8] else None,
|
||||
'estimated_completion': str(r[9]) if r[9] else None,
|
||||
'created_at': str(r[10]),
|
||||
'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,
|
||||
'sale_id': r[17], 'created_at': str(r[18]),
|
||||
'created_by_name': r[19],
|
||||
'total': round(final or estimated, 2),
|
||||
'paid': 0.0, # to be computed if needed
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -390,7 +456,8 @@ def update_service_order(conn, so_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||
'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
|
||||
'employee_id', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -454,7 +521,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
|
||||
so.order_number
|
||||
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
|
||||
@@ -466,7 +533,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
||||
cur.close()
|
||||
raise ValueError("Service order item not found")
|
||||
|
||||
so_id, inventory_id, quantity, status, order_number = row
|
||||
so_id, inventory_id, quantity, status, order_number, branch_id = row
|
||||
if status == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot reserve a cancelled item")
|
||||
|
||||
@@ -247,7 +247,8 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
||||
'accounting.view', 'accounting.create', 'accounting.close',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'
|
||||
]
|
||||
tenant_cur.executemany(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
||||
|
||||
Reference in New Issue
Block a user