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

@@ -13,6 +13,7 @@ Account codes (from SAT seed):
140 = IVA Acreditable
210 = Proveedores
220 = IVA Trasladado
240 = Anticipos de Clientes (created on demand for layaways)
410 = Ventas
420 = Devoluciones sobre Ventas
510 = Costo de Mercancia Vendida
@@ -20,10 +21,13 @@ Account codes (from SAT seed):
All functions receive a psycopg2 connection (caller controls commit).
"""
import logging
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from flask import g
logger = logging.getLogger('accounting')
def _to_dec(val):
"""Convert a value to Decimal for precise arithmetic."""
@@ -57,6 +61,48 @@ def _get_account_ids(cur, codes):
return rows
def _ensure_account(cur, code, name, parent_code, acc_type):
"""Return the account ID for a code, creating it if it is missing.
Tenants seeded before an auxiliary account (e.g. customer advances) was
introduced do not have it in their chart of accounts, so it is created
on first use. Creation is idempotent (accounts.code is unique).
Args:
cur: psycopg2 cursor
code: account code to look up / create
name: account name used when creating
parent_code: code of the parent account (None for roots)
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
Returns:
int: account ID
"""
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
row = cur.fetchone()
if row:
return row[0]
parent_id = None
if parent_code:
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
parent = cur.fetchone()
parent_id = parent[0] if parent else None
cur.execute("""
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
VALUES (%s, %s, %s, %s, %s, false, true)
ON CONFLICT (code) DO NOTHING
RETURNING id
""", (code, name, parent_id, acc_type, code))
row = cur.fetchone()
if row:
return row[0]
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
return cur.fetchone()[0]
def get_next_entry_number(conn):
"""Get the next sequential journal entry number.
@@ -576,6 +622,434 @@ def record_cancellation_entry(conn, sale):
return entry_id
def record_return_entry(conn, ret):
"""Generate the journal entry for a customer return (devolucion).
Reverses the revenue side of the original sale and puts the returned
goods back into inventory:
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
Debit 220 IVA Trasladado = tax
Credit 110/111/120 (refund account) = net + tax
Debit 130 Inventarios = cost
Credit 510 Costo de Mercancia Vendida = cost
The net/tax/cost split is read from return_items joined with sale_items,
so callers only need the return id and the original sale.
Args:
conn: psycopg2 connection to tenant DB
ret: dict with keys:
id: return id
sale_id: original sale id (optional)
sale_type: 'cash' | 'credit' (optional, looked up from sales)
payment_method: original payment method (optional, looked up)
total: refund total (used only when the item detail is missing)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
return_id = ret['id']
sale_id = ret.get('sale_id')
sale_type = ret.get('sale_type')
payment_method = ret.get('payment_method')
cur.execute("""
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
""", (return_id,))
item_rows = cur.fetchall()
if sale_id and (sale_type is None or payment_method is None):
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
sale_row = cur.fetchone()
if sale_row:
sale_type = sale_type or sale_row[0]
payment_method = payment_method or sale_row[1]
net = Decimal('0')
tax = Decimal('0')
cost_total = Decimal('0')
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
qty = int(quantity or 0)
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
).quantize(TWO, ROUND_HALF_UP)
net += line_net
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
if net == 0 and tax == 0:
# No item detail available: fall back to the refund total (no IVA split)
net = _to_dec(ret.get('total', 0))
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
if sale_type == 'credit':
refund_account_id = accounts['120'] # Clientes
refund_desc = f'Clientes - Devolucion #{return_id}'
elif payment_method in ('transferencia', 'tarjeta'):
refund_account_id = accounts['111'] # Bancos
refund_desc = f'Bancos - Devolucion #{return_id}'
else:
refund_account_id = accounts['110'] # Caja
refund_desc = f'Caja - Devolucion #{return_id}'
lines = [
{
'account_id': accounts['420'],
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
},
{
'account_id': refund_account_id,
'debit': 0,
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
'description': refund_desc,
},
]
if tax > 0:
lines.append({
'account_id': accounts['220'],
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'IVA Trasladado - Devolucion #{return_id}',
})
if cost_total > 0:
lines.append({
'account_id': accounts['130'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Inventarios - Devolucion #{return_id}',
})
lines.append({
'account_id': accounts['510'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Costo mercancia - Devolucion #{return_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
'return', return_id, lines
)
cur.close()
return entry_id
def record_layaway_entry(conn, layaway):
"""Generate the journal entry for a layaway (apartado) payment.
A layaway deposit is money held on behalf of the customer, not revenue,
so it is recorded as a customer advance:
Debit 110 Caja (or 111 Bancos) = amount
Credit 240 Anticipos de Clientes = amount
Args:
conn: psycopg2 connection
layaway: dict with keys:
id: layaway id
amount: float
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
Returns:
int or None: journal entry ID (None when amount <= 0)
"""
amount = _to_dec(layaway.get('amount', 0))
if amount <= 0:
return None
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
layaway_id = layaway['id']
method = layaway.get('payment_method', 'efectivo')
accounts = _get_account_ids(cur, ['110', '111'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
if method in ('transferencia', 'tarjeta'):
debit_account = accounts['111']
debit_desc = f'Bancos - Apartado #{layaway_id}'
else:
debit_account = accounts['110']
debit_desc = f'Caja - Apartado #{layaway_id}'
lines = [
{
'account_id': debit_account,
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': debit_desc,
},
{
'account_id': advance_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
]
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - anticipo ({method})',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def record_layaway_conversion_entry(conn, sale):
"""Generate the journal entry for a fully paid layaway converted to a sale.
Applies the advances collected during the layaway and recognizes the
revenue, trasladado IVA and cost of goods:
Debit 240 Anticipos de Clientes = total
Credit 410 Ventas = subtotal
Credit 220 IVA Trasladado = tax_total
Debit 510 Costo de Mercancia Vendida = cost
Credit 130 Inventarios = cost
Args:
conn: psycopg2 connection
sale: dict with keys:
id: sale id created for the layaway
layaway_id: layaway id (optional)
subtotal: float (net, after discounts, before tax)
tax_total: float
total: float (subtotal + tax_total)
items: [{unit_cost, quantity}] (optional; read from DB when absent)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
sale_id = sale['id']
layaway_id = sale.get('layaway_id')
subtotal = _to_dec(sale['subtotal'])
tax_total = _to_dec(sale['tax_total'])
total = _to_dec(sale['total'])
items = sale.get('items')
if not items:
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
cost_total = Decimal('0')
for item in items:
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
lines = [
{
'account_id': advance_account,
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
{
'account_id': accounts['410'],
'debit': 0,
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
},
{
'account_id': accounts['220'],
'debit': 0,
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
},
]
if cost_total > 0:
lines.append({
'account_id': accounts['510'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Costo mercancia - Apartado #{layaway_id}',
})
lines.append({
'account_id': accounts['130'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Inventarios - Apartado #{layaway_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - venta #{sale_id}',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def ensure_supplier_payments_table(conn):
"""Create the supplier_payments table if it does not exist (idempotent).
The table links each payment to the purchase order (cuentas por pagar)
it settles. It is created on first use so tenants that predate it work
without a manual migration.
"""
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS supplier_payments (
id SERIAL PRIMARY KEY,
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
amount NUMERIC(12,2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
reference VARCHAR(100),
employee_id INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
)
cur.close()
def record_supplier_payment_entry(conn, payment):
"""Register a payment to a supplier against a purchase order (CxP).
Debit 210 Proveedores = amount
Credit 110 Caja / 111 Bancos = amount
The payment is stored in supplier_payments (created on first use) and the
purchase order is marked 'paid' once the accumulated payments cover its
total. This is what feeds the 'paid' column of the accounts-payable aging.
Args:
conn: psycopg2 connection
payment: dict with keys:
po_id: purchase order id (required)
amount: float (required, in the PO currency)
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
reference: str (optional)
Returns:
dict: {entry_id, payment_id, po_id, status, paid, balance}
"""
cur = conn.cursor()
po_id = payment.get('po_id')
amount = _to_dec(payment.get('amount', 0))
if not po_id:
raise ValueError("po_id is required")
if amount <= 0:
raise ValueError("amount must be greater than 0")
cur.execute("""
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
""", (po_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"Purchase order {po_id} not found")
supplier_id, po_total, po_status = row
if po_status == 'cancelled':
raise ValueError("Cannot pay a cancelled purchase order")
ensure_supplier_payments_table(conn)
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
already_paid = _to_dec(cur.fetchone()[0])
po_total = _to_dec(po_total)
paid_total = already_paid + amount
if paid_total > po_total:
raise ValueError(
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
f"of purchase order {po_id}"
)
entry_date = date.today()
_check_period_open(cur, entry_date)
try:
created_by = getattr(g, 'employee_id', None)
except RuntimeError:
created_by = None
cur.execute("""
INSERT INTO supplier_payments
(po_id, supplier_id, amount, payment_method, reference, employee_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
po_id, supplier_id, float(amount),
payment.get('payment_method', 'efectivo'),
payment.get('reference'), created_by
))
payment_id = cur.fetchone()[0]
accounts = _get_account_ids(cur, ['110', '111', '210'])
method = payment.get('payment_method', 'efectivo')
if method in ('transferencia', 'tarjeta'):
credit_account = accounts['111']
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
else:
credit_account = accounts['110']
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
entry_number = get_next_entry_number(conn)
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Pago a proveedor - OC #{po_id}',
'purchase', po_id,
[
{
'account_id': accounts['210'],
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Proveedores - Pago OC #{po_id}',
},
{
'account_id': credit_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': credit_desc,
},
]
)
new_status = 'paid' if paid_total >= po_total else po_status
if new_status != po_status:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
cur.close()
return {
'entry_id': entry_id,
'payment_id': payment_id,
'po_id': po_id,
'status': new_status,
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
}
def create_manual_entry(conn, entry_data):
"""Create a manual journal entry (type='diario').

View File

@@ -3,8 +3,17 @@
import requests
import json
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
import os as _os
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
except ImportError:
OPENROUTER_API_KEY = _os.environ.get('OPENROUTER_API_KEY', '')
HERMES_API_URL = _os.environ.get('HERMES_API_URL', '')
HERMES_API_KEY = _os.environ.get('HERMES_API_KEY', '')
QWEN_API_URL = _os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = _os.environ.get('QWEN_API_KEY', '')
QWEN_MODEL = _os.environ.get('QWEN_MODEL', 'qwen3.6')
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
@@ -394,7 +403,7 @@ def classify_part(part_number):
for url, key, model_id in backends:
_validate_model(model_id)
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
result = _post_chat_completion(url, key, model_id, messages, max_tokens=800, temperature=0.2, timeout=70)
if result is None:
continue
content = result["content"]
@@ -543,7 +552,7 @@ def chat_cache_clear():
_RESPONSE_CACHE.clear()
def chat(user_message, conversation_history=None, inventory_context=None):
def chat(user_message, conversation_history=None, inventory_context=None, persona=None):
"""Send a message to the AI and get a response with search suggestions.
Caches responses for repeated identical questions (subject to bypass
@@ -553,6 +562,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
user_message: The user's chat message.
conversation_history: Previous messages in the conversation.
inventory_context: Optional inventory summary string to inject into the system prompt.
persona: Optional persona intro (first line of system prompt) for tenant branding.
"""
# Cache lookup — only when there's no conversation history (stateless)
cache_key = None
@@ -564,6 +574,9 @@ def chat(user_message, conversation_history=None, inventory_context=None):
return cached
system_content = SYSTEM_PROMPT
if persona:
# Replace the hardcoded first line (persona de marca por tenant)
system_content = persona + "\n" + system_content.split("\n", 1)[1]
if inventory_context:
system_content = SYSTEM_PROMPT + "\n\n" + inventory_context

View File

@@ -0,0 +1,357 @@
"""Business anomaly detection (A5): discounts, below-cost sales, cancellations
and off-hours activity.
Works on the tenant DB tables ``sale_items`` (discount_pct, unit_price,
unit_cost), ``sales`` (hour, employee, total, status) and ``audit_log``
(SALE / CANCEL). Everything is computed with a robust z-score (median + MAD)
so a handful of extreme tickets cannot drag the baseline.
Cold start is explicit: statistical rules only fire once there are at least
``min_samples`` observations in the window. The hard rules (a sale below its
recorded cost) fire regardless, because they do not need a baseline — but
``unit_cost`` of 0/NULL is treated as "unknown" and never flagged.
Public API:
detect_anomalies(conn, days=90, min_samples=5, z_threshold=3.5) -> dict
"""
import os
from datetime import datetime, timezone
import numpy as np
DEFAULT_WINDOW_DAYS = 90
DEFAULT_MIN_SAMPLES = 5
DEFAULT_Z = 3.5
# Local business hours (in BUSINESS_TZ). Sales outside are "off-hours".
BUSINESS_TZ = os.environ.get('ANOMALY_TZ', 'America/Mexico_City')
OFF_HOURS_START = int(os.environ.get('ANOMALY_OFF_HOURS_START', '7'))
OFF_HOURS_END = int(os.environ.get('ANOMALY_OFF_HOURS_END', '21'))
# Minimum repeated cancellations before we raise a flag (cold-start guard).
MIN_CANCELLATIONS = int(os.environ.get('ANOMALY_MIN_CANCELLATIONS', '3'))
CANCEL_RATE_THRESHOLD = float(os.environ.get('ANOMALY_CANCEL_RATE', '0.25'))
# A discount must be at least this high (percent points) to be worth flagging;
# keeps the detector from calling a 2% discount "anomalous" in a cash-register
# that normally discounts 0%.
DISCOUNT_ALERT_MIN = float(os.environ.get('ANOMALY_DISCOUNT_MIN', '15'))
# Off-hours needs at least this many tickets before it is reported, so a
# single late-night sale does not raise a flag.
OFF_HOURS_MIN = int(os.environ.get('ANOMALY_OFF_HOURS_MIN', '2'))
SEVERITY_POINTS = {'critical': 40, 'high': 25, 'medium': 15, 'low': 8}
def _robust_stats(values):
"""Return (median, scale) robust stats, or (None, None) for empty input.
Scale is the MAD when it is non-zero. When most values are identical
(MAD == 0 — very common with discounts, where the baseline is 0%), it
falls back to a scaled mean absolute deviation so a clear outlier is
still measurable instead of yielding a division by zero.
"""
arr = np.asarray(values, dtype=float)
if arr.size == 0:
return None, None
med = float(np.median(arr))
mad = float(np.median(np.abs(arr - med)))
if mad > 1e-9:
return med, mad
meanad = float(np.mean(np.abs(arr - med)))
if meanad > 1e-9:
return med, meanad * 1.2533
return med, 0.0
def _robust_z(value, med, scale):
"""Robust z-score (0.6745 * (x - med) / scale), None if scale == 0."""
if med is None or scale is None or scale <= 1e-9:
return None
return 0.6745 * (float(value) - med) / scale
def _is_off_hours(local_hour):
if local_hour is None:
return False
return local_hour < OFF_HOURS_START or local_hour > OFF_HOURS_END
def _anomaly(kind, severity, detail, metric=None, sample=None):
return {
'type': kind,
'severity': severity,
'detail': detail,
'metric': metric,
'sample_size': sample,
}
def _load_items(cur, days):
"""Sale items in the window, joined with their sale/employee/hour."""
cur.execute("""
SELECT si.sale_id, si.inventory_id, si.part_number, si.name,
si.quantity, si.unit_price, si.unit_cost, si.discount_pct,
s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id),
EXTRACT(HOUR FROM (s.created_at AT TIME ZONE %s))::int AS local_hour
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
LEFT JOIN employees e ON e.id = s.employee_id
WHERE s.created_at >= NOW() - make_interval(days => %s)
""", (BUSINESS_TZ, days))
return cur.fetchall()
def _load_cancellations(cur, days):
"""Cancelled-sale counts per employee (sales.status + audit_log CANCEL)."""
cur.execute("""
SELECT s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id), COUNT(*)
FROM sales s
LEFT JOIN employees e ON e.id = s.employee_id
WHERE s.status = 'cancelled'
AND s.created_at >= NOW() - make_interval(days => %s)
GROUP BY 1, 2
""", (days,))
by_sales = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
try:
cur.execute("""
SELECT employee_id, COUNT(*)
FROM audit_log
WHERE action ILIKE '%%CANCEL%%'
AND created_at >= NOW() - make_interval(days => %s)
GROUP BY 1
""", (days,))
for emp_id, cnt in cur.fetchall():
name, prev = by_sales.get(emp_id, (None, 0))
by_sales[emp_id] = (name or f'Empleado {emp_id}', max(prev, cnt))
except Exception:
cur.connection.rollback()
return by_sales
def _load_sale_counts(cur, days):
"""Total sales count per employee in the window (cancel-rate denominator)."""
cur.execute("""
SELECT employee_id, COUNT(*)
FROM sales
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY 1
""", (days,))
return {r[0]: r[1] for r in cur.fetchall()}
def detect_anomalies(conn, days=DEFAULT_WINDOW_DAYS, min_samples=DEFAULT_MIN_SAMPLES,
z_threshold=DEFAULT_Z, limit=50):
"""Scan a tenant for business anomalies.
Returns a dict with per-employee and per-SKU findings, plus a summary and
a ``cold_start`` flag (True when there is not enough data for statistical
rules — only hard rules are reported then).
"""
days = max(1, int(days))
min_samples = max(1, int(min_samples))
cur = conn.cursor()
try:
items = _load_items(cur, days)
cancels = _load_cancellations(cur, days)
sale_counts = _load_sale_counts(cur, days)
finally:
cur.close()
total_items = len(items)
total_sales = len({r[0] for r in items})
employees_seen = {(r[8], r[9]) for r in items if r[8] is not None}
cold_start = total_items < min_samples
emp = {} # employee_id -> {name, anomalies: [], sales_count}
def add_emp(emp_id, name, anomaly):
if emp_id is None:
emp_id = 0
name = name or 'Sin empleado'
entry = emp.setdefault(emp_id, {'employee_id': emp_id, 'employee_name': name,
'anomalies': []})
entry['anomalies'].append(anomaly)
sku = {} # inventory_id -> {part_number, name, anomalies: [], tickets: n}
def add_sku(inv_id, part_number, name, anomaly):
if inv_id is None:
return
entry = sku.setdefault(inv_id, {'inventory_id': inv_id, 'part_number': part_number,
'name': name, 'anomalies': []})
entry['anomalies'].append(anomaly)
# ── 1. Sales below recorded cost (hard rule, no baseline needed) ──────
below_cost_emp = {}
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if cost is None or float(cost) <= 0:
continue # unknown cost — cannot judge
effective = float(price or 0) * (1 - float(disc or 0) / 100.0)
if effective < float(cost):
margin = effective - float(cost)
below_cost_emp[emp_id] = below_cost_emp.get(emp_id, 0) + 1
add_emp(emp_id, emp_name, _anomaly(
'below_cost', 'high',
f"Vendió {name} a {effective:.2f} por debajo del costo {float(cost):.2f}",
metric={'sale_id': sale_id, 'inventory_id': inv_id,
'unit_price': effective, 'unit_cost': float(cost),
'margin': round(margin, 2)},
))
add_sku(inv_id, part_number, name, _anomaly(
'below_cost', 'high',
f"Ticket #{sale_id} vendido bajo costo ({margin:.2f})",
metric={'sale_id': sale_id, 'margin': round(margin, 2)},
))
# ── 2. Discounts: robust z of the tenant-wide distribution ────────────
discounts = [float(r[7]) for r in items if r[7] is not None]
disc_med, disc_scale = _robust_stats(discounts)
if not cold_start:
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if disc is None or float(disc) < DISCOUNT_ALERT_MIN:
continue
z = _robust_z(float(disc), disc_med, disc_scale)
if z is not None and z > z_threshold:
add_emp(emp_id, emp_name, _anomaly(
'discount_atypical', 'medium',
f"Descuento {float(disc):.1f}% en {name} (z={z:.1f})",
metric={'sale_id': sale_id, 'discount_pct': float(disc),
'z': round(z, 2), 'baseline_median': round(disc_med, 2)},
sample=len(discounts),
))
add_sku(inv_id, part_number, name, _anomaly(
'discount_atypical', 'medium',
f"Descuento {float(disc):.1f}% (z={z:.1f})",
metric={'z': round(z, 2), 'discount_pct': float(disc)},
sample=len(discounts),
))
# Employee-level: each cajero's median discount vs the tenant baseline.
per_emp_disc = {}
for r in items:
if r[7] is not None and r[8] is not None:
per_emp_disc.setdefault(r[8], []).append(float(r[7]))
for emp_id, vals in per_emp_disc.items():
if len(vals) < min_samples:
continue
med = float(np.median(vals))
z = _robust_z(med, disc_med, disc_scale)
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
add_emp(emp_id, dict(employees_seen).get(emp_id, f'Empleado {emp_id}'),
_anomaly(
'discount_employee', 'high',
f"Descuento mediano {med:.1f}% sobre la media del negocio ({disc_med:.1f}%)",
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
sample=len(vals),
))
# SKU-level: each SKU's median discount vs the tenant baseline.
per_sku_disc = {}
for r in items:
if r[7] is not None and r[1] is not None:
per_sku_disc.setdefault(r[1], []).append(float(r[7]))
for inv_id, vals in per_sku_disc.items():
if len(vals) < min_samples:
continue
med = float(np.median(vals))
z = _robust_z(med, disc_med, disc_scale)
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
part_number = next((r[2] for r in items if r[1] == inv_id), None)
name = next((r[3] for r in items if r[1] == inv_id), None)
add_sku(inv_id, part_number, name, _anomaly(
'discount_sku', 'medium',
f"Descuento mediano {med:.1f}% sobre la media ({disc_med:.1f}%)",
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
sample=len(vals),
))
# ── 3. Repeated cancellations (employee) ──────────────────────────────
for emp_id, (emp_name, cnt) in cancels.items():
total = sale_counts.get(emp_id, cnt) + cnt
rate = cnt / total if total else 0
repeats = cnt >= MIN_CANCELLATIONS
proportional = total >= min_samples and rate >= CANCEL_RATE_THRESHOLD
if repeats or proportional:
add_emp(emp_id, emp_name, _anomaly(
'cancellations', 'high' if repeats else 'medium',
f"{cnt} cancelaciones en {days} días ({rate * 100:.0f}% de sus ventas)",
metric={'cancellations': cnt, 'sale_rate': round(rate, 3)},
sample=total,
))
# ── 4. Off-hours sales (rule; needs a repeat to avoid one-off noise) ──
off_hours = {}
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
emp_id, emp_name, hour) in items:
if _is_off_hours(hour):
off_hours.setdefault(emp_id, {'name': emp_name, 'sales': set()})
off_hours[emp_id]['sales'].add(sale_id)
for emp_id, info in off_hours.items():
count = len(info['sales'])
if count < OFF_HOURS_MIN and total_items >= min_samples:
continue
add_emp(emp_id, info['name'], _anomaly(
'off_hours', 'low' if count < 3 else 'medium',
f"{count} ventas fuera de horario ({OFF_HOURS_START}:00-{OFF_HOURS_END}:59)",
metric={'off_hours_sales': count},
sample=total_items,
))
# ── Score + serialise ─────────────────────────────────────────────────
def _score(anomalies):
total = 0.0
for a in anomalies:
total += SEVERITY_POINTS.get(a['severity'], 5)
return min(total, 100.0)
employees = []
for entry in emp.values():
entry['score'] = round(_score(entry['anomalies']), 1)
entry['sales_count'] = sale_counts.get(entry['employee_id'], 0)
employees.append(entry)
employees.sort(key=lambda e: e['score'], reverse=True)
skus = []
for entry in sku.values():
entry['score'] = round(_score(entry['anomalies']), 1)
skus.append(entry)
skus.sort(key=lambda s: s['score'], reverse=True)
by_type = {}
for entry in employees:
for a in entry['anomalies']:
by_type[a['type']] = by_type.get(a['type'], 0) + 1
for entry in skus:
for a in entry['anomalies']:
by_type[a['type']] = by_type.get(a['type'], 0) + 1
return {
'generated_at': datetime.now(timezone.utc).isoformat(),
'window_days': days,
'cold_start': cold_start,
'sample': {
'sale_items': total_items,
'sales': total_sales,
'employees': len(employees_seen),
},
'thresholds': {
'min_samples': min_samples,
'z_threshold': z_threshold,
'business_hours': [OFF_HOURS_START, OFF_HOURS_END],
'timezone': BUSINESS_TZ,
'min_cancellations': MIN_CANCELLATIONS,
},
'employees': employees[:limit],
'skus': skus[:limit],
'summary': {
'flagged_employees': len(employees),
'flagged_skus': len(skus),
'by_type': by_type,
},
}

985
pos/services/b2b_service.py Normal file
View File

@@ -0,0 +1,985 @@
# /home/Autopartes/pos/services/b2b_service.py
"""Nexus B2B yonke marketplace: wholesale used parts between deshuesaderos.
A yonke (seller) publishes its used/rebuild pieces to a shared catalog; another
yonke (buyer) places an order. Listings live in the MASTER DB (nexus_autoparts)
so they are visible cross-tenant; the pieces themselves stay in each seller's
tenant DB.
Tables (master DB):
b2b_listings — published pieces (UNIQUE seller_tenant_id+inventory_id)
marketplace_orders — order header (buyer/seller tenant ids, total, status)
marketplace_order_items — order lines (+ listing_id link)
Order lifecycle:
pending → confirmed → shipped → received → completed
pending/confirmed → cancelled
Actor permissions per transition:
buyer : cancelled (from pending), received (from confirmed/shipped)
seller : confirmed (from pending), shipped (from pending/confirmed),
cancelled (from pending/confirmed)
both : completed (from received)
The source states are deliberately permissive (the UI exposes "Marcar
enviado"/"Recibido" a step early) so a valid actor never hits a dead end.
Conventions:
* Master reads/writes go through the `master_conn` passed by the caller
(the blueprint owns commit/rollback), EXCEPT `update_order_status`, which
commits the master transaction itself: on `received` it must persist the
status BEFORE running the cross-tenant side effects, so the order state can
never be rolled back while pieces were already created in another tenant.
* Tenant connections are opened/committed/closed inside this module, because
one order touches two tenants (buyer + seller) at once.
* Everything degrades: side effects at `received` run inside SAVEPOINTs and
never abort the order on failure — failures are returned as `warnings`.
"""
from decimal import Decimal
from psycopg2 import errors as pg_errors
from psycopg2.extras import RealDictCursor
from tenant_db import get_tenant_conn
# ─── Domain constants ──────────────────────────────────────────────────────
LISTING_STATUSES = ('available', 'reserved', 'sold', 'removed')
ORDER_STATUSES = ('pending', 'confirmed', 'shipped', 'received', 'completed', 'cancelled')
# status -> {new_status: (allowed actor roles)}
ORDER_TRANSITIONS = {
'pending': {'confirmed': ('seller',),
'shipped': ('seller',),
'cancelled': ('buyer', 'seller')},
'confirmed': {'shipped': ('seller',),
'received': ('buyer',),
'cancelled': ('seller',)},
'shipped': {'received': ('buyer',)},
'received': {'completed': ('buyer', 'seller')},
'completed': {},
'cancelled': {},
}
USED_CONDITIONS = ('usado', 'reconstruido')
class B2BError(Exception):
"""Base class for all B2B errors (mapped to HTTP by the blueprint)."""
class B2BValidationError(B2BError):
"""Bad input / illegal state transition -> HTTP 400."""
class B2BForbidden(B2BError):
"""Caller is not allowed -> HTTP 403."""
class B2BNotFound(B2BError):
"""Entity missing -> HTTP 404."""
# ─── Small helpers ─────────────────────────────────────────────────────────
def _to_int(val, default=None):
if val is None or val == '':
return default
try:
return int(float(str(val).replace(',', '')))
except (TypeError, ValueError):
return default
def _to_float(val, default=0.0):
if val is None or val == '':
return default
try:
return float(str(val).replace(',', ''))
except (TypeError, ValueError):
return default
def _jval(v):
"""Make a psycopg2 value JSON-safe (Decimal -> float, date -> isoformat)."""
if isinstance(v, Decimal):
return float(v)
if hasattr(v, 'isoformat'):
return v.isoformat()
return v
def _clean(d):
return {k: _jval(v) for k, v in (d or {}).items()}
def _cur(conn):
return conn.cursor(cursor_factory=RealDictCursor)
def _tenant_name(master_conn, tenant_id):
"""Best-effort tenant display name from the master DB (never raises)."""
if not tenant_id:
return None
try:
cur = _cur(master_conn)
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
row = cur.fetchone()
cur.close()
return row['name'] if row else None
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return None
def is_seller(master_conn, tenant_id):
"""True when the tenant has the seller flag enabled in the master DB."""
try:
cur = _cur(master_conn)
cur.execute("SELECT COALESCE(is_seller, false) AS is_seller FROM tenants WHERE id = %s",
(tenant_id,))
row = cur.fetchone()
cur.close()
return bool(row['is_seller']) if row else False
except Exception:
try:
master_conn.rollback()
except Exception:
pass
return False
def _audit_best_effort(conn, action, entity_type=None, entity_id=None, new_value=None):
try:
from services.audit import log_action
log_action(conn, action, entity_type, entity_id, new_value=new_value)
except Exception:
pass
# ─── Listings: serialization ───────────────────────────────────────────────
_LISTING_FIELDS = (
'id', 'seller_tenant_id', 'seller_inventory_id', 'part_number', 'name',
'condition', 'condition_grade', 'price_wholesale', 'donor_make',
'donor_model', 'donor_year', 'image_url', 'status', 'notes',
'created_at', 'updated_at',
)
_LISTING_COLS = ', '.join(_LISTING_FIELDS)
_LISTING_COLS_L = ', '.join('l.' + f for f in _LISTING_FIELDS)
def _listing_dict(row, seller_name=None, include_private=False):
"""Map a b2b_listings row to the API dict.
`inventory_id` is the public alias of `seller_inventory_id` (the contract
field). Extra fields (condition, notes, seller_tenant_id, timestamps) are
additive and harmless for the UI.
"""
d = _clean(dict(row))
d['inventory_id'] = d.get('seller_inventory_id')
d['price_wholesale'] = float(d.get('price_wholesale') or 0)
if seller_name is not None:
d['seller_name'] = seller_name
if not include_private:
d.pop('seller_tenant_id', None)
return d
# ─── Seller: my listings ───────────────────────────────────────────────────
def list_my_listings(master_conn, tenant_id, status=None):
"""Listings published by `tenant_id` (removed ones hidden by default)."""
cur = _cur(master_conn)
where = ["seller_tenant_id = %s"]
params = [tenant_id]
if status:
status = (status or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
where.append("status = %s")
params.append(status)
else:
where.append("status <> 'removed'")
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings "
f"WHERE {' AND '.join(where)} ORDER BY created_at DESC, id DESC",
params,
)
rows = cur.fetchall()
cur.close()
return [_listing_dict(r, include_private=True) for r in rows]
def get_listing(master_conn, listing_id):
"""Single listing + seller_name, or None."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
cur.close()
if not row:
return None
return _listing_dict(row, seller_name=_tenant_name(master_conn, row['seller_tenant_id']))
# ─── Seller: publish / edit / remove ───────────────────────────────────────
def _fetch_piece(tenant_id, inventory_id):
"""Read the source piece from the seller's tenant DB (+ primary image)."""
conn = get_tenant_conn(tenant_id)
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT id, part_number, name, condition, condition_grade,
donor_make, donor_model, donor_year, image_url, is_active
FROM inventory WHERE id = %s
""", (inventory_id,))
row = cur.fetchone()
if row:
cur.execute("""
SELECT url FROM inventory_images
WHERE inventory_id = %s
ORDER BY is_primary DESC, sort_order ASC, id ASC
LIMIT 1
""", (inventory_id,))
img = cur.fetchone()
if img and img.get('url'):
row = dict(row)
row['image_url'] = img['url']
cur.close()
return dict(row) if row else None
finally:
conn.close()
def publish_listing(master_conn, tenant_id, data):
"""Create or refresh a listing for one of the tenant's used pieces.
Raises B2BForbidden when the tenant is not a seller, B2BValidationError on
bad input / ineligible piece, B2BNotFound when the piece does not exist.
"""
data = data or {}
if not is_seller(master_conn, tenant_id):
raise B2BForbidden(
'Tu cuenta no está habilitada como vendedor (is_seller). '
'Contacta al administrador para activar el modo mayoreo.'
)
inventory_id = _to_int(data.get('inventory_id'))
if not inventory_id:
raise B2BValidationError('inventory_id requerido')
if 'price_wholesale' not in data or data.get('price_wholesale') in (None, ''):
raise B2BValidationError('price_wholesale requerido')
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
piece = _fetch_piece(tenant_id, inventory_id)
if not piece:
raise B2BNotFound('Pieza no encontrada')
condition = (piece.get('condition') or '').strip().lower()
if condition not in USED_CONDITIONS:
raise B2BValidationError(
"Solo se pueden publicar piezas usadas o reconstruidas "
f"(condition actual: {condition or 'desconocida'})"
)
if piece.get('is_active') is False:
raise B2BValidationError('La pieza no está activa en inventario')
notes = (data.get('notes') or '').strip() or None
cur = _cur(master_conn)
cur.execute("""
INSERT INTO b2b_listings
(seller_tenant_id, seller_inventory_id, part_number, name, condition,
condition_grade, price_wholesale, donor_make, donor_model, donor_year,
image_url, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'available', %s)
ON CONFLICT (seller_tenant_id, seller_inventory_id) DO UPDATE SET
part_number = EXCLUDED.part_number,
name = EXCLUDED.name,
condition = EXCLUDED.condition,
condition_grade = EXCLUDED.condition_grade,
price_wholesale = EXCLUDED.price_wholesale,
donor_make = EXCLUDED.donor_make,
donor_model = EXCLUDED.donor_model,
donor_year = EXCLUDED.donor_year,
image_url = EXCLUDED.image_url,
notes = COALESCE(EXCLUDED.notes, b2b_listings.notes),
status = 'available',
updated_at = NOW()
RETURNING id
""", (
tenant_id, inventory_id, piece.get('part_number'), piece.get('name'),
condition or 'usado', piece.get('condition_grade'), price,
piece.get('donor_make'), piece.get('donor_model'), piece.get('donor_year'),
piece.get('image_url'), notes,
))
listing_id = cur.fetchone()['id']
cur.close()
listing = get_listing(master_conn, listing_id)
_audit_publish_best_effort(tenant_id, listing)
return listing
def _audit_publish_best_effort(tenant_id, listing):
try:
conn = get_tenant_conn(tenant_id)
try:
_audit_best_effort(conn, 'B2B_LISTING_PUBLISH', 'b2b_listing',
(listing or {}).get('id'),
{'price_wholesale': (listing or {}).get('price_wholesale')})
conn.commit()
finally:
conn.close()
except Exception:
pass
def update_listing(master_conn, tenant_id, listing_id, data):
"""Update price/status of a listing (owner only). Returns the listing."""
data = data or {}
sets = []
params = []
if 'price_wholesale' in data:
price = _to_float(data.get('price_wholesale'), None)
if price is None or price < 0:
raise B2BValidationError('price_wholesale inválido')
sets.append('price_wholesale = %s')
params.append(price)
if 'status' in data:
status = (data.get('status') or '').strip().lower()
if status not in LISTING_STATUSES:
raise B2BValidationError(f"status inválido: {status}")
sets.append('status = %s')
params.append(status)
if 'notes' in data:
sets.append('notes = %s')
params.append((data.get('notes') or '').strip() or None)
if not sets:
raise B2BValidationError('Nada que actualizar (price_wholesale, status, notes)')
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
sets.append('updated_at = NOW()')
params.append(listing_id)
cur.execute(f"UPDATE b2b_listings SET {', '.join(sets)} WHERE id = %s", params)
cur.close()
return get_listing(master_conn, listing_id)
def remove_listing(master_conn, tenant_id, listing_id):
"""Soft-delete a listing (status='removed'). Owner only. Returns bool."""
cur = _cur(master_conn)
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Listing no encontrado')
if row['seller_tenant_id'] != tenant_id:
cur.close()
raise B2BForbidden('Este listing pertenece a otro vendedor')
cur.execute(
"UPDATE b2b_listings SET status = 'removed', updated_at = NOW() WHERE id = %s",
(listing_id,),
)
cur.close()
return True
# ─── Buyer: catalog ────────────────────────────────────────────────────────
def list_catalog(master_conn, buyer_tenant_id, filters=None, page=1, per_page=12):
"""Available listings from OTHER tenants, with seller_name and pagination."""
filters = filters or {}
page = max(_to_int(page, 1) or 1, 1)
per_page = min(max(_to_int(per_page, 12) or 12, 1), 100)
where = ["l.status = 'available'", 'l.seller_tenant_id <> %s']
params = [buyer_tenant_id]
q = (filters.get('q') or '').strip()
if q:
where.append("(l.name ILIKE %s OR l.part_number ILIKE %s "
"OR l.donor_make ILIKE %s OR l.donor_model ILIKE %s)")
params.extend([f'%{q}%'] * 4)
for field, col in (('make', 'l.donor_make'), ('model', 'l.donor_model')):
val = (filters.get(field) or '').strip()
if val:
where.append(f"{col} ILIKE %s")
params.append(f'%{val}%')
year = _to_int(filters.get('year'))
if year:
where.append("l.donor_year = %s")
params.append(year)
grade = (filters.get('grade') or '').strip().upper()
if grade:
where.append("UPPER(l.condition_grade) = %s")
params.append(grade)
where_sql = ' AND '.join(where)
cur = _cur(master_conn)
cur.execute(f"""
SELECT COUNT(*) AS total
FROM b2b_listings l
WHERE {where_sql}
""", params)
total = cur.fetchone()['total'] or 0
cur.execute(f"""
SELECT {_LISTING_COLS_L}, t.name AS seller_name
FROM b2b_listings l
LEFT JOIN tenants t ON t.id = l.seller_tenant_id
WHERE {where_sql}
ORDER BY l.created_at DESC, l.id DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = [_listing_dict(r, seller_name=r.get('seller_name')) for r in rows]
pagination = {
'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
return items, pagination
# ─── Orders ────────────────────────────────────────────────────────────────
_ORDER_COLS = (
'id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, '
'status, notes, created_at, updated_at'
)
def _order_items(master_conn, order_ids):
"""Bulk-load order lines grouped by order_id."""
if not order_ids:
return {}
cur = _cur(master_conn)
cur.execute("""
SELECT id, order_id, listing_id, part_number, part_name, quantity,
unit_price, subtotal
FROM marketplace_order_items
WHERE order_id = ANY(%s)
ORDER BY order_id, id
""", (list(order_ids),))
rows = cur.fetchall()
cur.close()
out = {}
for r in rows:
item = _clean(dict(r))
item['name'] = item.get('part_name') # UI-friendly alias
out.setdefault(r['order_id'], []).append(item)
return out
def _order_dict(row, items, role):
d = _clean(dict(row))
d['total'] = float(d.get('total') or 0)
d['role'] = role
if role == 'buyer':
d['other_party'] = d.get('seller_name')
d['other_tenant_id'] = d.get('seller_tenant_id')
else:
d['other_party'] = d.get('buyer_name')
d['other_tenant_id'] = d.get('buyer_tenant_id')
d['items'] = items or []
return d
def create_order(master_conn, buyer_tenant_id, items, notes=None):
"""Create a pending order + items and reserve the listings.
All lines must reference available listings from ONE other tenant.
"""
if not isinstance(items, list) or not items:
raise B2BValidationError('items requerido (lista no vacía)')
wanted = []
for it in items:
if not isinstance(it, dict):
raise B2BValidationError('cada item debe ser un objeto {listing_id, quantity}')
listing_id = _to_int(it.get('listing_id'))
qty = _to_int(it.get('quantity'), 1)
if not listing_id:
raise B2BValidationError('listing_id requerido en cada item')
if qty is None or qty < 1:
raise B2BValidationError('quantity debe ser >= 1')
wanted.append({'listing_id': listing_id, 'quantity': qty})
listing_ids = [w['listing_id'] for w in wanted]
cur = _cur(master_conn)
cur.execute(
f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s) FOR UPDATE",
(listing_ids,),
)
rows = {r['id']: dict(r) for r in cur.fetchall()}
cur.close()
sellers = set()
resolved = []
for w in wanted:
listing = rows.get(w['listing_id'])
if not listing:
raise B2BNotFound(f"Listing {w['listing_id']} no encontrado")
if listing['status'] != 'available':
raise B2BValidationError(
f"Listing {w['listing_id']} no está disponible (status: {listing['status']})")
if listing['seller_tenant_id'] == buyer_tenant_id:
raise B2BValidationError('No puedes comprar tus propias piezas')
sellers.add(listing['seller_tenant_id'])
resolved.append((listing, w['quantity']))
if len(sellers) > 1:
raise B2BValidationError(
'Una orden solo puede tener un vendedor; crea una orden por vendedor')
seller_tenant_id = sellers.pop()
total = round(sum(
_to_float(listing['price_wholesale'], 0) * qty
for listing, qty in resolved), 2)
buyer_name = _tenant_name(master_conn, buyer_tenant_id) or ''
seller_name = _tenant_name(master_conn, seller_tenant_id) or ''
cur = _cur(master_conn)
cur.execute("""
INSERT INTO marketplace_orders
(buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, status, notes)
VALUES (%s, %s, %s, %s, %s, 'pending', %s)
RETURNING id
""", (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, (notes or '').strip() or None))
order_id = cur.fetchone()['id']
order_items = []
for listing, qty in resolved:
price = _to_float(listing['price_wholesale'], 0)
subtotal = round(price * qty, 2)
cur.execute("""
INSERT INTO marketplace_order_items
(order_id, listing_id, part_number, part_name, quantity, unit_price, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (order_id, listing['id'], listing.get('part_number'), listing.get('name'),
qty, price, subtotal))
order_items.append({
'listing_id': listing['id'],
'part_number': listing.get('part_number'),
'part_name': listing.get('name'),
'name': listing.get('name'),
'quantity': qty,
'unit_price': price,
'subtotal': subtotal,
})
cur.execute("""
UPDATE b2b_listings SET status = 'reserved', updated_at = NOW()
WHERE id = ANY(%s)
""", ([listing['id'] for listing, _ in resolved],))
cur.close()
return {
'id': order_id,
'total': total,
'status': 'pending',
'seller_tenant_id': seller_tenant_id,
'buyer_tenant_id': buyer_tenant_id,
'seller_name': seller_name,
'buyer_name': buyer_name,
'items': order_items,
}
def list_orders(master_conn, tenant_id, role):
"""Orders where `tenant_id` is buyer ('buyer') or seller ('seller')."""
col = 'buyer_tenant_id' if role == 'buyer' else 'seller_tenant_id'
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE {col} = %s "
f"ORDER BY created_at DESC, id DESC",
(tenant_id,),
)
rows = cur.fetchall()
cur.close()
items = _order_items(master_conn, [r['id'] for r in rows])
return [_order_dict(r, items.get(r['id']), role) for r in rows]
def get_order(master_conn, tenant_id, order_id):
"""Order detail; only participants. Raises B2BNotFound / B2BForbidden."""
cur = _cur(master_conn)
cur.execute(f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s", (order_id,))
row = cur.fetchone()
cur.close()
if not row:
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
raise B2BForbidden('No participas en esta orden')
items = _order_items(master_conn, [order_id])
return _order_dict(row, items.get(order_id), role)
def update_order_status(master_conn, tenant_id, order_id, new_status, branch_id=None):
"""Apply an order status transition (role-checked).
Commits the master transaction itself: when moving to `received` the status
must be durable before the cross-tenant side effects (marking the seller's
piece sold and creating the piece in the buyer's inventory) run.
"""
new_status = (new_status or '').strip().lower()
if new_status not in ORDER_STATUSES:
raise B2BValidationError(
f"status inválido: {new_status} (válidos: {', '.join(ORDER_STATUSES)})")
cur = _cur(master_conn)
cur.execute(
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s FOR UPDATE",
(order_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise B2BNotFound('Orden no encontrada')
if row['buyer_tenant_id'] == tenant_id:
role = 'buyer'
elif row['seller_tenant_id'] == tenant_id:
role = 'seller'
else:
cur.close()
raise B2BForbidden('No participas en esta orden')
current = row['status'] or 'pending'
allowed = ORDER_TRANSITIONS.get(current, {}).get(new_status)
if not allowed:
cur.close()
raise B2BValidationError(f"No se puede pasar de '{current}' a '{new_status}'")
if role not in allowed:
cur.close()
raise B2BForbidden(
f"Rol '{role}' no puede cambiar la orden de '{current}' a '{new_status}'")
cur.execute(
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s",
(new_status, order_id),
)
cur.close()
master_conn.commit()
result = get_order(master_conn, tenant_id, order_id)
result['previous_status'] = current
if new_status == 'received':
extras = _finalize_received(master_conn, dict(row), branch_id=branch_id)
result.update(extras)
return result
# ─── Received: cross-tenant side effects (best-effort) ─────────────────────
def _resolve_branch(conn, branch_id=None):
if branch_id:
return branch_id
try:
cur = conn.cursor()
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
r = cur.fetchone()
cur.close()
return r[0] if r else None
except Exception:
return None
def _mark_seller_piece_sold(conn, inventory_id):
"""Mark the seller's inventory row as sold (SAVEPOINT, never raises).
`inventory` has no `status` column in the standard tenant schema, so we set
`is_active = false` (piece no longer available) when it is absent; if a
tenant ever adds `inventory.status`, we write 'sold' there instead.
"""
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_sold")
try:
cur.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'inventory' AND column_name = 'status' LIMIT 1
""")
has_status = cur.fetchone() is not None
if has_status:
cur.execute("UPDATE inventory SET status = 'sold' WHERE id = %s", (inventory_id,))
else:
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (inventory_id,))
cur.execute("RELEASE SAVEPOINT b2b_sold")
return True
except Exception:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_sold")
except Exception:
pass
return False
finally:
cur.close()
def _insert_buyer_inventory(conn, item, listing, unit_price, warnings):
"""Insert the purchased piece into the buyer's inventory (SAVEPOINT)."""
from services.yonke_service import generate_unique_sku
name = (listing.get('name') or item.get('part_name') or item.get('part_number')
or 'Pieza usada')
candidate = (listing.get('part_number') or item.get('part_number') or '').strip() or None
grade = listing.get('condition_grade')
cur = conn.cursor()
inv_id = None
for _attempt in range(5):
if candidate is None:
try:
candidate = generate_unique_sku(conn, None)
except Exception:
candidate = None
cur.execute("SAVEPOINT b2b_inv")
try:
cur.execute("""
INSERT INTO inventory
(part_number, name, unit, cost, price_1, price_2, price_3, tax_rate,
condition, condition_grade, is_unique_piece,
donor_make, donor_model, donor_year, ownership, image_url, is_active)
VALUES (%s, %s, 'PZA', %s, %s, %s, %s, %s,
'usado', %s, true,
%s, %s, %s, 'propio', %s, true)
RETURNING id
""", (
candidate, name, unit_price, unit_price, unit_price, unit_price, 0.16,
grade, listing.get('donor_make'), listing.get('donor_model'),
listing.get('donor_year'), listing.get('image_url'),
))
inv_id = cur.fetchone()[0]
cur.execute("RELEASE SAVEPOINT b2b_inv")
break
except pg_errors.UniqueViolation:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
candidate = None
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
except Exception:
pass
warnings.append(f"no se pudo crear la pieza en el inventario del comprador: {e}")
cur.close()
return None
cur.close()
return inv_id
def _seed_stock(conn, inv_id, branch_id, qty, cost, warnings):
"""Seed initial stock for a freshly created buyer piece (SAVEPOINT)."""
if not branch_id or qty <= 0:
return
from services.inventory_engine import record_initial
cur = conn.cursor()
cur.execute("SAVEPOINT b2b_stock")
try:
record_initial(conn, inv_id, branch_id, qty, cost=cost)
cur.execute("RELEASE SAVEPOINT b2b_stock")
except Exception as e:
try:
cur.execute("ROLLBACK TO SAVEPOINT b2b_stock")
except Exception:
pass
warnings.append(f"no se registró la operación de stock: {e}")
# Idempotent safety net: the trigger normally creates this row already.
try:
cur.execute("""
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = EXCLUDED.stock
""", (inv_id, branch_id, qty))
except Exception as e:
warnings.append(f"no se pudo fijar el stock inicial: {e}")
cur.close()
def _finalize_received(master_conn, order, branch_id=None):
"""On `received`: sell the listings/seller pieces and stock the buyer.
Best-effort by design: every step runs in its own SAVEPOINT / connection,
failures are collected in `warnings`, and the order is never aborted.
"""
warnings = []
extras = {'listings_sold': 0, 'inventory_created': 0, 'warnings': warnings}
order_id = order['id']
seller_id = order['seller_tenant_id']
buyer_id = order['buyer_tenant_id']
cur = _cur(master_conn)
cur.execute("""
SELECT id, listing_id, part_number, part_name, quantity, unit_price
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
""", (order_id,))
items = [dict(r) for r in cur.fetchall()]
listing_ids = [it['listing_id'] for it in items if it['listing_id']]
listings = {}
if listing_ids:
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s)",
(listing_ids,))
for r in cur.fetchall():
listings[r['id']] = dict(r)
# Legacy rows may lack listing_id: fall back to (seller, part_number).
for it in items:
if it['listing_id'] in listings:
continue
cur.execute(f"""
SELECT {_LISTING_COLS} FROM b2b_listings
WHERE seller_tenant_id = %s AND part_number = %s
ORDER BY (status = 'reserved') DESC, id DESC LIMIT 1
""", (seller_id, it.get('part_number')))
r = cur.fetchone()
if r:
listings[it['listing_id']] = dict(r)
# 1) Mark listings sold (master DB) ------------------------------------
sold = 0
for it in items:
listing = listings.get(it['listing_id']) if it['listing_id'] else None
if not listing and it['listing_id']:
continue
try:
if listing:
cur.execute(
"UPDATE b2b_listings SET status = 'sold', updated_at = NOW() WHERE id = %s",
(listing['id'],))
else:
cur.execute("""
UPDATE b2b_listings SET status = 'sold', updated_at = NOW()
WHERE seller_tenant_id = %s AND part_number = %s AND status = 'reserved'
""", (seller_id, it.get('part_number')))
sold += cur.rowcount
except Exception as e:
master_conn.rollback()
warnings.append(f"no se pudo marcar el listing como vendido: {e}")
cur = _cur(master_conn)
try:
master_conn.commit()
except Exception as e:
warnings.append(f"no se pudo confirmar el estado de los listings: {e}")
extras['listings_sold'] = sold
cur.close()
# 2) Mark the seller's pieces sold (seller tenant DB) ------------------
seller_conn = None
try:
seller_conn = get_tenant_conn(seller_id)
for it in items:
listing = listings.get(it['listing_id'])
inv_id = _to_int((listing or {}).get('seller_inventory_id'))
if inv_id:
_mark_seller_piece_sold(seller_conn, inv_id)
seller_conn.commit()
except Exception as e:
warnings.append(f"no se pudieron marcar las piezas del vendedor: {e}")
if seller_conn is not None:
try:
seller_conn.rollback()
except Exception:
pass
finally:
if seller_conn is not None:
try:
seller_conn.close()
except Exception:
pass
# 3) Create the purchased pieces in the buyer's inventory --------------
buyer_conn = None
try:
buyer_conn = get_tenant_conn(buyer_id)
buyer_branch = _resolve_branch(buyer_conn, branch_id)
created = 0
for it in items:
listing = listings.get(it['listing_id']) or {}
qty = max(_to_int(it.get('quantity'), 1) or 1, 1)
price = _to_float(it.get('unit_price'), 0)
inv_id = _insert_buyer_inventory(buyer_conn, it, listing, price, warnings)
if inv_id:
_seed_stock(buyer_conn, inv_id, buyer_branch, qty, price, warnings)
created += 1
buyer_conn.commit()
extras['inventory_created'] = created
try:
_audit_best_effort(buyer_conn, 'B2B_ORDER_RECEIVED', 'marketplace_order',
order_id, {'inventory_created': created})
buyer_conn.commit()
except Exception:
pass
except Exception as e:
warnings.append(f"no se pudo crear el inventario del comprador: {e}")
if buyer_conn is not None:
try:
buyer_conn.rollback()
except Exception:
pass
finally:
if buyer_conn is not None:
try:
buyer_conn.close()
except Exception:
pass
return extras
# ─── Seller status (UI badge) ──────────────────────────────────────────────
def get_seller_status(master_conn, tenant_id):
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
seller = is_seller(master_conn, tenant_id)
cur = _cur(master_conn)
cur.execute("""
SELECT COUNT(*) AS c FROM b2b_listings
WHERE seller_tenant_id = %s AND status <> 'removed'
""", (tenant_id,))
listings_count = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders
WHERE seller_tenant_id = %s AND status IN ('pending', 'confirmed', 'shipped')
""", (tenant_id,))
incoming = cur.fetchone()['c'] or 0
cur.execute("""
SELECT COUNT(*) AS c FROM marketplace_orders WHERE seller_tenant_id = %s
""", (tenant_id,))
total_incoming = cur.fetchone()['c'] or 0
cur.close()
return {
'is_seller': seller,
'listings_count': listings_count,
'incoming_orders': incoming,
'incoming_orders_total': total_incoming,
}

View File

@@ -3,13 +3,21 @@
from tenant_db import get_master_conn
PLANS = {
# `demo` is a time-limited full trial (see scripts/expire_demos.py). It has
# no resource limits on purpose: a prospect must be able to import their
# real inventory without hitting a wall. Access is gated by the
# subscriptions.expires_at date, not by data volume.
'demo': {'name': 'Demo', 'price_mxn': 0, 'max_products': None, 'max_employees': None, 'max_branches': None},
# Yonke: plan comercial para negocios de SOLO partes usadas (sin catálogo TecDoc)
'yonke': {'name': 'Yonke', 'price_mxn': 299, 'max_products': None, 'max_employees': 10, 'max_branches': 1},
'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1},
'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2},
'standard': {'name': 'Estandar', 'price_mxn': 999, 'max_products': 20000, 'max_employees': 10, 'max_branches': 3},
'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5},
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
}
PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
PLAN_ORDER = ['free', 'demo', 'basic', 'standard', 'pro', 'enterprise']
def get_plan(tenant_id):

View File

@@ -737,9 +737,10 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
classified = _classify_vehicle_parts(master_conn, mye_id)
# Merge local inventory and supplier catalog classifications
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
result = []
# Iterate in canonical Nexpart order so the UI is stable
@@ -769,9 +770,10 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
)
classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
group_data = classified.get(group_slug, {})
if not group_data:
return []
@@ -1172,9 +1174,10 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
"""
classified = _classify_vehicle_parts(master_conn, mye_id)
# Also merge inventory and supplier catalog so the leaf IDs may be a mix
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
all_ids = (
classified
@@ -1327,9 +1330,10 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
)
classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
if not subgroup_data:
return []
@@ -1852,49 +1856,169 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
}
def _get_alternatives(cur, part_id):
"""Get cross-references + aftermarket parts for a given OEM part."""
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None,
name=None, category=None, limit=20,
use_semantic=True,
semantic_threshold=0.72,
semantic_pool=80,
semantic_timeout=20.0):
"""Alternatives for a supplier-catalog item, on live (non-TecDoc) tables.
Two sources, in order:
1. exact — other ``supplier_catalog`` items that share an interchange
cross-reference or an SKU with the base item
(``supplier_catalog_interchange``).
2. semantic — ``semantic_search.find_similar`` over same-category catalog
names (substitute parts that are worded differently). Degrades to
nothing when the embedding provider is unavailable.
Works with either a connection or a cursor (``conn``/``cur``); the caller
owns it. Never raises on the semantic step.
Returns list[dict]: {part_number, manufacturer, name, type, similarity,
catalog_id} — the caller adds stock/bodega fields.
"""
cur = conn.cursor() if hasattr(conn, 'cursor') else conn
results = []
seen = set()
# Cross-references (other OEM numbers that reference this part)
cur.execute("""
SELECT pcr.cross_reference_number, pcr.source_ref
FROM part_cross_references pcr
WHERE pcr.part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1] or 'OEM Cross-Ref',
'name': None,
'type': 'cross_reference',
'local_stock': 0,
'bodega_count': 0,
})
base = None
if catalog_id:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE id = %s AND is_active = true", (catalog_id,)
)
base = cur.fetchone()
elif part_number:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE UPPER(sku) = UPPER(%s) AND is_active = true LIMIT 1",
(part_number,)
)
base = cur.fetchone()
# Aftermarket alternatives
cur.execute("""
SELECT ap.part_number, m.name_manufacture,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1],
'name': r[2],
'type': 'aftermarket',
'local_stock': 0,
'bodega_count': 0,
})
if base:
base_id, base_sup, base_sku, base_name, base_cat = base
else:
base_id, base_sup, base_sku = None, None, None
base_name = name
base_cat = category
if base_id:
seen.add(base_id)
# ── 1. Exact interchange / SKU cross-references ────────────────────────
if base_id:
cur.execute(
"SELECT DISTINCT part_number FROM supplier_catalog_interchange "
"WHERE catalog_id = %s AND part_number IS NOT NULL",
(base_id,)
)
xrefs = [r[0] for r in cur.fetchall()]
keys_upper = set()
if base_sku:
keys_upper.add(str(base_sku).upper())
for x in xrefs:
keys_upper.add(str(x).upper())
keys_upper.discard(None)
if keys_upper:
key_list = list(keys_upper)
# (a) catalog items that *are* one of our keys
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE UPPER(sku) = ANY(%s) AND is_active = true
LIMIT %s
""", (key_list, limit * 2))
exact_rows = list(cur.fetchall())
# (b) catalog items whose interchange list contains one of our keys
cur.execute("""
SELECT DISTINCT sc.id, sc.supplier_name, sc.sku, sc.name
FROM supplier_catalog sc
JOIN supplier_catalog_interchange sci ON sci.catalog_id = sc.id
WHERE UPPER(sci.part_number) = ANY(%s) AND sc.is_active = true
LIMIT %s
""", (key_list, limit * 2))
exact_rows.extend(cur.fetchall())
for cid, sup, sku, cname in exact_rows:
if not cid or cid in seen:
continue
seen.add(cid)
results.append({
'part_number': sku,
'manufacturer': sup,
'name': cname,
'type': 'cross_reference',
'similarity': None,
'catalog_id': cid,
'local_stock': 0,
'bodega_count': 0,
})
if len(results) >= limit:
break
# ── 2. Semantic substitutes (same category, different wording) ─────────
if use_semantic and base_name and len(results) < limit:
pool_cat = base_cat
try:
if pool_cat:
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE is_active = true AND category = %s AND id <> COALESCE(%s, -1)
LIMIT %s
""", (pool_cat, base_id, semantic_pool))
else:
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE is_active = true AND id <> COALESCE(%s, -1)
LIMIT %s
""", (base_id, semantic_pool))
pool = [r for r in cur.fetchall() if r[0] not in seen]
except Exception:
pool = []
if pool:
try:
from services import semantic_search as ss
ranked = ss.find_similar(base_name, [r[3] for r in pool],
timeout=semantic_timeout)
except Exception:
ranked = None
if ranked:
for item in ranked:
if item['similarity'] < semantic_threshold:
continue
cid, sup, sku, cname = pool[item['index']]
if cid in seen:
continue
seen.add(cid)
results.append({
'part_number': sku,
'manufacturer': sup,
'name': cname,
'type': 'semantic',
'similarity': round(item['similarity'], 3),
'catalog_id': cid,
'local_stock': 0,
'bodega_count': 0,
})
if len(results) >= limit:
break
return results
def _get_alternatives(cur, part_id):
"""Back-compat shim: alternatives for a supplier-catalog id.
The retired TecDoc tables (``part_cross_references`` / ``aftermarket_parts``)
are gone; this now delegates to ``_alternatives_from_supplier_catalog``.
"""
return _alternatives_from_supplier_catalog(cur, catalog_id=part_id)
# ─────────────────────────────────────────────────────────────────────────────
# SMART SEARCH
# ─────────────────────────────────────────────────────────────────────────────
@@ -1939,7 +2063,46 @@ def _search_meili_fallback(master_conn, q, limit):
return None
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
def _rerank_document(item):
"""Texto con el que el reranker semántico evalúa un resultado."""
parts = [
item.get('name'),
item.get('oem_part_number'),
item.get('brand') or item.get('vehicle_info'),
]
return ' '.join(str(p) for p in parts if p)
def _apply_rerank(query, results, scope='pos'):
"""Reordena ``results`` por relevancia semántica (QWEN /rerank).
Solo cambia el ORDEN: la paginación, el tope de ``limit`` y el filtrado por
vehículo/sucursal ya ocurrieron antes y quedan intactos.
Degradación obligatoria (``semantic_search.rerank`` nunca lanza): si el
rerank está apagado, sin configurar, tarda más que el timeout, falla o
devuelve un orden inconsistente, se conserva el orden original.
"""
if not results or not scope:
return results
try:
from services import semantic_search as ss
if not ss.is_rerank_enabled(scope):
return results
head = results[:ss.max_candidates()]
order = ss.rerank_order(query, [_rerank_document(r) for r in head])
reordered = [head[i] for i in order if 0 <= i < len(head)]
if len(reordered) != len(head):
# Salvaguarda: un orden incompleto perdería items → orden original.
return results
return reordered + results[len(head):]
except Exception as e:
print(f"[catalog_service] rerank omitido: {e}")
return results
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
tenant_id=None, rerank_scope='pos'):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
@@ -1947,6 +2110,9 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
3. Search local inventory items by part_number or name
4. Always enriches results with local stock from tenant DB
5. Reordena los candidatos con rerank semántico según ``rerank_scope``
('pos' por defecto = flag SEARCH_RERANK_POS_ENABLED, apagado; el
catálogo público/dashboard pasa 'catalog' → SEARCH_RERANK_ENABLED)
"""
q = q.strip()
if not q or len(q) < 2:
@@ -1974,6 +2140,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
'image_url': li['image_url'],
'local_stock': li['stock'],
'local_price': li['price_1'],
'brand': li.get('brand'),
'vehicle_info': '',
'source': 'local_inventory',
})
@@ -1983,7 +2150,10 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
# ── Inject supplier catalog items ───────────────────────────────────────
if master_conn:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
# Meilisearch first (typo tolerance, sub-100ms); SQL fallback below.
supplier_items = _search_meili_supplier_catalog(q, limit)
if not supplier_items:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
for si in supplier_items:
if f"sc:{si['id']}" in seen_local_ids:
continue
@@ -2003,7 +2173,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
if len(results) >= limit:
break
return results
return _apply_rerank(q, results, rerank_scope)
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
@@ -2027,6 +2197,33 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
return prices
def _search_meili_supplier_catalog(q, limit):
"""Typo-tolerant supplier catalog search via Meilisearch.
Returns [] on any failure so the SQL fallback always applies.
Result shape matches _search_supplier_catalog.
"""
try:
import meilisearch
from services.meili_search import MEILI_URL, MEILI_API_KEY
client = meilisearch.Client(MEILI_URL, MEILI_API_KEY)
res = client.index('nexus_supplier_catalog').search(q, {'limit': limit})
out = []
for h in res.get('hits', []):
out.append({
'id': h['id'],
'sku': h.get('sku'),
'name': h.get('name'),
'image_url': h.get('image_url'),
'category': h.get('category'),
'supplier_price': h.get('supplier_price'),
'supplier_currency': None,
})
return out
except Exception:
return []
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
"""Search supplier catalog items by SKU or name.
@@ -2092,13 +2289,181 @@ def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
return results
def _tenant_db_name(tenant_conn):
"""db_name de la BD del tenant a partir de su conexión (o None)."""
try:
return tenant_conn.info.dbname
except Exception:
return None
def _meili_inventory_ids(db_name, q, limit):
"""IDs de inventario que Meilisearch considera relevantes.
None ⇒ Meilisearch no disponible (usar SQL); [] ⇒ cero resultados.
"""
try:
from services.meili_search import search_inventory
except Exception:
return None
hits = search_inventory(db_name, q, limit=limit)
if hits is None:
return None
return [h['id'] for h in hits if h.get('id') is not None]
def _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id):
"""Trae stock/precio frescos de Postgres para los IDs que dio Meilisearch.
Respeta el filtro de vehículo/sucursal y conserva el orden de relevancia
de Meilisearch.
"""
cur = tenant_conn.cursor()
try:
where_vehicle = ""
params = []
if mye_id:
where_vehicle = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
if branch_id:
if mye_id:
params = [branch_id, list(ids), mye_id]
else:
params = [branch_id, list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle, params)
else:
params = [list(ids), mye_id] if mye_id else [list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(SUM(s.stock), 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle + """
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand""", params)
rows = cur.fetchall()
finally:
cur.close()
by_id = {r[0]: r for r in rows}
ordered = [by_id[i] for i in ids if i in by_id]
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in ordered]
def _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit):
"""Inventory items whose SKU alias matches the query (A6 cross-refs).
``inventory_sku_aliases`` holds 2-3 alternative supplier SKUs per item;
this lets a search by an alternate number find the product even when the
main part_number does not match.
"""
clean_q = q.replace(' ', '').upper()
params = []
if branch_id:
stock_join = ("LEFT JOIN inventory_stock_summary s "
"ON s.inventory_id = i.id AND s.branch_id = %s")
params.append(branch_id)
else:
stock_join = "LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id"
params.append(f'%{clean_q}%')
mye_clause = ""
if mye_id:
mye_clause = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
params.append(mye_id)
params.append(limit)
cur = tenant_conn.cursor()
try:
cur.execute(f"""
SELECT DISTINCT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(s.stock, 0)
FROM inventory i
JOIN inventory_sku_aliases a
ON a.inventory_id = i.id AND a.is_active = true
{stock_join}
WHERE i.is_active = true
AND REPLACE(UPPER(a.sku), ' ', '') LIKE %s
{mye_clause}
ORDER BY i.name
LIMIT %s
""", params)
rows = cur.fetchall()
finally:
cur.close()
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in rows]
def _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit):
"""Append alias-borne matches to a result list, de-duplicated by id."""
try:
alias_rows = _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
return rows
if not alias_rows:
return rows
seen = {r['inventory_id'] for r in rows}
merged = list(rows)
for r in alias_rows:
if r['inventory_id'] not in seen:
merged.append(r)
seen.add(r['inventory_id'])
return merged[:limit]
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
"""Search tenant inventory items by part_number or name.
If mye_id is provided, only returns items compatible with that vehicle.
Primero consulta Meilisearch (índice `nexus_inventory_<db>` del tenant) e
hidrata desde Postgres para tener stock/precio frescos. Si Meilisearch
está apagado, falla o no devuelve nada, cae al SQL de abajo (ILIKE).
En ambos caminos se añaden los items cuyo SKU alias (cross-ref) coincide.
"""
if tenant_conn is None:
return []
db_name = _tenant_db_name(tenant_conn)
if db_name:
ids = _meili_inventory_ids(db_name, q, limit)
if ids:
try:
rows = _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
rows = []
if rows:
return _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit)
cur = tenant_conn.cursor()
clean_q = q.replace(' ', '').upper()
@@ -2122,7 +2487,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s
@@ -2137,7 +2502,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
@@ -2145,7 +2510,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
AND i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
ORDER BY i.name
LIMIT %s
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
@@ -2154,7 +2519,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
@@ -2167,30 +2532,32 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
ORDER BY i.name
LIMIT %s
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
rows = cur.fetchall()
cur.close()
return [
results = [
{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'stock': int(r[5]) if r[5] is not None else 0,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
}
for r in rows
]
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
# ─────────────────────────────────────────────────────────────────────────────
@@ -2332,11 +2699,50 @@ def get_bodega_availability(master_conn, part_id):
]
def get_alternatives(master_conn, part_id):
"""Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None,
category=None, tenant_conn=None, branch_id=None, limit=20,
use_semantic=True, semantic_threshold=0.72,
semantic_timeout=20.0):
"""Public wrapper: alternatives for a supplier-catalog item.
Rebuilt on the live tables (``supplier_catalog`` /
``supplier_catalog_interchange``) + semantic substitutes; the retired
TecDoc ``aftermarket_parts`` / ``part_cross_references`` tables are no
longer queried.
Pass ``catalog_id`` (``supplier_catalog.id``, e.g. ``inventory.catalog_part_id``)
and/or ``part_number``. When ``tenant_conn`` is given, each alternative is
enriched with local stock (``local_stock``) from that tenant.
NOTE: the dashboard endpoints that still serve the old TecDoc-based result
are ``dashboard/server.py`` ``/api/catalog/part/<id>`` (line ~1326),
``/api/parts/<id>/alternatives`` (line ~1938) and
``/api/parts/<id>/aftermarket`` (line ~4916); POS uses
``GET /catalog/part/<id>`` (``blueprints/catalog_bp.py``) via
``get_part_detail``. Those call sites are owned by other workstreams.
"""
cur = master_conn.cursor()
results = _get_alternatives(cur, part_id)
cur.close()
try:
results = _alternatives_from_supplier_catalog(
cur, catalog_id=catalog_id, part_number=part_number, name=name,
category=category, limit=limit, use_semantic=use_semantic,
semantic_threshold=semantic_threshold,
semantic_timeout=semantic_timeout,
)
finally:
cur.close()
if tenant_conn is not None and results:
alt_pns = [a['part_number'] for a in results if a.get('part_number')]
alt_cids = [a['catalog_id'] for a in results if a.get('catalog_id')]
try:
local = _get_local_stock_bulk(tenant_conn, branch_id, alt_pns, alt_cids)
except Exception:
local = {}
for a in results:
l = local.get(a.get('part_number')) or local.get(f"cat:{a.get('catalog_id')}")
a['local_stock'] = l['stock'] if l else 0
a.setdefault('bodega_count', 0)
return results

