Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user