feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
Expected columns (case-insensitive, whitespace-tolerant):
|
||||
part_number, stock, price
|
||||
Optional:
|
||||
min_order, warehouse_location, currency
|
||||
name, min_order, warehouse_location, currency
|
||||
|
||||
Resolution rules:
|
||||
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
|
||||
- Parts not found in the master catalog are skipped and reported.
|
||||
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
|
||||
via UPSERT; new rows are inserted.
|
||||
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
|
||||
- If matched → linked to catalog (part_id set, seller fields NULL).
|
||||
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
|
||||
- Existing rows are updated via UPSERT on the composite unique key.
|
||||
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
|
||||
"""
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
# Normalize header names
|
||||
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
cur.close()
|
||||
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
||||
|
||||
# Pre-load cross-reference map for fast lookup
|
||||
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
|
||||
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
oem_count = 0
|
||||
seller_count = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
||||
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
part_number = norm.get('part_number', '')
|
||||
stock_str = norm.get('stock', '0')
|
||||
price_str = norm.get('price', '0')
|
||||
part_name = norm.get('name', '')
|
||||
|
||||
if not part_number:
|
||||
errors.append(f'Fila {i}: part_number vacio')
|
||||
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Resolve part_number → part_id
|
||||
# Resolve part_number → part_id (OEM catalog or cross-reference)
|
||||
part_id = None
|
||||
cur.execute(
|
||||
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
|
||||
(part_number,)
|
||||
)
|
||||
row_part = cur.fetchone()
|
||||
if not row_part:
|
||||
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
|
||||
skipped += 1
|
||||
continue
|
||||
part_id = row_part[0]
|
||||
if row_part:
|
||||
part_id = row_part[0]
|
||||
else:
|
||||
# Try cross-reference
|
||||
xref_id = xref_map.get(part_number)
|
||||
if xref_id:
|
||||
part_id = xref_id
|
||||
|
||||
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
||||
user_id = norm.get('user_id') or bodega_id # backward compat
|
||||
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
currency = (norm.get('currency') or 'MXN').upper()
|
||||
min_order = int(norm.get('min_order') or 1)
|
||||
|
||||
# UPSERT on (user_id, part_id, warehouse_location) — the existing
|
||||
# unique constraint. Don't block if user_id FK fails.
|
||||
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (user_id, part_id, warehouse_location)
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
bodega_id = EXCLUDED.bodega_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||
if part_id:
|
||||
# OEM-matched listing
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||
oem_count += 1
|
||||
else:
|
||||
# Seller listing (no catalog match)
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
seller_part_name = EXCLUDED.seller_part_name,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
|
||||
seller_count += 1
|
||||
|
||||
was_insert = cur.fetchone()[0]
|
||||
if was_insert:
|
||||
inserted += 1
|
||||
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
'skipped': skipped,
|
||||
'oem_count': oem_count,
|
||||
'seller_count': seller_count,
|
||||
'errors': errors[:20], # cap to avoid huge responses
|
||||
'total_errors': len(errors),
|
||||
}
|
||||
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
|
||||
Returns parts WITH stock > 0 from VERIFIED bodegas only.
|
||||
Aggregates identical parts across bodegas so the buyer sees each part once
|
||||
with a list of bodegas that have it in stock.
|
||||
|
||||
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
|
||||
(part_id IS NULL) in a single unified result set.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
|
||||
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
||||
params = []
|
||||
like = f'%{query}%' if query else None
|
||||
city_lower = city.lower() if city else None
|
||||
params_common = []
|
||||
|
||||
# Build city filter once
|
||||
city_clause = ""
|
||||
if city_lower:
|
||||
city_clause = "AND LOWER(b.city) = LOWER(%s)"
|
||||
params_common.append(city)
|
||||
|
||||
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
|
||||
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
|
||||
params_oem = []
|
||||
|
||||
if query:
|
||||
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
like = f'%{query}%'
|
||||
params.extend([like, like, like])
|
||||
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
params_oem.extend([like, like, like])
|
||||
|
||||
if brand:
|
||||
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
|
||||
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
|
||||
clauses.append("""
|
||||
clauses_oem.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
|
||||
)
|
||||
""")
|
||||
params.append(brand)
|
||||
params_oem.append(brand)
|
||||
|
||||
if city:
|
||||
clauses.append("LOWER(b.city) = LOWER(%s)")
|
||||
params.append(city)
|
||||
where_oem = " AND ".join(clauses_oem)
|
||||
|
||||
where_sql = " AND ".join(clauses)
|
||||
# ─── Part B: Seller listings (no parts catalog join) ──────────────
|
||||
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
|
||||
params_seller = []
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
p.id_part,
|
||||
p.oem_part_number,
|
||||
COALESCE(p.name_es, p.name_part) AS name,
|
||||
p.image_url,
|
||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||
MIN(wi.price) AS min_price,
|
||||
MAX(wi.price) AS max_price,
|
||||
SUM(wi.stock_quantity) AS total_stock,
|
||||
-- List of bodega names that have this part in stock
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
JOIN parts p ON p.id_part = wi.part_id
|
||||
WHERE {where_sql}
|
||||
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||
if query:
|
||||
clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
|
||||
params_seller.extend([like, like])
|
||||
|
||||
where_seller = " AND ".join(clauses_seller)
|
||||
|
||||
# Combined query with UNION ALL
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
-- OEM-matched parts
|
||||
SELECT
|
||||
p.id_part AS id,
|
||||
p.oem_part_number AS part_number,
|
||||
COALESCE(p.name_es, p.name_part) AS name,
|
||||
p.image_url,
|
||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||
MIN(wi.price) AS min_price,
|
||||
MAX(wi.price) AS max_price,
|
||||
SUM(wi.stock_quantity) AS total_stock,
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'oem' AS listing_type
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
JOIN parts p ON p.id_part = wi.part_id
|
||||
WHERE {where_oem} {city_clause}
|
||||
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Seller listings
|
||||
SELECT
|
||||
wi.id_inventory AS id,
|
||||
wi.seller_part_number AS part_number,
|
||||
wi.seller_part_name AS name,
|
||||
NULL AS image_url,
|
||||
COUNT(DISTINCT b.id_bodega) AS bodega_count,
|
||||
MIN(wi.price) AS min_price,
|
||||
MAX(wi.price) AS max_price,
|
||||
SUM(wi.stock_quantity) AS total_stock,
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'seller' AS listing_type
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
WHERE {where_seller} {city_clause}
|
||||
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
|
||||
) combined
|
||||
ORDER BY total_stock DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
"""
|
||||
|
||||
all_params = params_oem + params_common + params_seller + params_common + [limit]
|
||||
cur.execute(sql, all_params)
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return [
|
||||
{
|
||||
'id_part': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'id': r[0],
|
||||
'part_number': r[1],
|
||||
'name': r[2],
|
||||
'image_url': r[3],
|
||||
'bodega_count': r[4],
|
||||
'min_price': float(r[5]) if r[5] is not None else None,
|
||||
'max_price': float(r[6]) if r[6] is not None else None,
|
||||
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
|
||||
'bodega_names': r[8], # may expose; adjust if sensitive
|
||||
'bodega_names': r[8],
|
||||
'listing_type': r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
|
||||
"""Return the list of verified bodegas that have a specific seller listing
|
||||
(warehouse_inventory row with part_id IS NULL) in stock.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
|
||||
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
WHERE wi.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
|
||||
ORDER BY wi.price ASC
|
||||
""", (wi_id,))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [
|
||||
{
|
||||
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
|
||||
'price': float(r[4]) if r[4] is not None else None,
|
||||
'stock_hint': 'En stock',
|
||||
'min_order': r[6] or 1,
|
||||
'currency': r[7] or 'MXN',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -397,32 +504,60 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
# Insert items
|
||||
total = 0.0
|
||||
for item in items:
|
||||
part_id = int(item['part_id'])
|
||||
part_id = item.get('part_id')
|
||||
wi_id = item.get('wi_id')
|
||||
quantity = int(item['quantity'])
|
||||
if quantity < 1:
|
||||
continue
|
||||
|
||||
# Lookup part info + price
|
||||
cur.execute("""
|
||||
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||
FROM parts p
|
||||
LEFT JOIN warehouse_inventory wi
|
||||
ON wi.part_id = p.id_part AND wi.bodega_id = %s
|
||||
WHERE p.id_part = %s LIMIT 1
|
||||
""", (bodega_id, part_id))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
continue
|
||||
oem, name, db_price = r
|
||||
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||
subtotal = round(unit_price * quantity, 2)
|
||||
total += subtotal
|
||||
if part_id:
|
||||
# OEM-matched part
|
||||
part_id = int(part_id)
|
||||
cur.execute("""
|
||||
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
|
||||
FROM parts p
|
||||
LEFT JOIN warehouse_inventory wi
|
||||
ON wi.part_id = p.id_part AND wi.bodega_id = %s
|
||||
WHERE p.id_part = %s LIMIT 1
|
||||
""", (bodega_id, part_id))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
continue
|
||||
oem, name, db_price = r
|
||||
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||
subtotal = round(unit_price * quantity, 2)
|
||||
total += subtotal
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
|
||||
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
elif wi_id:
|
||||
# Seller listing (no catalog match)
|
||||
wi_id = int(wi_id)
|
||||
cur.execute("""
|
||||
SELECT seller_part_number, seller_part_name, price
|
||||
FROM warehouse_inventory
|
||||
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
|
||||
""", (wi_id, bodega_id))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
continue
|
||||
seller_pn, seller_name, db_price = r
|
||||
unit_price = float(item.get('unit_price') or db_price or 0)
|
||||
subtotal = round(unit_price * quantity, 2)
|
||||
total += subtotal
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
|
||||
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
# Update header total
|
||||
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||
|
||||
Reference in New Issue
Block a user