Files
Autoparts-DB/pos/services/sat_keys.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
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
2026-10-12 00:57:31 +00:00

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)