View File

@@ -63,6 +63,28 @@ def _format_rate(val):
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
def _item_sat_keys(item):
"""Return (clave_prod_serv, clave_unidad), falling back to SAT rules.
Precedence: the value carried by the sale item, then the rule engine over
the item name/brand/category (``services.sat_keys``), then the historic
hard-coded default. Never raises, so a CFDI can always be built.
"""
cps = item.get('clave_prod_serv')
cu = item.get('clave_unidad')
if cps and cu:
return cps, cu
try:
from services import sat_keys
k, u, _src = sat_keys.resolve_key(
name=item.get('name'), brand=item.get('brand'),
category=item.get('category'), part_number=item.get('part_number'),
)
except Exception:
k, u = None, None
return (cps or k or '25174800'), (cu or u or 'H87')
def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
"""Create a subelement with the given namespace."""
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
@@ -83,6 +105,9 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
items: [{part_number, name, quantity, unit_price, discount_amount,
tax_rate, tax_amount, subtotal,
clave_prod_serv, clave_unidad}]
(clave_prod_serv / clave_unidad are optional: when absent they are
resolved by services.sat_keys rules over name/brand/category,
falling back to 25174800 / H87)
tenant_config: dict with keys:
rfc, razon_social, regimen_fiscal, cp (codigo postal),
serie (optional), nombre_comercial (optional)
@@ -175,10 +200,10 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
concepto.set('NoIdentificacion', item.get('part_number') or '')
concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price))
@@ -301,10 +326,10 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
concepto.set('NoIdentificacion', item.get('part_number') or '')
concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price))
@@ -556,10 +581,10 @@ def build_global_invoice_xml(sales, tenant_config, year, month):
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price))

