Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -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 logging
import threading
from flask import g
from services.audit import log_action
@@ -26,6 +27,8 @@ from services.accounting_engine import record_sale_entry, record_cancellation_en
from services.currency import convert, to_mxn, get_exchange_rate
from services.savings_engine import record_sale_savings
logger = logging.getLogger('pos')
def _safe_g(attr, default=None):
"""Safely read flask.g attribute outside of app context."""
@@ -40,6 +43,122 @@ def _to_dec(val):
return Decimal(str(val))
def get_max_discount_pct(conn, employee_id=None):
"""Resolve the max discount % the current employee is allowed to grant.
Prefers the value carried on flask.g (set from the JWT at login); falls
back to the employees table when it is absent (e.g. tokens issued before
the field was added). A NULL/0 limit means "no limit" (100%), matching
the historical behaviour of the sale flow.
"""
limit = _safe_g('max_discount_pct')
if limit in (None, ''):
employee_id = employee_id or _safe_g('employee_id')
if employee_id:
cur = conn.cursor()
cur.execute("SELECT max_discount_pct FROM employees WHERE id = %s", (employee_id,))
row = cur.fetchone()
cur.close()
limit = row[0] if row else None
return float(limit or 100)
def _credit_scoring_enforced(conn):
"""Tenant opt-in flag for blocking credit sales to 'risk' customers (A9).
Reads tenant_config.credit_scoring_enforce. Defaults to False so the
existing sale flow is unchanged unless a tenant explicitly enables it.
"""
try:
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'credit_scoring_enforce'")
row = cur.fetchone()
cur.close()
return bool(row) and str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
except Exception:
return False
# Used/rebuilt pieces sold by a Yonke get a warranty expressed in days (the
# yonke module never uses month-based coverage).
YONKE_WARRANTY_CONDITIONS = ('usado', 'reconstruido')
def _get_yonke_default_warranty_days(conn):
"""Read tenant_config.yonke_default_warranty_days (None when unset/blank).
The yonke module stores the coverage as a tenant-wide default (see
yonke_service._store_default_warranty_days); per-piece overrides travel on
the sale line. Raises if tenant_config is unavailable — the caller runs
this inside a SAVEPOINT so it can never abort the sale.
"""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'yonke_default_warranty_days'")
row = cur.fetchone()
cur.close()
if not row or row[0] is None or str(row[0]).strip() == '':
return None
try:
return int(str(row[0]).strip())
except (TypeError, ValueError):
return None
def _register_yonke_warranties(conn, sale_id, sold_items, customer_id):
"""Auto-register day-based warranties for used/rebuilt items in a sale.
Coverage comes from the line's `warranty_days` when present, otherwise from
the tenant default. A piece with no configured coverage is skipped. The
caller wraps this in a SAVEPOINT so a failure never breaks the sale.
Returns the number of warranties registered.
"""
used_items = [
it for it in sold_items
if str(it.get('condition') or '').strip().lower() in YONKE_WARRANTY_CONDITIONS
]
if not used_items:
return 0
default_days = _get_yonke_default_warranty_days(conn)
# sale_items were inserted in `sold_items` order; pair them back by id so
# each warranty points at its sale line.
cur = conn.cursor()
cur.execute("SELECT id FROM sale_items WHERE sale_id = %s ORDER BY id", (sale_id,))
sale_item_ids = [r[0] for r in cur.fetchall()]
cur.close()
from services.warranty_engine import register_warranty
registered = 0
for line_no, item in enumerate(sold_items):
if str(item.get('condition') or '').strip().lower() not in YONKE_WARRANTY_CONDITIONS:
continue
if line_no >= len(sale_item_ids):
break
days = item.get('warranty_days') or default_days
try:
days = int(days)
except (TypeError, ValueError):
continue
if days <= 0:
continue
register_warranty(
conn,
sale_id=sale_id,
sale_item_id=sale_item_ids[line_no],
inventory_id=item['inventory_id'],
customer_id=customer_id,
warranty_months=0,
part_number=item.get('part_number'),
name=item.get('name'),
notes='Garantía Yonke (pieza usada)',
warranty_days=days,
)
registered += 1
return registered
def calculate_totals(items):
"""Compute subtotal, discount amounts, tax, and total for a list of items.
@@ -241,7 +360,7 @@ def process_sale(conn, sale_data):
# 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, retail_price
tax_rate, branch_id, retail_price, condition
FROM inventory
WHERE id = ANY(%s) AND is_active = true
ORDER BY id
@@ -264,6 +383,7 @@ def process_sale(conn, sale_data):
stock_map = {r[0]: r[1] for r in cur.fetchall()}
# Validate and enrich items
max_discount = get_max_discount_pct(conn, employee_id)
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
@@ -284,7 +404,6 @@ def process_sale(conn, sale_data):
unit_cost = float(inv[3]) if inv[3] else 0
# Validate discount against employee max
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}% "
@@ -302,6 +421,10 @@ def process_sale(conn, sale_data):
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
# condition drives the Yonke day-based warranty hook below;
# warranty_days lets a single line override the tenant default.
'condition': (inv[10] or '').strip().lower(),
'warranty_days': item.get('warranty_days'),
})
# Calculate totals
@@ -321,6 +444,27 @@ def process_sale(conn, sale_data):
f"Required: ${totals['total']:.2f}"
)
# Optional credit-risk enforcement (A9). Off unless the tenant sets
# tenant_config.credit_scoring_enforce = true. Never break the sale on
# scoring errors — only block when we positively classify 'risk'.
if _credit_scoring_enforced(conn):
try:
from services.credit_scoring import score_customer
_cs = score_customer(conn, customer_id)
except Exception as exc:
logger.warning("credit scoring failed for customer %s: %s", customer_id, exc)
_cs = None
if _cs and _cs.get('tier') == 'risk':
reasons = _cs.get('reasons') or []
detail = next(
(r for r in reasons if 'Mora' in r or 'atraso' in r or 'excedido' in r),
reasons[-1] if reasons else '',
)
raise ValueError(
f"Venta a crédito bloqueada: cliente en riesgo "
f"(score {_cs['score']}/100). {detail}"
)
# Pending payment sale (e.g. "Pendiente")
is_pending = payment_method == 'pendiente'
if is_pending:
@@ -349,8 +493,8 @@ def process_sale(conn, sale_data):
(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, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
status, device_id, notes, currency, exchange_rate, courier_id)
VALUES (%s,%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,
@@ -358,7 +502,7 @@ def process_sale(conn, sale_data):
totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat,
status, _safe_g('device_id'), notes,
currency, exchange_rate
currency, exchange_rate, sale_data.get('courier_id')
))
sale_id, created_at = cur.fetchone()
@@ -467,6 +611,33 @@ def process_sale(conn, sale_data):
cur.close()
# Yonke warranty hook (non-blocking). Every sold used/rebuilt piece gets a
# day-based warranty from tenant_config.yonke_default_warranty_days (or the
# line's warranty_days). SAVEPOINT-isolated like the other hooks: a missing
# table or an invalid config only reverts this hook, never the sale. Kept
# before the accounting/savings hooks because record_sale_savings commits —
# that way the warranty lands in the same transaction as the sale.
try:
_cur = conn.cursor()
_cur.execute("SAVEPOINT sp_yonke_warranty")
_cur.close()
try:
registered = _register_yonke_warranties(
conn, sale_id, totals['items'], customer_id
)
_c = conn.cursor()
_c.execute("RELEASE SAVEPOINT sp_yonke_warranty")
_c.close()
if registered:
logger.info("Registered %s Yonke warranty(ies) for sale #%s", registered, sale_id)
except Exception as w_err:
_c = conn.cursor()
_c.execute("ROLLBACK TO SAVEPOINT sp_yonke_warranty")
_c.close()
logger.warning("Yonke warranty hook for sale #%s failed: %s", sale_id, w_err)
except Exception:
pass # Warranty errors never block sales
# Auto-generate accounting entry (non-blocking)
# Accounting is always in MXN — convert if sale was in another currency
try:
@@ -482,8 +653,10 @@ def process_sale(conn, sale_data):
'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
except Exception as acc_err:
# Accounting errors never block sales, but they must never be silent:
# a lost entry is a hole in the ledger that only shows up at audit time.
logger.error(f'Accounting entry for sale #{sale_id} failed: {acc_err}')
# Calculate and record savings vs retail price (non-blocking)
try:
@@ -491,10 +664,70 @@ def process_sale(conn, sale_data):
except Exception:
pass # Savings errors never block sales
# WhatsApp learning hook (non-blocking)
# Auto-invoice hook (non-blocking): enqueue CFDI when the tenant has
# automatic invoicing on, or when the cashier explicitly requested it
# with the "Facturar CFDI" checkbox (generate_cfdi). Either way the
# customer must have an RFC.
if customer_id and status == 'completed':
try:
generate_cfdi = bool(sale_data.get('generate_cfdi'))
cur_ai = conn.cursor()
cur_ai.execute("SELECT value FROM tenant_config WHERE key = 'invoice_auto'")
row_ai = cur_ai.fetchone()
cur_ai.execute(
"SELECT rfc, razon_social, email, uso_cfdi, cp FROM customers WHERE id = %s",
(customer_id,))
cust = cur_ai.fetchone()
cur_ai.close()
auto_invoice = bool(row_ai and (row_ai[0] or '').lower() == 'true')
if (generate_cfdi or auto_invoice) and cust and cust[0]:
customer_rec = {
'rfc': cust[0], 'razon_social': cust[1] or '',
'name': customer_name or cust[1], 'email': cust[2],
'uso_cfdi': cust[3], 'cp': cust[4],
}
from services.cfdi_facturapi_builder import build_ingreso_payload
from services.cfdi_queue import enqueue_cfdi
tcfg = {'cp': cust[4] or '00000', 'serie': 'A'}
sale_for_cfdi = {
'id': sale_id, 'items': enriched_items,
'payment_method': payment_method, 'sale_type': sale_type,
'currency': currency, 'exchange_rate': exchange_rate,
}
payload = build_ingreso_payload(sale_for_cfdi, tcfg, customer_rec)
enqueue_cfdi(conn, sale_id, 'ingreso', payload)
except Exception:
pass # invoicing errors never block sales
# Loyalty points hook (non-blocking): 1 point per $10 MXN on completed sales
if customer_id and status == 'completed':
try:
from services.crm_engine import add_loyalty_points
points = int(totals['total'] // 10)
if points > 0:
add_loyalty_points(conn, customer_id, points, points_type='earned',
source_type='sale', source_id=sale_id,
description=f'Venta #{sale_id}')
except Exception:
pass # Loyalty errors never block sales
# WhatsApp learning hook (non-blocking). Se aísla en un SAVEPOINT: si falla
# (p.ej. tabla ausente en algún tenant) se revierte SOLO el hook. Sin esto, un
# error SQL deja la transacción abortada y el commit() posterior pierde la venta.
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale_id, customer_id, conn)
_cur = conn.cursor()
_cur.execute("SAVEPOINT sp_wa_learning")
_cur.close()
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale_id, customer_id, conn)
_c = conn.cursor()
_c.execute("RELEASE SAVEPOINT sp_wa_learning")
_c.close()
except Exception:
_c = conn.cursor()
_c.execute("ROLLBACK TO SAVEPOINT sp_wa_learning")
_c.close()
except Exception:
pass # Learning errors never block sales
@@ -550,6 +783,7 @@ def process_sale(conn, sale_data):
'created_at': str(created_at),
'currency': currency,
'exchange_rate': exchange_rate,
'courier_id': sale_data.get('courier_id'),
}
@@ -868,8 +1102,8 @@ def pay_pending_sale(conn, sale_id, data):
'cost_total': cost_total,
'payment_method': payment_method,
})
except Exception:
pass
except Exception as acc_err:
logger.error(f'Accounting entry for paid remission #{sale_id} failed: {acc_err}')
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
old_value={'status': 'pending_payment', 'total': totals['total']},
@@ -986,8 +1220,9 @@ def cancel_sale(conn, sale_id, reason):
'sale_type': _sale_row[3] or 'cash',
'payment_method': _sale_row[4] or 'efectivo',
})
except Exception:
pass # Accounting errors never block cancellations
except Exception as acc_err:
# Accounting errors never block cancellations, but must be logged
logger.error(f'Accounting reversal for cancelled sale #{sale_id} failed: {acc_err}')
# Reverse customer credit balance if credit sale
if s_type == 'credit' and s_cust_id: