FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
@@ -17,8 +17,19 @@ from services.inventory_engine import (
|
||||
record_sale as inventory_record_sale,
|
||||
record_operation,
|
||||
get_stock,
|
||||
get_stock_bulk,
|
||||
)
|
||||
from services.accounting_engine import record_sale_entry, record_cancellation_entry
|
||||
from services.currency import convert, to_mxn, get_exchange_rate
|
||||
from services.savings_engine import record_sale_savings
|
||||
|
||||
|
||||
def _safe_g(attr, default=None):
|
||||
"""Safely read flask.g attribute outside of app context."""
|
||||
try:
|
||||
return getattr(g, attr, default)
|
||||
except RuntimeError:
|
||||
return default
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
@@ -182,8 +193,18 @@ def process_sale(conn, sale_data):
|
||||
amount_paid = float(sale_data.get('amount_paid', 0))
|
||||
payment_details = sale_data.get('payment_details', [])
|
||||
notes = sale_data.get('notes')
|
||||
branch_id = getattr(g, 'branch_id', None)
|
||||
employee_id = getattr(g, 'employee_id', None)
|
||||
branch_id = _safe_g('branch_id')
|
||||
employee_id = _safe_g('employee_id')
|
||||
|
||||
# ── Multi-currency support ───────────────────────────────────────────
|
||||
currency = sale_data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
raise ValueError(f"Unsupported currency: {currency}. Only MXN and USD are supported.")
|
||||
|
||||
exchange_rate = sale_data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
@@ -195,7 +216,26 @@ def process_sale(conn, sale_data):
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
|
||||
# Validate and enrich items from inventory
|
||||
# ─── Batch preload: inventory items + stock + customer credit ─────────
|
||||
inv_ids = [item.get('inventory_id') for item in items]
|
||||
if not inv_ids:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Lock inventory rows to prevent race conditions on concurrent sales
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
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()}
|
||||
|
||||
# Batch stock check
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
|
||||
# Validate and enrich items
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
@@ -203,17 +243,11 @@ def process_sale(conn, sale_data):
|
||||
if qty <= 0:
|
||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id
|
||||
FROM inventory WHERE id = %s AND is_active = true
|
||||
""", (inv_id,))
|
||||
inv = cur.fetchone()
|
||||
inv = inv_rows.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
|
||||
# Check stock (allow negative stock for offline tolerance, but warn)
|
||||
current_stock = get_stock(conn, inv_id, inv[8]) # inv[8] = branch_id
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
|
||||
# Use provided price or fetch from inventory
|
||||
unit_price = float(item.get('unit_price', inv[4])) # default to price_1
|
||||
@@ -222,8 +256,8 @@ def process_sale(conn, sale_data):
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
|
||||
# Validate discount against employee max
|
||||
max_discount = float(getattr(g, 'max_discount_pct', 100) or 100)
|
||||
if g.employee_role not in ('owner', 'admin') and discount_pct > max_discount:
|
||||
max_discount = float(_safe_g('max_discount_pct', 100) or 100)
|
||||
if _safe_g('employee_role', 'cashier') not in ('owner', 'admin') and discount_pct > max_discount:
|
||||
raise ValueError(
|
||||
f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
|
||||
f"for item {inv[2]}"
|
||||
@@ -245,9 +279,9 @@ def process_sale(conn, sale_data):
|
||||
# Calculate totals
|
||||
totals = calculate_totals(enriched_items)
|
||||
|
||||
# Validate credit sale
|
||||
# Validate credit sale (with row lock to prevent race conditions)
|
||||
if sale_type == 'credit' and customer_id:
|
||||
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
||||
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s FOR UPDATE", (customer_id,))
|
||||
cust = cur.fetchone()
|
||||
if cust:
|
||||
credit_limit = float(cust[0] or 0)
|
||||
@@ -271,54 +305,67 @@ def process_sale(conn, sale_data):
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
# Create sale record
|
||||
# Create sale record (with currency)
|
||||
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, device_id, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s)
|
||||
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)
|
||||
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,
|
||||
getattr(g, 'device_id', None), notes
|
||||
_safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
# Create sale items and deduct inventory
|
||||
sale_items = []
|
||||
for idx, item in enumerate(totals['items']):
|
||||
cur.execute("""
|
||||
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)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
# Create sale items (batch insert) and deduct inventory
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
# Fetch retail_price for savings calculation
|
||||
cur.execute("SELECT retail_price FROM inventory WHERE id = %s", (item['inventory_id'],))
|
||||
rp_row = cur.fetchone()
|
||||
retail_price = rp_row[0] if rp_row 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']
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
||||
retail_price
|
||||
))
|
||||
sale_item_id = cur.fetchone()[0]
|
||||
|
||||
# Deduct inventory via inventory_engine (NEVER create operations directly)
|
||||
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)
|
||||
""", [row + (currency, exchange_rate) for row in sale_items_data])
|
||||
|
||||
# Deduct inventory via inventory_engine
|
||||
sale_items = []
|
||||
for item in totals['items']:
|
||||
# Pre-calculate remaining stock to avoid redundant get_stock() call
|
||||
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_sale(
|
||||
conn,
|
||||
item['inventory_id'],
|
||||
item.get('branch_id', branch_id),
|
||||
item['quantity'],
|
||||
sale_id=sale_id,
|
||||
cost_at_time=item.get('unit_cost')
|
||||
cost_at_time=item.get('unit_cost'),
|
||||
remaining_stock=remaining_after
|
||||
)
|
||||
|
||||
sale_items.append({
|
||||
'id': sale_item_id,
|
||||
'inventory_id': item['inventory_id'],
|
||||
'part_number': item['part_number'],
|
||||
'name': item['name'],
|
||||
@@ -340,15 +387,15 @@ def process_sale(conn, sale_data):
|
||||
ref = pd.get('reference', '')
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref))
|
||||
(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)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', '')))
|
||||
(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))
|
||||
|
||||
# Update customer credit balance if credit sale
|
||||
if sale_type == 'credit' and customer_id:
|
||||
@@ -371,19 +418,29 @@ def process_sale(conn, sale_data):
|
||||
cur.close()
|
||||
|
||||
# Auto-generate accounting entry (non-blocking)
|
||||
# Accounting is always in MXN — convert if sale was in another currency
|
||||
try:
|
||||
total_mxn = to_mxn(totals['total'], currency, rate=exchange_rate, conn=conn)
|
||||
tax_mxn = to_mxn(totals['tax_total'], currency, rate=exchange_rate, conn=conn)
|
||||
sub_mxn = to_mxn(totals['subtotal'] - totals['discount_total'], currency, rate=exchange_rate, conn=conn)
|
||||
record_sale_entry(conn, {
|
||||
'id': sale_id,
|
||||
'sale_type': sale_type,
|
||||
'total': totals['total'],
|
||||
'tax_total': totals['tax_total'],
|
||||
'subtotal': totals['subtotal'] - totals['discount_total'],
|
||||
'total': total_mxn,
|
||||
'tax_total': tax_mxn,
|
||||
'subtotal': sub_mxn,
|
||||
'cost_total': sum(item.get('unit_cost', 0) * item['quantity'] for item in enriched_items),
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass # Accounting errors never block sales
|
||||
|
||||
# Calculate and record savings vs retail price (non-blocking)
|
||||
try:
|
||||
record_sale_savings(conn, sale_id)
|
||||
except Exception:
|
||||
pass # Savings errors never block sales
|
||||
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
@@ -403,6 +460,8 @@ def process_sale(conn, sale_data):
|
||||
'status': 'completed',
|
||||
'items': sale_items,
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
}
|
||||
|
||||
|
||||
@@ -448,8 +507,8 @@ def cancel_sale(conn, sale_id, reason):
|
||||
raise ValueError("Sale is already cancelled")
|
||||
|
||||
# Permission check: cashiers can only cancel own sales within 30 min
|
||||
role = getattr(g, 'employee_role', 'cashier')
|
||||
emp_id = getattr(g, 'employee_id', None)
|
||||
role = _safe_g('employee_role', 'cashier')
|
||||
emp_id = _safe_g('employee_id')
|
||||
|
||||
if role == 'cashier':
|
||||
if s_emp_id != emp_id:
|
||||
@@ -513,7 +572,7 @@ def cancel_sale(conn, sale_id, reason):
|
||||
# Push notification to owner/admin (best-effort, non-blocking)
|
||||
try:
|
||||
from services.push_service import notify_owner
|
||||
emp_name = getattr(g, 'employee_name', 'Empleado')
|
||||
emp_name = _safe_g('employee_name', 'Empleado')
|
||||
notify_owner(
|
||||
conn,
|
||||
'Venta Cancelada',
|
||||
|
||||
Reference in New Issue
Block a user