View File

@@ -69,6 +69,27 @@ def _resolve_metodo_pago(sale):
return "PUE"
def _item_sat_keys(item):
"""Return (clave_prod_serv, clave_unidad) with SAT-rule fallback.
Precedence: sale-item value, then ``services.sat_keys`` rules over
name/brand/category, then the historic hard-coded default. Never raises.
"""
cps = item.get("clave_prod_serv")
cu = item.get("clave_unidad")
if cps and cu:
return cps, cu
try:
from services import sat_keys
k, u, _src = sat_keys.resolve_key(
name=item.get("name"), brand=item.get("brand"),
category=item.get("category"), part_number=item.get("part_number"),
)
except Exception:
k, u = None, None
return (cps or k or "25174800"), (cu or u or "H87")
def _build_items(sale_items):
items = []
for item in sale_items or []:
@@ -76,12 +97,13 @@ def _build_items(sale_items):
unit_price = _to_dec(item.get("unit_price", 0))
discount = _to_dec(item.get("discount_amount", 0))
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
product_key, unit_key = _item_sat_keys(item)
# Facturapi price is unit price before taxes and discounts
product = {
"description": item.get("name") or "Autoparte",
"product_key": item.get("clave_prod_serv") or "25174800",
"unit_key": item.get("clave_unidad") or "H87",
"product_key": product_key,
"unit_key": unit_key,
"unit_name": "Pieza",
"price": _fmt2(unit_price),
"tax_included": False,
@@ -136,65 +158,91 @@ def build_ingreso_payload(sale, tenant_config, customer=None):
"folio_number": sale["id"],
}
# Optional exchange rate for USD
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
payload["exchange"] = _fmt6(sale["exchange_rate"])
payload["currency"] = sale["currency"]
# Optional currency/exchange rate for non-MXN sales
currency = (sale.get("currency") or "MXN").upper()
if currency != "MXN":
payload["currency"] = currency
if sale.get("exchange_rate"):
payload["exchange"] = _fmt6(sale["exchange_rate"])
return payload
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E"
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones
payload["payment_method"] = "PUE"
payload["related_documents"] = [{"relationship": "01", "documents": original_uuid}]
if folio_number is not None:
payload["folio_number"] = folio_number
if idempotency_key:
payload["idempotency_key"] = idempotency_key
return payload
def build_pago_payload(payment, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
def _normalize_payment_date(value):
"""Return the ISO8601 datetime string Facturapi expects for a payment."""
if not value:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
text = str(value).replace(" ", "T")
if len(text) == 10:
return f"{text}T12:00:00"
return text[:19]
def build_pago_payload(payment, tenant_config, customer, original_uuid, idempotency_key=None):
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago).
`complements[0].data` is a list of payments (Facturapi JS SDK v5 types),
each payment carrying its own related_documents with the installment number
and the previous balance of the linked PPD invoice.
"""
tenant_cp = tenant_config.get("cp", "00000")
customer_payload = _build_customer_payload(customer, tenant_cp)
amount = _to_dec(payment.get("amount", 0))
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00"
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16")
base = (amount / (Decimal("1") + rate)).quantize(TWO, ROUND_HALF_UP)
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = {
"type": "P",
"customer": customer_payload,
"payment_form": forma_pago,
"complements": [
{
"type": "pago",
"data": {
"payment_form": forma_pago,
"payment_date": payment_date,
"amount": _fmt2(amount),
"related_documents": [
{
"uuid": original_uuid,
"amount": _fmt2(amount),
"taxes": [
{
"type": "IVA",
"rate": 0.16,
"factor": "Tasa",
"base": _fmt2(base),
}
],
}
],
},
"data": [
{
"payment_form": forma_pago,
"date": _normalize_payment_date(payment.get("date")),
"related_documents": [
{
"uuid": original_uuid,
"amount": _fmt2(amount),
"installment": int(payment.get("installment") or 1),
"last_balance": _fmt2(payment.get("last_balance") or 0),
"taxes": [
{
"base": _fmt2(base),
"type": "IVA",
"rate": _fmt6(rate),
"factor": "Tasa",
"withholding": False,
}
],
}
],
}
],
}
],
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
return payload

View File

@@ -3,9 +3,11 @@
Flow:
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
2. process_queue() — sends pending items to Facturapi, updates status
3. retry_failed() — retries failed items with exponential backoff
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
2. enqueue_pending_complements() — enqueues the missing payment complements
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns
3. process_queue() — sends pending items to Facturapi, updates status
4. retry_failed() — retries failed items with exponential backoff
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
Facturapi endpoints used:
POST /v2/invoices — create and stamp an invoice
@@ -17,9 +19,11 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from services import facturapi_service
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
logger = logging.getLogger(__name__)
@@ -27,6 +31,64 @@ logger = logging.getLogger(__name__)
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
MAX_RETRIES = len(BACKOFF_INTERVALS)
# Movements older than this window are only reconciled on explicit backfill.
COMPLEMENT_LOOKBACK_DAYS = 7
TWO = Decimal("0.01")
def _send_cfdi_pdf_wa(conn, invoice_id_external, sale_id, tenant_config):
"""Descarga el PDF timbrado y lo envía por WhatsApp al cliente (si tiene teléfono)."""
try:
from services import facturapi_service
cur = conn.cursor()
cur.execute("""
SELECT c.phone FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
cur.close()
if not row or not row[0]:
return
phone = row[0]
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
raw = {k: v for k, v in cur.fetchall()}
cur.close()
cfg = {
'provider': raw.get('whatsapp_provider', 'baileys'),
'phone_number_id': raw.get('wa_phone_number_id', ''),
'access_token': raw.get('wa_access_token', ''),
'bridge_url': raw.get('whatsapp_bridge_url', ''),
}
# Solo enviar si hay proveedor configurado (Meta o Baileys con bridge)
if cfg['provider'] != 'meta' and not cfg['bridge_url']:
return
pdf_bytes = facturapi_service.download_pdf(tenant_config, invoice_id_external)
import base64 as _b64
b64 = _b64.b64encode(pdf_bytes).decode()
filename = f"CFDI_{sale_id}.pdf"
from services.whatsapp_service import wa_send_document
result = wa_send_document(cfg, phone, b64, filename,
caption="Adjuntamos tu factura CFDI. Gracias por tu compra.")
try:
from services.audit import log_action
log_action(conn, 'CFDI_PDF_SENT', 'sale', sale_id,
new_value={'result': result, 'phone': phone})
except Exception:
pass
except Exception as e:
try:
logger.warning("PDF WA send failed for sale %s: %s", sale_id, e)
except Exception:
pass
def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001.
@@ -81,6 +143,340 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
}
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
def _to_dec(val):
if val is None:
return Decimal("0")
return Decimal(str(val))
def _payload_customer(payload):
"""Adapt the receptor block of a stored CFDI payload to builder input."""
customer = payload.get("customer") or {}
tax_id = customer.get("tax_id")
if not tax_id:
return None
address = customer.get("address") or {}
return {
"rfc": tax_id,
"razon_social": customer.get("legal_name"),
"regimen_fiscal": customer.get("tax_system"),
"cp": address.get("zip"),
}
def _pago_related_amounts(payload):
"""Amounts of the related documents of a stored pago payload."""
amounts = []
for complement in payload.get("complements") or []:
data = complement.get("data")
blocks = data if isinstance(data, list) else [data]
for block in blocks:
if not isinstance(block, dict):
continue
for doc in block.get("related_documents") or []:
try:
amounts.append(round(float(doc.get("amount") or 0), 2))
except (TypeError, ValueError):
continue
return amounts
def _items_signature(payload):
"""Comparable signature of a payload's concepts (product key, qty, price)."""
signature = []
for item in payload.get("items") or []:
product = item.get("product") or {}
try:
signature.append(
(
str(product.get("product_key") or ""),
int(item.get("quantity") or 0),
round(float(product.get("price") or 0), 2),
)
)
except (TypeError, ValueError):
continue
return tuple(sorted(signature))
def _stored_signatures(cur, sale_id, cfdi_type):
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
cur.execute(
"""
SELECT payload_unsigned FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
""",
(sale_id, cfdi_type),
)
values = []
for (payload_unsigned,) in cur.fetchall():
try:
payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if cfdi_type == "pago":
values.extend(_pago_related_amounts(payload))
else:
signature = _items_signature(payload)
if signature:
values.append(signature)
return values
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
cur.execute(
"""
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
s.total, s.subtotal, s.discount_total, s.tax_total
FROM cfdi_queue q
JOIN sales s ON s.id = q.sale_id
WHERE q.type = 'ingreso' AND q.status = 'stamped'
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
AND (%s::timestamptz IS NULL OR EXISTS (
SELECT 1 FROM sale_payments sp
WHERE sp.sale_id = q.sale_id
AND sp.amount > 0
AND sp.created_at >= %s))
ORDER BY q.sale_id
""",
(since_dt, since_dt),
)
created = []
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
continue
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT id, amount, method, created_at
FROM sale_payments
WHERE sale_id = %s AND amount > 0
ORDER BY id
""",
(sale_id,),
)
payments = cur.fetchall()
if not payments:
continue
already = _stored_signatures(cur, sale_id, "pago")
base_total = _to_dec(subtotal) - _to_dec(discount_total)
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
balance = _to_dec(total)
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
paid = _to_dec(amount)
previous_balance = balance if balance > Decimal("0") else Decimal("0")
balance -= paid
if paid <= 0:
continue
if round(float(paid), 2) in already:
already.remove(round(float(paid), 2))
continue
if since_dt and created_at and created_at < since_dt:
continue
try:
payload = build_pago_payload(
{
"id": payment_id,
"amount": paid,
"payment_method": method,
"date": created_at,
"installment": installment,
"last_balance": previous_balance,
"tax_rate": tax_rate,
},
tenant_config,
customer,
original_uuid,
idempotency_key=f"nexus-rep-{payment_id}",
)
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
except Exception as e:
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"sale_payment_id": payment_id,
"amount": float(paid),
"installment": installment,
"original_uuid": original_uuid,
}
)
return created
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
cur.execute(
"""
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
ing.uuid_fiscal, ing.payload_unsigned
FROM returns r
JOIN sales s ON s.id = r.sale_id
JOIN LATERAL (
SELECT q.uuid_fiscal, q.payload_unsigned
FROM cfdi_queue q
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
LIMIT 1
) ing ON TRUE
WHERE r.status = 'completed'
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
ORDER BY r.id
""",
(since_dt, since_dt),
)
created = []
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
if since_dt and created_at and created_at < since_dt:
continue
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
invoice_payload = {}
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
si.tax_rate, si.clave_prod_serv, si.clave_unidad
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
ORDER BY ri.id
""",
(return_id,),
)
items = []
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
price = float(unit_price or 0)
items.append(
{
"part_number": part_number,
"name": name or part_number,
"quantity": int(quantity or 0),
"unit_price": price,
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
"clave_prod_serv": clave_prod,
"clave_unidad": clave_unidad,
}
)
if not items:
continue
try:
payload = build_egreso_payload(
{
"id": sale_id,
"items": items,
"currency": currency,
"exchange_rate": exchange_rate,
"forma_pago_sat": invoice_payload.get("payment_form"),
},
tenant_config,
customer,
original_uuid,
folio_number=return_id,
idempotency_key=f"nexus-egreso-{return_id}",
)
except Exception as e:
logger.warning("Could not build credit note for return %s: %s", return_id, e)
continue
signature = _items_signature(payload)
already = _stored_signatures(cur, sale_id, "egreso")
if signature and signature in already:
already.remove(signature)
continue
try:
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
except Exception as e:
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"return_id": return_id,
"original_uuid": original_uuid,
}
)
return created
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
"""Enqueue the missing payment complements and credit notes.
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
original UUID, and every completed return of a stamped sale gets a CFDI
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
the matching type/amount is skipped.
Args:
conn: psycopg2 connection
tenant_config: issuer config (only the default zip is used)
since: datetime or ISO string; reconcile movements created after it.
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
backfill: reconcile the whole history, ignoring `since`
Returns:
dict: {pagos: [...], egresos: [...], since: iso string}
"""
if backfill:
since_dt = None
elif since:
if isinstance(since, datetime):
since_dt = since
else:
since_dt = datetime.fromisoformat(str(since))
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
tenant_config = tenant_config or {}
cur = conn.cursor()
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
try:
result[key] = sweep(conn, cur, tenant_config, since_dt)
except Exception as e:
logger.warning("Could not reconcile %s complements: %s", key, e)
conn.rollback()
conn.commit()
cur.close()
return result
def process_queue(conn, tenant_config, dry_run=False):
"""Process all pending CFDI items in the queue.
@@ -96,6 +492,15 @@ def process_queue(conn, tenant_config, dry_run=False):
Returns:
dict: {processed: int, stamped: int, failed: int, details: [...]}
"""
# First enqueue the complements (REP / credit notes) that are still missing,
# so they are stamped in this same run.
if not dry_run:
try:
enqueue_pending_complements(conn, tenant_config)
except Exception as e:
logger.warning("Complement reconciliation failed: %s", e)
conn.rollback()
cur = conn.cursor()
cur.execute(
@@ -164,10 +569,17 @@ def process_queue(conn, tenant_config, dry_run=False):
error_message = NULL
WHERE id = %s
""",
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
)
conn.commit()
# Enviar PDF por WhatsApp al cliente (best effort)
try:
if _sale_id:
_send_cfdi_pdf_wa(conn, invoice_id, _sale_id, tenant_config)
except Exception:
pass
results["stamped"] += 1
results["details"].append(
{

View File

@@ -0,0 +1,463 @@
"""Credit risk scoring for store credit ("fiado") — A9.
Pure rule-based / heuristic scoring: no ML dependencies, works with very little
history and stays conservative when evidence is scarce. Every point of the score
is traceable to an explicit rule listed in ``reasons``.
Public API:
score_customer(tenant_conn, customer_id) -> dict
is_enforcement_enabled(tenant_conn) -> bool
The score is 0..100 where **higher is safer**. It is built from up to four
independent signals, each 0..100, then combined with fixed weights over the
signals that actually have data (missing signals are not invented):
tenure 0.15 months since customers.created_at
purchase 0.25 frequency + recency + value from sales
payment 0.35 punctuality on credit sales + open overdue aging
utilization 0.25 credit_balance / credit_limit
Credit sales are assumed to be due 30 days after the sale, matching the
receivables aging convention in ``blueprints/accounting_bp.py``.
"""
import logging
from datetime import datetime, timedelta, timezone
logger = logging.getLogger('pos')
# Credit sale terms used across the app for receivables aging.
CREDIT_TERMS_DAYS = 30
# Weight of each signal in the combined score.
_WEIGHTS = {
'tenure': 0.15,
'purchase': 0.25,
'payment': 0.35,
'utilization': 0.25,
}
_TIER_ORDER = {'risk': 0, 'medium': 1, 'good': 2}
# Enforcement flag key in tenant_config. Off by default.
ENFORCE_FLAG = 'credit_scoring_enforce'
def _now():
return datetime.now(timezone.utc)
def _aware(dt):
"""Normalize a possibly-naive datetime to UTC-aware."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _clamp(value, low=0, high=100):
return max(low, min(high, value))
def is_enforcement_enabled(tenant_conn):
"""Return True when the tenant opted into blocking credit sales for 'risk'.
Defaults to False on any error or missing flag so the sale flow never
breaks because of scoring.
"""
try:
cur = tenant_conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (ENFORCE_FLAG,))
row = cur.fetchone()
cur.close()
if not row:
return False
return str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
except Exception as exc: # pragma: no cover - defensive
logger.warning("credit_scoring: could not read enforce flag: %s", exc)
return False
def _collect_evidence(cur, customer_id):
"""Read every raw signal in a few queries. Returns a dict or None if the
customer does not exist."""
cur.execute("""
SELECT id, name, created_at, credit_limit, credit_balance,
price_tier, is_active
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
if not row:
return None
ev = {
'customer_id': row[0],
'name': row[1],
'created_at': _aware(row[2]),
'credit_limit': float(row[3] or 0),
'credit_balance': float(row[4] or 0),
'price_tier': row[5],
'is_active': row[6],
}
# Completed sales aggregate (frequency / value / recency).
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(total), 0), MIN(created_at),
MAX(created_at), COALESCE(AVG(total), 0),
COUNT(*) FILTER (WHERE sale_type = 'credit')
FROM sales
WHERE customer_id = %s AND status = 'completed'
""", (customer_id,))
total_orders, ltv, first_purchase, last_purchase, aov, credit_sales = cur.fetchone()
ev.update({
'total_orders': int(total_orders or 0),
'ltv': float(ltv or 0),
'first_purchase': _aware(first_purchase),
'last_purchase': _aware(last_purchase),
'avg_order_value': float(aov or 0),
'credit_sales': int(credit_sales or 0),
})
# Open (unpaid) credit sales: balance and due date for aging.
cur.execute("""
SELECT s.id, s.total, s.created_at,
s.total - COALESCE(SUM(sp.amount), 0) AS balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0.005
ORDER BY s.created_at
""", (customer_id,))
now = _now()
open_sales = []
for sale_id, total, created_at, balance in cur.fetchall():
created_at = _aware(created_at)
due = created_at + timedelta(days=CREDIT_TERMS_DAYS) if created_at else None
overdue = max(0, (now - due).days) if due else 0
open_sales.append({
'sale_id': sale_id,
'total': float(total or 0),
'created_at': created_at,
'balance': float(balance or 0),
'due_date': due,
'days_overdue': overdue,
})
ev['open_sales'] = open_sales
ev['open_balance'] = round(sum(s['balance'] for s in open_sales), 2)
ev['max_overdue_days'] = max((s['days_overdue'] for s in open_sales), default=0)
ev['overdue_balance'] = round(
sum(s['balance'] for s in open_sales if s['days_overdue'] > 0), 2
)
# Aging buckets (by days overdue).
buckets = {'current': 0.0, 'd1_30': 0.0, 'd31_60': 0.0, 'd61_90': 0.0, 'd90_plus': 0.0}
for s in open_sales:
d = s['days_overdue']
if d == 0:
buckets['current'] += s['balance']
elif d <= 30:
buckets['d1_30'] += s['balance']
elif d <= 60:
buckets['d31_60'] += s['balance']
elif d <= 90:
buckets['d61_90'] += s['balance']
else:
buckets['d90_plus'] += s['balance']
ev['aging'] = {k: round(v, 2) for k, v in buckets.items()}
# Settled credit sales: was the final payment within terms?
cur.execute("""
SELECT s.created_at, MAX(sp.created_at) AS last_payment
FROM sales s
JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING SUM(sp.amount) >= s.total - 0.005
""", (customer_id,))
settled = []
for created_at, last_payment in cur.fetchall():
created_at = _aware(created_at)
last_payment = _aware(last_payment)
if not created_at or not last_payment:
continue
due = created_at + timedelta(days=CREDIT_TERMS_DAYS)
settled.append({'days_late': max(0, (last_payment - due).days)})
ev['settled_credit_sales'] = settled
# Direct customer payments (abonos) — extra evidence of engagement.
cur.execute("""
SELECT COUNT(*), COALESCE(SUM(amount), 0), MAX(created_at)
FROM customer_payments WHERE customer_id = %s
""", (customer_id,))
cp_count, cp_total, cp_last = cur.fetchone()
ev.update({
'customer_payments_count': int(cp_count or 0),
'customer_payments_total': float(cp_total or 0),
'customer_payments_last': _aware(cp_last),
})
return ev
# ─── Signal scoring ─────────────────────────────────────
def _tenure_signal(ev, reasons):
created_at = ev['created_at']
if not created_at:
return None
days = max(0, (_now() - created_at).days)
months = days / 30.44
if months < 3:
score = 45
elif months < 6:
score = 60
elif months < 12:
score = 75
elif months < 24:
score = 85
else:
score = 95
reasons.append(f"Cliente con {int(months)} meses de antigüedad ({days} días).")
if ev.get('is_active') is False:
score = min(score, 40)
reasons.append("Cliente marcado como inactivo.")
return score
def _purchase_signal(ev, reasons):
total_orders = ev['total_orders']
if total_orders <= 0:
reasons.append("Sin historial de compras registrado.")
return None
if total_orders == 1:
freq = 50
elif total_orders <= 3:
freq = 65
elif total_orders <= 9:
freq = 80
elif total_orders <= 24:
freq = 90
else:
freq = 95
last = ev['last_purchase']
days_since = (_now() - last).days if last else None
if days_since is None:
recency = 60
elif days_since <= 30:
recency = 100
elif days_since <= 90:
recency = 80
elif days_since <= 180:
recency = 60
elif days_since <= 365:
recency = 40
else:
recency = 25
score = round(0.6 * freq + 0.4 * recency)
reasons.append(
f"{total_orders} compras por ${ev['ltv']:.2f} "
f"(ticket promedio ${ev['avg_order_value']:.2f}), "
f"última hace {days_since} días."
if days_since is not None else
f"{total_orders} compras por ${ev['ltv']:.2f}."
)
return _clamp(score)
def _payment_signal(ev, reasons):
has_history = (
ev['credit_sales'] > 0
or ev['open_balance'] > 0
or ev['customer_payments_count'] > 0
)
if not has_history:
reasons.append("Sin historial de crédito ni abonos.")
return None
settled = ev['settled_credit_sales']
if settled:
on_time = sum(1 for s in settled if s['days_late'] <= 0)
late_ratio = (len(settled) - on_time) / len(settled)
base = 90 - 60 * late_ratio
reasons.append(
f"{on_time}/{len(settled)} créditos liquidados dentro del plazo de "
f"{CREDIT_TERMS_DAYS} días."
)
else:
base = 70 if ev['credit_sales'] > 0 or ev['open_balance'] > 0 else 75
reasons.append("Sin créditos liquidados aún; sin historial de puntualidad.")
max_overdue = ev['max_overdue_days']
if max_overdue > 0:
penalty = min(55.0, 15.0 + max_overdue * 0.5)
base -= penalty
reasons.append(
f"Mora abierta: ${ev['overdue_balance']:.2f} con hasta {max_overdue} "
f"días de atraso."
)
if ev['credit_limit'] > 0:
util = ev['credit_balance'] / ev['credit_limit']
if util > 1.0:
base -= 15
reasons.append(f"Crédito excedido ({util * 100:.0f}% del límite).")
elif util > 0.9:
base -= 8
reasons.append(f"Crédito casi al límite ({util * 100:.0f}%).")
return _clamp(round(base))
def _utilization_signal(ev, reasons):
limit = ev['credit_limit']
balance = ev['credit_balance']
if limit <= 0:
if balance > 0:
reasons.append(f"Saldo de ${balance:.2f} sin límite de crédito definido.")
return 45
return None
util = balance / limit
if util <= 0.001:
score = 100
elif util <= 0.3:
score = 95
elif util <= 0.6:
score = 80
elif util <= 0.9:
score = 60
elif util <= 1.0:
score = 40
else:
score = 15
reasons.append(f"Utilización de crédito {util * 100:.0f}% (${balance:.2f} / ${limit:.2f}).")
return score
def _tier_from_score(score, data_points, max_overdue_days, behavioral_evidence):
if max_overdue_days > 60:
return 'risk'
if score >= 75 and data_points >= 2 and behavioral_evidence:
tier = 'good'
elif score >= 50:
tier = 'medium'
else:
tier = 'risk'
# Conservative when mora is building or evidence is thin.
if max_overdue_days > 30 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if not behavioral_evidence and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
if data_points <= 1 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
tier = 'medium'
return tier
def _recommended_limit(tier, ev):
if ev['total_orders'] <= 0:
return 0.0
aov = ev['avg_order_value'] or (ev['ltv'] / ev['total_orders'])
if tier == 'good':
factor = 4.0
elif tier == 'medium':
factor = 2.0
else:
return 0.0 # risk: do not recommend extending new credit
suggested = aov * factor
suggested = round(suggested / 100.0) * 100.0
return float(min(suggested, 200000.0))
def score_customer(tenant_conn, customer_id):
"""Score a customer's credit risk.
Returns a dict::
{
'score': 0..100, # higher = safer
'tier': 'good'|'medium'|'risk',
'recommended_limit': float,
'reasons': [str, ...],
'data_points': int, # number of signals with real data (0..4)
'components': {...}, # per-signal sub-scores (None when absent)
'metrics': {...}, # raw evidence for auditing
'computed_at': iso8601 str,
}
Raises ValueError if the customer does not exist.
"""
cur = tenant_conn.cursor()
try:
ev = _collect_evidence(cur, customer_id)
finally:
cur.close()
if ev is None:
raise ValueError(f"Customer {customer_id} not found")
reasons = []
components = {
'tenure': _tenure_signal(ev, reasons),
'purchase': _purchase_signal(ev, reasons),
'payment': _payment_signal(ev, reasons),
'utilization': _utilization_signal(ev, reasons),
}
available = {k: v for k, v in components.items() if v is not None}
data_points = len(available)
if available:
weight_sum = sum(_WEIGHTS[k] for k in available)
score = sum(available[k] * _WEIGHTS[k] for k in available) / weight_sum
else:
score = 50.0
score = int(round(_clamp(score)))
behavioral = components['purchase'] is not None or components['payment'] is not None
# Hard cap: heavy mora can never yield a high score.
if ev['max_overdue_days'] > 60:
score = min(score, 39)
elif ev['max_overdue_days'] > 30:
score = min(score, 60)
tier = _tier_from_score(score, data_points, ev['max_overdue_days'], behavioral)
recommended = _recommended_limit(tier, ev)
if data_points <= 1:
reasons.append(
f"Pocos datos ({data_points} señal{'es' if data_points != 1 else ''}); "
"evaluación conservadora."
)
return {
'customer_id': customer_id,
'score': score,
'tier': tier,
'recommended_limit': recommended,
'reasons': reasons,
'data_points': data_points,
'components': components,
'metrics': {
'months_active': round((_now() - ev['created_at']).days / 30.44, 1) if ev['created_at'] else None,
'total_orders': ev['total_orders'],
'ltv': round(ev['ltv'], 2),
'avg_order_value': round(ev['avg_order_value'], 2),
'days_since_last_purchase': (_now() - ev['last_purchase']).days if ev['last_purchase'] else None,
'credit_limit': ev['credit_limit'],
'credit_balance': ev['credit_balance'],
'open_credit_balance': ev['open_balance'],
'max_overdue_days': ev['max_overdue_days'],
'overdue_balance': ev['overdue_balance'],
'aging': ev['aging'],
'credit_sales': ev['credit_sales'],
'settled_credit_sales': len(ev['settled_credit_sales']),
},
'computed_at': _now().isoformat(),
}

View File

