Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
811 lines
30 KiB
Python
811 lines
30 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:
|
|
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.
|
|
|
|
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
|
"""
|
|
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'}
|
|
|
|
inserted = 0
|
|
updated = 0
|
|
skipped = 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')
|
|
|
|
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
|
|
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]
|
|
|
|
# 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 (user_id, part_id, warehouse_location) — the existing
|
|
# unique constraint. Don't block if user_id FK fails.
|
|
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))
|
|
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,
|
|
'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.
|
|
"""
|
|
cur = master_conn.cursor()
|
|
|
|
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
|
params = []
|
|
|
|
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])
|
|
|
|
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("""
|
|
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)
|
|
|
|
if city:
|
|
clauses.append("LOWER(b.city) = LOWER(%s)")
|
|
params.append(city)
|
|
|
|
where_sql = " AND ".join(clauses)
|
|
|
|
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
|
|
ORDER BY total_stock DESC
|
|
LIMIT %s
|
|
""", params + [limit])
|
|
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
|
|
return [
|
|
{
|
|
'id_part': r[0],
|
|
'oem_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
|
|
}
|
|
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
|
|
]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 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 = int(item['part_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
|
|
|
|
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')))
|
|
|
|
# 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
|