Files
Autoparts-DB/pos/services/marketplace_service.py
consultoria-as a236187f3a 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
2026-05-26 04:24:07 +00:00

946 lines
36 KiB
Python

"""
Marketplace B2B — service layer for bodegas, warehouse inventory and
Purchase Orders (Phase 1).
State machine:
draft → submitted → confirmed → ready → delivered → closed
↘ rejected (terminal)
Public API is grouped by concern:
- Bodegas: list_bodegas, get_bodega, verify_bodega
- Inventory: upload_inventory_csv, search_inventory
- POs: create_po_draft, submit_po, transition_po,
get_po_detail, list_pos_for_buyer, list_pos_for_seller
- Notifications: notify_po_status_change (used internally by transition_po)
All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts).
The caller is responsible for committing and closing.
"""
import csv
import io
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
# ═══════════════════════════════════════════════════════════════════════════
# STATE MACHINE
# ═══════════════════════════════════════════════════════════════════════════
PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed')
# Map: current_status → {new_status: {actor_kinds}}
# 'buyer' = user who created the PO; 'seller' = bodega owner/user
PO_TRANSITIONS = {
'draft': {'submitted': {'buyer'}},
'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}},
'confirmed': {'ready': {'seller'}},
'ready': {'delivered': {'buyer', 'seller'}},
'delivered': {'closed': {'buyer', 'seller'}},
# terminal: rejected, closed
}
def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool:
allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status)
if not allowed:
return False
return actor_kind in allowed
# ═══════════════════════════════════════════════════════════════════════════
# BODEGAS
# ═══════════════════════════════════════════════════════════════════════════
def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]:
"""Return all bodegas, optionally filtered."""
cur = master_conn.cursor()
clauses = []
params = []
if verified_only:
clauses.append("verified = TRUE")
if city:
clauses.append("LOWER(city) = LOWER(%s)")
params.append(city)
where = "WHERE " + " AND ".join(clauses) if clauses else ""
cur.execute(f"""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified
FROM bodegas
{where}
ORDER BY name
""", params)
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'verified': r[7],
}
for r in rows
]
def get_bodega(master_conn, bodega_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state,
address, verified, commission_pct
FROM bodegas WHERE id_bodega = %s
""", (bodega_id,))
r = cur.fetchone()
cur.close()
if not r:
return None
return {
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0),
}
def create_bodega(master_conn, *, name: str, whatsapp_phone: str,
owner_name: str = None, email: str = None,
city: str = None, state: str = None, address: str = None) -> int:
"""Register a new bodega (unverified by default). Admin verifies later."""
cur = master_conn.cursor()
cur.execute("""
INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id_bodega
""", (name, owner_name, whatsapp_phone, email, city, state, address))
bodega_id = cur.fetchone()[0]
cur.close()
return bodega_id
def verify_bodega(master_conn, bodega_id: int) -> bool:
cur = master_conn.cursor()
cur.execute("""
UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s
""", (bodega_id,))
ok = cur.rowcount > 0
cur.close()
return ok
# ═══════════════════════════════════════════════════════════════════════════
# INVENTORY — warehouse_inventory CSV upload + search
# ═══════════════════════════════════════════════════════════════════════════
def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
"""Bulk-upload a bodega's inventory from a CSV string.
Expected columns (case-insensitive, whitespace-tolerant):
part_number, stock, price
Optional:
name, min_order, warehouse_location, currency
Resolution rules:
- 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, oem_count, seller_count}
"""
reader = csv.DictReader(io.StringIO(csv_text))
# Normalize header names
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
required = {'part_number', 'stock', 'price'}
missing = required - set(fieldnames)
if missing:
return {
'ok': False,
'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}',
'inserted': 0, 'updated': 0, 'skipped': 0,
}
# Resolve bodega → its legacy user_id (warehouse_inventory still requires it)
cur = master_conn.cursor()
cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,))
if not cur.fetchone():
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
norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()}
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')
skipped += 1
continue
try:
stock = int(stock_str)
price = float(price_str)
except ValueError:
errors.append(f'Fila {i}: stock o price invalido')
skipped += 1
continue
# 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 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
try:
user_id = int(user_id)
except (ValueError, TypeError):
user_id = bodega_id
location = norm.get('warehouse_location') or 'Principal'
currency = (norm.get('currency') or 'MXN').upper()
min_order = int(norm.get('min_order') or 1)
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
try:
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
else:
updated += 1
except Exception as e:
errors.append(f'Fila {i}: DB error: {str(e)[:100]}')
skipped += 1
master_conn.rollback() # so next INSERTs can proceed
continue
cur.close()
master_conn.commit()
return {
'ok': True,
'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),
}
def search_inventory(master_conn, *, query: str = None, brand: str = None,
city: str = None, limit: int = 50) -> list[dict]:
"""Browse warehouse_inventory filtered by query / brand / city.
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()
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_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:
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_oem.append(brand)
where_oem = " AND ".join(clauses_oem)
# ─── Part B: Seller listings (no parts catalog join) ──────────────
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
params_seller = []
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
"""
all_params = params_oem + params_common + params_seller + params_common + [limit]
cur.execute(sql, all_params)
rows = cur.fetchall()
cur.close()
return [
{
'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],
'listing_type': r[9],
}
for r in rows
]
def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
"""Return the list of verified bodegas that currently have a given OEM part
in stock. Used when the buyer wants to pick WHICH bodega to order from.
"""
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.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
ORDER BY wi.price ASC
""", (part_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', # don't expose exact quantity
'min_order': r[6] or 1,
'currency': r[7] or 'MXN',
}
for r in rows
]
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
# ═══════════════════════════════════════════════════════════════════════════
def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
buyer_display_name: str, buyer_phone: str, buyer_email: str,
bodega_id: int, items: list,
delivery_method: str = 'pickup',
delivery_address: str = None,
buyer_notes: str = None) -> int:
"""Create a PO in 'draft' status with its items.
Args:
items: list of dicts with keys: part_id, quantity, unit_price (optional)
If unit_price is missing, it's pulled from warehouse_inventory.
Returns the new po_id.
"""
if not items:
raise ValueError('A PO must have at least one item')
cur = master_conn.cursor()
# Create header
cur.execute("""
INSERT INTO purchase_orders (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, status, delivery_method, delivery_address, buyer_notes
) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s)
RETURNING id_po
""", (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, delivery_method, delivery_address, buyer_notes,
))
po_id = cur.fetchone()[0]
# Insert items
total = 0.0
for item in items:
part_id = item.get('part_id')
wi_id = item.get('wi_id')
quantity = int(item['quantity'])
if quantity < 1:
continue
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, 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",
(round(total, 2), po_id))
# Log initial status
cur.execute("""
INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado')
""", (po_id, buyer_user_id))
cur.close()
master_conn.commit()
return po_id
def transition_po(master_conn, *, po_id: int, new_status: str,
actor_user_id: int, actor_kind: str,
note: str = None) -> dict:
"""Transition a PO to a new status with full validation and notification.
Returns: {ok, from_status, to_status, notified} or {ok: False, error}
"""
if new_status not in PO_STATUSES:
return {'ok': False, 'error': f'Invalid status: {new_status}'}
cur = master_conn.cursor()
cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,))
row = cur.fetchone()
if not row:
cur.close()
return {'ok': False, 'error': f'PO {po_id} not found'}
from_status = row[0]
if not _is_valid_transition(from_status, new_status, actor_kind):
cur.close()
return {
'ok': False,
'error': f'Transition {from_status}{new_status} not allowed for {actor_kind}',
}
# Timestamp columns per state
ts_field = {
'submitted': 'submitted_at',
'confirmed': 'confirmed_at',
'ready': 'ready_at',
'delivered': 'delivered_at',
'closed': 'closed_at',
}.get(new_status)
if ts_field:
cur.execute(
f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s",
(new_status, po_id),
)
else:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s",
(new_status, po_id))
# Log history row
cur.execute("""
INSERT INTO po_status_history
(po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, %s, %s, %s, %s, %s)
""", (po_id, from_status, new_status, actor_user_id, actor_kind, note))
cur.close()
master_conn.commit()
# Fire notifications — non-blocking (failures logged, not raised)
notified = []
try:
notified = notify_po_status_change(master_conn, po_id, new_status)
except Exception as e:
print(f'[marketplace] notification failed for PO {po_id}: {e}')
return {
'ok': True,
'from_status': from_status,
'to_status': new_status,
'notified': notified,
}
def get_po_detail(master_conn, po_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name,
po.buyer_phone, po.buyer_email,
po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone,
b.email AS bodega_email,
po.status, po.total_amount, po.currency,
po.buyer_notes, po.seller_notes,
po.delivery_method, po.delivery_address,
po.created_at, po.submitted_at, po.confirmed_at, po.ready_at,
po.delivered_at, po.closed_at
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE po.id_po = %s
""", (po_id,))
r = cur.fetchone()
if not r:
cur.close()
return None
po = {
'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2],
'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5],
'bodega_id': r[6], 'bodega_name': r[7],
'bodega_phone': r[8], 'bodega_email': r[9],
'status': r[10],
'total_amount': float(r[11]) if r[11] is not None else 0.0,
'currency': r[12],
'buyer_notes': r[13], 'seller_notes': r[14],
'delivery_method': r[15], 'delivery_address': r[16],
'created_at': r[17].isoformat() if r[17] else None,
'submitted_at': r[18].isoformat() if r[18] else None,
'confirmed_at': r[19].isoformat() if r[19] else None,
'ready_at': r[20].isoformat() if r[20] else None,
'delivered_at': r[21].isoformat() if r[21] else None,
'closed_at': r[22].isoformat() if r[22] else None,
}
# Items
cur.execute("""
SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer,
quantity, unit_price, subtotal, confirmed_qty, notes
FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item
""", (po_id,))
po['items'] = [
{
'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2],
'part_name': ir[3], 'manufacturer': ir[4],
'quantity': ir[5],
'unit_price': float(ir[6]) if ir[6] is not None else 0.0,
'subtotal': float(ir[7]) if ir[7] is not None else 0.0,
'confirmed_qty': ir[8],
'notes': ir[9],
}
for ir in cur.fetchall()
]
# Status history
cur.execute("""
SELECT from_status, to_status, actor_kind, note, created_at
FROM po_status_history WHERE po_id = %s ORDER BY created_at
""", (po_id,))
po['history'] = [
{
'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2],
'note': h[3], 'at': h[4].isoformat() if h[4] else None,
}
for h in cur.fetchall()
]
cur.close()
return po
def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None,
limit: int = 50) -> list[dict]:
"""Return POs created by a buyer (filtered by tenant or user)."""
cur = master_conn.cursor()
clauses = ['po.buyer_tenant_id = %s']
params = [buyer_tenant_id]
if buyer_user_id is not None:
clauses.append('po.buyer_user_id = %s')
params.append(buyer_user_id)
where = ' AND '.join(clauses)
cur.execute(f"""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.bodega_id, b.name AS bodega_name,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE {where}
ORDER BY po.created_at DESC
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'bodega_id': r[4], 'bodega_name': r[5],
'created_at': r[6].isoformat() if r[6] else None,
'submitted_at': r[7].isoformat() if r[7] else None,
'item_count': r[8],
}
for r in rows
]
def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]:
"""Inbox: POs addressed to a seller (bodega)."""
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
WHERE po.bodega_id = %s AND po.status != 'draft'
ORDER BY
CASE po.status
WHEN 'submitted' THEN 1
WHEN 'confirmed' THEN 2
WHEN 'ready' THEN 3
ELSE 4
END,
po.submitted_at DESC
LIMIT %s
""", (bodega_id, limit))
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6],
'created_at': r[7].isoformat() if r[7] else None,
'submitted_at': r[8].isoformat() if r[8] else None,
'item_count': r[9],
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# NOTIFICATIONS — WhatsApp + Email
# ═══════════════════════════════════════════════════════════════════════════
# Per-status message templates. Each is a (subject, body) tuple.
# The body is plain text — same text goes to WA and email, with an optional
# HTML wrapper for email.
_PO_MESSAGE_TEMPLATES = {
'submitted': (
'Nuevo pedido Nexus #{po_id}',
'Tienes un nuevo pedido en Nexus Marketplace.\n\n'
'Pedido: #{po_id}\n'
'Comprador: {buyer_display_name}\n'
'Total: ${total_amount:,.2f} {currency}\n'
'Items: {item_count}\n\n'
'Entra al marketplace para confirmar o rechazar.'
),
'confirmed': (
'Pedido #{po_id} confirmado por {bodega_name}',
'Tu pedido fue confirmado.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Total: ${total_amount:,.2f} {currency}\n\n'
'Te avisaremos cuando este listo para recoger / entregar.'
),
'rejected': (
'Pedido #{po_id} rechazado',
'Tu pedido fue rechazado por {bodega_name}.\n\n'
'Pedido: #{po_id}\n'
'Puedes intentar con otra bodega en el marketplace.'
),
'ready': (
'Pedido #{po_id} listo',
'Tu pedido esta listo.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Metodo: {delivery_method}\n\n'
'Pasa a recogerlo o espera la entrega.'
),
'delivered': (
'Pedido #{po_id} entregado',
'El pedido #{po_id} fue marcado como entregado.\n'
'Gracias por usar Nexus Marketplace.'
),
'closed': (
'Pedido #{po_id} cerrado',
'El pedido #{po_id} fue cerrado.'
),
}
def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]:
"""Send WhatsApp + email notification about a PO status change.
Returns a list of channel names that were successfully notified
(e.g. ['whatsapp', 'email']). Failures are logged but not raised.
"""
template = _PO_MESSAGE_TEMPLATES.get(new_status)
if not template:
return [] # no message defined for this status
po = get_po_detail(master_conn, po_id)
if not po:
return []
# Resolve context variables for the template
ctx = {
'po_id': po_id,
'buyer_display_name': po.get('buyer_display_name') or 'Cliente',
'bodega_name': po.get('bodega_name') or 'Bodega',
'total_amount': po.get('total_amount') or 0,
'currency': po.get('currency') or 'MXN',
'delivery_method': po.get('delivery_method') or 'pickup',
'item_count': len(po.get('items') or []),
}
subject_tpl, body_tpl = template
try:
subject = subject_tpl.format(**ctx)
body = body_tpl.format(**ctx)
except (KeyError, ValueError) as e:
print(f'[marketplace] template format error for {new_status}: {e}')
return []
# Decide the recipient based on who should be notified for this status
# - submitted → notify seller (new order arrived)
# - confirmed/rejected/ready → notify buyer (status update)
# - delivered → notify both (handled as 2 sends)
# - closed → notify buyer
recipients = []
if new_status == 'submitted':
recipients = [{
'kind': 'seller',
'phone': po.get('bodega_phone'),
'email': po.get('bodega_email'),
}]
elif new_status in ('confirmed', 'rejected', 'ready', 'closed'):
recipients = [{
'kind': 'buyer',
'phone': po.get('buyer_phone'),
'email': po.get('buyer_email'),
}]
elif new_status == 'delivered':
recipients = [
{'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')},
{'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')},
]
channels_used = []
for recipient in recipients:
# WhatsApp
if recipient.get('phone'):
try:
from services import whatsapp_service
result = whatsapp_service.send_message(recipient['phone'], body)
if result and not result.get('error'):
channels_used.append(f"whatsapp:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] WA send failed: {e}')
# Email
if recipient.get('email'):
try:
sent = _send_email(recipient['email'], subject, body)
if sent:
channels_used.append(f"email:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] email send failed: {e}')
return channels_used
def _send_email(to_email: str, subject: str, body_text: str) -> bool:
"""Send a plain-text email via SMTP (config in pos/config.py).
Returns True if the mail was actually sent, False if SMTP is not
configured (silent no-op so dev environments don't crash).
"""
import config
if not config.SMTP_USER or not config.SMTP_PASS:
print('[marketplace] SMTP not configured — skipping email')
return False
msg = MIMEMultipart('alternative')
msg['From'] = config.SMTP_FROM
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASS)
server.send_message(msg)
print(f'[marketplace] email sent to {to_email}: {subject}')
return True