@@ -7,7 +7,16 @@ Provides:
- Customer analytics (LTV, frequency, churn risk)
"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
def _aware(dt):
"""Normalize a possibly-naive datetime (psycopg2 may return either) to UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
# ─── Customer Activities ─────────────────────────────
@@ -302,11 +311,15 @@ def get_customer_analytics(conn, customer_id):
WHERE customer_id = %s AND status = 'completed'
""", (customer_id,))
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
first_purchase = _aware(first_purchase)
last_purchase = _aware(last_purchase)
now = datetime.now(timezone.utc)
# Days since last purchase
days_since_last = None
if last_purchase:
days_since_last = (datetime.utcnow() - last_purchase).days
days_since_last = (now - last_purchase).days
# Purchase frequency (orders per month)
frequency = 0.0
@@ -314,13 +327,69 @@ def get_customer_analytics(conn, customer_id):
months = max(1, (last_purchase - first_purchase).days / 30.0)
frequency = round(total_orders / months, 2)
# Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
churn_risk = 'low'
if days_since_last is not None:
if days_since_last > 90:
# Real inter-purchase interval per customer (A12). Uses the gaps between
# consecutive completed sales instead of fixed 60/90-day thresholds.
cur.execute("""
SELECT created_at FROM sales
WHERE customer_id = %s AND status = 'completed'
ORDER BY created_at
""", (customer_id,))
purchase_dates = [_aware(r[0]) for r in cur.fetchall() if r[0] is not None]
intervals = [
(purchase_dates[i] - purchase_dates[i - 1]).days
for i in range(1, len(purchase_dates))
]
intervals = [d for d in intervals if d >= 0]
avg_interval = round(sum(intervals) / len(intervals), 1) if intervals else None
# Next-purchase prediction + churn probability.
predicted_next_purchase_date = None
days_until_next_purchase = None
churn_probability = 0.0
churn_reason = ''
if avg_interval and last_purchase and avg_interval > 0:
predicted = last_purchase + timedelta(days=round(avg_interval))
predicted_next_purchase_date = predicted
days_until_next_purchase = (predicted - now).days
# How many of its own typical cycles the customer has been silent.
ratio = (days_since_last or 0) / max(float(avg_interval), 1.0)
if ratio > 2.0:
churn_risk = 'high'
churn_probability = 0.9
churn_reason = (f"Lleva {days_since_last} días sin comprar, más de 2× "
f"su intervalo habitual ({avg_interval:.0f} días).")
elif ratio > 1.5:
churn_risk = 'high'
churn_probability = 0.7
churn_reason = (f"Rebasó 1.5× su intervalo habitual de compra "
f"({avg_interval:.0f} días).")
elif ratio > 1.0:
churn_risk = 'medium'
churn_probability = 0.4
churn_reason = f"Ya superó su intervalo habitual de compra ({avg_interval:.0f} días)."
else:
churn_risk = 'low'
churn_probability = 0.15
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)."
else:
# Fewer than two purchases: not enough history for a cadence. Fall back
# to the legacy fixed thresholds but report that we lacked data.
if days_since_last is None:
churn_risk = 'low'
churn_probability = 0.0
churn_reason = 'Sin compras registradas.'
elif days_since_last > 90:
churn_risk = 'high'
churn_probability = 0.75
churn_reason = f"Sin compras desde hace {days_since_last} días."
elif days_since_last > 60:
churn_risk = 'medium'
churn_probability = 0.45
churn_reason = f"Sin compras desde hace {days_since_last} días."
else:
churn_risk = 'low'
churn_probability = 0.15
churn_reason = f"Compra reciente (hace {days_since_last} días)."
# Favorite categories (from inventory via sale_items)
cur.execute("""
@@ -362,6 +431,15 @@ def get_customer_analytics(conn, customer_id):
'days_since_last_purchase': days_since_last,
'purchase_frequency_monthly': frequency,
'churn_risk': churn_risk,
# A12 additions: real inter-purchase cadence + prediction.
'avg_days_between_purchases': avg_interval,
'purchase_intervals_count': len(intervals),
'predicted_next_purchase_date': (
predicted_next_purchase_date.isoformat() if predicted_next_purchase_date else None
),
'days_until_next_purchase': days_until_next_purchase,
'churn_probability': churn_probability,
'churn_reason': churn_reason,
'favorite_categories': categories,
'loyalty': {
'points_balance': loyalty_balance,

233
pos/services/cross_sell.py Normal file
View File

@@ -0,0 +1,233 @@
"""Cross-sell recommendations for the POS cart ("esto + esto").
Given the inventory_ids already in the cart, suggest complementary parts
from two independent sources, merged and ranked:
1. Curated part kits — ``services.part_kits.KIT_SUGGESTIONS`` (Spanish
aftermarket knowledge; works from day one, no sales data required).
2. Real co-occurrence — inventory items that historically appear in the
same ``sale_id`` as one of the cart items (learns from actual sales).
Items that already have stock are preferred, and items already in the cart
are never suggested.
Public API:
get_cross_sell(conn, inventory_ids, limit=5) -> list[dict]
"""
from services.part_kits import get_kit_suggestions
# Same accent-stripping used by catalog_service so that "Batería" in the
# inventory matches the unaccented keywords in KIT_SUGGESTIONS.
_ACCENTS = [
('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N'),
]
# Ranking weights: stock first (a suggestion you cannot sell is useless),
# then source (real co-occurrence beats the curated kit), then evidence.
_W_STOCK = 1000
_W_COOC = 100
_W_KIT = 50
_W_FREQ = 10
def _norm_text(value):
"""Upper-case and strip accents for keyword comparison."""
text = (value or '').upper()
for a, b in _ACCENTS:
text = text.replace(a, b)
return text
def _norm_expr(column):
"""SQL expression that upper-cases and strips accents from a column."""
expr = f"UPPER({column})"
for a, b in _ACCENTS:
expr = f"REPLACE({expr}, '{a}', '{b}')"
return expr
def _cart_items(conn, inventory_ids):
"""Load the cart items (id, name) so we can derive kit keywords."""
cur = conn.cursor()
try:
cur.execute(
"SELECT id, part_number, name FROM inventory WHERE id = ANY(%s)",
(list(inventory_ids),),
)
return cur.fetchall()
finally:
cur.close()
def _kit_candidates(conn, cart_items, exclude_ids, limit):
"""Inventory rows whose name matches a curated kit keyword.
Returns list of (inventory_id, part_number, name, price, stock, reason).
"""
keyword_sources = {}
patterns = []
for _inv_id, _pn, name in cart_items:
for keyword in get_kit_suggestions(name):
norm = _norm_text(keyword)
if not norm or norm in keyword_sources:
continue
keyword_sources[norm] = (name, keyword)
patterns.append(f'%{norm}%')
if not patterns:
return []
name_expr = _norm_expr('i.name')
sql = f"""
SELECT DISTINCT ON (i.id)
i.id, i.part_number, i.name, i.price_1,
COALESCE(s.stock, 0) AS stock, {name_expr} AS name_norm
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND i.id <> ALL(%s)
AND {name_expr} LIKE ANY(%s)
ORDER BY i.id, i.name
LIMIT %s
"""
cur = conn.cursor()
try:
cur.execute(sql, (list(exclude_ids), patterns, limit * 4))
rows = cur.fetchall()
finally:
cur.close()
out = []
for inv_id, part_number, name, price, stock, name_norm in rows:
source_name, keyword = None, None
for norm, (src, kw) in keyword_sources.items():
if norm in (name_norm or ''):
source_name, keyword = src, kw
break
if source_name is None:
continue
out.append((inv_id, part_number, name, price, stock,
f"Complemento habitual de {source_name} ({keyword})"))
return out
def _cooccurrence_candidates(conn, inventory_ids, exclude_ids, limit):
"""Inventory items historically sold in the same sale as a cart item.
Returns list of (inventory_id, part_number, name, price, stock, freq, paired_with).
"""
cur = conn.cursor()
try:
cur.execute("""
SELECT si2.inventory_id,
COUNT(DISTINCT si2.sale_id) AS freq,
MIN(si1.name) AS paired_with
FROM sale_items si1
JOIN sale_items si2
ON si2.sale_id = si1.sale_id
AND si2.inventory_id IS NOT NULL
AND si2.inventory_id <> ALL(%s)
WHERE si1.inventory_id = ANY(%s)
GROUP BY si2.inventory_id
ORDER BY freq DESC, si2.inventory_id
LIMIT %s
""", (list(exclude_ids), list(inventory_ids), limit * 4))
pairs = cur.fetchall()
if not pairs:
return []
ids = [r[0] for r in pairs]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.price_1,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.id = ANY(%s)
""", (ids,))
by_id = {r[0]: r for r in cur.fetchall()}
finally:
cur.close()
out = []
for inv_id, freq, paired_with in pairs:
row = by_id.get(inv_id)
if not row:
continue
_rid, part_number, name, price, stock = row
out.append((inv_id, part_number, name, price, stock, freq, paired_with))
return out
def get_cross_sell(conn, inventory_ids, limit=5):
"""Suggest complementary parts for the given cart contents.
Args:
conn: psycopg2 connection to the tenant DB.
inventory_ids: iterable of inventory ids currently in the cart.
limit: max number of suggestions.
Returns:
list of dicts: {inventory_id, name, part_number, price, reason, stock, source}
"""
try:
cart_ids = [int(i) for i in (inventory_ids or [])]
except (TypeError, ValueError):
cart_ids = []
cart_ids = list(dict.fromkeys(cart_ids))[:50]
if not cart_ids:
return []
scored = {} # inventory_id -> dict with score + payload
try:
cart_items = _cart_items(conn, cart_ids)
except Exception:
conn.rollback()
return []
# 1. Co-occurrence (real sales) — highest evidence weight.
try:
for inv_id, part_number, name, price, stock, freq, paired_with in \
_cooccurrence_candidates(conn, cart_ids, cart_ids, limit):
score = (stock > 0) * _W_STOCK + _W_COOC + min(freq, 5) * _W_FREQ
scored[inv_id] = {
'inventory_id': inv_id,
'name': name,
'part_number': part_number,
'price': float(price) if price is not None else None,
'stock': int(stock or 0),
'reason': f"Suele venderse con {paired_with or 'este artículo'}",
'source': 'cooccurrence',
'_score': score,
}
except Exception:
conn.rollback()
# 2. Curated kits — fill in whatever co-occurrence did not cover.
try:
for inv_id, part_number, name, price, stock, reason in \
_kit_candidates(conn, cart_items, cart_ids, limit):
score = (stock > 0) * _W_STOCK + _W_KIT
if inv_id in scored:
# Already suggested by real sales: bump evidence, keep reason.
scored[inv_id]['_score'] += _W_KIT
continue
scored[inv_id] = {
'inventory_id': inv_id,
'name': name,
'part_number': part_number,
'price': float(price) if price is not None else None,
'stock': int(stock or 0),
'reason': reason,
'source': 'kit',
'_score': score,
}
except Exception:
conn.rollback()
ordered = sorted(scored.values(), key=lambda d: d['_score'], reverse=True)
for item in ordered:
item.pop('_score', None)
return ordered[:max(1, limit)]

View File

@@ -108,6 +108,18 @@ def _get_user_key() -> str | None:
return USER_KEY.strip() or None
def _find_user_key(tenant_config: dict) -> str | None:
"""Return the Facturapi user key (env or sk_user_* tenant key), if any."""
user_key = _get_user_key()
if user_key:
return user_key
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
return None
def _is_user_key_mode(tenant_config: dict) -> bool:
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
@@ -138,7 +150,9 @@ def get_organization(org_id: str, api_key: str) -> dict:
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
cer_b64 and key_b64 are base64-encoded strings.
cer_b64 and key_b64 are base64-encoded strings. Uses the documented
PUT /organizations/{id}/certificate endpoint with the `cer`, `key` and
`password` multipart fields.
"""
api_key = get_api_key(tenant_config)
org_id = _get_org_id(tenant_config)
@@ -150,11 +164,11 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
url = f"{BASE_URL}/organizations/{org_id}/certificate"
files = {
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
"secret": (None, password),
"cer": ("certificate.cer", cer_bytes, "application/octet-stream"),
"key": ("private_key.key", key_bytes, "application/octet-stream"),
"password": (None, password),
}
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
resp = requests.put(url, auth=(api_key, ""), files=files, timeout=60)
if not resp.ok:
raise FacturapiError(
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
@@ -170,13 +184,9 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
1. FACTURAPI_USER_KEY environment variable
2. tenant_config.facturapi_key if it starts with sk_user_
"""
user_key = _get_user_key()
user_key = _find_user_key(tenant_config)
if user_key:
return user_key
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
@@ -347,7 +357,7 @@ def get_org_status(tenant_config: dict) -> dict:
}
has_secret = bool(_get_secret_key(tenant_config))
has_user = bool(_get_user_key_for_tenant(tenant_config))
has_user = bool(_find_user_key(tenant_config))
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
@@ -366,10 +376,10 @@ def get_org_status(tenant_config: dict) -> dict:
result["error"] = "No Facturapi key available"
return result
def _fetch():
org = get_organization(org_id, api_key)
legal = org.get("legal", {})
cert = org.get("certificate", {})
def _fetch(key=None):
org = get_organization(org_id, key or api_key)
legal = org.get("legal") or {}
cert = org.get("certificate") or {}
return {
"configured": True,
"has_csd": bool(cert.get("has_certificate")),
@@ -383,10 +393,11 @@ def get_org_status(tenant_config: dict) -> dict:
except FacturapiError as e:
# If the org reports "not configured for Live" (401) and we have a user key,
# try to backfill legal data and retry.
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
user_key = _find_user_key(tenant_config)
if e.status_code == 401 and user_key:
try:
update_organization_legal(tenant_config, org_id)
result.update(_fetch())
result.update(_fetch(user_key))
except FacturapiError as e2:
result["error"] = str(e2)
else:

View File

@@ -35,194 +35,93 @@ def get_compat_source(tenant_id):
return 'both'
def _norm_pn(pn):
"""Normalize a part number: uppercase, strip spaces and separators."""
if not pn:
return ''
return (str(pn).replace(' ', '').replace('-', '').replace('/', '')
.replace('.', '').replace('_', '').upper())
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
brand=None, name=None):
"""Find vehicle compatibility for an inventory item by part_number.
"""Find vehicle compatibility for an inventory item via the supplier catalog.
Searches (in order of precision):
1. parts.oem_part_number exact normalized
2. aftermarket_parts.part_number exact normalized
3. part_cross_references.cross_reference_number exact normalized
4. parts.oem_part_number partial (ILIKE) — strips separators too
5. aftermarket_parts.part_number partial (ILIKE)
6. part_cross_references.cross_reference_number partial (ILIKE)
7. parts.name_part / parts.name_es by inventory item name (ILIKE)
8. parts.name_part / parts.name_es by inventory item name (tsvector)
Legacy note: the original implementation queried the removed TecDoc tables
(parts / aftermarket_parts / part_cross_references / vehicle_parts) and
always failed. This version uses the LIVE master tables:
1. supplier_catalog.sku exact-normalized
2. supplier_catalog_interchange.part_number exact-normalized (cross-refs)
3. prefix fallback on catalog SKU (only for >=5 char numbers, few matches)
then collects DISTINCT model_year_engine_id from supplier_catalog_compat
and inserts them into the tenant inventory_vehicle_compat table.
If a brand hint is provided, results are preferentially filtered to
vehicles of that brand.
Returns:
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
Returns dict {'matched': bool, 'matches': [...], 'myes': [...], 'inserted': int}
(same shape as the legacy implementation).
"""
cur = master_conn.cursor()
oem_ids = set()
clean_pn = _norm_pn(part_number)
catalog_ids = set()
# Normalization helpers
clean_pn = (part_number or '').replace(' ', '').upper()
no_seps = clean_pn.replace('-', '').replace('/', '').replace('.', '').replace('_', '')
name_q = (name or '').strip()
brand_hint = (brand or '').strip().upper()
# ── 1. Exact normalized ──────────────────────────────────────────────
if clean_pn:
# 1. Exact match on catalog SKU (with and without inner spaces)
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
""", (clean_pn,))
SELECT id FROM supplier_catalog
WHERE REPLACE(UPPER(sku), '-', '') = %s
OR REPLACE(UPPER(REPLACE(sku, ' ', '')), '-', '') = %s
LIMIT 50
""", (clean_pn, clean_pn))
for r in cur.fetchall():
oem_ids.add(r[0])
catalog_ids.add(r[0])
if clean_pn and not oem_ids:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
""", (clean_pn,))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
if clean_pn and not oem_ids:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') = %s
""", (clean_pn,))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# ── 2. Partial / separator-stripped ──────────────────────────────────
if clean_pn and len(clean_pn) >= 3 and len(oem_ids) < 10:
# OEM part number partial
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') LIKE %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
oem_ids.add(r[0])
# Aftermarket partial
if len(oem_ids) < 10:
# 2. Exact match on interchange part numbers (cross references)
if not catalog_ids:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') LIKE %s
AND oem_part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
SELECT DISTINCT catalog_id FROM supplier_catalog_interchange
WHERE REPLACE(UPPER(REPLACE(part_number, '-', '')), ' ', '') = %s
LIMIT 50
""", (clean_pn,))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
catalog_ids.add(r[0])
# Cross-reference partial
if len(oem_ids) < 10:
# 3. Prefix fallback for longer numbers when exact found little
if len(clean_pn) >= 5 and len(catalog_ids) < 3:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') LIKE %s
AND part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# Separator-stripped fallback (e.g. KYB-343412 → KYB343412)
if len(no_seps) >= 4 and len(oem_ids) < 10:
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER(oem_part_number), ' ', ''), '-', ''), '/', ''), '.', '') = %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
SELECT id FROM supplier_catalog
WHERE REPLACE(UPPER(sku), '-', '') LIKE %s
LIMIT 10
""", (no_seps, list(oem_ids) or [0]))
""", (clean_pn[:8] + '%',))
for r in cur.fetchall():
oem_ids.add(r[0])
catalog_ids.add(r[0])
# ── 3. Search by item name (when part_number yields nothing) ─────────
if not oem_ids and name_q and len(name_q) >= 3:
# Name-based ILIKE search on parts
mye_ids = []
if catalog_ids:
cur.execute("""
SELECT id_part FROM parts
WHERE UPPER(name_part) LIKE %s OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
oem_ids.add(r[0])
# Name-based ILIKE search on aftermarket_parts
if len(oem_ids) < 20:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE UPPER(COALESCE(name_aftermarket_parts, '')) LIKE %s
OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
if not oem_ids:
cur.close()
return {'matched': False, 'matches': [], 'myes': []}
# ── Resolve MYEs ─────────────────────────────────────────────────────
oem_ids = list(oem_ids)
if brand_hint and brand_hint != 'GENERAL':
# Brand-aware: only MYEs for vehicles of the hinted brand
cur.execute("""
SELECT DISTINCT vp.model_year_engine_id
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s
SELECT DISTINCT model_year_engine_id FROM supplier_catalog_compat
WHERE catalog_id = ANY(%s) AND model_year_engine_id IS NOT NULL
LIMIT 500
""", (oem_ids, brand_hint))
""", (list(catalog_ids),))
mye_ids = [r[0] for r in cur.fetchall()]
# Fallback: if brand filter yields nothing, the brand hint may be an
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
# OEM vehicle brand. Search without brand filter.
if not mye_ids:
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
else:
# No brand hint — return all MYEs for these parts
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
cur.close()
# ── Insert into tenant table ─────────────────────────────────────────
inserted = 0
cur2 = tenant_conn.cursor()
for mye_id in mye_ids:
cur2.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, 'auto_match', 1.0)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id))
if cur2.rowcount > 0:
inserted += 1
tenant_conn.commit()
cur2.close()
if mye_ids:
cur2 = tenant_conn.cursor()
for mye_id in mye_ids:
cur2.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, 'supplier_catalog', 0.95)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id))
if cur2.rowcount > 0:
inserted += 1
tenant_conn.commit()
cur2.close()
return {
'matched': len(mye_ids) > 0,
'matches': oem_ids,
'matches': sorted(catalog_ids),
'myes': mye_ids,
'inserted': inserted,
}

474
pos/services/invoice_ocr.py Normal file
View File

