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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user