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>
383 lines
17 KiB
Python
383 lines
17 KiB
Python
#!/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()
|