@@ -0,0 +1,474 @@
# /home/Autopartes/pos/services/invoice_ocr.py
"""Supplier-invoice OCR / parsing.
Accepts an image or PDF of a supplier invoice and returns structured lines:
[{part_number, name, quantity, unit_cost}]
Pipeline (first stage that yields lines wins):
1. PDF with a text layer -> ``pdftotext -layout``
2. Scanned PDF -> ``pdftoppm`` (first page) + Tesseract
3. Image -> Tesseract
4. Vision fallback -> ``ai_chat`` (QWEN / OpenRouter vision model)
Stock is NEVER touched here: the caller returns the parsed lines so the
front-end can confirm before creating a purchase order.
Public API:
parse_invoice(data, filename=None, content_type=None, use_vision=True) -> dict
"""
import base64
import io
import json
import os
import re
import shutil
import subprocess
import tempfile
_MONEY_RE = re.compile(r"^\$?\s?\d{1,3}(?:[,\d]{0,15})(?:\.\d{1,2})?$")
_INT_RE = re.compile(r"^\d{1,4}$")
_PN_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-\./_]{2,}$")
# Lines that are clearly headers/totals, not invoice items.
_SKIP_TOKENS = {
"factura", "remision", "total", "subtotal", "iva", "ieps", "rfc", "fecha",
"pedido", "cliente", "proveedor", "telefono", "direccion", "calle",
"colonia", "cantidad", "cant", "descripcion", "precio", "importe",
"unidad", "folio", "orden", "pagina", "gracias", "nota", "clave",
"codigo", "producto", "articulo", "descuento", "moneda", "condiciones",
"subtotal:", "total:", "iva:", "rfc:", "fecha:", "importe:", "precio:",
}
# Any of these as a whole word marks a header/footer line (RFC, date, totals).
_HEADER_WORDS = {
"rfc", "fecha", "folio", "cliente", "proveedor", "factura", "remision",
"remisión", "pedido", "telefono", "tel", "direccion", "dirección", "calle",
"colonia", "condiciones", "moneda", "subtotal", "total", "iva", "ieps",
"clave", "descripcion", "descripción", "cantidad", "unidad", "descuento",
"importe", "precio", "page",
}
_VISION_PROMPT = (
"Eres un experto en facturas de refaccionarias mexicanas. Extrae TODAS las "
"lineas de producto de la imagen. Devuelve SOLO JSON valido, sin texto extra, "
"con esta forma exacta: "
'{"lines":[{"part_number":"...","name":"...","quantity":1,"unit_cost":0.0}]}. '
"part_number = clave/SKU/numero de parte; name = descripcion; quantity = "
"cantidad (entero); unit_cost = precio unitario (numero, sin simbolos). "
"Si no hay lineas claras devuelve {\"lines\": []}."
)
# ── Binary helpers ─────────────────────────────────────────────────────────
def _is_pdf(data, filename):
if data[:5] == b"%PDF-":
return True
return bool(filename and filename.lower().endswith(".pdf"))
def _pdftotext(data):
"""Extract the text layer of a PDF. Returns '' on failure."""
exe = shutil.which("pdftotext")
if not exe:
return ""
try:
proc = subprocess.run(
[exe, "-layout", "-", "-"],
input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
)
if proc.returncode == 0:
return proc.stdout.decode("utf-8", "replace")
except Exception:
pass
return ""
def _pdf_first_page_png(data, dpi=200):
"""Render the first PDF page to PNG bytes (for OCR). None on failure."""
exe = shutil.which("pdftoppm")
if not exe:
return None
try:
with tempfile.TemporaryDirectory() as td:
pdf_path = os.path.join(td, "in.pdf")
with open(pdf_path, "wb") as f:
f.write(data)
prefix = os.path.join(td, "page")
subprocess.run(
[exe, "-png", "-r", str(dpi), "-f", "1", "-l", "1", pdf_path, prefix],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120,
)
for fn in sorted(os.listdir(td)):
if fn.startswith("page") and fn.endswith(".png"):
with open(os.path.join(td, fn), "rb") as f:
return f.read()
except Exception:
pass
return None
def _ocr_langs():
try:
import pytesseract
langs = pytesseract.get_languages(config="")
for wanted in ("spa", "eng"):
if wanted in langs:
return wanted
except Exception:
pass
return "eng"
def _ocr_image(img_bytes):
"""Run Tesseract over image bytes. Returns '' on failure."""
try:
from PIL import Image
import pytesseract
except Exception:
return ""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
return pytesseract.image_to_string(img, lang=_ocr_langs())
except Exception:
return ""
def _ocr_rows(img_bytes, y_tolerance=12):
"""OCR an image and rebuild table rows from word positions.
Tesseract's default layout analysis splits a monospaced invoice table into
one block per column, so ``image_to_string`` returns each column stacked
instead of each row. We regroup words by their vertical center instead.
Returns a list of row strings (falls back to plain lines on failure).
"""
try:
from PIL import Image
import pytesseract
except Exception:
return []
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
data = pytesseract.image_to_data(
img, lang=_ocr_langs(), output_type=pytesseract.Output.DICT
)
except Exception:
text = _ocr_image(img_bytes)
return text.splitlines() if text else []
groups = {} # representative y -> list of (left, text)
for i, txt in enumerate(data.get("text", [])):
txt = (txt or "").strip()
if not txt:
continue
try:
top = int(data["top"][i])
h = int(data["height"][i] or 0)
left = int(data["left"][i])
except (KeyError, ValueError, TypeError):
continue
cy = top + h // 2
key = None
for k in groups:
if abs(k - cy) <= max(y_tolerance, h // 2):
key = k
break
groups.setdefault(cy if key is None else key, []).append((left, txt))
rows = []
for cy in sorted(groups):
rows.append(" ".join(t for _, t in sorted(groups[cy])))
return rows
def _ocr_rows_text(img_bytes):
"""Row-reconstructed OCR text (falls back to raw Tesseract output)."""
rows = _ocr_rows(img_bytes)
if rows:
return "\n".join(rows)
return _ocr_image(img_bytes)
# ── Text line parser ───────────────────────────────────────────────────────
def _is_money(tok):
if tok.startswith("$"):
return True
if not _MONEY_RE.match(tok):
return False
return ("." in tok) or ("," in tok)
def _to_money(tok):
cleaned = re.sub(r"[^\d.]", "", tok.replace(",", ""))
try:
return float(cleaned)
except ValueError:
return None
def _is_part_number(tok):
if _is_money(tok):
return False
if not _PN_RE.match(tok):
return False
has_alpha = any(c.isalpha() for c in tok)
digits = sum(c.isdigit() for c in tok)
if has_alpha:
return digits >= 1 and len(tok) >= 4
# pure numeric part numbers are accepted only if long enough to not be a qty
return len(tok) >= 4
def _skip_line(line):
low = line.lower()
if low.strip(".:-") in _SKIP_TOKENS:
return True
words = set(re.findall(r"[a-záéíóúñ]+", low))
if words & _HEADER_WORDS:
return True
if words and all(t in _SKIP_TOKENS or t in ("de", "del", "la", "el") for t in words):
return True
return False
def _parse_line(line):
tokens = line.split()
if len(tokens) < 3:
return None
money_idx = [i for i, t in enumerate(tokens) if _is_money(t)]
int_idx = [i for i, t in enumerate(tokens) if _INT_RE.match(t)]
pn_idx = [i for i, t in enumerate(tokens) if _is_part_number(t)]
if not pn_idx or (not money_idx and not int_idx):
return None
qty, qty_i = None, None
for i in int_idx:
if i in money_idx:
continue
v = int(tokens[i])
if 1 <= v <= 9999:
qty, qty_i = v, i
break
unit, unit_i = None, None
if money_idx:
unit_i = money_idx[0]
unit = _to_money(tokens[unit_i])
pn_i = pn_idx[0]
used = {i for i in (pn_i, qty_i) if i is not None} | set(money_idx)
name = " ".join(t for i, t in enumerate(tokens) if i not in used).strip(" -|")
if not name:
name = None
if qty is None and unit is None:
return None
return {
"part_number": tokens[pn_i],
"name": name,
"quantity": qty,
"unit_cost": unit,
}
def parse_lines(text):
"""Parse raw invoice text into structured lines."""
out = []
seen = set()
for raw in (text or "").splitlines():
line = raw.strip()
if len(line) < 6 or _skip_line(line):
continue
row = _parse_line(line)
if not row:
continue
key = (row["part_number"].upper(), row["quantity"], row["unit_cost"])
if key in seen:
continue
seen.add(key)
out.append(row)
return out
# ── Vision fallback ────────────────────────────────────────────────────────
def _extract_json(text):
if not text:
return None
s = text.strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s)
try:
return json.loads(s)
except Exception:
pass
m = re.search(r"\{.*\}", s, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except Exception:
return None
return None
def _coerce_lines(obj):
if not obj:
return []
raw = obj.get("lines") if isinstance(obj, dict) else None
if raw is None and isinstance(obj, list):
raw = obj
if not isinstance(raw, list):
return []
lines = []
for item in raw:
if not isinstance(item, dict):
continue
pn = item.get("part_number") or item.get("clave") or item.get("sku")
name = item.get("name") or item.get("descripcion") or item.get("description")
qty = item.get("quantity", item.get("cantidad"))
cost = item.get("unit_cost", item.get("precio", item.get("unit_price")))
try:
qty = int(qty) if qty is not None else None
except (ValueError, TypeError):
qty = None
try:
cost = float(cost) if cost is not None else None
except (ValueError, TypeError):
cost = None
if pn or name:
lines.append({
"part_number": str(pn).strip() if pn else None,
"name": str(name).strip() if name else None,
"quantity": qty, "unit_cost": cost,
})
return lines
def _vision_extract(image_bytes, hint_text=None):
"""Extract lines with the vision model. Returns (lines, error)."""
try:
from services import ai_chat
except Exception as e:
return [], f"ai_chat import: {e}"
b64 = base64.b64encode(image_bytes).decode("ascii")
prompt = _VISION_PROMPT
if hint_text:
prompt += "\n\nTexto OCR parcial (puede estar incompleto):\n" + hint_text[:1500]
# 1) reuse chat_with_image (per task requirement)
try:
res = ai_chat.chat_with_image(prompt, b64)
lines = _coerce_lines(res)
if lines:
return lines, None
msg = res.get("message") if isinstance(res, dict) else None
lines = _coerce_lines(_extract_json(msg) if msg else None)
if lines:
return lines, None
except Exception:
pass
# 2) direct vision call with an invoice-specific system prompt
try:
data_url = "data:image/jpeg;base64," + b64
messages = [
{"role": "system", "content": "Responde SOLO JSON valido."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
]},
]
backends = []
if ai_chat.QWEN_ENABLED:
backends.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL))
if ai_chat.OPENROUTER_API_KEY:
backends.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL))
for url, key, model in backends:
result = ai_chat._post_chat_completion(
url, key, model, messages, max_tokens=1200, temperature=0.1, timeout=60
)
if not result:
continue
lines = _coerce_lines(_extract_json(result["content"]))
if lines:
return lines, None
return [], "vision sin lineas"
except Exception as e:
return [], f"vision: {e}"
# ── Public API ─────────────────────────────────────────────────────────────
def parse_invoice(data, filename=None, content_type=None, use_vision=True):
"""Parse an invoice image/PDF into structured lines.
Returns:
{
"lines": [{part_number, name, quantity, unit_cost}],
"count": int,
"method": str, # pdftotext | pdftoppm+tesseract | tesseract | ...+vision
"warnings": [str],
"text_preview": str,
}
"""
warnings = []
text = ""
method = "unknown"
vision_image = None # bytes to hand to the vision model when OCR fails
if not data:
return {"lines": [], "count": 0, "method": "empty",
"warnings": ["archivo vacío"], "text_preview": ""}
if _is_pdf(data, filename):
text = _pdftotext(data)
if len(text.strip()) >= 30:
method = "pdftotext"
else:
png = _pdf_first_page_png(data)
if png:
vision_image = png
text = _ocr_rows_text(png)
method = "pdftoppm+tesseract" if text.strip() else "pdftoppm"
if not text.strip():
warnings.append("PDF sin capa de texto y OCR vacío")
else:
method = "pdf"
warnings.append("No se pudo renderizar el PDF")
if not vision_image:
# for vision fallback on a text PDF we still need a raster copy
vision_image = _pdf_first_page_png(data)
else:
vision_image = data
text = _ocr_rows_text(data)
method = "tesseract"
if not text.strip():
warnings.append("OCR sin texto reconocible")
lines = parse_lines(text)
if not lines and use_vision and vision_image:
vlines, verr = _vision_extract(vision_image, hint_text=text)
if vlines:
lines = vlines
method += "+vision"
elif verr:
warnings.append(f"fallback visión sin resultados: {verr}")
return {
"lines": lines,
"count": len(lines),
"method": method,
"warnings": warnings,
"text_preview": text[:800],
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,139 @@ INDEX_SETTINGS = {
'typoTolerance': {'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}},
}
# ─── Synonyms (A6) ──────────────────────────────────────────────────────────
# Spanish/English aftermarket equivalents so that "brake pad" finds "BALATA"
# and vice-versa. Built from a curated core plus the existing bilingual
# taxonomy (services.translations.PART_TRANSLATIONS). Multi-word synonyms are
# supported by Meilisearch.
CURATED_SYNONYMS = [
('balata', 'pastilla de freno', 'brake pad', 'brake pads'),
('disco de freno', 'disco', 'brake disc', 'brake rotor'),
('bujia', 'bujía', 'spark plug', 'spark plugs'),
('clutch', 'embrague', 'cloch', 'clochs'),
('faro', 'faro delantero', 'headlight', 'headlamp'),
('mofle', 'mofle', 'silenciador', 'muffler'),
('amortiguador', 'shock absorber', 'strut'),
('rotula', 'rótula', 'ball joint'),
('terminal', 'terminal de direccion', 'tie rod end'),
('banda', 'banda serpentina', 'belt', 'serpentine belt'),
('banda de distribucion', 'timing belt'),
('bomba de agua', 'water pump'),
('radiador', 'radiator'),
('alternador', 'alternator'),
('marcha', 'arrancador', 'starter', 'starter motor'),
('bobina', 'bobina de encendido', 'ignition coil'),
('bateria', 'batería', 'battery'),
('filtro de aire', 'air filter'),
('filtro de aceite', 'oil filter'),
('filtro de gasolina', 'fuel filter'),
('sensor', 'sensor'),
('collarín', 'collarín de clutch', 'throw out bearing', 'release bearing'),
('bomba de gasolina', 'fuel pump'),
('foco', 'foco', 'bulb', 'lamp'),
('cristal', 'cristal', 'glass', 'windshield'),
('parabrisas', 'parabrisas', 'windshield'),
('espejo', 'espejo lateral', 'mirror', 'side mirror'),
('abanico', 'ventilador', 'cooling fan', 'fan'),
('compresor', 'compresor de aire', 'a/c compressor', 'ac compressor'),
('valvula', 'válvula', 'valve'),
('junta', 'empaque', 'gasket'),
('manguera', 'hose'),
]
def _strip_accents(text):
if not text:
return ''
out = text
for a, b in (('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N')):
out = out.replace(a, b)
return out
def _symmetrize(groups):
"""{term: [other equivalent terms]} from groups of equivalent terms."""
synonyms = {}
for group in groups:
terms = []
for term in group:
t = (term or '').strip().lower()
if t and t not in terms:
terms.append(t)
for term in terms:
others = [t for t in terms if t != term]
synonyms.setdefault(term, [])
for o in others:
if o not in synonyms[term]:
synonyms[term].append(o)
# Accent-insensitive variant so "bujia" matches "bujía".
plain = _strip_accents(term)
if plain and plain != term:
synonyms.setdefault(plain, [])
for o in others:
if o not in synonyms[plain]:
synonyms[plain].append(o)
return synonyms
def build_synonyms(include_taxonomy=True):
"""Build the synonym map for the search indexes.
Curated colloquial Mexican terms first, then the curated bilingual
taxonomy in ``services.translations.PART_TRANSLATIONS`` (English → Spanish).
"""
groups = list(CURATED_SYNONYMS)
if include_taxonomy:
try:
from services.translations import PART_TRANSLATIONS
for en, es in PART_TRANSLATIONS.items():
en, es = (en or '').strip(), (es or '').strip()
if not en or not es or '/' in es:
continue
if len(en) > 40 or len(es) > 40:
continue
if en.lower() == es.lower():
continue
groups.append((en.lower(), es.lower()))
except Exception:
pass
return _symmetrize(groups)
_SYNONYMS_CACHE = None
def get_synonyms():
"""Cached synonym map (built once per process)."""
global _SYNONYMS_CACHE
if _SYNONYMS_CACHE is None:
_SYNONYMS_CACHE = build_synonyms()
return _SYNONYMS_CACHE
# Per-index settings used by scripts/index_meilisearch.py. ``aliases`` holds
# inventory_sku_aliases / interchange numbers for cross-reference search.
INVENTORY_SEARCHABLE_ATTRIBUTES = ['part_number', 'name', 'brand', 'aliases']
CATALOG_SEARCHABLE_ATTRIBUTES = ['sku', 'name', 'category', 'supplier_name', 'aliases']
def inventory_index_settings():
return {
'searchableAttributes': list(INVENTORY_SEARCHABLE_ATTRIBUTES),
'filterableAttributes': ['brand'],
'synonyms': get_synonyms(),
}
def catalog_index_settings():
return {
'searchableAttributes': list(CATALOG_SEARCHABLE_ATTRIBUTES),
'filterableAttributes': ['category', 'supplier_name'],
'synonyms': get_synonyms(),
}
_client = None
_client_url = None
@@ -124,6 +257,51 @@ def search_parts(query, limit=50, offset=0):
return None
def is_enabled():
"""True si MEILI_ENABLED está activo (se lee en cada llamada)."""
return os.environ.get('MEILI_ENABLED', 'true').lower() == 'true'
def search_index(index_uid, query, options=None):
"""Búsqueda genérica en cualquier índice.
Returns:
dict con la respuesta de Meilisearch, o None si Meilisearch está
apagado, sin API key, o falla — el caller debe usar su fallback SQL.
"""
if not is_enabled():
return None
try:
return get_client().index(index_uid).search(query, options or {})
except MeilisearchApiError as e:
# Índice inexistente (tenant sin reindexar) es esperado: silencio.
if e.code != 'index_not_found':
print(f"[meili_search] Search error on {index_uid}: {e}")
return None
except Exception as e:
print(f"[meili_search] Search error on {index_uid}: {e}")
return None
def inventory_index_name(db_name):
"""Índice de inventario de un tenant (ver scripts/index_meilisearch.py)."""
return f"nexus_inventory_{db_name}"
def search_inventory(db_name, query, limit=50, offset=0):
"""Busca en el índice de inventario de un tenant.
Returns:
list de hits (posiblemente vacía) o None si Meilisearch no está
disponible o el índice no existe → el caller debe usar SQL.
"""
res = search_index(inventory_index_name(db_name), query,
{'limit': limit, 'offset': offset})
if res is None:
return None
return res.get('hits', [])
def add_part(part_doc):
"""Add or update a single part document."""
try:
@@ -157,3 +335,56 @@ def clear_index():
except Exception as e:
print(f"[meili_search] Clear error: {e}")
return False
# ─── A6: settings / synonym refresh ─────────────────────────────────────────
def _index_uids(prefix=None):
"""UIDs of existing indexes (optionally filtered by prefix)."""
client = get_client()
try:
result = client.get_indexes()
except TypeError: # older meilisearch-python returns a dict
result = client.get_indexes()
if isinstance(result, dict):
results = result.get('results', [])
else:
results = getattr(result, 'results', result)
uids = []
for item in results:
uid = item.get('uid') if isinstance(item, dict) else getattr(item, 'uid', None)
if uid and (prefix is None or uid.startswith(prefix)):
uids.append(uid)
return uids
def apply_index_settings(index_uid, settings=None):
"""Apply searchable attributes + synonyms to one index (no reindex)."""
if settings is None:
if index_uid == 'nexus_supplier_catalog':
settings = catalog_index_settings()
elif index_uid.startswith('nexus_inventory_'):
settings = inventory_index_settings()
else:
settings = {'synonyms': get_synonyms()}
task = get_client().index(index_uid).update_settings(settings)
return task
def apply_synonyms_to_all(prefixes=('nexus_inventory_', 'nexus_supplier_catalog')):
"""Push the synonym map to every inventory/catalog index.
Updating ``synonyms`` does not require reindexing documents, so this is
safe to run against a live Meilisearch. Returns {index_uid: ok}.
"""
results = {}
for prefix in prefixes:
uids = _index_uids(prefix)
for uid in uids:
try:
apply_index_settings(uid)
results[uid] = True
except Exception as e:
results[uid] = f"error: {e}"
return results

View File

@@ -14,13 +14,22 @@ References:
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
"""
import logging
import time
import requests
from typing import Optional
logger = logging.getLogger(__name__)
BASE_URL = "https://api.mercadolibre.com"
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
# MercadoLibre rate-limits per app/user with HTTP 429 (+ Retry-After). Retry
# with exponential backoff before giving up so a burst of listing updates
# doesn't fail the whole batch.
RATE_LIMIT_MAX_RETRIES = 4
RATE_LIMIT_MAX_SLEEP = 60.0
class MeliError(Exception):
def __init__(self, message, status_code=None, response_body=None):
@@ -40,8 +49,10 @@ class MeliService:
refresh_token: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
on_refresh=None,
):
self.access_token = access_token
self.on_refresh = on_refresh
self.refresh_token = refresh_token
self.client_id = client_id
self.client_secret = client_secret
@@ -50,6 +61,21 @@ class MeliService:
# ─── Low-level request ───────────────────────────────────────────────
@staticmethod
def _rate_limit_sleep(resp, attempt: int) -> float:
"""Seconds to wait before retrying a 429.
Honors the Retry-After header when present, otherwise exponential
backoff (1, 2, 4, ...) capped at RATE_LIMIT_MAX_SLEEP.
"""
header = resp.headers.get("Retry-After")
if header:
try:
return max(0.0, min(float(header), RATE_LIMIT_MAX_SLEEP))
except (TypeError, ValueError):
pass
return min(2.0 ** attempt, RATE_LIMIT_MAX_SLEEP)
def _request(
self,
method: str,
@@ -57,11 +83,32 @@ class MeliService:
params: Optional[dict] = None,
json_payload: Optional[dict] = None,
retry_on_401: bool = True,
max_rate_limit_retries: int = RATE_LIMIT_MAX_RETRIES,
) -> dict:
url = f"{BASE_URL}{path}"
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
# 429 (rate limit) is retried with backoff; every other status is
# handled below. The 401 refresh path only runs once per call.
attempt = 0
while True:
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code != 429:
break
if attempt >= max_rate_limit_retries:
raise MeliError(
f"Meli API rate limit exceeded (429) after {attempt} retries: {resp.text}",
status_code=429,
response_body=resp.text,
)
delay = self._rate_limit_sleep(resp, attempt)
logger.warning(
"Meli 429 on %s %s — retry %s/%s in %.1fs",
method, path, attempt + 1, max_rate_limit_retries, delay,
)
time.sleep(delay)
attempt += 1
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
self._refresh_token()
@@ -106,15 +153,31 @@ class MeliService:
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
body = (resp.text or '')
# Credenciales de la app rechazadas: el client_secret cambió en el
# dev center de MercadoLibre — hay que actualizarlo, no reconectar.
if 'invalid_client' in body:
raise MeliAuthError(
"Las credenciales de la app de MercadoLibre (Client ID/Secret) fueron "
"rechazadas. Verifica que el Client Secret en Configuración → MercadoLibre "
"sea el vigente en el dev center (probablemente se regeneró).",
status_code=resp.status_code, response_body=body,
)
raise MeliAuthError(
f"Token refresh failed: {resp.status_code} {resp.text}",
f"Token refresh failed: {resp.status_code} {body}",
status_code=resp.status_code,
response_body=resp.text,
response_body=body,
)
data = resp.json()
self.access_token = data["access_token"]
if "refresh_token" in data:
self.refresh_token = data["refresh_token"]
# Persist rotated tokens so the connection survives restarts/rotation
if self.on_refresh:
try:
self.on_refresh(data)
except Exception:
pass # never break the request because persistence failed
return data
# ─── Auth / User ─────────────────────────────────────────────────────
@@ -237,6 +300,35 @@ class MeliService:
def close_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "closed"})
# ─── Item description ────────────────────────────────────────────────
#
# ML keeps the description in a sub-resource: POST /items/{id}/description
# creates it (a freshly published item has none) and PUT updates it.
def get_item_description(self, item_id: str) -> dict:
return self._request("GET", f"/items/{item_id}/description")
def set_item_description(self, item_id: str, plain_text: str, create: bool = True) -> dict:
"""Create (create=True) or update (create=False) an item's description.
Falls back to the opposite verb when ML answers "description already
exists" (400/405/409) — the two endpoints are idempotent in practice.
"""
method = "POST" if create else "PUT"
fallback = "PUT" if create else "POST"
try:
return self._request(
method, f"/items/{item_id}/description",
json_payload={"plain_text": plain_text},
)
except MeliError as e:
if e.status_code in (400, 404, 405, 409):
return self._request(
fallback, f"/items/{item_id}/description",
json_payload={"plain_text": plain_text},
)
raise
# ─── Questions & Answers ─────────────────────────────────────────────
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
@@ -270,6 +362,14 @@ class MeliService:
def get_category_attributes(self, category_id: str) -> list:
return self._request("GET", f"/categories/{category_id}/attributes")
def get_listing_prices(self, site_id: str, price: float, category_id: str) -> list:
"""MeLi fee calculator: returns fee details per listing type for a price/category."""
return self._request(
"GET",
f"/sites/{site_id}/listing_prices",
params={"price": price, "category_id": category_id},
)
def get_shipping_preferences(self, user_id: str) -> dict:
return self._request("GET", f"/users/{user_id}/shipping_preferences")

View File

@@ -244,7 +244,7 @@ def _send_push(conn, tenant_id, recipient_type, recipient_id, title, body, metad
def _send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata):
"""Send WhatsApp notification via existing service."""
try:
from services.whatsapp_service import send_message
from services.whatsapp_service import wa_send_text
cur = conn.cursor()
phone = None
if recipient_type == 'customer' and recipient_id:
@@ -255,7 +255,22 @@ def _send_whatsapp(conn, tenant_id, recipient_type, recipient_id, body, metadata
cur.close()
if phone:
send_message(phone, body)
# Route via tenant provider (Meta Cloud API / WA CRM or Baileys)
cfg = {}
try:
cur2 = conn.cursor()
cur2.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
raw = {k: v for k, v in cur2.fetchall()}
cur2.close()
cfg = {
'provider': raw.get('whatsapp_provider', 'baileys'),
'phone_number_id': raw.get('wa_phone_number_id', ''),
'access_token': raw.get('wa_access_token', ''),
'bridge_url': raw.get('whatsapp_bridge_url', ''),
}
except Exception:
pass
wa_send_text(cfg, phone, body)
except Exception:
pass

267
pos/services/part_vision.py Normal file
View File

@@ -0,0 +1,267 @@
# /home/Autopartes/pos/services/part_vision.py
"""A4 — Vision grading for used/yonke parts.
Given a photo of a used part (plus optional metadata: name, category,
declared condition) a VLM suggests a condition grade:
A = excelente (sin daños, funcional con seguridad)
B = bueno (desgaste normal de uso, funcional)
C = regular (desgaste/defecto menor, requiere revisión o reparación)
D = con daño (daño estructural visible / funcionalidad dudosa)
Backends (reused from ``services.ai_chat``): QWEN -> Hermes -> OpenRouter.
When no backend answers (e.g. the current QWEN key returns 401) the function
degrades gracefully: it never raises and returns a dict with
``condition_grade_suggested=None`` and a human-readable ``error``.
Public API:
suggest_grade(image_bytes=None, image_base64=None, name=None,
category=None, condition='usado', notes=None) -> dict
"""
import base64
import json
import re
GRADES = ('A', 'B', 'C', 'D')
GRADE_RUBRIC = """Escala de grados para piezas usadas de yonke:
- A (excelente): sin daños visibles, no requiere reparación; funcionamiento prácticamente seguro.
- B (bueno): desgaste normal por uso, sin daños estructurales; funciona; puede tener detalles cosméticos (rayones leves, óxido superficial).
- C (regular): desgaste notable o defecto menor que puede requerir reparación o cambio de componentes secundarios; funcionalidad probable pero a verificar.
- D (con daño): daño visible estructural, pieza rota/incompleta/muy corroída, o funcionalidad dudosa; se vende como refacción o no funcional."""
GRADE_INSTRUCTIONS = """Evalúa la pieza de la foto y asigna un grado.
Reglas:
1. Basa el grado SOLO en lo observable en la foto. Si la foto no muestra la pieza, está borrosa o no es de una autoparte, baja la confianza y explícalo en "notes".
2. Sé conservador: ante duda razonable entre dos grados, asigna el peor (menor).
3. "defects" enumera defectos concretos y visibles (en español). Vacío si no hay.
4. "confidence" es tu certeza global del grado (0.0 a 1.0).
Responde EXCLUSIVAMENTE con JSON válido, sin texto adicional:
{
"condition_grade_suggested": "A" | "B" | "C" | "D",
"condition_suggested": "usado",
"defects": ["..."],
"confidence": 0.0,
"notes": "una o dos frases en español"
}"""
def _build_prompt(name=None, category=None, condition=None):
"""Build the user prompt embedding the piece metadata."""
lines = [GRADE_INSTRUCTIONS, "", GRADE_RUBRIC, ""]
meta = []
if name:
meta.append(f"- Nombre declarado: {name}")
if category:
meta.append(f"- Categoría: {category}")
if condition:
meta.append(f"- Condición declarada: {condition}")
if meta:
lines.append("Metadatos de la pieza:")
lines.extend(meta)
else:
lines.append("No hay metadatos de la pieza; evalúa solo lo visible.")
return "\n".join(lines)
def _empty_result():
return {
'condition_grade_suggested': None,
'condition_suggested': 'usado',
'defects': [],
'confidence': 0.0,
'notes': '',
'source': None,
'error': None,
}
def _extract_json_object(text):
"""Best-effort JSON extraction from an LLM reply.
Handles markdown fences, leading/trailing prose and trailing commas.
Returns a dict or None.
"""
if not text:
return None
s = str(text).strip()
if s.startswith('```'):
s = re.sub(r'^```[A-Za-z0-9_-]*\s*', '', s)
s = re.sub(r'```\s*$', '', s).strip()
try:
parsed = json.loads(s)
return parsed if isinstance(parsed, dict) else None
except (json.JSONDecodeError, ValueError):
pass
m = re.search(r'\{.*\}', s, re.DOTALL)
if not m:
return None
frag = m.group(0)
for candidate in (frag, re.sub(r',\s*([}\]])', r'\1', frag)):
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, ValueError):
continue
return None
def _normalize_grade(data):
"""Coerce a raw model dict into the public grade schema."""
out = _empty_result()
if not isinstance(data, dict):
return out
raw_grade = (data.get('condition_grade_suggested')
or data.get('condition_grade')
or data.get('grade'))
grade = str(raw_grade).strip().upper()[:1] if raw_grade else None
out['condition_grade_suggested'] = grade if grade in GRADES else None
cond = str(data.get('condition_suggested') or data.get('condition') or 'usado').strip().lower()
out['condition_suggested'] = 'reconstruido' if 'reconstr' in cond else 'usado'
defects = data.get('defects')
if isinstance(defects, str):
defects = re.split(r'[;\n]|,', defects)
if isinstance(defects, list):
out['defects'] = [str(d).strip() for d in defects if str(d).strip()][:20]
try:
conf = float(data.get('confidence'))
if conf > 1:
conf = conf / 100.0
out['confidence'] = max(0.0, min(1.0, conf))
except (TypeError, ValueError):
out['confidence'] = 0.0
notes = data.get('notes') or data.get('message') or data.get('reason') or ''
out['notes'] = str(notes).strip()[:1000]
return out
def _is_usable(parsed):
"""True when a normalized dict carries a real grading signal.
Deliberately ignores ``notes``: the shared vision helper falls back to a
plain ``{"message": ...}`` dict on backend errors, and treating that as a
valid grade would hide outages behind a misleading answer.
"""
return bool(parsed.get('condition_grade_suggested') or parsed.get('defects'))
def _backends():
"""Ordered vision backends (url, key, model, tag) from ai_chat config."""
from services import ai_chat
out = []
if getattr(ai_chat, 'QWEN_ENABLED', False):
out.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL, 'qwen'))
if getattr(ai_chat, 'HERMES_ENABLED', False):
out.append((ai_chat.HERMES_CHAT_URL, ai_chat.HERMES_API_KEY, ai_chat.HERMES_MODEL, 'hermes'))
if getattr(ai_chat, 'OPENROUTER_API_KEY', None):
out.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL, 'openrouter'))
return out
def suggest_grade(image_bytes=None, image_base64=None, name=None, category=None,
condition='usado', notes=None):
"""Suggest a condition grade for a used part photo.
Returns:
{
"condition_grade_suggested": "A"|"B"|"C"|"D"|None,
"condition_suggested": "usado"|"reconstruido",
"defects": [str, ...],
"confidence": float, # 0.0 - 1.0
"notes": str,
"source": "qwen"|"hermes"|"openrouter"|None,
"error": str|None # set when no grade could be produced
}
"""
result = _empty_result()
if not image_bytes and not image_base64:
result['error'] = 'No se proporcionó imagen de la pieza.'
return result
b64 = image_base64
if not b64:
try:
b64 = base64.b64encode(image_bytes).decode('ascii')
except Exception as e: # pragma: no cover - defensive
result['error'] = f'Imagen inválida: {e}'
return result
b64_clean = b64.split(',', 1)[1] if b64.startswith('data:') else b64
prompt = _build_prompt(name=name, category=category, condition=condition)
# 1) Reuse the shared vision entry point (handles its own JSON parsing).
try:
from services import ai_chat
res = ai_chat.chat_with_image(prompt, b64_clean)
if isinstance(res, dict):
parsed = _normalize_grade(res)
if not _is_usable(parsed) and res.get('message'):
extracted = _extract_json_object(res.get('message'))
if extracted:
parsed = _normalize_grade(extracted)
if _is_usable(parsed):
parsed['source'] = 'vision'
return parsed
except Exception as e:
print(f"[PART_VISION] chat_with_image failed: {e}")
# 2) Direct OpenAI-compatible call with each backend's own tag.
backends = _backends()
if not backends:
result['error'] = ('Servicio de visión no configurado: sin backend de IA disponible '
'(QWEN/Hermes/OpenRouter).')
return result
messages = [
{"role": "system", "content": "Eres un perito de autopartes usadas. Responde SOLO en JSON válido."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64_clean}},
{"type": "text", "text": prompt},
]},
]
last_error = None
soft = None # valid JSON with notes but no grade (model refused/unsure)
for url, key, model, tag in backends:
try:
from services import ai_chat
res = ai_chat._post_chat_completion(
url, key, model, messages, max_tokens=500, temperature=0.1, timeout=45
)
except Exception as e:
last_error = f'{tag}: {e}'
continue
if not res:
last_error = f'{tag}: sin respuesta (ver logs [AI])'
continue
parsed = _extract_json_object(res.get('content'))
if not parsed:
last_error = f'{tag}: respuesta no-JSON'
continue
normalized = _normalize_grade(parsed)
if _is_usable(normalized):
normalized['source'] = tag
return normalized
if soft is None and normalized.get('notes'):
normalized['source'] = tag
soft = normalized
last_error = f'{tag}: JSON sin grado'
if soft is not None:
soft['error'] = 'El modelo no asignó un grado; revisa notes.'
return soft
result['error'] = ('El servicio de visión no pudo graduar la pieza '
f'({last_error or "sin backend disponible"}).')
return result

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:

View File

@@ -0,0 +1,780 @@
# /home/Autopartes/pos/services/product_categorizer.py
"""Product auto-categorization for tenant inventory.
Given one or more items (part_number / name / brand) assign a tenant
``categories.id`` using, in order:
1. A rule engine over the Nexpart taxonomy (``services/nexpart_taxonomy``):
the item (Spanish) name is matched against the Spanish translations of
the 558 Nexpart part-types / 108 subgroups / 14 groups, plus a small
supplemental Spanish keyword table for terms the catalog lacks.
2. A fallback to the LLM classifier ``ai_chat.classify_part`` when the rule
engine finds nothing (opt-in via ``use_llm``).
The rule engine maps a Nexpart node to a *canonical category* (a short Spanish
name like "Frenos", "Motor", "Encendido"). The canonical name is then resolved
against the tenant's own ``categories`` table: an existing row is reused when a
close match exists, otherwise a new row is created.
Public API:
categorize_item(conn, part_number, name, brand, ...) -> dict
categorize_items(conn, items, ...) -> dict
categorize_inventory(conn, limit, ...) -> dict
Every result dict has the shape:
{part_number, category_id, category_name, canonical_category,
confidence, source}
``source`` is one of: 'taxonomy', 'keyword', 'brand', 'llm', 'none'.
"""
import re
import unicodedata
from difflib import SequenceMatcher
from services import nexpart_taxonomy as nt
from services.nexpart_taxonomy import NEXPART_TAXONOMY, TAXONOMY_OVERRIDES_ES
try: # translations.py is optional; the taxonomy overrides are the main source
from services.translations import PART_TRANSLATIONS
except Exception: # pragma: no cover
PART_TRANSLATIONS = {}
# ── Canonical category names ───────────────────────────────────────────────
# Short Spanish names that both align with the Nexpart groups and, in
# practice, with the names tenants already use in their `categories` table.
CANONICAL_BY_GROUP = {
"Ignition & Filters": "Filtros y Encendido",
"Belts, Hoses, Water Pumps & Cooling System Parts": "Enfriamiento",
"Starting & Charging System Parts (Alternators, Batteries & Cables)": "Eléctrico",
"Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Frenos",
"Fuel & Emissions Parts": "Combustible y Emisiones",
"Heating & Air Conditioning": "Aire Acondicionado",
"Engine Parts": "Motor",
"Drivetrain Parts": "Tren Motriz",
"Steering & Suspension Parts": "Suspensión",
"Exhaust, Clutch & Flywheel Parts": "Escape",
"Wipers, Lamps & Fuses": "Luces y Limpiaparabrisas",
"Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería",
"Chemicals, Waxes & Lubricants": "Lubricantes y Químicos",
"Tires, Wheels, Tools & Accessory Parts": "Llantas y Accesorios",
}
# Subgroup overrides: more specific than the group default.
CANONICAL_BY_SUBGROUP = {
# Ignition & Filters
"Filters & PCV": "Filtros",
"Spark Plugs & Glow Plugs": "Encendido",
"Tune-Up & Ignition Parts": "Encendido",
"Ignition Wires": "Encendido",
"Computers & Relays": "Eléctrico",
# Starting & Charging
"Batteries": "Eléctrico",
"Starters": "Eléctrico",
"Alternators & Voltage Regulators": "Eléctrico",
"Starter Solenoids, Switches & Relays": "Eléctrico",
# Cooling
"Radiators & Electric Fan Motors": "Enfriamiento",
"Water Pumps, Fan Blades & Clutches": "Enfriamiento",
"Thermostats, Housings & Radiator Caps": "Enfriamiento",
"Engine Coolant & Bypass Hoses": "Enfriamiento",
# Brakes
"Front Friction, Drums & Rotors": "Frenos",
"Rear Friction, Drums & Rotors": "Frenos",
"Front Calipers, Wheel Cylinders, Hoses": "Frenos",
"Rear Calipers, Wheel Cylinders, Hoses": "Frenos",
"Master Cylinders, Boosters & Switches": "Frenos",
"Brake Cables, Studs, Nuts & Spindle Nuts": "Frenos",
"Front Brake Hardware & ABS Sensors": "Frenos",
"Rear Brake Hardware & ABS Sensors": "Frenos",
"Miscellaneous Disc Hardware": "Frenos",
"Miscellaneous Drum Hardware": "Frenos",
"Front Wheel Bearings & Seals": "Baleros y Mazas",
"Rear Wheel Bearings & Seals": "Baleros y Mazas",
"Spindles & Hubs": "Baleros y Mazas",
# Fuel & emissions
"Fuel Pumps & Tanks": "Combustible y Emisiones",
"Fuel Injection Parts, Mass Air Flow Sensors": "Combustible y Emisiones",
"Carburetors, Carburetor Kits & Components": "Combustible y Emisiones",
"EGR & Emissions Valves": "Combustible y Emisiones",
"Emission Sensors, Relays, Solenoids & Switches": "Combustible y Emisiones",
"Fuel Injection Sensors, Relays & Switches": "Combustible y Emisiones",
"Turbochargers & Superchargers": "Motor",
# AC
"AC Compressors, Kits & Parts": "Aire Acondicionado",
"AC Condensers & Evaporators": "Aire Acondicionado",
"AC Accumulators, Receiver Driers & Valves": "Aire Acondicionado",
"AC Hose Assemblies & Fittings": "Aire Acondicionado",
"Blower Motors & Parts": "Aire Acondicionado",
"Heater Cores & Heater Control Valves": "Aire Acondicionado",
# Engine
"Cams, Lifters & Timing Parts": "Motor",
"Crankshafts & Bearings": "Motor",
"Pistons, Rings & Rods": "Motor",
"Heads & Manifolds": "Motor",
"Gasket Sets": "Motor",
"Individual Gaskets & Seals": "Motor",
"Intake & Exhaust Valves": "Motor",
"Rockers & Push Rods": "Motor",
"Vacuum & Oil Pumps": "Motor",
"Engine Block Parts": "Motor",
"Engines & Kits": "Motor",
"Engine Mounts & Other Miscellaneous Engine Parts": "Motor",
# Drivetrain
"Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Tren Motriz",
"Axle & Differential Parts": "Tren Motriz",
"Transmission & Parts": "Transmisión",
"Transmission Kits & Gaskets": "Transmisión",
"Automatic Transmission Seals": "Transmisión",
"Manual Transmission Seals": "Transmisión",
"Manual Transmission Bearings": "Transmisión",
"Clutches & Clutch Kits": "Clutch",
"Clutch Hydraulics": "Clutch",
"Miscellaneous Clutch Parts": "Clutch",
# Steering & suspension
"Ball Joints & Control Arms": "Suspensión",
"Shock Absorbers & Struts": "Suspensión",
"Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Suspensión",
"Alignment Kits & Tools": "Suspensión",
"Steering Linkages, Rods & Arms": "Dirección",
"Rack & Pinion, Gear Box, Power Cylinder": "Dirección",
"Power Steering Pumps, Hoses & Kits": "Dirección",
"King Pins, Trailing Arms, Alignment & Other Chassis": "Dirección",
# Exhaust
"All Exhaust & Diagrams": "Escape",
"Catalytic Converter": "Escape",
"Manifolds & Headers": "Escape",
"Individual Exhaust Parts": "Escape",
# Wipers / lamps / fuses
"Arms, Blades & Refills": "Limpiaparabrisas",
"Wiper Motors & Washer Pumps": "Limpiaparabrisas",
"Headlamps & Flashers": "Luces",
"Exterior Lamps": "Luces",
"Interior Lamps": "Luces",
"Lighting Modules & Switches": "Luces",
"Lighting Relays & Sensors": "Luces",
# Body / electrical / misc
"Bumpers & License Plates": "Carrocería",
"Door, Window & Tailgate Parts": "Carrocería",
"Glass": "Carrocería",
"Hood & Tailgate Parts": "Carrocería",
"Hoods Fenders & Body Parts": "Carrocería",
"Hood, Fender & Body Parts": "Carrocería",
"Interior & Steering Wheel": "Carrocería",
"Electrical Motors": "Eléctrico",
"Electronics, Sensors, Relays & Miscellaneous Parts": "Eléctrico",
"Sensors, Switches & Relays": "Eléctrico",
"Switches, Relays & Miscellaneous Parts": "Eléctrico",
"Electronics Audio/Visual & Mirrors": "Carrocería",
# Chemicals / tools
"Engine & Transmission Lubricants & Additives": "Lubricantes y Químicos",
"Tires & Wheels": "Llantas y Accesorios",
"Tools, Jacks, Hardware & Manuals": "Llantas y Accesorios",
"Wheel & Hardware": "Llantas y Accesorios",
}
# Extra names a canonical category should be matched against in the tenant's
# `categories` table (in addition to the canonical name itself).
CATEGORY_ALIASES = {
"Filtros y Encendido": ["Filtros", "Encendido", "Filtros y Encendido", "Afinacion y Encendido"],
"Filtros": ["Filtros", "Filtro de aceite", "Filtros de aire", "Filtros de gasolina"],
"Encendido": ["Encendido", "Ignición y Eléctrico", "Bujías"],
"Enfriamiento": ["Enfriamiento", "Refrigeración", "Sistema de Enfriamiento", "Radiadores"],
"Eléctrico": ["Eléctrico", "Eléctrico e Iluminación", "Ignición y Eléctrico", "Baterías y Eléctrico"],
"Frenos": ["Frenos", "Frenos (Discos)", "Balatas", "Sistema de Frenos"],
"Baleros y Mazas": ["Baleros", "Balero", "Mazas", "Baleros y Sellos", "Baleros de Rueda"],
"Combustible y Emisiones": ["Combustible", "Combustible y Aire", "Inyección", "Emisiones"],
"Aire Acondicionado": ["Aire Acondicionado", "Calefacción y Aire Acondicionado", "A/C", "Heat & AC"],
"Motor": ["Motor", "Partes de Motor", "Engine", "Motor y Componentes"],
"Tren Motriz": ["Tren Motriz", "Drivetrain", "Transmisión", "Flechas"],
"Transmisión": ["Transmisión", "Transmission", "Clutch y Transmisión", "Caja"],
"Clutch": ["Clutch", "Clutch y Transmisión", "Kits de Clutch"],
"Suspensión": ["Suspensión", "Suspension", "Amortiguadores", "Dirección y Suspensión"],
"Dirección": ["Dirección", "Steering", "Cremalleras", "Dirección y Rótulas"],
"Escape": ["Escape", "Exhaust", "Mofles", "Sistema de Escape"],
"Luces y Limpiaparabrisas": ["Luces", "Iluminación", "Limpiaparabrisas", "Faros y Calaveras"],
"Luces": ["Luces", "Iluminación", "Faros", "Calaveras"],
"Limpiaparabrisas": ["Limpiaparabrisas", "Plumas", "Wipers"],
"Carrocería": ["Carrocería", "Body & Lamp Assembly", "Body", "Accesorios"],
"Lubricantes y Químicos": ["Aceites y Lubricantes", "Lubricantes", "Químicos", "Químicos, Ceras y Lubricantes"],
"Llantas y Accesorios": ["Llantas", "Llantas y Rines", "Herramienta", "Herramientas", "Accesorios", "Tires, Wheels, Tools & Accessory Parts"],
}
# Supplemental Spanish rules for terms the Nexpart translations don't cover.
# Applied only when the taxonomy matcher found nothing. Longest match wins.
ES_RULES = [
("disco de freno", "Frenos"),
("discos de freno", "Frenos"),
("tambor de freno", "Frenos"),
("cinta de freno", "Frenos"),
("tambor", "Frenos"),
("balero de rueda", "Baleros y Mazas"),
("balero", "Baleros y Mazas"),
("maza de rueda", "Baleros y Mazas"),
("maza", "Baleros y Mazas"),
("husillo", "Baleros y Mazas"),
("cremallera", "Dirección"),
("terminal de direccion", "Dirección"),
("bieleta", "Dirección"),
("rotula", "Suspensión"),
("horquilla", "Suspensión"),
("birlo", "Frenos"),
("motoventilador", "Enfriamiento"),
("ventilador", "Enfriamiento"),
("calavera", "Luces"),
("faro", "Luces"),
("direccional", "Luces"),
("foco", "Luces"),
("espejo", "Carrocería"),
("salpicadera", "Carrocería"),
("defensa", "Carrocería"),
("cristal", "Carrocería"),
("empaque", "Motor"),
("junta", "Motor"),
("sello", "Motor"),
("sensor de oxigeno", "Combustible y Emisiones"),
("inyector", "Combustible y Emisiones"),
("bomba de gasolina", "Combustible y Emisiones"),
("catalizador", "Escape"),
("convertidor", "Escape"),
("mofle", "Escape"),
("silenciador", "Escape"),
("clutch", "Clutch"),
("embrague", "Clutch"),
("banda", "Enfriamiento"),
("manguera", "Enfriamiento"),
("radiador", "Enfriamiento"),
("termostato", "Enfriamiento"),
("bomba de agua", "Enfriamiento"),
("bomba de aceite", "Motor"),
("alternador", "Eléctrico"),
("arrancador", "Eléctrico"),
("marcha", "Eléctrico"),
("bateria", "Eléctrico"),
("sensor", "Eléctrico"),
("switch", "Eléctrico"),
("relevador", "Eléctrico"),
("fusible", "Eléctrico"),
("filtro", "Filtros"),
("bujia", "Encendido"),
("balata", "Frenos"),
("pastilla", "Frenos"),
("amortiguador", "Suspensión"),
("toe", "Suspensión"),
# Drivetrain / CV
("junta homocinetica", "Tren Motriz"),
("homocinetica", "Tren Motriz"),
("cubre polvo", "Tren Motriz"),
("cubrepolvo", "Tren Motriz"),
("guardapolvo", "Tren Motriz"),
("fuelle", "Tren Motriz"),
("tripode", "Tren Motriz"),
("flecha", "Tren Motriz"),
("reten", "Motor"),
("o ring", "Motor"),
# Consumables
("aditivo", "Lubricantes y Químicos"),
("aceite", "Lubricantes y Químicos"),
("grasa", "Lubricantes y Químicos"),
("liquido", "Lubricantes y Químicos"),
("spray", "Lubricantes y Químicos"),
("aerosol", "Lubricantes y Químicos"),
("limpiador", "Lubricantes y Químicos"),
("anticongelante", "Lubricantes y Químicos"),
("refrigerante", "Enfriamiento"),
("solvente", "Lubricantes y Químicos"),
("silicon", "Lubricantes y Químicos"),
("sellador", "Lubricantes y Químicos"),
("pegamento", "Lubricantes y Químicos"),
("acondicionador", "Lubricantes y Químicos"),
("afloja todo", "Lubricantes y Químicos"),
("pintura", "Carrocería"),
# Electrical modules
("modulo", "Eléctrico"),
("regulador", "Eléctrico"),
("solenoide", "Eléctrico"),
# Tools / accessories
("herramienta", "Llantas y Accesorios"),
("inflallanta", "Llantas y Accesorios"),
("desarmador", "Llantas y Accesorios"),
("pinza", "Llantas y Accesorios"),
("dado", "Llantas y Accesorios"),
# Misc frequent terms seen in real tenant catalogs
("barra de direccion", "Dirección"),
("terminal de direccion", "Dirección"),
("terminal", "Dirección"),
("espiga", "Suspensión"),
("resorte", "Suspensión"),
("barra", "Suspensión"),
("aspas", "Enfriamiento"),
("aspa", "Enfriamiento"),
("tulipa", "Luces"),
("conector", "Eléctrico"),
("bulbo", "Eléctrico"),
("resistencia", "Eléctrico"),
("tripoide", "Tren Motriz"),
("cople", "Tren Motriz"),
("motor", "Motor"),
("dir", "Dirección"),
]
# Weak brand-only hints, used when neither name rules nor the taxonomy hit.
BRAND_HINTS = {
"NGK": "Encendido", "DENSO": "Encendido", "ACDELCO": "Encendido",
"BREMBO": "Frenos", "RAYBESTOS": "Frenos", "WAGNER": "Frenos",
"MONROE": "Suspensión", "GABRIEL": "Suspensión", "MOOG": "Suspensión",
"FRAM": "Filtros", "PUROLATOR": "Filtros", "WIX": "Filtros",
"GATES": "Enfriamiento", "DAYCO": "Enfriamiento", "GMB": "Enfriamiento",
"TRW": "Dirección", "EXEDY": "Clutch", "LUK": "Clutch",
"FEL-PRO": "Motor", "MAHLE": "Motor", "VICTOR REINZ": "Motor",
"BOSCH": "Eléctrico", "VALEO": "Eléctrico",
}
# ── Normalization / indexes ────────────────────────────────────────────────
def _norm(s):
"""Uppercase, strip accents, keep alphanumerics, collapse whitespace."""
s = unicodedata.normalize("NFD", str(s or ""))
s = s.encode("ascii", "ignore").decode("ascii")
s = re.sub(r"[^A-Za-z0-9]+", " ", s).upper().strip()
return " ".join(s.split())
def _build_indexes():
pt_index = {} # normalized Spanish part-type -> English Nexpart part type
node_index = {} # normalized Spanish group/subgroup -> (group, subgroup|None)
for en, es in TAXONOMY_OVERRIDES_ES.items():
n = _norm(es)
if not n:
continue
if en in NEXPART_TAXONOMY:
node_index.setdefault(n, (en, None))
continue
found = None
for g, subs in NEXPART_TAXONOMY.items():
for sg in subs:
if en == sg:
found = (g, sg)
break
if found:
break
if found:
node_index.setdefault(n, found)
else:
pt_index.setdefault(n, en)
# Invert the TecDoc→Spanish dictionary for extra coverage.
for en, es in PART_TRANSLATIONS.items():
for part in str(es).split("/"):
n = _norm(part)
if n and n not in pt_index and n not in node_index:
pt_index[n] = en
return pt_index, node_index
_PT_INDEX, _NODE_INDEX = _build_indexes()
_ES_RULES_SORTED = sorted(
((_norm(k), v) for k, v in ES_RULES if _norm(k)), key=lambda kv: -len(kv[0])
)
def _key_usable(key):
"""Generic single tokens ("sensor", "kit") create false positives."""
tokens = key.split()
if len(tokens) >= 2:
return True
return len(key) >= 9
# ── Matchers ───────────────────────────────────────────────────────────────
def match_taxonomy(name):
"""Match an item name against the Nexpart taxonomy.
Returns (group, subgroup, part_type, kind, confidence) or None.
kind ∈ {'part_type', 'subgroup', 'group'}.
"""
n = _norm(name)
if not n:
return None
padded = " " + n + " "
best = None # (key_len, kind_rank, group, subgroup, part_type)
for key, en in _PT_INDEX.items():
if not _key_usable(key) or (" " + key + " ") not in padded:
continue
node = nt.tecdoc_to_nexpart(en)
if not node:
continue
cand = (len(key), 3, node[0], node[1], node[2])
if best is None or cand[:2] > best[:2]:
best = cand
for key, (g, sg) in _NODE_INDEX.items():
if not _key_usable(key) or (" " + key + " ") not in padded:
continue
rank = 2 if sg else 1
cand = (len(key), rank, g, sg, None)
if best is None or cand[:2] > best[:2]:
best = cand
if not best:
return None
if best[1] == 3:
return (best[2], best[3], best[4], "part_type", 0.85)
if best[1] == 2:
return (best[2], best[3], None, "subgroup", 0.65)
return (best[2], None, None, "group", 0.5)
def canonical_category(group, subgroup):
"""Map a Nexpart (group, subgroup) to a canonical Spanish category."""
if subgroup and subgroup in CANONICAL_BY_SUBGROUP:
return CANONICAL_BY_SUBGROUP[subgroup]
if group and group in CANONICAL_BY_GROUP:
return CANONICAL_BY_GROUP[group]
return "Otros"
def match_rules(name):
"""Supplemental Spanish keyword match. Returns (canonical, confidence)."""
n = _norm(name)
if not n:
return None
padded = " " + n + " "
for key, canonical in _ES_RULES_SORTED:
if " " + key + " " in padded:
return canonical, 0.6
return None
def classify_name(name, brand=None):
"""Rule-only classification. Returns (canonical, confidence, source)."""
hit = match_taxonomy(name)
if hit:
group, subgroup, _pt, _kind, conf = hit
return canonical_category(group, subgroup), conf, "taxonomy"
rule = match_rules(name)
if rule:
return rule[0], rule[1], "keyword"
brand_key = _norm(brand)
if brand_key and brand_key in BRAND_HINTS:
return BRAND_HINTS[brand_key], 0.4, "brand"
return None, 0.0, "none"
# ── Tenant category resolution ─────────────────────────────────────────────
def _similarity(a, b):
if not a or not b:
return 0.0
if a == b:
return 1.0
ta, tb = a.split(), b.split()
if len(ta) >= 1 and len(tb) >= 1:
seta, setb = set(ta), set(tb)
# whole-string containment (word aligned) is a strong signal
if setb <= seta or seta <= setb:
return 0.85
return SequenceMatcher(None, a, b).ratio()
class CategoryResolver:
"""Resolve canonical category names to rows in the tenant `categories`."""
def __init__(self, conn, create=True):
self.conn = conn
self.create = create
self._cache = {}
self._existing = None # list of (id, name, norm)
self.available = self._load()
def _load(self):
cur = self.conn.cursor()
try:
cur.execute("SELECT to_regclass('public.categories')")
if cur.fetchone()[0] is None:
return False
cur.execute(
"SELECT id, name FROM categories "
"WHERE is_active IS NULL OR is_active = true"
)
self._existing = [(r[0], r[1], _norm(r[1])) for r in cur.fetchall()]
return True
except Exception:
return False
finally:
cur.close()
def _best_existing(self, canonical):
target = _norm(canonical)
candidates = [target] + [_norm(a) for a in CATEGORY_ALIASES.get(canonical, [])]
best_id, best_name, best_score = None, None, 0.0
for cid, name, norm_name in self._existing:
score = max(_similarity(norm_name, c) for c in candidates if c)
if score > best_score:
best_id, best_name, best_score = cid, name, score
if best_score >= 0.72:
return best_id, best_name
return None, None
def resolve(self, canonical):
"""Return (category_id, category_name) for a canonical category."""
if canonical in self._cache:
return self._cache[canonical]
if not self.available:
self._cache[canonical] = (None, canonical)
return self._cache[canonical]
cid, name = self._best_existing(canonical)
if cid is not None:
self._cache[canonical] = (cid, name)
return self._cache[canonical]
if not self.create:
self._cache[canonical] = (None, canonical)
return self._cache[canonical]
cur = self.conn.cursor()
try:
cur.execute(
"INSERT INTO categories (name) VALUES (%s) RETURNING id",
(canonical,),
)
cid = cur.fetchone()[0]
self.conn.commit()
finally:
cur.close()
self._existing.append((cid, canonical, _norm(canonical)))
self._cache[canonical] = (cid, canonical)
return self._cache[canonical]
# ── Public API ─────────────────────────────────────────────────────────────
def categorize_item(conn, part_number, name=None, brand=None,
use_llm=False, persist=False, resolver=None, create_categories=True):
"""Classify a single item and optionally persist it.
Returns {part_number, category_id, category_name, canonical_category,
confidence, source, persisted}.
"""
if resolver is None:
resolver = CategoryResolver(conn, create=create_categories)
canonical, confidence, source = classify_name(name or part_number, brand)
if canonical is None and use_llm:
canonical, confidence, source = _llm_category(part_number, name)
if canonical is None:
result = {
"part_number": part_number, "category_id": None,
"category_name": None, "canonical_category": None,
"confidence": 0.0, "source": "none", "persisted": 0,
}
else:
cid, cname = resolver.resolve(canonical)
result = {
"part_number": part_number, "category_id": cid,
"category_name": cname, "canonical_category": canonical,
"confidence": round(confidence, 2), "source": source, "persisted": 0,
}
if persist and result["category_id"] is not None and part_number:
result["persisted"] = _persist_item_category(conn, part_number, result["category_id"])
return result
def _llm_category(part_number, name=None):
"""Ask ai_chat.classify_part for a category. Returns (canonical, conf, src)."""
if not part_number:
return None, 0.0, "none"
try:
from services.ai_chat import classify_part
res = classify_part(str(part_number))
except Exception:
return None, 0.0, "none"
if not res:
return None, 0.0, "none"
raw = (res.get("category") or "").strip()
if not raw:
return None, 0.0, "none"
canon = _map_llm_category(raw)
return canon, 0.5, "llm"
def map_llm_category(raw):
"""Public alias: map a free-text Spanish category (LLM output)."""
return _map_llm_category(raw)
def _map_llm_category(raw):
"""Map a free-text Spanish category (LLM output) to a canonical name."""
n = _norm(raw)
if not n:
return None
# direct canonical / alias hit
for canonical, aliases in CATEGORY_ALIASES.items():
for a in [canonical] + list(aliases):
if _norm(a) == n:
return canonical
# fall back to the keyword rules over the LLM's own words
rule = match_rules(raw)
if rule:
return rule[0]
return raw.strip()
def _persist_item_category(conn, part_number, category_id):
cur = conn.cursor()
try:
cur.execute(
"UPDATE inventory SET category_id = %s "
"WHERE part_number = %s AND is_active = true "
"AND (category_id IS NULL OR category_id <> %s)",
(category_id, part_number, category_id),
)
n = cur.rowcount
conn.commit()
return n
finally:
cur.close()
def categorize_items(conn, items, use_llm=False, persist=False,
create_categories=True, sample_limit=20):
"""Categorize a list of {part_number, name, brand} dicts.
Returns {count, persisted, by_source, categories, results, sample}.
"""
# A dry run must not mutate the tenant's categories table.
create_categories = create_categories and persist
resolver = CategoryResolver(conn, create=create_categories)
results = []
by_source = {}
persisted = 0
for it in items or []:
r = categorize_item(
conn, it.get("part_number"), it.get("name"), it.get("brand"),
use_llm=use_llm, persist=persist, resolver=resolver,
create_categories=create_categories,
)
persisted += r.get("persisted", 0)
by_source[r["source"]] = by_source.get(r["source"], 0) + 1
results.append(r)
categories = {}
for r in results:
if r["category_name"]:
categories[r["category_name"]] = categories.get(r["category_name"], 0) + 1
return {
"count": len(results),
"persisted": persisted,
"by_source": by_source,
"categories": categories,
"results": results[:sample_limit] if sample_limit else results,
"sample": results[:sample_limit],
}
def categorize_inventory(conn, limit=200, only_uncategorized=True, use_llm=False,
persist=True, create_categories=True, offset=0,
after_id=None, sample_limit=20):
"""Categorize inventory rows in bulk.
``after_id`` enables checkpointed backfills (process rows with id > after_id).
Returns {scanned, persisted, by_source, categories, sample, last_id}.
"""
cur = conn.cursor()
where = ["is_active = true"]
params = []
if only_uncategorized:
where.append("(category_id IS NULL)")
if after_id is not None:
where.append("id > %s")
params.append(after_id)
sql = (
"SELECT id, part_number, name, brand FROM inventory "
"WHERE " + " AND ".join(where) +
" ORDER BY id LIMIT %s OFFSET %s"
)
params.extend([int(limit), int(offset)])
cur.execute(sql, params)
rows = cur.fetchall()
cur.close()
# A dry run must not mutate the tenant's categories table.
create_categories = create_categories and persist
resolver = CategoryResolver(conn, create=create_categories)
by_source = {}
categories = {}
persisted = 0
sample = []
last_id = after_id
for inv_id, part_number, name, brand in rows:
canonical, confidence, source = classify_name(name or part_number, brand)
if canonical is None and use_llm:
canonical, confidence, source = _llm_category(part_number, name)
cid, cname = (None, None)
if canonical:
cid, cname = resolver.resolve(canonical)
n = 0
if persist and cid is not None:
c2 = conn.cursor()
try:
c2.execute(
"UPDATE inventory SET category_id = %s "
"WHERE id = %s AND (category_id IS NULL OR category_id <> %s)",
(cid, inv_id, cid),
)
n = c2.rowcount
conn.commit()
finally:
c2.close()
persisted += n
by_source[source] = by_source.get(source, 0) + 1
if cname:
categories[cname] = categories.get(cname, 0) + 1
if len(sample) < sample_limit:
sample.append({
"inventory_id": inv_id, "part_number": part_number, "name": name,
"category_id": cid, "category_name": cname,
"canonical_category": canonical,
"confidence": round(confidence, 2), "source": source,
})
last_id = inv_id
return {
"scanned": len(rows),
"persisted": persisted,
"by_source": by_source,
"categories": categories,
"sample": sample,
"last_id": last_id,
}
def stats():
"""Diagnostic helper for the rule engine."""
return {
"part_type_keys": len(_PT_INDEX),
"node_keys": len(_NODE_INDEX),
"es_rules": len(_ES_RULES_SORTED),
"brand_hints": len(BRAND_HINTS),
"canonical_categories": sorted(set(CANONICAL_BY_GROUP.values())),
}

View File

@@ -0,0 +1,255 @@
"""Expositor Prometheus mínimo, sin dependencias externas.
El Python de producción no trae ``prometheus_client`` (y no se instala), así que
el formato de texto de Prometheus 0.0.4 se arma a mano.
El registro vive en memoria del proceso y es thread-safe (``threading.Lock``);
con gunicorn multiproceso cada worker lleva sus propios contadores y Prometheus
scrapea al que responda. Para tasas y latencias eso es suficiente; si algún día
se necesita un contador global exacto hay que habilitar el modo multiproceso con
un directorio compartido.
Uso::
from services.prom_metrics import metrics
metrics.configure('pos')
metrics.request_started() # before_request
metrics.request_finished(method, path, status, duration) # after_request
metrics.record_exception() # teardown_request (exc)
metrics.request_aborted() # teardown si no hubo after_request
body = metrics.render() # GET /metrics
"""
import os
import threading
import time
CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
DEFAULT_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
# Tope de series distintas por métrica; lo que se salga de aquí se agrupa en
# path="/otras" para que una ruta inesperada no infle la memoria.
MAX_SERIES = 400
# La consulta a pg_stat_activity es best-effort y se cachea unos segundos.
DB_GAUGE_TTL = 5.0
def escape_label(value):
return str(value).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
def format_labels(pairs):
if not pairs:
return ''
body = ','.join(f'{k}="{escape_label(v)}"' for k, v in pairs)
return '{' + body + '}'
def normalize_path(rule, path):
"""Colapsa una petición a un valor de label acotado.
Si Flask resolvió una regla (``request.url_rule.rule``) se usa tal cual, que
ya viene en forma plantilla (``/pos/api/inventory/items/<int:item_id>``).
Para 404 (sin regla) sólo se conserva el primer segmento.
"""
if rule:
return rule
segments = [s for s in (path or '/').split('/') if s]
if not segments:
return '/'
if len(segments) == 1:
return '/' + segments[0]
return '/' + segments[0] + '/...'
def _master_dsn():
dsn = os.environ.get('MASTER_DB_URL') or os.environ.get('DATABASE_URL')
if dsn:
return dsn
try:
from config import MASTER_DB_URL
return MASTER_DB_URL
except Exception:
return None
class Metrics:
def __init__(self, app='app', version=None, buckets=DEFAULT_BUCKETS):
self._lock = threading.Lock()
self._app = app
self._version = version or os.environ.get('NEXUS_APP_VERSION', 'dev')
self._buckets = tuple(sorted(buckets))
self._start = time.time()
self._requests = {} # (method, path, status_class) -> count
self._errors = {} # (method, path, status_class) -> count (4xx/5xx)
self._durations = {} # (method, path) -> [sum, count, [bucket counts]]
self._exceptions = 0
self._in_flight = 0
self._db_cache = (0.0, None)
# ─── Instrumentación ────────────────────────────────────────────────
def configure(self, app=None, version=None):
with self._lock:
if app:
self._app = app
if version:
self._version = version
def request_started(self):
with self._lock:
self._in_flight += 1
def request_finished(self, method, path, status, duration):
method = method or 'GET'
path = path or '/'
try:
status_class = f'{int(status) // 100}xx'
except (TypeError, ValueError):
status_class = 'other'
duration = max(0.0, float(duration or 0.0))
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
key = (method, path, status_class)
if key not in self._requests and len(self._requests) >= MAX_SERIES:
key = (method, '/otras', status_class)
self._requests[key] = self._requests.get(key, 0) + 1
if status_class in ('4xx', '5xx'):
ekey = (method, path, status_class)
if ekey not in self._errors and len(self._errors) >= MAX_SERIES:
ekey = (method, '/otras', status_class)
self._errors[ekey] = self._errors.get(ekey, 0) + 1
dkey = (method, path)
if dkey not in self._durations and len(self._durations) >= MAX_SERIES:
dkey = (method, '/otras')
entry = self._durations.get(dkey)
if entry is None:
entry = [0.0, 0, [0] * len(self._buckets)]
self._durations[dkey] = entry
entry[0] += duration
entry[1] += 1
for i, upper in enumerate(self._buckets):
if duration <= upper:
entry[2][i] += 1
def request_aborted(self):
"""Petición que no pasó por after_request (excepción que no se convirtió en respuesta)."""
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
def record_exception(self):
with self._lock:
self._exceptions += 1
# ─── Gauge de conexiones (consulta en el momento del scrape) ────────
def _db_connections(self):
now = time.time()
cached_at, cached_value = self._db_cache
if now - cached_at < DB_GAUGE_TTL:
return cached_value
value = None
dsn = _master_dsn()
if dsn:
try:
import psycopg2
conn = psycopg2.connect(dsn, connect_timeout=2)
try:
conn.autocommit = True
cur = conn.cursor()
cur.execute('SET statement_timeout = 2000')
cur.execute('SELECT count(*) FROM pg_stat_activity')
row = cur.fetchone()
cur.close()
if row:
value = int(row[0])
finally:
conn.close()
except Exception:
value = None
with self._lock:
self._db_cache = (now, value)
return value
# ─── Render ─────────────────────────────────────────────────────────
def render(self):
with self._lock:
app = self._app
version = self._version
start = self._start
requests = dict(self._requests)
errors = dict(self._errors)
durations = {k: (v[0], v[1], list(v[2])) for k, v in self._durations.items()}
exceptions = self._exceptions
in_flight = self._in_flight
db_connections = self._db_connections()
now = time.time()
out = []
def head(name, kind, help_text):
out.append(f'# HELP {name} {help_text}')
out.append(f'# TYPE {name} {kind}')
head('nexus_app_info', 'gauge', 'Application build information.')
out.append(f'nexus_app_info{format_labels([("app", app), ("version", version)])} 1')
head('nexus_uptime_seconds', 'gauge', 'Seconds since the process started.')
out.append(f'nexus_uptime_seconds{format_labels([("app", app)])} {now - start:.3f}')
head('nexus_process_start_time_seconds', 'gauge', 'Unix time the process started.')
out.append(f'nexus_process_start_time_seconds{format_labels([("app", app)])} {start:.3f}')
head('nexus_http_requests_in_flight', 'gauge', 'Requests currently being processed.')
out.append(f'nexus_http_requests_in_flight{format_labels([("app", app)])} {in_flight}')
head('nexus_http_requests_total', 'counter',
'HTTP requests by method, normalized path and status class.')
for method, path, status_class in sorted(requests):
labels = format_labels([('app', app), ('method', method),
('path', path), ('status', status_class)])
out.append(f'nexus_http_requests_total{labels} {requests[(method, path, status_class)]}')
head('nexus_http_errors_total', 'counter',
'HTTP responses with a 4xx or 5xx status class.')
for method, path, status_class in sorted(errors):
labels = format_labels([('app', app), ('method', method),
('path', path), ('status', status_class)])
out.append(f'nexus_http_errors_total{labels} {errors[(method, path, status_class)]}')
if durations:
head('nexus_http_request_duration_seconds', 'histogram',
'HTTP request latency by method and normalized path.')
for method, path in sorted(durations):
total, count, buckets = durations[(method, path)]
base = [('app', app), ('method', method), ('path', path)]
for i, upper in enumerate(self._buckets):
labels = format_labels(base + [('le', str(upper))])
out.append(f'nexus_http_request_duration_seconds_bucket{labels} {buckets[i]}')
inf = format_labels(base + [('le', '+Inf')])
out.append(f'nexus_http_request_duration_seconds_bucket{inf} {count}')
labels = format_labels(base)
out.append(f'nexus_http_request_duration_seconds_sum{labels} {total:.6f}')
out.append(f'nexus_http_request_duration_seconds_count{labels} {count}')
head('nexus_app_exceptions_total', 'counter',
'Unhandled exceptions raised while serving a request.')
out.append(f'nexus_app_exceptions_total{format_labels([("app", app)])} {exceptions}')
if db_connections is not None:
head('nexus_db_connections', 'gauge',
'Open connections on the master database (pg_stat_activity).')
labels = format_labels([('app', app), ('database', 'master')])
out.append(f'nexus_db_connections{labels} {db_connections}')
return '\n'.join(out) + '\n'
metrics = Metrics()

View File

@@ -45,11 +45,11 @@ def get_or_create_vapid_keys(conn):
# Check if keys already exist in config
try:
cur.execute("SELECT value FROM config WHERE key = 'vapid_private_key'")
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_private_key'")
row = cur.fetchone()
if row:
private_key = row[0]
cur.execute("SELECT value FROM config WHERE key = 'vapid_public_key'")
cur.execute("SELECT value FROM tenant_config WHERE key = 'vapid_public_key'")
pub_row = cur.fetchone()
public_key = pub_row[0] if pub_row else None
cur.close()
@@ -68,11 +68,11 @@ def get_or_create_vapid_keys(conn):
# Store in config table
try:
cur.execute("""
INSERT INTO config (key, value) VALUES ('vapid_private_key', %s)
INSERT INTO tenant_config (key, value) VALUES ('vapid_private_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (private_pem,))
cur.execute("""
INSERT INTO config (key, value) VALUES ('vapid_public_key', %s)
INSERT INTO tenant_config (key, value) VALUES ('vapid_public_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (public_b64,))
conn.commit()

325
pos/services/question_ai.py Normal file
View File

@@ -0,0 +1,325 @@
# /home/Autopartes/pos/services/question_ai.py
"""A7 — Intent classification + suggested answers for MercadoLibre questions.
Given a buyer question (optionally tied to a listing/inventory item) this
service:
1. Classifies the intent: compatibility | price | shipping | availability | other.
2. Builds a suggested answer from real data — ``inventory`` (name/brand/price),
``inventory_vehicle_compat`` (fitment) and stock — and falls back to the
LLM (``services.ai_chat``) when the question needs free text.
Public API:
classify_intent(text) -> (intent, confidence)
suggest_answer(tenant_conn, question_text, inventory_id=None, ...) -> dict
get_question_suggestion(tenant_conn, question_id, ...) -> dict
Returned dict:
{
"intent": str,
"suggested_answer": str,
"confidence": float, # 0.0 - 1.0 (intent + data certainty)
"source": str, # rules | inventory | compat_db | template | llm | none
"auto_ok": bool, # safe to auto-send (high confidence + template)
"evidence": dict # data used to build the answer
}
"""
import re
INTENTS = ('compatibility', 'price', 'shipping', 'availability', 'other')
# Ordered by priority: fitment questions often also contain "tienen"/"hay",
# so compatibility is matched first.
_INTENT_PATTERNS = [
('compatibility', [
r'compatib', r'\bsirve\b', r'\bsirven\b', r'le queda', r'queda (a|en|para)',
r'le queda a', r'es para (un|una|mi)', r'funciona (para|en)', r'aplica para',
r'\bfits?\b', r'quedar[áa]', r'le sirve', r'modelo \d{4}',
]),
('price', [
r'\bprecio\b', r'cu[áa]nto (cuesta|vale|es|sale)', r'\bcosto\b', r'\bvale\b',
r'\bdescuento\b', r'\bfactura\b', r'\bmeses\b', r'\bmsi\b', r'\bbarato\b',
r'\bpago\b', r'\bcuotas?\b',
]),
('shipping', [
r'env[íi]o', r'env[íi]an', r'\benvias?\b', r'\bmandan\b', r'\bllega\b',
r'\bentrega\b', r'paqueter[íi]a', r'\bgu[íi]a\b', r'mensajer[íi]a',
r'\benvio\b',
]),
('availability', [
r'\btienes\b', r'\btiene[n]?\b', r'\bhay\b', r'disponib', r'\bstock\b',
r'existencia', r'inventario', r'cu[áa]ntas? (piezas|unidades|tienes)',
r'tienen en',
]),
]
_YEAR_RE = re.compile(r'\b(19[89]\d|20[0-3]\d)\b')
def classify_intent(text):
"""Rule-based intent classifier. Returns (intent, confidence)."""
if not text:
return 'other', 0.0
low = str(text).lower()
for intent, patterns in _INTENT_PATTERNS:
for pat in patterns:
if re.search(pat, low):
return intent, 0.85
# A bare model-year usually means "does it fit?".
if _YEAR_RE.search(low):
return 'compatibility', 0.6
return 'other', 0.35
# ─── data helpers ───────────────────────────────────────────────────────────
def _inventory_row(conn, inventory_id):
"""Return a small dict for the inventory item backing a listing."""
if not inventory_id:
return None
cur = conn.cursor()
try:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.condition, c.name
FROM inventory i
LEFT JOIN part_categories c ON c.id = i.category_id
WHERE i.id = %s
""", (inventory_id,))
row = cur.fetchone()
except Exception:
return None
finally:
cur.close()
if not row:
return None
return {
'id': row[0], 'part_number': row[1] or '', 'name': row[2] or '',
'brand': row[3] or '', 'price': float(row[4] or 0), 'condition': row[5] or '',
'category': row[6] or '',
}
def _compat_summary(conn, inventory_id, limit=6):
"""Human-readable fitment list from inventory_vehicle_compat (text rows)."""
cur = conn.cursor()
try:
cur.execute("""
SELECT make, model, year
FROM inventory_vehicle_compat
WHERE inventory_id = %s AND (make IS NOT NULL OR model IS NOT NULL)
GROUP BY make, model, year
ORDER BY make, model, year
LIMIT %s
""", (inventory_id, limit))
rows = cur.fetchall()
except Exception:
return [], 0
finally:
cur.close()
lines = []
for make, model, year in rows:
parts = [str(p).strip() for p in (make, model) if p]
label = ' '.join(parts)
if year:
label = f"{label} {year}".strip()
if label:
lines.append(label)
return lines, len(lines)
def _trailing_confirm():
return ("Si tu vehículo no aparece, compárteme marca, modelo y año "
"(o los últimos 8 dígitos de tu VIN) y te confirmo la compatibilidad.")
def _config_value(conn, key, default=None):
cur = conn.cursor()
try:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
return row[0] if row and row[0] else default
except Exception:
return default
finally:
cur.close()
def _stock(conn, inventory_id):
try:
from services.inventory_engine import get_stock
return get_stock(conn, inventory_id)
except Exception:
return None
# ai_chat returns these generic strings when every backend fails. They are not
# real answers, so a question must fall back to the template instead.
_LLM_ERROR_PREFIXES = (
'El asistente está ocupado',
'El asistente esta ocupado',
'El asistente tardó mucho',
'El asistente tardo mucho',
'El asistente no está disponible',
'El asistente no esta disponible',
)
def _llm_answer(tenant_conn, question_text, item):
"""Free-text answer via ai_chat. Returns (text, error)."""
try:
from services import ai_chat
except Exception as e:
return None, f'ai_chat import: {e}'
context = None
if item:
context = (f"Producto publicado: {item['name']} (marca {item['brand'] or 'N/D'}, "
f"código {item['part_number'] or 'N/D'}). Precio ${item['price']:.2f} MXN.")
prompt = ("Eres un vendedor de autopartes que responde preguntas de compradores "
f"en MercadoLibre. Responde breve (máximo 3 líneas), en español y con tono "
f"amable. Nunca inventes compatibilidad que no puedas confirmar.\n\nPregunta: {question_text}")
try:
res = ai_chat.chat(prompt, inventory_context=context)
except Exception as e:
return None, str(e)
if isinstance(res, dict):
msg = (res.get('message') or '').strip()
if msg and not msg.startswith(_LLM_ERROR_PREFIXES):
return msg, None
if msg:
return None, f'backend sin respuesta: {msg}'
return None, 'sin respuesta del modelo'
# ─── answer builder ─────────────────────────────────────────────────────────
def suggest_answer(tenant_conn, question_text, inventory_id=None, listing_id=None,
master_conn=None, use_llm=True):
"""Classify a question and build a suggested answer.
``tenant_conn`` is an open tenant connection (not committed). The function
never raises for backend/data failures: it degrades to a template answer.
"""
intent, conf = classify_intent(question_text)
item = _inventory_row(tenant_conn, inventory_id) if inventory_id else None
out = {
'intent': intent,
'suggested_answer': '',
'confidence': conf,
'source': 'rules',
'auto_ok': False,
'evidence': {},
'listing_id': listing_id,
'inventory_id': inventory_id,
}
if item:
out['evidence']['item'] = {
'part_number': item['part_number'], 'name': item['name'],
'brand': item['brand'], 'price': item['price'],
}
if intent == 'compatibility' and inventory_id:
lines, n = _compat_summary(tenant_conn, inventory_id)
out['evidence']['compat_count'] = n
if lines:
out['suggested_answer'] = (
"¡Sí! Esta pieza es compatible con: " + ", ".join(lines) + ". " + _trailing_confirm()
)
out['confidence'] = 0.9
out['source'] = 'compat_db'
# Fitment can still be model/trim specific -> human review, no auto-send.
out['auto_ok'] = False
return out
elif intent == 'price' and item and item['price'] > 0:
out['suggested_answer'] = (
f"El precio es ${item['price']:,.2f} MXN. "
"El envío se calcula al finalizar la compra según tu código postal. ¿Te ayudo con algo más?"
)
out['confidence'] = 0.9
out['source'] = 'inventory'
out['auto_ok'] = True
return out
elif intent == 'availability' and inventory_id:
stock = _stock(tenant_conn, inventory_id)
out['evidence']['stock'] = stock
if stock is not None:
if stock > 0:
out['suggested_answer'] = (
f"Sí, tenemos {stock} {'pieza' if stock == 1 else 'piezas'} disponibles. "
"Puedes comprar directo desde la publicación."
)
out['confidence'] = 0.9
else:
out['suggested_answer'] = (
"Por el momento no tenemos existencia de esta pieza. "
"Déjame tu pregunta y te aviso en cuanto llegue."
)
out['confidence'] = 0.8
out['source'] = 'inventory'
out['auto_ok'] = True
return out
elif intent == 'shipping':
mode = _config_value(tenant_conn, 'meli_shipping_mode', '')
extra = f" Modalidad: {mode}." if mode else ''
out['suggested_answer'] = (
"El costo y tiempo de envío se muestran al ingresar tu código postal en MercadoLibre." + extra +
" Si estás en la ciudad, también puedes pasar a recogerlo a la tienda."
)
out['confidence'] = 0.75
out['source'] = 'template'
out['auto_ok'] = out['confidence'] >= 0.8
return out
# Fallback: free text for "other", or when the data needed was missing.
if use_llm:
text, err = _llm_answer(tenant_conn, question_text, item)
if text:
out['suggested_answer'] = text
out['source'] = 'llm'
out['confidence'] = max(out['confidence'], 0.6)
out['auto_ok'] = False
return out
out['evidence']['llm_error'] = err
if not out['suggested_answer']:
if intent == 'compatibility':
out['suggested_answer'] = ("Con gusto te confirmo la compatibilidad. " + _trailing_confirm())
else:
out['suggested_answer'] = (
"¡Hola! Gracias por tu pregunta; en un momento te atendemos con el detalle."
)
out['source'] = 'template'
out['confidence'] = min(out['confidence'], 0.5)
out['auto_ok'] = False
return out
def get_question_suggestion(tenant_conn, question_id, master_conn=None, use_llm=True):
"""Resolve a local marketplace question then build its suggested answer."""
cur = tenant_conn.cursor()
try:
cur.execute("""
SELECT q.question_text, q.listing_id, l.inventory_id
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
WHERE q.id = %s
""", (question_id,))
row = cur.fetchone()
finally:
cur.close()
if not row:
raise ValueError('Question not found')
q_text, listing_id, inventory_id = row
return suggest_answer(
tenant_conn, q_text or '', inventory_id=inventory_id,
listing_id=listing_id, master_conn=master_conn, use_llm=use_llm,
)

338
pos/services/sat_keys.py Normal file
View File

@@ -0,0 +1,338 @@
# /home/Autopartes/pos/services/sat_keys.py
"""SAT (CFDI 4.0) product/unit key resolution for auto parts.
Assigns ``clave_prod_serv`` (``c_ClaveProdServ``) and ``clave_unidad``
(``c_ClaveUnidad``) per item from the part name/brand/category using, in order:
1. ``RULES`` — longest keyword hit over the normalized Spanish name.
2. ``CANONICAL_BY_CATEGORY`` — a canonical category (from
``product_categorizer``) mapped to a code.
3. an optional LLM fallback (``ai_chat.classify_part`` → category → rule).
4. ``DEFAULT_*`` (``25174800`` / ``H87``) — the current hard-coded default.
Codes come from the public SAT group ``2517`` (autopartes) suggestions; see
scripts/assign_sat_keys.py for provenance. **Never raises**: any missing
template/LLM failure degrades to ``DEFAULT_*`` so the CFDI pipeline cannot
break because of this module.
Public API:
resolve_key(name=None, brand=None, category=None, canonical=None,
part_number=None, use_llm=False) -> (clave_prod_serv, clave_unidad, source)
"""
import re
import unicodedata
DEFAULT_KEY = "25174800" # Componentes y sistemas de vehículos / autopartes
DEFAULT_UNIT = "H87" # Pieza
# Canonical Spanish categories (product_categorizer.CANONICAL_*) -> SAT key.
CANONICAL_BY_CATEGORY = {
"Frenos": "25171700",
"Baleros y Mazas": "25171900",
"Suspensión": "25172000",
"Dirección": "25172000",
"Motor": "26101700",
"Enfriamiento": "25174000",
"Encendido": "25173900",
"Filtros": "40161500",
"Filtros y Encendido": "40161500",
"Eléctrico": "25173900",
"Combustible y Emisiones": "25172400",
"Aire Acondicionado": "40101800",
"Transmisión": "26111800",
"Clutch": "26111900",
"Tren Motriz": "25173800",
"Escape": "25173700",
"Luces": "25172900",
"Limpiaparabrisas": "25171500",
"Luces y Limpiaparabrisas": "25172900",
"Carrocería": "25172600",
"Lubricantes y Químicos": "15121500",
"Llantas y Accesorios": "25172500",
"Otros": DEFAULT_KEY,
}
# Keyword rules over the normalized name. ORDER MATTERS (first hit wins), so
# more specific terms go first. Keys are already normalized (uppercase,
# accent-stripped, single-spaced).
RULES = [
# ── Exhaust / emissions ────────────────────────────────────────────────
("catalizador", "12161600"),
("convertidor catalitico", "12161600"),
("mofle", "25173700"),
("silenciador", "25173700"),
("sistema de escape", "25173700"),
("tubo de escape", "25173700"),
("linea de escape", "25173700"),
("multiple de escape", "25173700"),
("sensor de oxigeno", "25173700"),
# ── Clutch ─────────────────────────────────────────────────────────────
("kit de clutch", "26111900"),
("embrague", "26111900"),
("clutch", "26111900"),
("plato de clutch", "26112000"),
("disco de clutch", "26112000"),
# ── Batteries ──────────────────────────────────────────────────────────
("bateria", "26111700"),
("acumulador", "26111700"),
# ── Tires / wheels ─────────────────────────────────────────────────────
("llanta", "25172500"),
("neumatico", "25172500"),
("rin", "25172500"),
# ── Wipers ─────────────────────────────────────────────────────────────
("limpiaparabrisas", "25171500"),
("pluma limpia", "25171500"),
("wiper", "25171500"),
("brazo limpiaparabrisas", "25171500"),
# ── Suspension / steering ──────────────────────────────────────────────
("amortiguador", "25172000"),
("horquilla", "25172000"),
("rotula", "25172000"),
("bieleta", "25172000"),
("espiga", "25172000"),
("base amortiguador", "25172000"),
("resorte", "25172000"),
("suspension", "25172000"),
("cremallera", "25172000"),
("terminal de direccion", "25172000"),
("barra de direccion", "25172000"),
("direccion", "25172000"),
# ── Brakes ─────────────────────────────────────────────────────────────
("balata", "25171700"),
("pastilla", "25171700"),
("disco de freno", "25171700"),
("tambor", "25171700"),
("cilindro de freno", "25171700"),
("freno", "25171700"),
("birlo", "25171700"),
# ── Wheel bearings / hubs ──────────────────────────────────────────────
("maza", "25171900"),
("balero", "25171900"),
("husillo", "25171900"),
# ── Cooling ────────────────────────────────────────────────────────────
("radiador", "25174000"),
("termostato", "25174000"),
("bomba de agua", "25174000"),
("manguera", "25174000"),
("banda", "25174000"),
("polea", "25174000"),
("motoventilador", "25174000"),
("ventilador", "25174000"),
("refrigerante", "25174000"),
("anticongelante", "25174000"),
# ── Filters ────────────────────────────────────────────────────────────
("filtro", "40161500"),
# ── Electrical / ignition ──────────────────────────────────────────────
("bujia", "25173900"),
("cable bujia", "25173900"),
("bobina", "25173900"),
("alternador", "25173900"),
("arrancador", "25173900"),
("marcha", "25173900"),
("modulo", "25173900"),
("regulador", "25173900"),
("relevador", "25173900"),
("fusible", "25173900"),
("sensor", "25173900"),
("switch", "25173900"),
("bomba de gasolina", "25172400"),
("inyector", "25172400"),
("cuerpo de aceleracion", "25172400"),
("carburador", "25172400"),
# ── Engine internals / gaskets ─────────────────────────────────────────
("empaque", "26101700"),
("junta", "26101700"),
("sello", "26101700"),
("reten", "26101700"),
("piston", "26101700"),
("valvula", "26101700"),
("bomba de aceite", "26101700"),
("arbol de levas", "26101700"),
("cigue", "26101700"),
# ── Lighting ───────────────────────────────────────────────────────────
("faro", "25172900"),
("calavera", "25172900"),
("direccional", "25172900"),
("tulipa", "25172900"),
("foco", "25172900"),
("luces", "25172900"),
("luz", "25172900"),
# ── Body / glass ───────────────────────────────────────────────────────
("espejo", "25172600"),
("defensa", "25172600"),
("salpicadera", "25172600"),
("cofre", "25172600"),
("puerta", "25172200"),
("parabrisas", "25172300"),
("cristal", "25172300"),
# ── Drivetrain ─────────────────────────────────────────────────────────
("homocinetica", "25173800"),
("tripode", "25173800"),
("flecha", "25173800"),
("transmision", "26111800"),
("diferencial", "25173800"),
("cople", "25173800"),
# ── Consumables ────────────────────────────────────────────────────────
("aceite", "15121500"),
("grasa", "15121500"),
("aditivo", "15121500"),
("lubricante", "15121500"),
("silicon", "15121500"),
("sellador", "15121500"),
("spray", "15121500"),
("limpiador", "15121500"),
# ── Tools / accessories ────────────────────────────────────────────────
("herramienta", "25191700"),
("desarmador", "25191700"),
("dado", "25191700"),
("gato hidraul", "25191700"),
# ── English aliases (supplier catalogs ship English names) ─────────────
("air filter", "40161500"),
("oil filter", "40161500"),
("fuel filter", "40161500"),
("cabin filter", "40161500"),
("filter", "40161500"),
("water pump", "25174000"),
("fuel pump", "25172400"),
("power steering pump", "25172000"),
("oil pump", "26101700"),
("spark plug", "25173900"),
("ignition", "25173900"),
("muffler", "25173700"),
("exhaust", "25173700"),
("catalytic", "12161600"),
("brake", "25171700"),
("caliper", "25171700"),
("rotor", "25171700"),
("shoe", "25171700"),
("drum", "25171700"),
("pad", "25171700"),
("alternator", "25173900"),
("starter", "25173900"),
("relay", "25173900"),
("fuse", "25173900"),
("cable", "25173900"),
("injector", "25172400"),
("battery", "26111700"),
("radiator", "25174000"),
("thermostat", "25174000"),
("hose", "25174000"),
("belt", "25174000"),
("shock", "25172000"),
("strut", "25172000"),
("control arm", "25172000"),
("ball joint", "25172000"),
("tie rod", "25172000"),
("bearing", "25171900"),
("hub", "25171900"),
("cv joint", "25173800"),
("axle", "25173800"),
("driveshaft", "25173800"),
("transmission", "26111800"),
("gasket", "26101700"),
("valve", "26101700"),
("piston", "26101700"),
("engine mount", "26101700"),
("motor mount", "26101700"),
("mirror", "25172600"),
("bumper", "25172600"),
("fender", "25172600"),
("door", "25172200"),
("windshield", "25172300"),
("glass", "25172300"),
("lamp", "25172900"),
("light", "25172900"),
("wiper", "25171500"),
("tire", "25172500"),
("wheel", "25172500"),
("rim", "25172500"),
# ── Generic fallbacks (kept last) ──────────────────────────────────────
("motor", "26101700"),
("bomba", "26101700"),
]
def _norm(s):
s = unicodedata.normalize("NFD", str(s or ""))
s = s.encode("ascii", "ignore").decode("ascii")
s = re.sub(r"[^A-Za-z0-9]+", " ", s).upper().strip()
return " ".join(s.split())
# RULES keys normalized once (uppercase) so they compare against _norm(name).
_RULES_NORM = [(_norm(k), code) for k, code in RULES]
def resolve_by_rules(name):
"""Return a SAT key from the keyword rules, or ``None``.
Single-token keys match on token prefix (so ``freno`` also catches
``frenos``); multi-token keys match as a contiguous substring.
"""
n = _norm(name)
if not n:
return None
padded = " " + n + " "
tokens = n.split()
for key, code in _RULES_NORM:
if " " in key:
if " " + key + " " in padded:
return code
elif any(
t == key or t.startswith(key)
or (len(t) >= 5 and key.startswith(t))
for t in tokens
):
return code
return None
def resolve_key(name=None, brand=None, canonical=None, category=None,
part_number=None, use_llm=False, timeout=None):
"""Resolve ``(clave_prod_serv, clave_unidad, source)`` for one item.
``source`` ∈ {'rules', 'category', 'llm', 'default'}. Never raises.
"""
# 1. keyword rules over name (fall back to part_number if no name)
code = resolve_by_rules(name) or resolve_by_rules(part_number)
if code:
return code, DEFAULT_UNIT, "rules"
# 2. canonical category (either passed directly or as free text)
for cat in (canonical, category):
if not cat:
continue
hit = CANONICAL_BY_CATEGORY.get(str(cat).strip())
if hit:
return hit, DEFAULT_UNIT, "category"
# 3. optional LLM fallback: classify_part -> category -> rule/map
if use_llm:
llm_code = _llm_key(part_number or name, brand, timeout=timeout)
if llm_code:
return llm_code, DEFAULT_UNIT, "llm"
return DEFAULT_KEY, DEFAULT_UNIT, "default"
def _llm_key(part_number, brand=None, timeout=None):
"""Ask ai_chat.classify_part for the category and map it. Never raises."""
if not part_number:
return None
try:
from services.ai_chat import classify_part
res = classify_part(str(part_number))
except Exception:
return None
if not res:
return None
raw = (res.get("category") or "").strip()
if not raw:
return None
hit = CANONICAL_BY_CATEGORY.get(raw)
if hit:
return hit
# The LLM may answer in free text ("Sistema de Frenos"): try the name rules
# and then the canonical map over its own words.
return resolve_by_rules(raw)

View File

@@ -0,0 +1,304 @@
# /home/Autopartes/pos/services/semantic_search.py
"""Búsqueda semántica sobre el catálogo: rerank + embeddings vía API QWEN.
El proveedor QWEN (``QWEN_API_URL``) expone, además de chat, dos endpoints
compatibles con OpenAI/Cohere y ya verificados en producción:
POST {QWEN_API_URL}/rerank
{"model": "rerank", "query": "...", "documents": ["a", "b", ...]}
→ {"results": [{"index": 1, "relevance_score": 0.70, "document": {...}}, ...]}
(ordenado por relevancia descendente)
POST {QWEN_API_URL}/embeddings
{"model": "qwen3-embedding", "input": "texto"}
→ {"data": [{"embedding": [4096 floats]}, ...]} (OpenAI-compatible)
Regla de oro: **ninguna función lanza excepción**. Si falta configuración, hay
timeout o cualquier error de red/API, ``rerank()`` devuelve el orden original y
``embed()``/``find_similar()`` devuelven ``None``. Así el buscador del POS
nunca se rompe ni se cae por culpa del rerank; en el peor caso la búsqueda es
la de siempre.
Variables de entorno (ver ``pos/config.py``):
QWEN_API_URL URL base del proveedor (igual que ``ai_chat``)
QWEN_API_KEY API key (igual que ``ai_chat``)
QWEN_RERANK_MODEL default ``rerank``
QWEN_EMBED_MODEL default ``qwen3-embedding``
SEARCH_RERANK_ENABLED default ``true`` — scope ``catalog`` (público)
SEARCH_RERANK_POS_ENABLED default ``false`` — scope ``pos`` (caja/POS)
SEARCH_RERANK_TIMEOUT default ``2`` segundos (ambos scopes)
SEARCH_RERANK_MAX_CANDIDATES default ``50`` documentos por llamada
Nota de rendimiento (medido 2026-10-11 contra api.nan.builders, RTT ~40 ms):
el endpoint /rerank tarda ~0.6–1.8 s independientemente del número de
documentos (5 a 51). Por eso el POS va apagado por defecto: su búsqueda en
Meilisearch ya responde en ~18 ms y el rerank la encarecería 30–80×.
"""
import math
import os
import requests
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
from config import QWEN_API_URL, QWEN_API_KEY
from config import QWEN_RERANK_MODEL, QWEN_EMBED_MODEL
from config import SEARCH_RERANK_ENABLED, SEARCH_RERANK_POS_ENABLED
from config import SEARCH_RERANK_TIMEOUT, SEARCH_RERANK_MAX_CANDIDATES
except ImportError:
QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
QWEN_RERANK_MODEL = os.environ.get('QWEN_RERANK_MODEL', 'rerank')
QWEN_EMBED_MODEL = os.environ.get('QWEN_EMBED_MODEL', 'qwen3-embedding')
SEARCH_RERANK_ENABLED = os.environ.get('SEARCH_RERANK_ENABLED', 'true').lower() == 'true'
SEARCH_RERANK_POS_ENABLED = os.environ.get('SEARCH_RERANK_POS_ENABLED', 'false').lower() == 'true'
SEARCH_RERANK_TIMEOUT = float(os.environ.get('SEARCH_RERANK_TIMEOUT', '2'))
SEARCH_RERANK_MAX_CANDIDATES = int(os.environ.get('SEARCH_RERANK_MAX_CANDIDATES', '50'))
DEFAULT_RERANK_MODEL = 'rerank'
DEFAULT_EMBED_MODEL = 'qwen3-embedding'
DEFAULT_TIMEOUT = 2.0
DEFAULT_MAX_CANDIDATES = 50
# Scopes del flag: 'catalog' = catálogo público / API / dashboard;
# 'pos' = caja y chat del POS, donde la latencia es crítica.
SCOPE_CATALOG = 'catalog'
SCOPE_POS = 'pos'
def is_configured():
"""True si hay URL y API key (condición previa a cualquier llamada)."""
return bool((QWEN_API_URL or '').strip() and (QWEN_API_KEY or '').strip())
def is_rerank_enabled(scope=SCOPE_CATALOG):
"""Flag de rerank para un scope.
``SEARCH_RERANK_ENABLED`` es el interruptor maestro (default ``true``) y
aplica al scope ``catalog``. El POS requiere además
``SEARCH_RERANK_POS_ENABLED=true`` (default ``false``) porque el rerank
agrega cientos de ms a una búsqueda que hoy es de ~18 ms.
"""
if not SEARCH_RERANK_ENABLED:
return False
if (scope or SCOPE_CATALOG).lower() == SCOPE_POS:
return bool(SEARCH_RERANK_POS_ENABLED)
return True
def rerank_model():
return QWEN_RERANK_MODEL or DEFAULT_RERANK_MODEL
def embed_model():
return QWEN_EMBED_MODEL or DEFAULT_EMBED_MODEL
def max_candidates():
"""Tope de documentos por llamada (acota payload y latencia)."""
try:
value = int(SEARCH_RERANK_MAX_CANDIDATES)
except (TypeError, ValueError):
return DEFAULT_MAX_CANDIDATES
return value if value > 0 else DEFAULT_MAX_CANDIDATES
def _timeout(timeout=None):
if timeout is not None:
return timeout
try:
return float(SEARCH_RERANK_TIMEOUT)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT
def _endpoint(path):
url = (QWEN_API_URL or '').strip()
if not url:
return None
return url.rstrip('/') + path
def _headers():
return {
'Authorization': f"Bearer {QWEN_API_KEY}",
'Content-Type': 'application/json',
}
def _identity_order(count):
return [{'index': i, 'relevance_score': None} for i in range(count)]
def rerank(query, documents, top_n=None, timeout=None):
"""Reordena ``documents`` por relevancia semántica respecto a ``query``.
Args:
query: texto de búsqueda del usuario.
documents: lista de textos candidatos.
top_n: opcional, trunca el resultado a los N más relevantes. Por
defecto devuelve **todos** los documentos: el caller pagina/muestra
y no debe perder items.
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
Returns:
list[dict]: ``[{'index': <int>, 'relevance_score': <float|None>}, ...]``
en orden de relevancia descendente. En degradación (sin URL/key,
timeout, HTTP error, respuesta vacía o índice inválido) devuelve el
orden original con ``relevance_score=None``. Nunca lanza.
"""
docs = list(documents or [])
identity = _identity_order(len(docs))
if not docs or not query:
return identity[:top_n] if top_n else identity
url = _endpoint('/rerank')
if not url or not (QWEN_API_KEY or '').strip():
return identity[:top_n] if top_n else identity
try:
resp = requests.post(
url,
headers=_headers(),
json={
'model': rerank_model(),
'query': query,
'documents': docs,
},
timeout=_timeout(timeout),
)
if resp.status_code >= 400:
print(f"[semantic_search] rerank HTTP {resp.status_code}: {resp.text[:200]}")
return identity[:top_n] if top_n else identity
results = (resp.json() or {}).get('results') or []
except Exception as e:
print(f"[semantic_search] rerank error: {e}")
return identity[:top_n] if top_n else identity
ordered = []
seen = set()
for item in results:
try:
idx = int(item.get('index'))
except (TypeError, ValueError, AttributeError):
continue
if idx < 0 or idx >= len(docs) or idx in seen:
continue
seen.add(idx)
score = item.get('relevance_score')
ordered.append({
'index': idx,
'relevance_score': float(score) if isinstance(score, (int, float)) else None,
})
if not ordered:
return identity[:top_n] if top_n else identity
# Documentos que la API no devolvió (p.ej. si aplicó su propio tope)
# conservan su orden original al final — nunca se pierden.
for i in range(len(docs)):
if i not in seen:
ordered.append({'index': i, 'relevance_score': None})
return ordered[:top_n] if top_n else ordered
def rerank_order(query, documents, top_n=None, timeout=None):
"""Igual que ``rerank()`` pero devuelve solo los índices reordenados."""
return [r['index'] for r in rerank(query, documents, top_n=top_n, timeout=timeout)]
def embed(texts, timeout=None):
"""Devuelve los embeddings de ``texts`` vía el endpoint /embeddings.
Args:
texts: str o iterable de str.
timeout: segundos (default ``SEARCH_RERANK_TIMEOUT``).
Returns:
list[list[float]] en el mismo orden que la entrada, o ``None`` si no
hay configuración, hay timeout, error HTTP o respuesta malformada.
Nunca lanza.
"""
if isinstance(texts, str):
items = [texts]
else:
items = list(texts or [])
if not items:
return []
url = _endpoint('/embeddings')
if not url or not (QWEN_API_KEY or '').strip():
return None
try:
resp = requests.post(
url,
headers=_headers(),
json={'model': embed_model(), 'input': items},
timeout=_timeout(timeout),
)
if resp.status_code >= 400:
print(f"[semantic_search] embeddings HTTP {resp.status_code}: {resp.text[:200]}")
return None
data = (resp.json() or {}).get('data') or []
vectors = [row.get('embedding') for row in data]
if len(vectors) != len(items) or any(not isinstance(v, list) or not v for v in vectors):
print(f"[semantic_search] embeddings respuesta inesperada ({len(vectors)} de {len(items)})")
return None
return vectors
except Exception as e:
print(f"[semantic_search] embeddings error: {e}")
return None
def cosine_similarity(vec_a, vec_b):
"""Similitud coseno entre dos vectores (None si no es calculable)."""
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
return None
try:
dot = 0.0
norm_a = 0.0
norm_b = 0.0
for a, b in zip(vec_a, vec_b):
dot += a * b
norm_a += a * a
norm_b += b * b
if norm_a <= 0 or norm_b <= 0:
return None
return dot / (math.sqrt(norm_a) * math.sqrt(norm_b))
except (TypeError, ValueError):
return None
def similar_texts(text_a, text_b, timeout=None):
"""Similitud coseno entre dos textos (None si no hay embeddings)."""
vectors = embed([text_a, text_b], timeout=timeout)
if not vectors:
return None
return cosine_similarity(vectors[0], vectors[1])
def find_similar(query, candidates, top_n=None, timeout=None):
"""Ordena ``candidates`` por similitud coseno con ``query``.
Returns:
list[dict] ``[{'index', 'similarity'}, ...]`` ordenado desc, o ``None``
si no hay embeddings disponibles. Nunca lanza.
"""
items = list(candidates or [])
if not items or not query:
return None
vectors = embed([query] + items, timeout=timeout)
if not vectors:
return None
query_vec = vectors[0]
scored = []
for i, vec in enumerate(vectors[1:]):
sim = cosine_similarity(query_vec, vec)
if sim is not None:
scored.append({'index': i, 'similarity': sim})
if not scored:
return None
scored.sort(key=lambda r: r['similarity'], reverse=True)
return scored[:top_n] if top_n else scored

448
pos/services/sku_matcher.py Normal file
View File

@@ -0,0 +1,448 @@
# /home/Autopartes/pos/services/sku_matcher.py
"""Inventory <-> supplier-catalog SKU matching.
Tries to populate ``inventory.catalog_part_id`` (today 0% linked) and register
the matched catalog SKU as an alias in ``inventory_sku_aliases``.
Matching strategy (first hit wins), all over a *normalized* key (uppercase,
dashes/spaces/dots stripped):
1. exact — inventory.part_number == supplier_catalog.sku
2. xref — inventory.part_number == supplier_catalog_interchange.part_number
3. name — fuzzy name similarity (difflib, no sklearn available) against the
supplier-catalog names, blocked by first significant token.
4. semantic — optional (opt-in) cosine similarity over QWEN embeddings
(``semantic_search.embed``), used to fill the gaps difflib
leaves behind (same part, different wording/abbreviations).
Catalog-name embeddings are cached in-memory per ``CatalogIndex``
so a long run embeds each catalog name at most once. With no
network/provider the step degrades to a no-op.
``supplier_catalog`` / ``supplier_catalog_interchange`` live in the MASTER DB;
``inventory`` / ``inventory_sku_aliases`` live in the tenant DB. Because the
two databases are queried with separate connections, the join happens in
Python (the catalog is small: ~17k items / ~18k distinct cross-refs).
Public API:
match_inventory(tenant_conn, master_conn, limit=None, ...) -> summary dict
load_catalog_index(master_conn) -> CatalogIndex
"""
import re
import time
import unicodedata
from difflib import SequenceMatcher
_SKU_BLOCK_RE = re.compile(r"[^A-Z0-9]+")
def norm_sku(s):
"""Normalize a SKU/part number for exact matching."""
s = unicodedata.normalize("NFD", str(s or ""))
s = s.encode("ascii", "ignore").decode("ascii")
return _SKU_BLOCK_RE.sub("", s.upper())
def _name_tokens(s):
s = unicodedata.normalize("NFD", str(s or ""))
s = s.encode("ascii", "ignore").decode("ascii")
return [t for t in re.split(r"[^A-Za-z0-9]+", s.upper()) if t]
def _name_norm(s):
return " ".join(_name_tokens(s))
def _load_semantic_search():
"""Import ``semantic_search`` whether pos/ is on sys.path or not."""
try:
from services import semantic_search as ss
except Exception:
try:
import semantic_search as ss
except Exception:
return None
return ss
def _embed_texts(texts, batch=64, timeout=None, retries=1):
"""Embed ``texts`` in batches via ``semantic_search.embed``.
Returns the list of vectors in input order, or ``None`` when the provider
is unavailable / errors out (never raises). A single retry covers the
transient 429/timeout seen on the shared QWEN provider.
"""
items = list(texts or [])
if not items:
return []
ss = _load_semantic_search()
if ss is None:
return None
vectors = []
for i in range(0, len(items), max(int(batch), 1)):
chunk = items[i:i + max(int(batch), 1)]
out = None
for attempt in range(int(retries) + 1):
try:
out = ss.embed(chunk, timeout=timeout)
except Exception:
out = None
if out and len(out) == len(chunk):
break
if attempt < int(retries):
time.sleep(2.0)
if not out or len(out) != len(chunk):
return None
vectors.extend(out)
return vectors
class CatalogIndex:
"""In-memory index over supplier_catalog (+ interchange)."""
def __init__(self):
self.by_sku = {} # norm sku -> list of catalog dicts
self.by_xref = {} # norm part_number -> list of catalog dicts
self.by_token = {} # first significant name token -> list of dicts
self.total = 0
self._emb_cache = {} # name_norm -> embedding vector (lazy)
self.emb_requests = 0 # provider calls used by the semantic matcher
def add(self, cat_id, supplier, sku, name, category):
rec = {
"catalog_id": cat_id, "supplier": supplier, "sku": sku,
"name": name, "category": category, "name_norm": _name_norm(name),
}
self.total += 1
ns = norm_sku(sku)
if ns:
self.by_sku.setdefault(ns, []).append(rec)
tokens = _name_tokens(name)
if tokens:
self.by_token.setdefault(tokens[0], []).append(rec)
return rec
def add_xref(self, norm_pn, rec):
if norm_pn:
self.by_xref.setdefault(norm_pn, []).append(rec)
def match_sku(self, norm_pn):
return self.by_sku.get(norm_pn)
def match_xref(self, norm_pn):
return self.by_xref.get(norm_pn)
def match_name(self, name, threshold=0.82):
"""Best fuzzy name match (blocked by first token). Returns (rec, score)."""
nt = _name_norm(name)
tokens = _name_tokens(name)
if not tokens or len(nt) < 4:
return None, 0.0
candidates = self.by_token.get(tokens[0])
if not candidates:
return None, 0.0
best, best_score = None, 0.0
for rec in candidates:
if not rec["name_norm"]:
continue
score = SequenceMatcher(None, nt, rec["name_norm"]).ratio()
if score > best_score:
best, best_score = rec, score
if best and best_score >= threshold:
return best, best_score
return None, best_score
def _embed_names(self, names, batch=64, timeout=None):
"""Return {name_norm: vector}, embedding only the uncached names.
Catalog-side cache: each distinct ``name_norm`` is embedded once per
index. Returns ``None`` (degraded) when the provider is unavailable.
"""
missing = [n for n in dict.fromkeys(names) if n and n not in self._emb_cache]
if missing:
self.emb_requests += 1
vectors = _embed_texts(missing, batch=batch, timeout=timeout)
if vectors is None:
return None
for n, v in zip(missing, vectors):
self._emb_cache[n] = v
return self._emb_cache
def match_name_semantic(self, name, threshold=0.86, top_k=200,
batch=64, timeout=None):
"""Best semantic name match (QWEN embeddings). Returns (rec, score).
Only the closest ``top_k`` candidates (blocked by first token, then
pre-ranked with difflib) are embedded, keeping cost bounded. Degrades
to ``(None, 0.0)`` when embeddings are unavailable.
"""
nt = _name_norm(name)
tokens = _name_tokens(name)
if not tokens or len(nt) < 4:
return None, 0.0
candidates = self.by_token.get(tokens[0]) or []
if not candidates:
return None, 0.0
ss = _load_semantic_search()
if ss is None:
return None, 0.0
try:
if hasattr(ss, 'is_configured') and not ss.is_configured():
return None, 0.0
except Exception:
pass
pool = [(SequenceMatcher(None, nt, r["name_norm"]).ratio(), r)
for r in candidates if r["name_norm"]]
pool.sort(key=lambda t: t[0], reverse=True)
pool = [r for _, r in pool[:max(int(top_k), 1)]]
if not pool:
return None, 0.0
vecs = self._embed_names([r["name_norm"] for r in pool],
batch=batch, timeout=timeout)
if not vecs:
return None, 0.0
query_vecs = _embed_texts([nt], batch=1, timeout=timeout)
if not query_vecs:
return None, 0.0
query_vec = query_vecs[0]
best, best_sim = None, 0.0
for rec in pool:
vec = vecs.get(rec["name_norm"])
if not vec:
continue
try:
sim = ss.cosine_similarity(query_vec, vec)
except Exception:
sim = None
if sim is not None and sim > best_sim:
best, best_sim = rec, sim
if best and best_sim >= threshold:
return best, best_sim
return None, best_sim
def load_catalog_index(master_conn, supplier=None):
"""Build a CatalogIndex from the master supplier_catalog tables."""
idx = CatalogIndex()
cur = master_conn.cursor()
try:
if supplier:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE is_active = true AND supplier_name = %s",
(supplier,),
)
else:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE is_active = true"
)
recs = {}
for cat_id, sup, sku, name, category in cur.fetchall():
recs[cat_id] = idx.add(cat_id, sup, sku, name, category)
# Interchange cross-refs, deduped (406k rows / ~18k distinct part numbers)
cur.execute(
"SELECT MIN(catalog_id), UPPER(part_number) FROM supplier_catalog_interchange "
"GROUP BY UPPER(part_number)"
)
for catalog_id, part_number in cur.fetchall():
rec = recs.get(catalog_id)
if rec is None:
continue
idx.add_xref(norm_sku(part_number), rec)
finally:
cur.close()
return idx
def match_inventory(tenant_conn, master_conn, limit=None, offset=0,
only_unmatched=True, create_aliases=True, use_names=True,
name_threshold=0.82, apply=True, supplier=None,
after_id=None, sample_limit=20, use_semantic=False,
semantic_threshold=0.86, semantic_top_k=200,
semantic_batch=64, semantic_timeout=20.0,
semantic_prefer_below=None, collect=False, index=None,
only_linked=False):
"""Match tenant inventory items against the supplier catalog.
Args:
only_unmatched: only consider items whose catalog_part_id is NULL/0.
create_aliases: insert matched catalog SKUs into inventory_sku_aliases.
use_names: enable the fuzzy-name fallback.
apply: persist changes (False = dry run).
use_semantic: enable the embedding-based name fallback (opt-in; it
needs the QWEN provider and is slower, so pair it with ``limit``).
semantic_threshold: minimum cosine similarity (qwen3-embedding scores
unrelated names low and near-synonyms high; 0.86 is conservative).
semantic_prefer_below: when set, also run the semantic matcher for
items whose fuzzy score is below this value and keep whichever
scores higher (lets semantic *correct* weak difflib hits).
Returns a summary dict with counts by method and a sample of matches.
"""
idx = index if index is not None else load_catalog_index(master_conn, supplier=supplier)
cur = tenant_conn.cursor()
where = ["is_active = true"]
params = []
if only_linked:
where.append("(catalog_part_id IS NOT NULL AND catalog_part_id <> 0)")
elif only_unmatched:
where.append("(catalog_part_id IS NULL OR catalog_part_id = 0)")
if after_id is not None:
where.append("id > %s")
params.append(after_id)
sql = (
"SELECT id, part_number, name, catalog_part_id FROM inventory "
"WHERE " + " AND ".join(where) + " ORDER BY id"
)
if limit is not None:
sql += " LIMIT %s OFFSET %s"
params.extend([int(limit), int(offset)])
cur.execute(sql, params)
rows = cur.fetchall()
cur.close()
by_method = {"sku": 0, "sku_prefix": 0, "interchange": 0,
"interchange_prefix": 0, "name": 0, "name_semantic": 0}
updated = 0
aliases = 0
skipped_existing = 0
sample = []
all_results = []
last_id = after_id
def _lookup(norm_pn):
"""(rec, method, score) for an exact/cross-ref key, else (None, None, 0).
Cross-refs on very short numeric keys are skipped: a bare "3341" can
collide with unrelated candidates in the 406k interchange table.
"""
if not norm_pn:
return None, None, 0.0
hit = idx.match_sku(norm_pn)
if hit:
return hit[0], "sku", 1.0
if len(norm_pn) >= 5 or any(c.isalpha() for c in norm_pn):
hit = idx.match_xref(norm_pn)
if hit:
return hit[0], "interchange", 0.95
return None, None, 0.0
for inv_id, part_number, name, current_cpid in rows:
last_id = inv_id
norm_pn = norm_sku(part_number)
match, method, score = _lookup(norm_pn)
# Many tenant SKUs are "<vendor>-<manufacturer_code>"; retry stripped.
if match is None and part_number and "-" in str(part_number):
head, rest = str(part_number).split("-", 1)
if head.isalpha() and 2 <= len(head) <= 4 and rest.strip():
match, method, score = _lookup(norm_sku(rest))
if match:
method = "sku_prefix" if method == "sku" else "interchange_prefix"
score = 0.96 if method == "sku_prefix" else 0.9
if match is None and use_names and name:
match, score = idx.match_name(name, threshold=name_threshold)
if match:
method = "name"
needs_semantic = (
use_semantic and name and
(match is None or
(semantic_prefer_below is not None and score < semantic_prefer_below))
)
if needs_semantic:
s_rec, s_score = idx.match_name_semantic(
name, threshold=semantic_threshold, top_k=semantic_top_k,
batch=semantic_batch, timeout=semantic_timeout,
)
if s_rec and (match is None or s_score > score):
match, method, score = s_rec, "name_semantic", s_score
if match is None:
continue
by_method[method] += 1
if apply:
cur = tenant_conn.cursor()
try:
cur.execute(
"UPDATE inventory SET catalog_part_id = %s "
"WHERE id = %s AND (catalog_part_id IS NULL OR catalog_part_id = 0)",
(match["catalog_id"], inv_id),
)
if cur.rowcount:
updated += 1
else:
skipped_existing += 1
if create_aliases and match["sku"]:
cur.execute(
"INSERT INTO inventory_sku_aliases (inventory_id, sku, label) "
"VALUES (%s, %s, %s) ON CONFLICT (inventory_id, sku) DO NOTHING",
(inv_id, match["sku"], f"Catálogo:{match['supplier'] or 'proveedor'}"),
)
aliases += cur.rowcount
tenant_conn.commit()
finally:
cur.close()
if collect:
all_results.append({
"inventory_id": inv_id, "part_number": part_number, "name": name,
"existing_catalog_id": current_cpid,
"matched_catalog_id": match["catalog_id"],
"matched_sku": match["sku"], "method": method,
"score": round(score, 3),
})
if len(sample) < sample_limit:
sample.append({
"inventory_id": inv_id, "part_number": part_number, "name": name,
"matched_catalog_id": match["catalog_id"],
"matched_sku": match["sku"], "matched_name": match["name"],
"supplier": match["supplier"], "method": method,
"score": round(score, 3),
})
return {
"scanned": len(rows),
"matched": sum(by_method.values()),
"by_method": by_method,
"updated": updated,
"aliases_created": aliases,
"skipped_existing": skipped_existing,
"catalog_size": idx.total,
"sample": sample,
"last_id": last_id,
"results": all_results,
"semantic": {
"enabled": bool(use_semantic),
"threshold": semantic_threshold,
"prefer_below": semantic_prefer_below,
"catalog_embeddings_cached": len(idx._emb_cache),
"embed_requests": idx.emb_requests,
},
}
def count_links(tenant_conn):
"""Return (total_items, linked_items) for progress reporting."""
cur = tenant_conn.cursor()
try:
cur.execute(
"SELECT count(*), count(*) FILTER "
"(WHERE catalog_part_id IS NOT NULL AND catalog_part_id <> 0) FROM inventory"
)
return cur.fetchone()
finally:
cur.close()

View File

@@ -10,11 +10,14 @@ On receive, automatically:
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
import logging
from services.inventory_engine import record_purchase
from services.accounting_engine import record_purchase_entry
from services.audit import log_action
logger = logging.getLogger('pos')
TWO = Decimal('0.01')
@@ -320,8 +323,9 @@ def receive_po(conn, po_id, received_items, supplier_invoice=None, notes=None):
'total': float(total_mxn),
'supplier_name': _get_supplier_name(conn, po_supplier_id),
})
except Exception:
pass # Accounting errors never block receiving
except Exception as acc_err:
# Accounting errors never block receiving, but must be logged
logger.error(f'Accounting entry for received PO #{po_id} failed: {acc_err}')
log_action(conn, 'PO_RECEIVE', 'purchase_order', po_id,
new_value={'received_qty': received_total, 'status': new_status})

