# /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)