Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -737,9 +737,10 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
classified = _classify_vehicle_parts(master_conn, mye_id)
# Merge local inventory and supplier catalog classifications
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
result = []
# Iterate in canonical Nexpart order so the UI is stable
@@ -769,9 +770,10 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
)
classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
group_data = classified.get(group_slug, {})
if not group_data:
return []
@@ -1172,9 +1174,10 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
"""
classified = _classify_vehicle_parts(master_conn, mye_id)
# Also merge inventory and supplier catalog so the leaf IDs may be a mix
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
all_ids = (
classified
@@ -1327,9 +1330,10 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
)
classified = _classify_vehicle_parts(master_conn, mye_id)
# supplier_catalog vive en la maestra: siempre se puede clasificar.
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
if tenant_conn:
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
if not subgroup_data:
return []
@@ -1852,49 +1856,169 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
}
def _get_alternatives(cur, part_id):
"""Get cross-references + aftermarket parts for a given OEM part."""
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None,
name=None, category=None, limit=20,
use_semantic=True,
semantic_threshold=0.72,
semantic_pool=80,
semantic_timeout=20.0):
"""Alternatives for a supplier-catalog item, on live (non-TecDoc) tables.
Two sources, in order:
1. exact — other ``supplier_catalog`` items that share an interchange
cross-reference or an SKU with the base item
(``supplier_catalog_interchange``).
2. semantic — ``semantic_search.find_similar`` over same-category catalog
names (substitute parts that are worded differently). Degrades to
nothing when the embedding provider is unavailable.
Works with either a connection or a cursor (``conn``/``cur``); the caller
owns it. Never raises on the semantic step.
Returns list[dict]: {part_number, manufacturer, name, type, similarity,
catalog_id} — the caller adds stock/bodega fields.
"""
cur = conn.cursor() if hasattr(conn, 'cursor') else conn
results = []
seen = set()
# Cross-references (other OEM numbers that reference this part)
cur.execute("""
SELECT pcr.cross_reference_number, pcr.source_ref
FROM part_cross_references pcr
WHERE pcr.part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1] or 'OEM Cross-Ref',
'name': None,
'type': 'cross_reference',
'local_stock': 0,
'bodega_count': 0,
})
base = None
if catalog_id:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE id = %s AND is_active = true", (catalog_id,)
)
base = cur.fetchone()
elif part_number:
cur.execute(
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
"WHERE UPPER(sku) = UPPER(%s) AND is_active = true LIMIT 1",
(part_number,)
)
base = cur.fetchone()
# Aftermarket alternatives
cur.execute("""
SELECT ap.part_number, m.name_manufacture,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = %s
LIMIT 50
""", (part_id,))
for r in cur.fetchall():
results.append({
'part_number': r[0],
'manufacturer': r[1],
'name': r[2],
'type': 'aftermarket',
'local_stock': 0,
'bodega_count': 0,
})
if base:
base_id, base_sup, base_sku, base_name, base_cat = base
else:
base_id, base_sup, base_sku = None, None, None
base_name = name
base_cat = category
if base_id:
seen.add(base_id)
# ── 1. Exact interchange / SKU cross-references ────────────────────────
if base_id:
cur.execute(
"SELECT DISTINCT part_number FROM supplier_catalog_interchange "
"WHERE catalog_id = %s AND part_number IS NOT NULL",
(base_id,)
)
xrefs = [r[0] for r in cur.fetchall()]
keys_upper = set()
if base_sku:
keys_upper.add(str(base_sku).upper())
for x in xrefs:
keys_upper.add(str(x).upper())
keys_upper.discard(None)
if keys_upper:
key_list = list(keys_upper)
# (a) catalog items that *are* one of our keys
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE UPPER(sku) = ANY(%s) AND is_active = true
LIMIT %s
""", (key_list, limit * 2))
exact_rows = list(cur.fetchall())
# (b) catalog items whose interchange list contains one of our keys
cur.execute("""
SELECT DISTINCT sc.id, sc.supplier_name, sc.sku, sc.name
FROM supplier_catalog sc
JOIN supplier_catalog_interchange sci ON sci.catalog_id = sc.id
WHERE UPPER(sci.part_number) = ANY(%s) AND sc.is_active = true
LIMIT %s
""", (key_list, limit * 2))
exact_rows.extend(cur.fetchall())
for cid, sup, sku, cname in exact_rows:
if not cid or cid in seen:
continue
seen.add(cid)
results.append({
'part_number': sku,
'manufacturer': sup,
'name': cname,
'type': 'cross_reference',
'similarity': None,
'catalog_id': cid,
'local_stock': 0,
'bodega_count': 0,
})
if len(results) >= limit:
break
# ── 2. Semantic substitutes (same category, different wording) ─────────
if use_semantic and base_name and len(results) < limit:
pool_cat = base_cat
try:
if pool_cat:
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE is_active = true AND category = %s AND id <> COALESCE(%s, -1)
LIMIT %s
""", (pool_cat, base_id, semantic_pool))
else:
cur.execute("""
SELECT id, supplier_name, sku, name FROM supplier_catalog
WHERE is_active = true AND id <> COALESCE(%s, -1)
LIMIT %s
""", (base_id, semantic_pool))
pool = [r for r in cur.fetchall() if r[0] not in seen]
except Exception:
pool = []
if pool:
try:
from services import semantic_search as ss
ranked = ss.find_similar(base_name, [r[3] for r in pool],
timeout=semantic_timeout)
except Exception:
ranked = None
if ranked:
for item in ranked:
if item['similarity'] < semantic_threshold:
continue
cid, sup, sku, cname = pool[item['index']]
if cid in seen:
continue
seen.add(cid)
results.append({
'part_number': sku,
'manufacturer': sup,
'name': cname,
'type': 'semantic',
'similarity': round(item['similarity'], 3),
'catalog_id': cid,
'local_stock': 0,
'bodega_count': 0,
})
if len(results) >= limit:
break
return results
def _get_alternatives(cur, part_id):
"""Back-compat shim: alternatives for a supplier-catalog id.
The retired TecDoc tables (``part_cross_references`` / ``aftermarket_parts``)
are gone; this now delegates to ``_alternatives_from_supplier_catalog``.
"""
return _alternatives_from_supplier_catalog(cur, catalog_id=part_id)
# ─────────────────────────────────────────────────────────────────────────────
# SMART SEARCH
# ─────────────────────────────────────────────────────────────────────────────
@@ -1939,7 +2063,46 @@ def _search_meili_fallback(master_conn, q, limit):
return None
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
def _rerank_document(item):
"""Texto con el que el reranker semántico evalúa un resultado."""
parts = [
item.get('name'),
item.get('oem_part_number'),
item.get('brand') or item.get('vehicle_info'),
]
return ' '.join(str(p) for p in parts if p)
def _apply_rerank(query, results, scope='pos'):
"""Reordena ``results`` por relevancia semántica (QWEN /rerank).
Solo cambia el ORDEN: la paginación, el tope de ``limit`` y el filtrado por
vehículo/sucursal ya ocurrieron antes y quedan intactos.
Degradación obligatoria (``semantic_search.rerank`` nunca lanza): si el
rerank está apagado, sin configurar, tarda más que el timeout, falla o
devuelve un orden inconsistente, se conserva el orden original.
"""
if not results or not scope:
return results
try:
from services import semantic_search as ss
if not ss.is_rerank_enabled(scope):
return results
head = results[:ss.max_candidates()]
order = ss.rerank_order(query, [_rerank_document(r) for r in head])
reordered = [head[i] for i in order if 0 <= i < len(head)]
if len(reordered) != len(head):
# Salvaguarda: un orden incompleto perdería items → orden original.
return results
return reordered + results[len(head):]
except Exception as e:
print(f"[catalog_service] rerank omitido: {e}")
return results
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
tenant_id=None, rerank_scope='pos'):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
@@ -1947,6 +2110,9 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
3. Search local inventory items by part_number or name
4. Always enriches results with local stock from tenant DB
5. Reordena los candidatos con rerank semántico según ``rerank_scope``
('pos' por defecto = flag SEARCH_RERANK_POS_ENABLED, apagado; el
catálogo público/dashboard pasa 'catalog' → SEARCH_RERANK_ENABLED)
"""
q = q.strip()
if not q or len(q) < 2:
@@ -1974,6 +2140,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
'image_url': li['image_url'],
'local_stock': li['stock'],
'local_price': li['price_1'],
'brand': li.get('brand'),
'vehicle_info': '',
'source': 'local_inventory',
})
@@ -1983,7 +2150,10 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
# ── Inject supplier catalog items ───────────────────────────────────────
if master_conn:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
# Meilisearch first (typo tolerance, sub-100ms); SQL fallback below.
supplier_items = _search_meili_supplier_catalog(q, limit)
if not supplier_items:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
for si in supplier_items:
if f"sc:{si['id']}" in seen_local_ids:
continue
@@ -2003,7 +2173,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
if len(results) >= limit:
break
return results
return _apply_rerank(q, results, rerank_scope)
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
@@ -2027,6 +2197,33 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
return prices
def _search_meili_supplier_catalog(q, limit):
"""Typo-tolerant supplier catalog search via Meilisearch.
Returns [] on any failure so the SQL fallback always applies.
Result shape matches _search_supplier_catalog.
"""
try:
import meilisearch
from services.meili_search import MEILI_URL, MEILI_API_KEY
client = meilisearch.Client(MEILI_URL, MEILI_API_KEY)
res = client.index('nexus_supplier_catalog').search(q, {'limit': limit})
out = []
for h in res.get('hits', []):
out.append({
'id': h['id'],
'sku': h.get('sku'),
'name': h.get('name'),
'image_url': h.get('image_url'),
'category': h.get('category'),
'supplier_price': h.get('supplier_price'),
'supplier_currency': None,
})
return out
except Exception:
return []
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
"""Search supplier catalog items by SKU or name.
@@ -2092,13 +2289,181 @@ def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
return results
def _tenant_db_name(tenant_conn):
"""db_name de la BD del tenant a partir de su conexión (o None)."""
try:
return tenant_conn.info.dbname
except Exception:
return None
def _meili_inventory_ids(db_name, q, limit):
"""IDs de inventario que Meilisearch considera relevantes.
None ⇒ Meilisearch no disponible (usar SQL); [] ⇒ cero resultados.
"""
try:
from services.meili_search import search_inventory
except Exception:
return None
hits = search_inventory(db_name, q, limit=limit)
if hits is None:
return None
return [h['id'] for h in hits if h.get('id') is not None]
def _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id):
"""Trae stock/precio frescos de Postgres para los IDs que dio Meilisearch.
Respeta el filtro de vehículo/sucursal y conserva el orden de relevancia
de Meilisearch.
"""
cur = tenant_conn.cursor()
try:
where_vehicle = ""
params = []
if mye_id:
where_vehicle = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
if branch_id:
if mye_id:
params = [branch_id, list(ids), mye_id]
else:
params = [branch_id, list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle, params)
else:
params = [list(ids), mye_id] if mye_id else [list(ids)]
cur.execute("""
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
i.brand, COALESCE(SUM(s.stock), 0) AS stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle + """
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand""", params)
rows = cur.fetchall()
finally:
cur.close()
by_id = {r[0]: r for r in rows}
ordered = [by_id[i] for i in ids if i in by_id]
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in ordered]
def _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit):
"""Inventory items whose SKU alias matches the query (A6 cross-refs).
``inventory_sku_aliases`` holds 2-3 alternative supplier SKUs per item;
this lets a search by an alternate number find the product even when the
main part_number does not match.
"""
clean_q = q.replace(' ', '').upper()
params = []
if branch_id:
stock_join = ("LEFT JOIN inventory_stock_summary s "
"ON s.inventory_id = i.id AND s.branch_id = %s")
params.append(branch_id)
else:
stock_join = "LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id"
params.append(f'%{clean_q}%')
mye_clause = ""
if mye_id:
mye_clause = """ AND EXISTS (
SELECT 1 FROM inventory_vehicle_compat ivc
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
params.append(mye_id)
params.append(limit)
cur = tenant_conn.cursor()
try:
cur.execute(f"""
SELECT DISTINCT i.id, i.part_number, i.name, i.image_url,
i.price_1, i.brand, COALESCE(s.stock, 0)
FROM inventory i
JOIN inventory_sku_aliases a
ON a.inventory_id = i.id AND a.is_active = true
{stock_join}
WHERE i.is_active = true
AND REPLACE(UPPER(a.sku), ' ', '') LIKE %s
{mye_clause}
ORDER BY i.name
LIMIT %s
""", params)
rows = cur.fetchall()
finally:
cur.close()
return [{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
} for r in rows]
def _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit):
"""Append alias-borne matches to a result list, de-duplicated by id."""
try:
alias_rows = _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
return rows
if not alias_rows:
return rows
seen = {r['inventory_id'] for r in rows}
merged = list(rows)
for r in alias_rows:
if r['inventory_id'] not in seen:
merged.append(r)
seen.add(r['inventory_id'])
return merged[:limit]
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
"""Search tenant inventory items by part_number or name.
If mye_id is provided, only returns items compatible with that vehicle.
Primero consulta Meilisearch (índice `nexus_inventory_<db>` del tenant) e
hidrata desde Postgres para tener stock/precio frescos. Si Meilisearch
está apagado, falla o no devuelve nada, cae al SQL de abajo (ILIKE).
En ambos caminos se añaden los items cuyo SKU alias (cross-ref) coincide.
"""
if tenant_conn is None:
return []
db_name = _tenant_db_name(tenant_conn)
if db_name:
ids = _meili_inventory_ids(db_name, q, limit)
if ids:
try:
rows = _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id)
except Exception:
try:
tenant_conn.rollback()
except Exception:
pass
rows = []
if rows:
return _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit)
cur = tenant_conn.cursor()
clean_q = q.replace(' ', '').upper()
@@ -2122,7 +2487,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s
@@ -2137,7 +2502,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
@@ -2145,7 +2510,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
AND i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
ORDER BY i.name
LIMIT %s
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
@@ -2154,7 +2519,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
if branch_id:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(s.stock, 0) as stock
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
@@ -2167,30 +2532,32 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
else:
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.image_url,
i.price_1, COALESCE(SUM(s.stock), 0) as stock
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
OR {_SQL_UNACCENT} LIKE %s)
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
ORDER BY i.name
LIMIT %s
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
rows = cur.fetchall()
cur.close()
return [
results = [
{
'inventory_id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'price_1': float(r[4]) if r[4] is not None else None,
'stock': int(r[5]) if r[5] is not None else 0,
'brand': r[5],
'stock': int(r[6]) if r[6] is not None else 0,
}
for r in rows
]
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
# ─────────────────────────────────────────────────────────────────────────────
@@ -2332,11 +2699,50 @@ def get_bodega_availability(master_conn, part_id):
]
def get_alternatives(master_conn, part_id):
"""Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None,
category=None, tenant_conn=None, branch_id=None, limit=20,
use_semantic=True, semantic_threshold=0.72,
semantic_timeout=20.0):
"""Public wrapper: alternatives for a supplier-catalog item.
Rebuilt on the live tables (``supplier_catalog`` /
``supplier_catalog_interchange``) + semantic substitutes; the retired
TecDoc ``aftermarket_parts`` / ``part_cross_references`` tables are no
longer queried.
Pass ``catalog_id`` (``supplier_catalog.id``, e.g. ``inventory.catalog_part_id``)
and/or ``part_number``. When ``tenant_conn`` is given, each alternative is
enriched with local stock (``local_stock``) from that tenant.
NOTE: the dashboard endpoints that still serve the old TecDoc-based result
are ``dashboard/server.py`` ``/api/catalog/part/<id>`` (line ~1326),
``/api/parts/<id>/alternatives`` (line ~1938) and
``/api/parts/<id>/aftermarket`` (line ~4916); POS uses
``GET /catalog/part/<id>`` (``blueprints/catalog_bp.py``) via
``get_part_detail``. Those call sites are owned by other workstreams.
"""
cur = master_conn.cursor()
results = _get_alternatives(cur, part_id)
cur.close()
try:
results = _alternatives_from_supplier_catalog(
cur, catalog_id=catalog_id, part_number=part_number, name=name,
category=category, limit=limit, use_semantic=use_semantic,
semantic_threshold=semantic_threshold,
semantic_timeout=semantic_timeout,
)
finally:
cur.close()
if tenant_conn is not None and results:
alt_pns = [a['part_number'] for a in results if a.get('part_number')]
alt_cids = [a['catalog_id'] for a in results if a.get('catalog_id')]
try:
local = _get_local_stock_bulk(tenant_conn, branch_id, alt_pns, alt_cids)
except Exception:
local = {}
for a in results:
l = local.get(a.get('part_number')) or local.get(f"cat:{a.get('catalog_id')}")
a['local_stock'] = l['stock'] if l else 0
a.setdefault('bodega_count', 0)
return results