View File

@@ -200,9 +200,11 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
# Apply pending migrations post-v1.0
from migrations.runner import MIGRATIONS, apply_migration
sorted_versions = sorted(MIGRATIONS.keys())
def _version_key(v):
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
for version in sorted_versions:
if version <= 'v1.0':
if _version_key(version) <= _version_key('v1.0'):
continue
success = apply_migration(db_name, version)
if not success:

View File

@@ -96,17 +96,106 @@ def detect_quote_intent(text, has_open_quote=False):
return None, None
def confirm_quotation(tenant_conn, phone):
"""Mark the open quotation as confirmed/accepted."""
def confirm_quotation(tenant_conn, phone, customer_id=None, tenant_id=None, branch_id=None):
"""Confirm the open quotation and convert it into a pending-payment sale.
- Validates stock for every line before touching anything.
- Creates the sale via pos_engine.process_sale (status 'pending_payment',
stock reserved, no cash movement — payable later at the counter).
- Notifies owners via the notification engine (best effort).
Returns dict {'quotation_id', 'sale_id', 'total', 'items'} on success,
dict {'error': ..., ...} on failure, or None if there was no open quote.
"""
qid = get_open_quotation(tenant_conn, phone)
if not qid:
return None
detail = get_quotation_detail(tenant_conn, qid)
if not detail or not detail.get('items'):
return None
# Resolve target branch (first active)
if not branch_id:
cur = tenant_conn.cursor()
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
row = cur.fetchone()
cur.close()
if not row:
return {'error': 'no_branch', 'quotation_id': qid}
branch_id = row[0]
# Resolve inventory ids and validate stock
cur = tenant_conn.cursor()
resolved = []
for it in detail['items']:
cur.execute("""
SELECT i.id, COALESCE(s.stock, 0) FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.part_number = %s AND i.is_active LIMIT 1
""", (it['part_number'],))
row = cur.fetchone()
if not row:
cur.close()
return {'error': 'item_not_found', 'part_number': it['part_number'],
'quotation_id': qid}
inv_id, stock = row
if (stock or 0) < it['quantity']:
cur.close()
return {'error': 'insufficient_stock', 'part_number': it['part_number'],
'available': int(stock or 0), 'requested': it['quantity'],
'quotation_id': qid}
resolved.append({
'inventory_id': inv_id,
'quantity': it['quantity'],
'unit_price': it['unit_price'],
'tax_rate': it['tax_rate'],
'discount_pct': 0,
})
cur.close()
from services.pos_engine import process_sale
sale_data = {
'items': resolved,
'customer_id': customer_id,
'payment_method': 'pendiente',
'sale_type': 'cash',
'branch_id': branch_id,
'amount_paid': 0,
'notes': f'Pedido por WhatsApp (cotizacion #{qid})',
}
try:
sale = process_sale(tenant_conn, sale_data)
except ValueError as e:
tenant_conn.rollback()
return {'error': 'sale_failed', 'message': str(e), 'quotation_id': qid}
except Exception:
tenant_conn.rollback()
raise
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
tenant_conn.commit()
clear_last_shown(tenant_conn, phone)
return qid
# Notify owners (best effort — never block the reply)
try:
if tenant_id:
from services.notification_engine import notify_new_sale
notify_new_sale(tenant_conn, tenant_id, sale['id'], sale['total'], 'whatsapp')
except Exception:
pass
return {
'quotation_id': qid,
'sale_id': sale['id'],
'total': sale['total'],
'items': detail['items'],
}
# ─── Persistent last-shown-part per phone ────────────────────────────

