- 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
339 lines
13 KiB
Python
339 lines
13 KiB
Python
# /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)
|