feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts

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>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

382
scripts/seed_marketplace.py Normal file
View File

@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Marketplace seed script — populates the DB with realistic test data so you
can exercise the marketplace UI end-to-end from a browser.
What it creates:
- 3 bodegas in different cities (all pre-verified)
- ~60 warehouse_inventory rows spread across the 3 bodegas
- 1 new employee in tenant_refaccionaria_demo configured as seller for bodega #1
(PIN: 9999)
- 3 purchase orders in different states (submitted, confirmed, delivered)
After running, you can log into the POS:
- As buyer: Ivan Alcaraz (existing owner) — to test Explorar + Mis Pedidos
- As seller: "Bodega Test Seller" (new) PIN 9999 — to test Inbox + Inventario
Run:
cd /home/Autopartes/pos && python3 ../scripts/seed_marketplace.py
Safe to re-run: the script cleans up prior seed data before re-inserting.
"""
import os
import sys
import bcrypt
# Ensure we can import from the POS package
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_master_conn, get_tenant_conn
from services import marketplace_service as mkt
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG — tweak these if you want different seed data
# ═══════════════════════════════════════════════════════════════════════════
TENANT_ID = 11 # tenant_refaccionaria_demo
SELLER_PIN = '9999' # login PIN for the seed seller employee
SELLER_NAME = 'Bodega Test Seller'
SELLER_EMAIL = 'seller@bodegatest.mx'
BODEGAS = [
{
'name': 'Bodega Central Tijuana',
'owner_name': 'Juan Perez',
'whatsapp_phone': '5216641230001',
'email': 'juan@bodegacentral.mx',
'city': 'Tijuana',
'state': 'BC',
'address': 'Blvd. Industrial 1234, Col. Otay, Tijuana BC',
},
{
'name': 'Refacciones del Norte GDL',
'owner_name': 'Maria Gonzalez',
'whatsapp_phone': '5213311230002',
'email': 'maria@refnorte.mx',
'city': 'Guadalajara',
'state': 'JAL',
'address': 'Av. Vallarta 5678, Zapopan JAL',
},
{
'name': 'AutoPartes del Bajio CDMX',
'owner_name': 'Carlos Torres',
'whatsapp_phone': '5215511230003',
'email': 'carlos@autopartesbajio.mx',
'city': 'Ciudad de Mexico',
'state': 'CDMX',
'address': 'Eje 5 Norte 910, Azcapotzalco CDMX',
},
]
# ═══════════════════════════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════════════════════════
def log(msg):
print(f'{msg}')
def hash_pin(pin: str) -> str:
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
# ═══════════════════════════════════════════════════════════════════════════
# CLEANUP — remove previous seed data before re-inserting
# ═══════════════════════════════════════════════════════════════════════════
def cleanup(master_conn, tenant_conn):
"""Remove previous seed data so re-running the script is idempotent."""
log('Cleaning up previous seed data...')
# Tenant side: remove the seed seller employee
cur = tenant_conn.cursor()
cur.execute("DELETE FROM employees WHERE email = %s", (SELLER_EMAIL,))
tenant_conn.commit()
cur.close()
# Master side: remove any bodegas with the seed names
cur = master_conn.cursor()
names = tuple(b['name'] for b in BODEGAS)
# Cascade will clean up purchase_orders, po_items, po_status_history,
# and warehouse_inventory rows referencing these bodegas.
# warehouse_inventory's FK to bodegas is not CASCADE — handle manually.
cur.execute("""
DELETE FROM warehouse_inventory
WHERE bodega_id IN (SELECT id_bodega FROM bodegas WHERE name = ANY(%s))
""", (list(names),))
cur.execute("""
DELETE FROM purchase_orders
WHERE bodega_id IN (SELECT id_bodega FROM bodegas WHERE name = ANY(%s))
""", (list(names),))
cur.execute("DELETE FROM bodegas WHERE name = ANY(%s)", (list(names),))
master_conn.commit()
cur.close()
log('Cleanup complete.')
# ═══════════════════════════════════════════════════════════════════════════
# STEP 1 — Create bodegas (pre-verified)
# ═══════════════════════════════════════════════════════════════════════════
def create_bodegas(master_conn) -> list[int]:
bodega_ids = []
for b in BODEGAS:
bid = mkt.create_bodega(
master_conn,
name=b['name'],
owner_name=b['owner_name'],
whatsapp_phone=b['whatsapp_phone'],
email=b['email'],
city=b['city'],
state=b['state'],
address=b['address'],
)
mkt.verify_bodega(master_conn, bid)
bodega_ids.append(bid)
log(f'Created+verified bodega #{bid}: {b["name"]}')
master_conn.commit()
return bodega_ids
# ═══════════════════════════════════════════════════════════════════════════
# STEP 2 — Populate warehouse_inventory
# ═══════════════════════════════════════════════════════════════════════════
def populate_inventory(master_conn, bodega_ids: list[int]):
"""Pick ~60 real OEM parts from the catalog, split across the 3 bodegas
with pseudo-realistic price and stock levels.
warehouse_inventory has an FK on user_id → users(id_user). We use a
single master user as the legacy owner AND differentiate bodegas via
a composite (user_id, part_id, warehouse_location) key where
warehouse_location becomes "Bodega#{bodega_id}" so different bodegas
can hold the same part without violating the UNIQUE constraint.
"""
cur = master_conn.cursor()
# Resolve a valid user_id for the FK (doesn't matter which — we use it
# as a plumbing placeholder only; the real ownership is via bodega_id).
cur.execute("SELECT id_user FROM users ORDER BY id_user LIMIT 1")
row = cur.fetchone()
if not row:
cur.close()
log('WARN: no users in master DB — cannot seed inventory')
return
owner_user_id = row[0]
# Grab 60 popular parts (prefer those with aftermarket equivalents so the
# buyer gets meaningful data when they open the detail view).
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part
FROM parts p
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
WHERE p.oem_part_number IS NOT NULL
GROUP BY p.id_part, p.oem_part_number, p.name_part
ORDER BY COUNT(ap.id_aftermarket_parts) DESC
LIMIT 60
""")
parts = cur.fetchall()
cur.close()
import random
random.seed(42)
rows_inserted = 0
for i, (part_id, oem, name) in enumerate(parts):
bodega_count = random.choice([1, 1, 2, 2, 3])
bodegas_for_this_part = random.sample(bodega_ids, bodega_count)
for bid in bodegas_for_this_part:
price = round(random.uniform(50, 3500), 2)
stock = random.randint(1, 20)
location = f'Bodega#{bid}' # unique per bodega → no conflict
# Each insert gets its own cursor + savepoint so one failure
# doesn't abort the whole transaction.
try:
cur2 = master_conn.cursor()
cur2.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, 1, %s, %s, 'MXN', NOW())
ON CONFLICT (user_id, part_id, warehouse_location) DO UPDATE
SET price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
bodega_id = EXCLUDED.bodega_id,
updated_at = NOW()
""", (owner_user_id, part_id, price, stock, location, bid))
master_conn.commit()
cur2.close()
rows_inserted += 1
except Exception as e:
master_conn.rollback()
if rows_inserted < 3: # only log the first few to avoid spam
print(f' inventory insert failed for part {part_id}: {str(e)[:120]}')
log(f'Populated {rows_inserted} warehouse_inventory rows across {len(bodega_ids)} bodegas')
# ═══════════════════════════════════════════════════════════════════════════
# STEP 3 — Create a seller employee
# ═══════════════════════════════════════════════════════════════════════════
def create_seller_employee(tenant_conn, bodega_id: int) -> int:
cur = tenant_conn.cursor()
pin_hash = hash_pin(SELLER_PIN)
cur.execute("""
INSERT INTO employees
(name, email, role, marketplace_role, bodega_id, is_active, pin, password_hash)
VALUES (%s, %s, 'employee', 'seller', %s, TRUE, %s, %s)
RETURNING id
""", (SELLER_NAME, SELLER_EMAIL, bodega_id, pin_hash, pin_hash))
emp_id = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
log(f'Created seller employee #{emp_id} "{SELLER_NAME}" for bodega #{bodega_id} (PIN: {SELLER_PIN})')
return emp_id
# ═══════════════════════════════════════════════════════════════════════════
# STEP 4 — Create sample POs in different states
# ═══════════════════════════════════════════════════════════════════════════
def create_sample_pos(master_conn, bodega_ids: list[int]):
"""Create 3 POs in states: submitted, confirmed, delivered."""
cur = master_conn.cursor()
cur.execute("""
SELECT p.id_part FROM parts p
JOIN warehouse_inventory wi ON wi.part_id = p.id_part
WHERE wi.bodega_id = %s
LIMIT 3
""", (bodega_ids[0],))
sample_parts = [row[0] for row in cur.fetchall()]
cur.close()
if not sample_parts:
log('WARN: no sample parts for PO seed, skipping')
return
# PO 1 — submitted (waiting for seller to confirm)
po1 = mkt.create_po_draft(
master_conn,
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
buyer_display_name='Ivan Alcaraz',
buyer_phone='5216649998877',
buyer_email='ivan@demo.mx',
bodega_id=bodega_ids[0],
items=[
{'part_id': sample_parts[0], 'quantity': 2, 'unit_price': 250.00},
{'part_id': sample_parts[1], 'quantity': 1, 'unit_price': 850.00},
],
delivery_method='pickup',
buyer_notes='Urgente — cliente esperando',
)
mkt.transition_po(master_conn, po_id=po1, new_status='submitted',
actor_user_id=1, actor_kind='buyer', note='Enviado')
log(f'Created PO #{po1} in state: submitted')
# PO 2 — confirmed (seller accepted, preparing)
po2 = mkt.create_po_draft(
master_conn,
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
buyer_display_name='Ivan Alcaraz',
buyer_phone='5216649998877',
buyer_email='ivan@demo.mx',
bodega_id=bodega_ids[0],
items=[
{'part_id': sample_parts[2], 'quantity': 3, 'unit_price': 420.00},
],
delivery_method='delivery',
delivery_address='Refaccionaria Demo, Av. Revolucion 100, Tijuana',
buyer_notes='Entregar en la tarde',
)
mkt.transition_po(master_conn, po_id=po2, new_status='submitted',
actor_user_id=1, actor_kind='buyer')
mkt.transition_po(master_conn, po_id=po2, new_status='confirmed',
actor_user_id=99, actor_kind='seller',
note='Confirmado — preparando pedido')
log(f'Created PO #{po2} in state: confirmed')
# PO 3 — delivered (full happy path, closed)
po3 = mkt.create_po_draft(
master_conn,
buyer_tenant_id=TENANT_ID, buyer_user_id=1,
buyer_display_name='Ivan Alcaraz',
buyer_phone='5216649998877',
buyer_email='ivan@demo.mx',
bodega_id=bodega_ids[1], # different bodega
items=[
{'part_id': sample_parts[0], 'quantity': 1, 'unit_price': 175.50},
],
delivery_method='pickup',
)
for new_state, kind in [('submitted', 'buyer'), ('confirmed', 'seller'),
('ready', 'seller'), ('delivered', 'buyer')]:
mkt.transition_po(master_conn, po_id=po3, new_status=new_state,
actor_user_id=1 if kind == 'buyer' else 99,
actor_kind=kind)
log(f'Created PO #{po3} in state: delivered')
# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════
def main():
print('╔══════════════════════════════════════════════╗')
print('║ Nexus Marketplace — Seed Script ║')
print('╚══════════════════════════════════════════════╝')
print()
master_conn = get_master_conn()
tenant_conn = get_tenant_conn(TENANT_ID)
try:
cleanup(master_conn, tenant_conn)
print()
print('STEP 1 — Create bodegas')
bodega_ids = create_bodegas(master_conn)
print()
print('STEP 2 — Populate warehouse_inventory')
populate_inventory(master_conn, bodega_ids)
print()
print('STEP 3 — Create seller employee')
seller_id = create_seller_employee(tenant_conn, bodega_ids[0])
print()
print('STEP 4 — Create sample POs')
create_sample_pos(master_conn, bodega_ids)
print()
print('╔══════════════════════════════════════════════╗')
print('║ DONE ║')
print('╚══════════════════════════════════════════════╝')
print()
print('Log in at http://localhost:5001/pos/login with:')
print()
print(f' BUYER (Ivan Alcaraz — existing owner)')
print(f' PIN: (your existing PIN)')
print(f' Test at: http://localhost:5001/pos/marketplace')
print(f' Should see: Explorar + Mis Pedidos tabs')
print()
print(f' SELLER ({SELLER_NAME})')
print(f' Email: {SELLER_EMAIL}')
print(f' PIN: {SELLER_PIN}')
print(f' Test at: http://localhost:5001/pos/marketplace')
print(f' Should see: Pedidos Recibidos + Mi Inventario + Explorar')
print()
print(f'Bodegas created: #{bodega_ids[0]} (Tijuana), '
f'#{bodega_ids[1]} (Guadalajara), #{bodega_ids[2]} (CDMX)')
finally:
master_conn.close()
tenant_conn.close()
if __name__ == '__main__':
main()