- 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
236 lines
8.8 KiB
Python
236 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Assign SAT ``clave_prod_serv`` / ``clave_unidad`` per product (CFDI 4.0).
|
|
|
|
Two modes:
|
|
|
|
sale-items (default, writes): backfill the existing ``sale_items`` columns
|
|
for a tenant. Historically they are 100% NULL, so every CFDI fell back
|
|
to the hard-coded default (25174800 / H87). Consumers that read
|
|
``sale_items`` directly (``services/global_invoice.py``,
|
|
``blueprints/invoicing_bp.py``) keep the default themselves, so the only
|
|
way to give them a real per-product key is to persist it here.
|
|
|
|
inventory (report only): show the key distribution that *would* be assigned
|
|
to ``inventory`` rows. ``inventory`` has no clave columns today, so this
|
|
mode never writes — see the module note in the summary for the proposed
|
|
``inventory.clave_prod_serv`` / ``inventory.clave_unidad varchar(10)``
|
|
columns.
|
|
|
|
Resolution order per item: ``services.sat_keys`` keyword rules over the name,
|
|
then the canonical category (from ``product_categorizer`` or the tenant
|
|
``categories`` row), then optionally the ``ai_chat`` LLM fallback, then the SAT
|
|
default. Every AI call degrades silently when the provider is down.
|
|
|
|
Usage (as root, from /home/Autopartes):
|
|
python3 scripts/assign_sat_keys.py --tenant 45 --limit 200 --dry-run
|
|
python3 scripts/assign_sat_keys.py --tenant 45 --limit 500 --use-llm
|
|
python3 scripts/assign_sat_keys.py --tenant 45 --mode inventory --dry-run
|
|
python3 scripts/assign_sat_keys.py --all --batch 500
|
|
|
|
Nothing is written unless ``--apply`` (mode sale-items); ``--dry-run`` is the
|
|
default to avoid surprises.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
|
|
|
|
from tenant_db import get_tenant_conn, get_master_conn # noqa: E402
|
|
from services import sat_keys # noqa: E402
|
|
from services.product_categorizer import classify_name # noqa: E402
|
|
|
|
DEFAULT_CHECKPOINT = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), '..', 'backups',
|
|
'sat_keys_checkpoint.json'
|
|
)
|
|
|
|
|
|
def list_tenants(only_id=None):
|
|
conn = get_master_conn()
|
|
cur = conn.cursor()
|
|
try:
|
|
if only_id:
|
|
cur.execute(
|
|
"SELECT id, name, db_name FROM tenants "
|
|
"WHERE is_active = true AND id = %s", (only_id,))
|
|
else:
|
|
cur.execute(
|
|
"SELECT id, name, db_name FROM tenants "
|
|
"WHERE is_active = true ORDER BY id")
|
|
return cur.fetchall()
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|
|
|
|
|
|
def _load_checkpoint(path):
|
|
try:
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_checkpoint(path, data):
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
except Exception as e:
|
|
print(f" ! checkpoint no guardado: {e}")
|
|
|
|
|
|
def _resolve(prod_name, brand, category_name, part_number, use_llm):
|
|
canonical = None
|
|
try:
|
|
canonical = classify_name(prod_name or part_number or '', brand)[0]
|
|
except Exception:
|
|
canonical = None
|
|
return sat_keys.resolve_key(
|
|
name=prod_name, brand=brand, category=category_name,
|
|
canonical=canonical, part_number=part_number, use_llm=use_llm,
|
|
)
|
|
|
|
|
|
def run_sale_items(tenant_id, name, conn, args, state):
|
|
after_id = None if args.reset else state.get('after_id')
|
|
t_key = str(tenant_id)
|
|
totals = {'scanned': 0, 'updated': 0, 'by_source': {}}
|
|
while True:
|
|
cur = conn.cursor()
|
|
where = ["(si.clave_prod_serv IS NULL OR si.clave_prod_serv = '')"]
|
|
params = []
|
|
if after_id is not None:
|
|
where.append("si.id > %s")
|
|
params.append(after_id)
|
|
sql = f"""
|
|
SELECT si.id, si.part_number, si.name, si.inventory_id,
|
|
i.name, i.brand, c.name
|
|
FROM sale_items si
|
|
LEFT JOIN inventory i ON i.id = si.inventory_id
|
|
LEFT JOIN categories c ON c.id = i.category_id
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY si.id
|
|
LIMIT %s
|
|
"""
|
|
params.append(args.batch)
|
|
cur.execute(sql, params)
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
if not rows:
|
|
break
|
|
|
|
for (si_id, si_pn, si_name, inv_id, inv_name, inv_brand, cat_name) in rows:
|
|
prod_name = inv_name or si_name
|
|
clave, unidad, source = _resolve(
|
|
prod_name, inv_brand, cat_name, si_pn, args.use_llm)
|
|
totals['by_source'][source] = totals['by_source'].get(source, 0) + 1
|
|
totals['scanned'] += 1
|
|
after_id = si_id
|
|
if args.apply:
|
|
c2 = conn.cursor()
|
|
try:
|
|
c2.execute(
|
|
"UPDATE sale_items SET clave_prod_serv = %s, "
|
|
"clave_unidad = %s WHERE id = %s",
|
|
(clave, unidad, si_id))
|
|
totals['updated'] += c2.rowcount
|
|
conn.commit()
|
|
finally:
|
|
c2.close()
|
|
if totals['scanned'] <= args.show:
|
|
print(f" {si_id:<8} {str(prod_name)[:40]:<40} -> {clave} / {unidad} ({source})")
|
|
|
|
state[t_key] = {'after_id': after_id, 'updated': totals['updated'],
|
|
'updated_at': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
|
if args.apply and not args.dry_run:
|
|
_save_checkpoint(args.checkpoint, state)
|
|
if len(rows) < args.batch:
|
|
break
|
|
if args.limit and totals['scanned'] >= args.limit:
|
|
break
|
|
return totals
|
|
|
|
|
|
def run_inventory(tenant_id, conn, args):
|
|
"""Report-only: distribution of keys the rules would assign to inventory."""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT i.id, i.part_number, i.name, i.brand, c.name
|
|
FROM inventory i
|
|
LEFT JOIN categories c ON c.id = i.category_id
|
|
WHERE i.is_active = true
|
|
ORDER BY i.id
|
|
LIMIT %s
|
|
""", (args.batch,))
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
by_source, by_key, sample = {}, {}, []
|
|
for inv_id, pn, nm, brand, cat in rows:
|
|
clave, unidad, source = _resolve(nm, brand, cat, pn, args.use_llm)
|
|
by_source[source] = by_source.get(source, 0) + 1
|
|
by_key[clave] = by_key.get(clave, 0) + 1
|
|
if len(sample) < args.show:
|
|
sample.append((inv_id, nm, clave, unidad, source))
|
|
print(f" inventory scanned={len(rows)} by_source={by_source}")
|
|
print(f" keys: {by_key}")
|
|
for inv_id, nm, clave, unidad, source in sample:
|
|
print(f" {inv_id:<8} {str(nm)[:40]:<40} -> {clave} / {unidad} ({source})")
|
|
return {'scanned': len(rows), 'by_source': by_source, 'by_key': by_key}
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description='Assign SAT product/unit keys.')
|
|
p.add_argument('--tenant', type=int)
|
|
p.add_argument('--all', action='store_true')
|
|
p.add_argument('--mode', choices=['sale-items', 'inventory'], default='sale-items')
|
|
p.add_argument('--limit', type=int, default=0, help='max items per tenant (0=all)')
|
|
p.add_argument('--batch', type=int, default=500)
|
|
p.add_argument('--apply', action='store_true', help='persist (sale-items only)')
|
|
p.add_argument('--dry-run', action='store_true', default=True)
|
|
p.add_argument('--use-llm', action='store_true', help='enable ai_chat fallback')
|
|
p.add_argument('--reset', action='store_true', help='ignore checkpoint')
|
|
p.add_argument('--show', type=int, default=8)
|
|
p.add_argument('--checkpoint', default=DEFAULT_CHECKPOINT)
|
|
args = p.parse_args()
|
|
|
|
# --apply is the only thing that turns off dry-run; keep it explicit.
|
|
if args.apply:
|
|
args.dry_run = False
|
|
if not args.all and not args.tenant:
|
|
p.error('--tenant ID o --all requerido')
|
|
|
|
state = _load_checkpoint(args.checkpoint)
|
|
tenants = list_tenants(None if args.all else args.tenant)
|
|
if not tenants:
|
|
print('sin tenants para procesar')
|
|
return 1
|
|
|
|
print(f"mode={args.mode} apply={not args.dry_run} use_llm={args.use_llm}")
|
|
grand = {}
|
|
for tid, name, db_name in tenants:
|
|
print(f"\n=== tenant {tid} — {name} ({db_name}) ===")
|
|
conn = get_tenant_conn(tid)
|
|
try:
|
|
if args.mode == 'inventory':
|
|
run_inventory(tid, conn, args)
|
|
else:
|
|
totals = run_sale_items(tid, name, conn, args, state)
|
|
print(f" total: scanned={totals['scanned']} updated={totals['updated']} "
|
|
f"by_source={totals['by_source']}")
|
|
for k, v in totals['by_source'].items():
|
|
grand[k] = grand.get(k, 0) + v
|
|
finally:
|
|
conn.close()
|
|
if args.mode == 'sale-items':
|
|
print(f"\nTOTAL by_source={grand}")
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|