Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -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').
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
357
pos/services/anomaly_detector.py
Normal file
357
pos/services/anomaly_detector.py
Normal 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
985
pos/services/b2b_service.py
Normal 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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
463
pos/services/credit_scoring.py
Normal file
463
pos/services/credit_scoring.py
Normal 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(),
|
||||
}
|
||||
@@ -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
233
pos/services/cross_sell.py
Normal 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)]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
474
pos/services/invoice_ocr.py
Normal 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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
267
pos/services/part_vision.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
780
pos/services/product_categorizer.py
Normal file
780
pos/services/product_categorizer.py
Normal 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())),
|
||||
}
|
||||
255
pos/services/prom_metrics.py
Normal file
255
pos/services/prom_metrics.py
Normal 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()
|
||||
@@ -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
325
pos/services/question_ai.py
Normal 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
338
pos/services/sat_keys.py
Normal 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)
|
||||
304
pos/services/semantic_search.py
Normal file
304
pos/services/semantic_search.py
Normal 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
448
pos/services/sku_matcher.py
Normal 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()
|
||||
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
336
pos/services/yonke_ai.py
Normal 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}
|
||||
1698
pos/services/yonke_service.py
Normal file
1698
pos/services/yonke_service.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user