feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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).
This commit is contained in:
2026-07-02 12:51:56 +00:00
parent 483498cfcc
commit f42910f4f6
71 changed files with 5388 additions and 626 deletions

View File

@@ -16,6 +16,8 @@ from flask import g
from services.audit import log_action
from services.inventory_engine import (
record_sale as inventory_record_sale,
record_reservation as inventory_record_reservation,
release_reservation as inventory_release_reservation,
record_operation,
get_stock,
get_stock_bulk,
@@ -313,24 +315,34 @@ def process_sale(conn, sale_data):
credit_limit = float(cust[0] or 0)
credit_balance = float(cust[1] or 0)
credit_available = credit_limit - credit_balance
if totals['total'] > credit_available and credit_limit > 0:
if totals['total'] > credit_available:
raise ValueError(
f"Insufficient credit. Available: ${credit_available:.2f}, "
f"Required: ${totals['total']:.2f}"
)
# Pending payment sale (e.g. "Pendiente")
is_pending = payment_method == 'pendiente'
if is_pending:
amount_paid = 0.0
sale_type = 'cash'
# Calculate change
change_given = 0.0
if sale_type == 'cash' and payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
# SAT payment method codes
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
forma_pago_map = {
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
# Determine sale status
status = 'pending_payment' if is_pending else 'completed'
# Create sale record (with currency)
cur.execute("""
INSERT INTO sales
@@ -338,14 +350,14 @@ def process_sale(conn, sale_data):
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, device_id, notes, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, totals['subtotal'], totals['discount_total'],
totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat,
_safe_g('device_id'), notes,
status, _safe_g('device_id'), notes,
currency, exchange_rate
))
sale_id, created_at = cur.fetchone()
@@ -404,23 +416,26 @@ def process_sale(conn, sale_data):
'subtotal': item['subtotal'],
})
# Record payment on cash register (cash movements for efectivo)
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
# Record payment on cash register (skip pending sales and zero-amount rows)
if not is_pending:
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
if amt <= 0:
continue
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id and amount_paid > 0:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
# Update customer credit balance if credit sale
if sale_type == 'credit' and customer_id:
@@ -430,6 +445,16 @@ def process_sale(conn, sale_data):
WHERE id = %s
""", (totals['total'], customer_id))
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
# Audit log
log_action(conn, 'SALE', 'sale', sale_id,
new_value={
@@ -506,6 +531,8 @@ def process_sale(conn, sale_data):
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': sale_type,
@@ -526,6 +553,342 @@ def process_sale(conn, sale_data):
}
def create_remission_note(conn, data):
"""Create a counter remission note: reserve stock, no payment, pending status.
data: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
notes: str,
branch_id: int,
register_id: int | null (optional)
}
"""
cur = conn.cursor()
items = data.get('items', [])
customer_id = data.get('customer_id')
notes = data.get('notes')
branch_id = data.get('branch_id') or _safe_g('branch_id')
register_id = data.get('register_id')
employee_id = _safe_g('employee_id')
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
raise ValueError("Unsupported currency")
exchange_rate = float(data.get('exchange_rate') or 1.0)
if currency != 'MXN' and not exchange_rate:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
if not branch_id:
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
row = cur.fetchone()
branch_id = row[0] if row else None
if not branch_id:
raise ValueError("No hay sucursal activa disponible")
if not items:
raise ValueError("No items in remission note")
inv_ids = [item.get('inventory_id') for item in items]
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id, retail_price
FROM inventory
WHERE id = ANY(%s) AND is_active = true
ORDER BY id
FOR UPDATE
""", (inv_ids,))
inv_rows = {r[0]: r for r in cur.fetchall()}
stock_map = get_stock_bulk(conn, branch_id)
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()}
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
inv = inv_rows.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
current_stock = stock_map.get(inv_id, 0)
unit_price = float(item.get('unit_price', inv[4]))
discount_pct = float(item.get('discount_pct', 0))
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
enriched_items.append({
'inventory_id': inv_id,
'part_number': inv[1],
'name': inv[2],
'quantity': qty,
'unit_price': unit_price,
'unit_cost': unit_cost,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
})
totals = calculate_totals(enriched_items)
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, status, device_id, notes, currency, exchange_rate,
courier_id)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
%s, %s, %s, %s, 0, 0, 'pending_payment',
%s, %s, %s, %s, %s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
_safe_g('device_id'), notes, currency, exchange_rate,
data.get('courier_id')
))
sale_id, created_at = cur.fetchone()
sale_items_data = []
for item in totals['items']:
inv = inv_rows.get(item['inventory_id'])
retail_price = inv[9] if inv else None
sale_items_data.append((
sale_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal'],
retail_price, currency, exchange_rate
))
cur.executemany("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", sale_items_data)
sale_items = []
for item in totals['items']:
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
remaining_after = stock_before - item['quantity']
inventory_record_reservation(
conn,
item['inventory_id'],
item.get('branch_id', branch_id),
item['quantity'],
sale_id=sale_id,
cost_at_time=item.get('unit_cost'),
remaining_stock=remaining_after
)
sale_items.append({
'inventory_id': item['inventory_id'],
'part_number': item['part_number'],
'name': item['name'],
'quantity': item['quantity'],
'unit_price': item['unit_price'],
'unit_cost': item.get('unit_cost', 0),
'discount_pct': item['discount_pct'],
'discount_amount': item['discount_amount'],
'tax_rate': item['tax_rate'],
'tax_amount': item['tax_amount'],
'subtotal': item['subtotal'],
})
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
new_value={
'total': totals['total'],
'items_count': len(sale_items),
'customer_id': customer_id,
})
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
cur.close()
return {
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': 'counter_remission',
'payment_method': 'remission',
'subtotal': totals['subtotal'],
'discount_total': totals['discount_total'],
'tax_total': totals['tax_total'],
'total': totals['total'],
'amount_paid': 0.0,
'change_given': 0.0,
'status': 'pending_payment',
'courier_id': data.get('courier_id'),
'items': sale_items,
'created_at': str(created_at),
'currency': currency,
'exchange_rate': exchange_rate,
}
def pay_pending_sale(conn, sale_id, data):
"""Pay a pending counter remission note and convert it into a completed sale.
data: {
payment_method: str,
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
cur = conn.cursor()
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate
FROM sales WHERE id = %s
FOR UPDATE
""", (sale_id,))
row = cur.fetchone()
if not row:
raise ValueError("Sale not found")
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate) = row
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
if status != 'pending_payment':
raise ValueError("La nota no esta pendiente de pago")
payment_method = data.get('payment_method', 'efectivo')
sale_type = 'cash'
amount_paid = float(data.get('amount_paid', 0))
payment_details = data.get('payment_details', [])
register_id = data.get('register_id')
reference = data.get('reference', '')
if 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")
totals = {'total': float(total)}
change_given = 0.0
if payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
cur.execute("""
UPDATE sales
SET status = 'completed',
sale_type = %s,
payment_method = %s,
amount_paid = %s,
change_given = %s,
register_id = COALESCE(%s, register_id),
metodo_pago_sat = 'PUE',
forma_pago_sat = %s
WHERE id = %s
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
if payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
else:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
# Release reservation and record actual sale movement
cur.execute("""
SELECT inventory_id, quantity, unit_cost
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for inv_id, qty, cost in cur.fetchall():
inventory_release_reservation(
conn, inv_id, branch_id, qty, sale_id=sale_id,
notes='Pago de nota de remision'
)
inventory_record_sale(
conn, inv_id, branch_id, qty,
sale_id=sale_id, cost_at_time=float(cost) if cost else None
)
# Accounting (non-blocking)
try:
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
cur.execute("""
SELECT COALESCE(SUM(unit_cost * quantity), 0)
FROM sale_items WHERE sale_id = %s
""", (sale_id,))
cost_total = float(cur.fetchone()[0] or 0)
record_sale_entry(conn, {
'id': sale_id,
'sale_type': sale_type,
'total': total_mxn,
'tax_total': tax_mxn,
'subtotal': sub_mxn,
'cost_total': cost_total,
'payment_method': payment_method,
})
except Exception:
pass
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
old_value={'status': 'pending_payment', 'total': totals['total']},
new_value={'status': 'completed', 'payment_method': payment_method})
cur.close()
return {
'id': sale_id,
'status': 'completed',
'payment_method': payment_method,
'amount_paid': amount_paid,
'change_given': change_given,
'total': totals['total'],
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
}
def cancel_sale(conn, sale_id, reason):
"""Cancel a sale: validate permissions, reverse inventory, update credit.
@@ -567,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
if s_status == 'cancelled':
raise ValueError("Sale is already cancelled")
# Permission check: cashiers can only cancel own sales within 30 min
# Permission check: non-admin employees can only cancel their own docs within 30 min
role = _safe_g('employee_role', 'cashier')
emp_id = _safe_g('employee_id')
if role == 'cashier':
if role not in ('owner', 'admin'):
if s_emp_id != emp_id:
raise ValueError("Cashiers can only cancel their own sales")
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
# Get sale items for inventory reversal
cur.execute("""
@@ -584,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
""", (sale_id,))
sale_items = cur.fetchall()
# Reverse inventory: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
if s_status == 'pending_payment':
# Pending remission: release reservation
for inv_id, qty, cost in sale_items:
inventory_release_reservation(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
)
else:
# Completed sale: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
# Update sale status
cur.execute("""
@@ -627,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
# Audit log
log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': 'completed', 'total': float(s_total)},
old_value={'status': s_status, 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
# Push notification to owner/admin (best-effort, non-blocking)