Files
Autoparts-DB/pos/services/sku_matcher.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

449 lines
17 KiB
Python

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