View File

@@ -378,17 +378,32 @@ def handle_quote_search(text, state_data, ctx):
search_text = search_text.strip()
state_data['search_description'] = search_text
# Memoria conversacional + historial de compras + persona por tenant
conv_history = []
try:
from blueprints.whatsapp_bp import _get_conversation_history
conv_history = _get_conversation_history(ctx.phone, ctx.tenant_conn, limit=4)
except Exception:
conv_history = []
purchase_ctx = None
try:
from services.part_kits import get_purchase_history
purchase_ctx = get_purchase_history(ctx.phone, ctx.tenant_conn) or None
except Exception:
purchase_ctx = None
persona = (ctx.wa_config or {}).get('ai_persona') or None
try:
from services.ai_chat import chat, chat_with_image
if ctx.media_kind == 'image' and ctx.media_base64:
ai_resp = chat_with_image(
user_message=search_text if search_text != '(imagen)' else 'Identifica esta parte automotriz y sugiere terminos de busqueda.',
image_base64=ctx.media_base64,
conversation_history=[],
inventory_context=None,
conversation_history=conv_history,
inventory_context=purchase_ctx,
)
else:
ai_resp = chat(search_text, conversation_history=[], inventory_context=None)
ai_resp = chat(search_text, conversation_history=conv_history, inventory_context=purchase_ctx, persona=persona)
search_query = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
@@ -669,7 +684,7 @@ def handle_quote_command_created(text, state_data, ctx):
try:
from services.wa_quotation import get_open_quotation, get_quotation_detail
from services.quote_image import generate_quote_image
from services.whatsapp_service import send_image
from services.whatsapp_service import wa_send_image
qid = get_open_quotation(ctx.tenant_conn, ctx.phone)
if qid:
@@ -688,11 +703,11 @@ def handle_quote_command_created(text, state_data, ctx):
}
tenant_name = _get_tenant_business_name(ctx.tenant_conn)
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
img_result = send_image(
img_result = wa_send_image(
ctx.wa_config,
ctx.phone,
caption="Aquí está tu cotización 👇",
base64_image=b64_img,
bridge_url=ctx.wa_config.get('bridge_url')
)
if img_result.get('success'):
return (
@@ -715,19 +730,45 @@ def handle_quote_command_created(text, state_data, ctx):
if t in ('confirmar', 'confirmo', 'acepto', 'si', 'sí', 'va', 'ordenar', 'pedir'):
from services.wa_quotation import confirm_quotation
qid = confirm_quotation(ctx.tenant_conn, ctx.phone)
if qid:
reply = (
"✅ *¡Pedido confirmado!*\n\n"
"Tu cotización fue registrada.\n"
"Nos pondremos en contacto contigo para coordinar la entrega.\n\n"
"¡Gracias por tu compra! 🙏"
)
clean_data = {
'customer_id': state_data.get('customer_id'),
'branch_name': state_data.get('branch_name'),
}
return reply, 'support_done', clean_data
result = confirm_quotation(ctx.tenant_conn, ctx.phone,
customer_id=ctx.customer_id,
tenant_id=ctx.tenant_id,
branch_id=ctx.branch_id)
clean_data = {
'customer_id': state_data.get('customer_id'),
'branch_name': state_data.get('branch_name'),
}
if result and result.get('sale_id'):
lines = []
lines.append("✅ *¡Pedido confirmado!*")
lines.append("")
lines.append(f"Tu pedido *#{result['sale_id']}* quedó registrado y separado.")
lines.append("Pasa por tu refaccionaria oco coordina la entrega al confirmar el pago.\n")
lines.append("*Detalle:*")
for it in result.get('items', []):
lines.append(f"• {it['quantity']} x {it['part_number']}")
lines.append("")
lines.append(f"*Total:* ${result['total']:,.2f}")
lines.append("")
lines.append("¡Gracias por tu compra! 🙏")
return "\n".join(lines), 'support_done', clean_data
if result and result.get('error') == 'insufficient_stock':
return (
f"⚠️ No hay stock suficiente de *{result['part_number']}*\n"
f"Disponible: {result['available']} — Solicitado: {result['requested']}\n\n"
"Tu cotización sigue activa. ¿Quieres *confirmar* el resto, "
"*agregar* otra pieza o *limpiar* el pedido?",
'quote_command_created', state_data)
if result and result.get('error') == 'item_not_found':
return (
f"⚠️ La pieza *{result['part_number']}* ya no está disponible.\n\n"
"¿Quieres *agregar* otra pieza o *limpiar* el pedido?",
'quote_command_created', state_data)
if result:
return (
"⚠️ No pude registrar tu pedido en este momento. "
"Intenta de nuevo en unos minutos o contáctanos directamente.",
'quote_command_created', state_data)
if t in ('limpiar', 'borrar', 'nueva', 'cancelar', 'reset'):
from services.wa_quotation import clear_quotation

View File

@@ -13,36 +13,46 @@ from services.audit import log_action
def register_warranty(conn, sale_id, sale_item_id, inventory_id,
customer_id, warranty_months, supplier_id=None,
part_number=None, name=None, notes=None):
part_number=None, name=None, notes=None,
warranty_days=None):
"""Register a warranty for a sold item.
Args:
warranty_months: int (e.g., 12, 24, 36)
warranty_days: optional int. When given, end_date = today + warranty_days
(instead of 30 * warranty_months) and it is stored in the
warranty_days column. Used by the Yonke (used parts) module.
Returns:
int: warranty_id
"""
if not warranty_months or warranty_months <= 0:
if (not warranty_months or warranty_months <= 0) and not warranty_days:
raise ValueError("warranty_months must be a positive integer")
start = date.today()
end = start + timedelta(days=30 * warranty_months)
if warranty_days:
end = start + timedelta(days=int(warranty_days))
else:
end = start + timedelta(days=30 * warranty_months)
cur = conn.cursor()
cur.execute("""
INSERT INTO warranties
(sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
part_number, name, warranty_months, start_date, end_date, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
part_number, name, warranty_months, start_date, end_date, notes,
warranty_days)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
part_number, name, warranty_months, start, end, notes
part_number, name, warranty_months, start, end, notes,
int(warranty_days) if warranty_days else None
))
w_id = cur.fetchone()[0]
cur.close()
log_action(conn, 'WARRANTY_REGISTER', 'warranty', w_id,
new_value={'months': warranty_months, 'end_date': str(end)})
new_value={'months': warranty_months, 'days': warranty_days,
'end_date': str(end)})
return w_id
@@ -141,7 +151,8 @@ def get_warranty(conn, warranty_id):
SELECT w.id, w.sale_id, w.sale_item_id, w.inventory_id, w.customer_id,
w.supplier_id, w.part_number, w.name, w.warranty_months,
w.start_date, w.end_date, w.status, w.notes, w.created_at,
c.name as customer_name, s.name as supplier_name
c.name as customer_name, s.name as supplier_name,
w.warranty_days
FROM warranties w
LEFT JOIN customers c ON w.customer_id = c.id
LEFT JOIN suppliers s ON w.supplier_id = s.id
@@ -158,6 +169,7 @@ def get_warranty(conn, warranty_id):
'start_date': str(row[9]), 'end_date': str(row[10]),
'status': row[11], 'notes': row[12], 'created_at': str(row[13]),
'customer_name': row[14], 'supplier_name': row[15],
'warranty_days': row[16],
}

View File

@@ -158,3 +158,302 @@ def send_image(phone, caption, base64_image, bridge_url=None):
).json()
except Exception as e:
return {'error': str(e)}
# ═══════════════════════════════════════════════════════════════════════════
# Meta Cloud API provider (WA CRM / self-hosted, 1 número por tenant)
#
# cfg keys (tenant_config):
# whatsapp_provider = 'meta' | 'baileys' (default: baileys)
# wa_phone_number_id — Meta phone number ID
# wa_access_token — permanent/long-lived access token
# wa_app_secret — App secret (valida X-Hub-Signature-256 en webhooks)
# wa_verify_token — token del GET verify del webhook
# wa_graph_url — (opcional) override de graph.facebook.com/v21.0
# ═══════════════════════════════════════════════════════════════════════════
import base64 as _b64
import secrets as _secrets
def _meta_is_meta(cfg):
return str(cfg.get('provider') or '').lower() == 'meta' and cfg.get('phone_number_id')
def _meta_digits(phone):
return ''.join(ch for ch in str(phone or '') if ch.isdigit())
def _meta_graph(cfg, method, path, json_payload=None, files=None, data=None, params=None):
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
url = f"{base}/{path}"
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
if method == 'GET':
return requests.get(url, headers=headers, params=params or {}, timeout=20).json()
if files:
return requests.post(url, headers={'Authorization': headers['Authorization']},
files=files, data=data or {}, timeout=60).json()
return requests.post(url, headers=headers, json=json_payload, timeout=30).json()
def meta_send_text(cfg, phone, text):
try:
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': _meta_digits(phone),
'type': 'text',
'text': {'preview_url': True, 'body': text},
})
if r.get('error'):
return {'error': r['error'].get('message', 'Meta error')}
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
except Exception as e:
return {'error': str(e)}
def meta_send_image(cfg, phone, caption, base64_image):
"""Upload media then send image message."""
try:
raw = base64_image
if ',' in raw and raw.strip().startswith('data:'):
raw = raw.split(',', 1)[1]
img_bytes = _b64.b64decode(raw)
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
files={'file': ('image.jpg', img_bytes, 'image/jpeg')},
data={'messaging_product': 'whatsapp'})
media_id = up.get('id')
if not media_id:
return {'error': up.get('error', {}).get('message', 'media upload failed')}
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': _meta_digits(phone),
'type': 'image',
'image': {'id': media_id, 'caption': caption or ''},
})
if r.get('error'):
return {'error': r['error'].get('message', 'Meta error')}
return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')}
except Exception as e:
return {'error': str(e)}
def meta_get_status(cfg):
try:
r = _meta_graph(cfg, 'GET', f"{cfg['phone_number_id']}",
params={'fields': 'display_phone_number,verified_name,quality_rating'})
if r.get('error'):
return {'state': 'error', 'error': r['error'].get('message')}
return {'state': 'connected', 'phone': r.get('display_phone_number'),
'name': r.get('verified_name'), 'quality': r.get('quality_rating')}
except Exception as e:
return {'state': 'error', 'error': str(e)}
def meta_verify_signature(app_secret, payload_bytes, signature_header):
"""Validate X-Hub-Signature-256: 'sha256=<hex>'."""
if not app_secret or not signature_header:
return True # not configured -> do not enforce
import hmac as _hmac
import hashlib as _hashlib
expected = 'sha256=' + _hmac.new(
app_secret.encode(), payload_bytes, _hashlib.sha256).hexdigest()
try:
return _hmac.compare_digest(expected, signature_header)
except Exception:
return False
def _provider_cfg_error(cfg, provider):
"""Error claro cuando el proveedor elegido está incompleto (sin fallback silencioso)."""
if provider == 'wacrm':
return {'error': 'WA CRM no configurado: faltan api_base y/o api_key (Configuración → WhatsApp)'}
if provider == 'meta':
return {'error': 'Meta Cloud API no configurada: faltan phone_number_id y/o access_token'}
if provider == 'baileys':
return {'error': 'Bridge de WhatsApp no configurado o retirado (WhatsApp pasa por WA CRM)'}
return {'error': f'Proveedor de WhatsApp desconocido: {provider}'}
def wa_send_text(cfg, phone, text):
"""Router: WA CRM, Meta directo o Baileys según config del tenant.
Si el proveedor elegido está incompleto, devuelve un error explícito en
lugar de degradar silenciosamente a otro proveedor.
"""
cfg = cfg or {}
provider = str(cfg.get('provider') or 'baileys').lower()
if provider == 'wacrm':
if not _wacrm_is_wacrm(cfg):
return _provider_cfg_error(cfg, 'wacrm')
return wacrm_send_text(cfg, phone, text)
if provider == 'meta':
if not _meta_is_meta(cfg):
return _provider_cfg_error(cfg, 'meta')
return meta_send_text(cfg, phone, text)
if not cfg.get('bridge_url'):
return _provider_cfg_error(cfg, 'baileys')
return send_message(phone, text, bridge_url=cfg.get('bridge_url'))
def wa_send_image(cfg, phone, caption, base64_image):
cfg = cfg or {}
provider = str(cfg.get('provider') or 'baileys').lower()
if provider == 'wacrm' and not _wacrm_is_wacrm(cfg):
return _provider_cfg_error(cfg, 'wacrm')
if provider == 'meta' and not _meta_is_meta(cfg):
return _provider_cfg_error(cfg, 'meta')
if provider == 'baileys' and not cfg.get('bridge_url'):
return _provider_cfg_error(cfg, 'baileys')
if _wacrm_is_wacrm(cfg):
# WA CRM exige URL pública para media; sin ella degradamos a texto.
public_base = (cfg.get('public_base') or '').rstrip('/')
if public_base:
import uuid as _uuid
import os as _os
token = _uuid.uuid4().hex
media_dir = '/tmp/wa_media'
_os.makedirs(media_dir, exist_ok=True)
raw = base64_image.split(',', 1)[1] if base64_image.strip().startswith('data:') else base64_image
path = _os.path.join(media_dir, token + '.png')
try:
with open(path, 'wb') as fh:
fh.write(_b64.b64decode(raw))
except Exception:
return {'error': 'no se pudo preparar la imagen'}
return wacrm_send_media(cfg, phone, 'image',
f'{public_base}/pos/api/public/media/{token}.png', caption)
return wa_send_text(cfg, phone, (caption or '') + '\n(imagen no disponible: configura wa_public_base)')
if _meta_is_meta(cfg or {}):
return meta_send_image(cfg, phone, caption, base64_image)
return send_image(phone, caption, base64_image, bridge_url=(cfg or {}).get('bridge_url'))
def wa_send_document(cfg, phone, base64_data, filename, caption=''):
"""Envía PDF/documento por el proveedor configurado (WA CRM, Meta o Baileys)."""
cfg = cfg or {}
provider = str(cfg.get('provider') or 'baileys').lower()
if provider == 'wacrm':
if not _wacrm_is_wacrm(cfg):
return _provider_cfg_error(cfg, 'wacrm')
if not (cfg.get('public_base') or '').strip():
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
if _wacrm_is_wacrm(cfg):
public_base = (cfg.get('public_base') or '').rstrip('/')
if not public_base:
return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'}
import uuid as _uuid
import os as _os
token = _uuid.uuid4().hex
media_dir = '/tmp/wa_media'
_os.makedirs(media_dir, exist_ok=True)
raw = base64_data.split(',', 1)[1] if base64_data.strip().startswith('data:') else base64_data
path = _os.path.join(media_dir, token + '_' + filename)
with open(path, 'wb') as fh:
fh.write(_b64.b64decode(raw))
return wacrm_send_media(cfg, phone, 'document',
f'{public_base}/pos/api/public/media/{token}_{filename}',
caption, filename)
try:
raw = base64_data
if ',' in raw and raw.strip().startswith('data:'):
raw = raw.split(',', 1)[1]
if _meta_is_meta(cfg):
img_bytes = _b64.b64decode(raw)
up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media",
files={'file': (filename, img_bytes, 'application/pdf')},
data={'messaging_product': 'whatsapp'})
media_id = up.get('id')
if not media_id:
return {'error': up.get('error', {}).get('message', 'media upload failed')}
r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': _meta_digits(phone),
'type': 'document',
'document': {'id': media_id, 'filename': filename, 'caption': caption or ''},
})
if r.get('error'):
return {'error': r['error'].get('message', 'Meta error')}
return {'ok': True}
# Baileys bridge: endpoint genérico send-file (best effort)
url = _get_url(cfg.get('bridge_url'))
r = requests.post(
f'{url}/send-file',
headers=HEADERS,
json={'phone': phone, 'base64': raw, 'filename': filename, 'caption': caption or ''},
timeout=30,
).json()
return r if isinstance(r, dict) else {'ok': True}
except Exception as e:
return {'error': str(e)}
# ═══════════════════════════════════════════════════════════════════════════
# Proveedor WA CRM (self-hosted) — API pública /api/v1
# cfg: provider='wacrm', api_base, api_key (+ public_base opcional)
# ═══════════════════════════════════════════════════════════════════════════
def _wacrm_is_wacrm(cfg):
return str((cfg or {}).get('provider') or '').lower() == 'wacrm' and (cfg or {}).get('api_base') and (cfg or {}).get('api_key')
def _wacrm_e164(phone):
digits = ''.join(ch for ch in str(phone or '') if ch.isdigit())
if not digits:
return ''
if digits.startswith('52'):
return '+' + digits
if len(digits) == 10:
return '+52' + digits
return '+' + digits
def wacrm_send_text(cfg, phone, text):
base = (cfg.get('api_base') or '').rstrip('/')
try:
r = requests.post(
f'{base}/api/v1/messages',
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
json={'to': _wacrm_e164(phone), 'type': 'text', 'text': text},
timeout=30,
)
data = r.json() if r.content else {}
if r.status_code >= 400 or data.get('error'):
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
except Exception as e:
return {'error': str(e)}
def wacrm_send_media(cfg, phone, kind, media_url, caption='', filename=None):
base = (cfg.get('api_base') or '').rstrip('/')
payload = {'to': _wacrm_e164(phone), 'type': kind, 'media_url': media_url, 'text': caption or ''}
if filename:
payload['filename'] = filename
try:
r = requests.post(
f'{base}/api/v1/messages',
headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'},
json=payload,
timeout=45,
)
data = r.json() if r.content else {}
if r.status_code >= 400 or data.get('error'):
return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'}
return {'ok': True, 'id': (data.get('data') or {}).get('message_id')}
except Exception as e:
return {'error': str(e)}
def wacrm_get_status(cfg):
base = (cfg.get('api_base') or '').rstrip('/')
try:
r = requests.get(f'{base}/api/v1/me',
headers={'Authorization': f"Bearer {cfg.get('api_key')}"}, timeout=15)
if r.status_code != 200:
return {'state': 'error', 'error': f'HTTP {r.status_code}'}
return {'state': 'connected', 'data': r.json().get('data', {})}
except Exception as e:
return {'state': 'error', 'error': str(e)}

336
pos/services/yonke_ai.py Normal file
View File

@@ -0,0 +1,336 @@
# /home/Autopartes/pos/services/yonke_ai.py
"""Non-conversational AI helpers for the Nexus Yonke module.
Four capabilities, each one isolated and best-effort:
* ``identify_piece`` — vision: name + category of a photo
* ``suggest_price`` — rule engine over cost/grade/category + local comparables
* ``find_duplicate_groups`` — semantic grouping of used-piece names
* ``validate_photo`` — vision: is it an auto part? is the photo usable?
Golden rule (same as ``semantic_search``): **no function raises**. When the AI
provider is not configured or does not answer, each function returns a
well-formed dict with ``available=False`` so the caller can keep going.
"""
import base64
import json
import re
import statistics
# Multipliers applied to a reference price depending on the piece grade.
GRADE_FACTOR = {'A': 1.15, 'B': 1.0, 'C': 0.8, 'D': 0.6}
# Target markup over acquisition cost for a grade-B used piece.
TARGET_MARKUP = 1.6
# Weight of the local market reference vs. the cost-based reference.
MARKET_WEIGHT = 0.6
def _coerce_float(val, default=0.0):
try:
return float(val)
except (TypeError, ValueError):
return default
def _b64(image_bytes):
return base64.b64encode(image_bytes).decode('ascii')
def _extract_json(text):
"""Return the first JSON object found in ``text``, or None."""
if not text:
return None
stripped = str(text).strip()
if stripped.startswith('```'):
lines = stripped.split('\n')
stripped = '\n'.join(lines[1:-1] if len(lines) > 2 else lines)
try:
parsed = json.loads(stripped)
return parsed if isinstance(parsed, dict) else None
except Exception:
pass
match = re.search(r'\{.*\}', stripped, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
return None
def _normalize_confidence(value):
conf = _coerce_float(value, 0.0)
if conf > 1:
conf = conf / 100.0
return round(max(0.0, min(1.0, conf)), 2)
def _vision_json(user_message, image_bytes=None, image_base64=None):
"""Ask the vision model for a JSON object. Returns (dict|None, error|None)."""
image = image_base64 or (_b64(image_bytes) if image_bytes else None)
if not image:
return None, 'no image'
try:
from services import ai_chat
except Exception as e: # pragma: no cover
return None, f'ai_chat unavailable: {e}'
try:
result = ai_chat.chat_with_image(user_message, image)
except Exception as e:
return None, str(e)
if not isinstance(result, dict):
return None, 'unexpected AI response'
if 'name' in result or 'category' in result or 'is_part' in result:
return result, None
if result.get('message'):
parsed = _extract_json(result['message'])
if parsed:
return parsed, None
return None, result.get('message') or 'AI did not return JSON'
def identify_piece(image_bytes=None, image_base64=None, hint_name=None):
"""Identify an auto part from a photo.
Returns ``{name, category, confidence, available, source}``; ``available``
is False when the provider is unreachable (the endpoint then answers 503).
"""
empty = {'name': None, 'category': None, 'confidence': 0.0,
'available': False, 'source': 'vision'}
if not image_bytes and not image_base64:
return dict(empty, error='no image')
prompt = (
'Eres un experto en autopartes (partes usadas de deshuesadero). '
'Identifica la pieza de la imagen. Responde SOLO con JSON válido, sin texto extra: '
'{"name": "nombre en español", "category": "categoría (Frenos, Motor, Eléctrico, '
'Suspensión, Carrocería, Enfriamiento, Luces, Transmisión, etc.)", '
'"confidence": 0.0}'
)
if hint_name:
prompt += f' Pista del usuario: "{hint_name}".'
parsed, error = _vision_json(prompt, image_bytes, image_base64)
if not parsed:
return dict(empty, error=error or 'AI unavailable')
name = (parsed.get('name') or '').strip() or None
category = (parsed.get('category') or '').strip() or None
if isinstance(name, str) and name.lower() in ('null', 'none', 'desconocido'):
name = None
return {
'name': name,
'category': category,
'confidence': _normalize_confidence(parsed.get('confidence')),
'available': bool(name or category),
'source': 'vision',
}
def validate_photo(image_bytes=None, image_base64=None):
"""Validate that a photo shows an auto part with usable quality.
Returns ``{ok, issues, quality, available}``. When the provider is down the
photo is NOT validated: ``available=False`` and ``ok=True`` (do not block).
"""
if not image_bytes and not image_base64:
return {'ok': False, 'issues': ['No se recibió imagen'], 'available': False}
prompt = (
'Evalúa esta foto destinada a un catálogo de autopartes usadas. Responde SOLO '
'con JSON válido: {"is_part": true, "quality": "good", '
'"issues": [], "confidence": 0.0}. '
'"quality" debe ser "good", "fair" o "poor". En "issues" enumera problemas '
'(borrosa, oscura, no se ve la pieza, fondo con ruido, etc.).'
)
parsed, error = _vision_json(prompt, image_bytes, image_base64)
if not parsed:
return {'ok': True, 'issues': [], 'available': False,
'error': error or 'AI unavailable'}
is_part = parsed.get('is_part')
if isinstance(is_part, str):
is_part = is_part.strip().lower() in ('true', '1', 'yes', 'sí', 'si')
quality = (parsed.get('quality') or '').strip().lower() or None
issues = parsed.get('issues') or []
if isinstance(issues, str):
issues = [issues]
issues = [str(i).strip() for i in issues if str(i).strip()]
if is_part is False:
issues.append('No parece una autoparte')
if quality == 'poor':
issues.append('Calidad de imagen deficiente')
return {
'ok': bool(is_part) and quality != 'poor',
'issues': issues,
'quality': quality,
'confidence': _normalize_confidence(parsed.get('confidence')),
'available': True,
}
def _local_comparables(conn, piece, limit=100):
"""Median price_1 of active local items sharing category or name tokens."""
name = (piece.get('name') or '').strip()
tokens = [t for t in re.split(r'\W+', name.lower()) if len(t) >= 4][:3]
patterns = [f'%{t}%' for t in tokens]
category_id = piece.get('category_id')
piece_id = piece.get('id') or 0
cur = conn.cursor()
try:
if category_id is None:
cur.execute("SELECT category_id FROM inventory WHERE id = %s", (piece_id,))
row = cur.fetchone()
category_id = row[0] if row else None
cur.execute("""
SELECT i.price_1
FROM inventory i
WHERE i.is_active = true AND i.price_1 > 0
AND i.id <> %s
AND (i.category_id = %s OR i.name ILIKE ANY(%s))
LIMIT %s
""", (piece_id, category_id, patterns, limit))
prices = [float(r[0]) for r in cur.fetchall() if r[0] is not None]
except Exception:
try:
conn.rollback()
except Exception:
pass
prices = []
finally:
cur.close()
return prices
def suggest_price(conn, piece):
"""Suggest a sale price for a used piece.
Rule engine: local comparables (median price of active items in the same
category / with a shared name token) scaled by grade, blended with a
cost-based reference (acquisition cost × grade factor × target markup).
Returns ``{suggested_price, low, high, basis, available}``.
"""
piece = piece or {}
grade = (piece.get('condition_grade') or 'B').upper()
grade = grade if grade in GRADE_FACTOR else 'B'
factor = GRADE_FACTOR[grade]
cost = _coerce_float(piece.get('acquisition_cost') or piece.get('cost') or 0)
current_price = _coerce_float(piece.get('price_1') or 0)
comparables = _local_comparables(conn, piece)
market_ref = statistics.median(comparables) if comparables else None
market_price = market_ref * factor if market_ref else None
cost_price = cost * factor * TARGET_MARKUP if cost > 0 else None
parts = []
if market_price is not None:
parts.append((MARKET_WEIGHT, market_price))
parts.append((1 - MARKET_WEIGHT, cost_price if cost_price else market_price))
elif cost_price is not None:
parts = [(1.0, cost_price)]
if parts:
total_w = sum(w for w, _ in parts)
suggested = sum(w * v for w, v in parts) / total_w
available = True
else:
suggested = current_price
available = bool(current_price)
suggested = round(suggested, 2)
low = round(suggested * 0.85, 2)
high = round(suggested * 1.2, 2)
basis_bits = [f'grado {grade}', f'costo {cost:.2f}']
if comparables:
basis_bits.append(
f'{len(comparables)} comparables locales (mediana {market_ref:.2f})'
)
else:
basis_bits.append('sin comparables locales')
if not available:
basis_bits.append('sin costo ni comparables: se usó el precio actual')
return {
'suggested_price': suggested,
'low': low,
'high': high,
'basis': ', '.join(basis_bits),
'available': available,
'grade': grade,
'comparables': len(comparables),
}
def find_duplicate_groups(conn, threshold=0.85, limit=300):
"""Group used pieces whose names are semantically almost identical.
Uses ``semantic_search.embed`` (one call for every name) and cosine
similarity, then union-find. Returns ``{'groups': [[{id,part_number,name,
donor_vin}]]}``. Degrades to ``groups=[]`` + ``available=False`` when
embeddings are not available.
"""
try:
threshold = float(threshold)
except (TypeError, ValueError):
threshold = 0.85
threshold = max(0.0, min(1.0, threshold))
cur = conn.cursor()
cur.execute("""
SELECT id, part_number, name, donor_vin
FROM inventory
WHERE is_active = true AND condition IN ('usado', 'reconstruido')
AND name IS NOT NULL AND name <> ''
ORDER BY id
LIMIT %s
""", (limit,))
rows = cur.fetchall()
cur.close()
items = [{'id': r[0], 'part_number': r[1], 'name': r[2], 'donor_vin': r[3]}
for r in rows]
if len(items) < 2:
return {'groups': [], 'available': True, 'threshold': threshold}
try:
from services import semantic_search
vectors = semantic_search.embed([it['name'] for it in items])
except Exception:
vectors = None
if not vectors or len(vectors) != len(items):
return {'groups': [], 'available': False, 'threshold': threshold,
'error': 'embeddings no disponibles'}
parent = list(range(len(items)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
for i in range(len(items)):
for j in range(i + 1, len(items)):
sim = semantic_search.cosine_similarity(vectors[i], vectors[j])
if sim is not None and sim >= threshold:
union(i, j)
grouped = {}
for idx in range(len(items)):
grouped.setdefault(find(idx), []).append(items[idx])
groups = [g for g in grouped.values() if len(g) >= 2]
groups.sort(key=len, reverse=True)
return {'groups': groups, 'available': True, 'threshold': threshold}

File diff suppressed because it is too large Load Diff