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

262
scripts/import_inventory.py Executable file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Nexus Autoparts — Inventory CSV Import Tool
Imports a refaccionaria's inventory from a CSV file into their tenant DB.
Handles flexible column names (Spanish, English, SICAR format) and creates
inventory + initial stock operations.
Usage:
python3 import_inventory.py --tenant=12 --csv=inventario.csv
python3 import_inventory.py --tenant=12 --csv=inventario.csv --branch=1 --dry-run
The CSV should have at least these columns (names are flexible):
part_number / numero_de_parte / codigo / clave / sku
name / nombre / descripcion
price / precio / precio_1 / precio_venta
stock / existencia / cantidad / qty
Optional columns:
brand / marca
cost / costo / precio_compra
price_2 / precio_2 / precio_mayoreo
price_3 / precio_3 / precio_credito
category / categoria / linea / departamento
location / ubicacion
min_stock / minimo / stock_minimo
unit / unidad (default: PZA)
barcode / codigo_barras
"""
import argparse
import csv
import io
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_tenant_conn
# ─── Column name mapping (flexible) ──────────────────────────────────
COLUMN_ALIASES = {
'part_number': ['part_number', 'numero_de_parte', 'codigo', 'clave', 'sku', 'no_parte', 'num_parte', 'numero', 'code'],
'name': ['name', 'nombre', 'descripcion', 'description', 'producto', 'articulo'],
'brand': ['brand', 'marca', 'fabricante', 'manufacturer'],
'price': ['price', 'precio', 'precio_1', 'precio_venta', 'pvp', 'price_1'],
'cost': ['cost', 'costo', 'precio_compra', 'costo_unitario'],
'stock': ['stock', 'existencia', 'cantidad', 'qty', 'quantity', 'inventario', 'saldo'],
'price_2': ['price_2', 'precio_2', 'precio_mayoreo', 'mayoreo'],
'price_3': ['price_3', 'precio_3', 'precio_credito', 'credito'],
'category': ['category', 'categoria', 'linea', 'departamento', 'rubro', 'grupo'],
'location': ['location', 'ubicacion', 'almacen', 'warehouse'],
'min_stock': ['min_stock', 'minimo', 'stock_minimo', 'reorden', 'min'],
'unit': ['unit', 'unidad', 'um'],
'barcode': ['barcode', 'codigo_barras', 'ean', 'upc'],
'tax_rate': ['tax_rate', 'iva', 'impuesto', 'tasa_iva'],
}
def resolve_columns(header: list[str]) -> dict:
"""Map CSV header names to our standard field names.
Returns: {standard_name: csv_column_name} for each matched field.
"""
mapping = {}
normalized = {h.strip().lower().replace(' ', '_'): h for h in header}
for field, aliases in COLUMN_ALIASES.items():
for alias in aliases:
if alias in normalized:
mapping[field] = normalized[alias]
break
return mapping
def parse_number(val, default=0):
"""Parse a string that might be '$1,234.56' or '1234.56' or empty."""
if not val:
return default
cleaned = str(val).replace('$', '').replace(',', '').replace(' ', '').strip()
try:
return float(cleaned)
except ValueError:
return default
def main():
parser = argparse.ArgumentParser(description='Import inventory CSV into a Nexus tenant')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--csv', required=True, help='Path to CSV file')
parser.add_argument('--branch', type=int, default=1, help='Branch ID (default: 1)')
parser.add_argument('--dry-run', action='store_true', help='Parse but don\'t insert')
parser.add_argument('--encoding', default='utf-8', help='CSV encoding (default: utf-8, try latin-1 for SICAR)')
args = parser.parse_args()
# Read CSV
csv_path = args.csv
if not os.path.exists(csv_path):
print(f'ERROR: File not found: {csv_path}')
sys.exit(1)
with open(csv_path, 'r', encoding=args.encoding, errors='replace') as f:
content = f.read()
# Detect delimiter (comma, semicolon, tab)
first_line = content.split('\n')[0]
if '\t' in first_line:
delimiter = '\t'
elif ';' in first_line and ',' not in first_line:
delimiter = ';'
else:
delimiter = ','
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
if not reader.fieldnames:
print('ERROR: CSV has no header row')
sys.exit(1)
col_map = resolve_columns(reader.fieldnames)
print(f'CSV columns detected: {list(reader.fieldnames)}')
print(f'Mapped to: {col_map}')
print()
# Validate required columns
required = ['part_number', 'name']
missing = [r for r in required if r not in col_map]
if missing:
print(f'ERROR: Required columns not found: {missing}')
print(f'Available: {list(reader.fieldnames)}')
sys.exit(1)
if 'price' not in col_map:
print('WARNING: No price column found — all prices will be 0')
if 'stock' not in col_map:
print('WARNING: No stock column found — all stock will be 0')
# Connect to tenant DB
conn = get_tenant_conn(args.tenant)
cur = conn.cursor()
# Parse rows
rows_parsed = 0
rows_inserted = 0
rows_updated = 0
rows_skipped = 0
errors = []
def get_val(row, field, default=''):
csv_col = col_map.get(field)
if not csv_col:
return default
return (row.get(csv_col) or '').strip()
for i, row in enumerate(reader, start=2):
rows_parsed += 1
part_number = get_val(row, 'part_number')
name = get_val(row, 'name')
if not part_number and not name:
rows_skipped += 1
continue
if not part_number:
part_number = name[:50].upper().replace(' ', '-')
price_1 = parse_number(get_val(row, 'price'))
cost = parse_number(get_val(row, 'cost'))
stock = int(parse_number(get_val(row, 'stock')))
brand = get_val(row, 'brand')
price_2 = parse_number(get_val(row, 'price_2'))
price_3 = parse_number(get_val(row, 'price_3'))
category = get_val(row, 'category')
location = get_val(row, 'location') or 'Principal'
min_stock = int(parse_number(get_val(row, 'min_stock')))
unit = get_val(row, 'unit') or 'PZA'
barcode = get_val(row, 'barcode')
tax_rate = parse_number(get_val(row, 'tax_rate'), default=0.16)
if args.dry_run:
if rows_parsed <= 5:
print(f' [DRY] {part_number:20} {name[:30]:30} ${price_1:.2f} stock={stock} brand={brand}')
continue
try:
# UPSERT by part_number within this branch
cur.execute("""
INSERT INTO inventory
(part_number, name, brand, price_1, price_2, price_3,
cost, tax_rate, unit, category, location,
min_stock, barcode, branch_id, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, TRUE)
ON CONFLICT (part_number, branch_id)
DO UPDATE SET
name = EXCLUDED.name,
brand = EXCLUDED.brand,
price_1 = EXCLUDED.price_1,
price_2 = EXCLUDED.price_2,
price_3 = EXCLUDED.price_3,
cost = EXCLUDED.cost,
tax_rate = EXCLUDED.tax_rate,
unit = EXCLUDED.unit,
category = EXCLUDED.category,
location = EXCLUDED.location,
min_stock = EXCLUDED.min_stock,
barcode = EXCLUDED.barcode
RETURNING id, (xmax = 0) AS was_insert
""", (part_number, name, brand, price_1, price_2, price_3,
cost, tax_rate, unit, category, location,
min_stock, barcode, args.branch))
inv_id, was_insert = cur.fetchone()
if was_insert:
rows_inserted += 1
else:
rows_updated += 1
# Set initial stock via inventory_operations (append-only model)
if stock > 0 and was_insert:
cur.execute("""
INSERT INTO inventory_operations
(inventory_id, operation_type, quantity, reference, notes)
VALUES (%s, 'adjustment', %s, 'CSV Import', 'Carga inicial desde CSV')
""", (inv_id, stock))
except Exception as e:
error_msg = f'Row {i}: {str(e)[:100]}'
errors.append(error_msg)
rows_skipped += 1
conn.rollback()
continue
if not args.dry_run:
conn.commit()
cur.close()
conn.close()
# Summary
print()
print('╔══════════════════════════════════════════════╗')
print('║ IMPORT COMPLETE ║')
print('╚══════════════════════════════════════════════╝')
print(f' Parsed: {rows_parsed}')
print(f' Inserted: {rows_inserted}')
print(f' Updated: {rows_updated}')
print(f' Skipped: {rows_skipped}')
if errors:
print(f' Errors: {len(errors)}')
for e in errors[:10]:
print(f'{e}')
if len(errors) > 10:
print(f' ... and {len(errors) - 10} more')
if args.dry_run:
print()
print(' (DRY RUN — nothing was written to the database)')
print()
if __name__ == '__main__':
main()

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()

225
scripts/setup_instance.sh Executable file
View File

@@ -0,0 +1,225 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════════
# Nexus Autoparts — Instance Setup Script
# Target: Raspberry Pi OS / Debian / Ubuntu
# Usage: sudo bash setup_instance.sh "Refaccionaria El Toro" "refac-eltoro"
# ═══════════════════════════════════════════════════════════════════════
set -e
BUSINESS_NAME="${1:-Refaccionaria Demo}"
INSTANCE_ID="${2:-refac-$(date +%s)}"
DB_PASSWORD="nexus_autoparts_2026"
DB_USER="nexus"
MASTER_DB="nexus_autoparts"
OWNER_PIN="${3:-1234}"
INSTALL_DIR="/home/Autopartes"
echo "╔══════════════════════════════════════════════╗"
echo "║ Nexus Autoparts — Instance Setup ║"
echo "║ Business: $BUSINESS_NAME"
echo "║ ID: $INSTANCE_ID"
echo "╚══════════════════════════════════════════════╝"
echo ""
# ─── 1. System deps ───────────────────────────────────────────────────
echo "→ Installing system dependencies..."
apt-get update -qq
apt-get install -y -qq \
postgresql postgresql-client \
python3 python3-pip python3-venv python3-dev \
nodejs npm \
ffmpeg espeak-ng \
git curl wget \
libpq-dev gcc \
2>/dev/null
echo " ✓ System deps installed"
# ─── 2. Python deps ──────────────────────────────────────────────────
echo "→ Installing Python packages..."
pip3 install --break-system-packages --quiet \
flask psycopg2-binary sqlalchemy pyjwt bcrypt requests \
gunicorn faster-whisper \
2>/dev/null || pip3 install --quiet \
flask psycopg2-binary sqlalchemy pyjwt bcrypt requests \
gunicorn faster-whisper
echo " ✓ Python packages installed"
# ─── 3. Node.js deps (WhatsApp bridge) ───────────────────────────────
if [ -d "/opt/whatsapp-bridge" ]; then
echo "→ Installing WhatsApp bridge deps..."
cd /opt/whatsapp-bridge && npm install --silent 2>/dev/null
echo " ✓ WhatsApp bridge ready"
else
echo " ⚠ WhatsApp bridge not found at /opt/whatsapp-bridge — skipping"
fi
# ─── 4. PostgreSQL setup ─────────────────────────────────────────────
echo "→ Configuring PostgreSQL..."
systemctl enable postgresql
systemctl start postgresql
# Create user if doesn't exist
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\" | grep -q 1" \
|| su - postgres -c "createuser -s $DB_USER"
# Set password
su - postgres -c "psql -c \"ALTER USER $DB_USER WITH PASSWORD '$DB_PASSWORD';\""
# Create master DB if doesn't exist
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='$MASTER_DB'\" | grep -q 1" \
|| su - postgres -c "createdb -O $DB_USER $MASTER_DB"
echo " ✓ PostgreSQL configured"
# ─── 5. Import schema (master + tenant template) ─────────────────────
echo "→ Importing database schema..."
if [ -f "$INSTALL_DIR/vehicle_database/sql/schema.sql" ]; then
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB \
-f "$INSTALL_DIR/vehicle_database/sql/schema.sql" 2>/dev/null || true
fi
if [ -f "$INSTALL_DIR/sql/marketplace_schema.sql" ]; then
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB \
-f "$INSTALL_DIR/sql/marketplace_schema.sql" 2>/dev/null || true
fi
echo " ✓ Schema imported"
# ─── 6. Create tenant for this refaccionaria ─────────────────────────
echo "→ Creating tenant database for '$BUSINESS_NAME'..."
TENANT_DB="tenant_${INSTANCE_ID//-/_}"
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='$TENANT_DB'\" | grep -q 1" && {
echo " ⚠ Tenant DB '$TENANT_DB' already exists — skipping creation"
} || {
# Create from template if available, else create fresh
if su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='tenant_template'\" | grep -q 1"; then
su - postgres -c "createdb -O $DB_USER -T tenant_template $TENANT_DB"
echo " ✓ Created from tenant_template"
else
su - postgres -c "createdb -O $DB_USER $TENANT_DB"
echo " ✓ Created fresh (no template available)"
fi
}
# Apply marketplace migration to tenant
if [ -f "$INSTALL_DIR/sql/marketplace_tenant_users.sql" ]; then
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" \
-f "$INSTALL_DIR/sql/marketplace_tenant_users.sql" 2>/dev/null || true
fi
# Apply plate_vehicles migration
if [ -f "$INSTALL_DIR/pos/migrations/v1.7_plates.sql" ]; then
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" \
-f "$INSTALL_DIR/pos/migrations/v1.7_plates.sql" 2>/dev/null || true
fi
echo " ✓ Tenant DB ready: $TENANT_DB"
# ─── 7. Register tenant in master ────────────────────────────────────
echo "→ Registering tenant in master DB..."
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB -c "
INSERT INTO tenants (name, db_name, is_active, subdomain)
VALUES ('$BUSINESS_NAME', '$TENANT_DB', true, '$INSTANCE_ID')
ON CONFLICT DO NOTHING;
" 2>/dev/null || true
# Get tenant ID
TENANT_ID=$(PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d $MASTER_DB -t -c "
SELECT id FROM tenants WHERE db_name = '$TENANT_DB' LIMIT 1;
" 2>/dev/null | tr -d ' ')
echo " ✓ Tenant registered: ID=$TENANT_ID"
# ─── 8. Create owner employee ────────────────────────────────────────
echo "→ Creating owner employee..."
PIN_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$OWNER_PIN'.encode(), bcrypt.gensalt()).decode())")
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -h localhost -d "$TENANT_DB" -c "
INSERT INTO employees (name, role, pin, password_hash, is_active, marketplace_role)
VALUES ('Administrador', 'owner', '$PIN_HASH', '$PIN_HASH', true, 'buyer')
ON CONFLICT DO NOTHING;
INSERT INTO branches (name, address, phone, is_active)
VALUES ('Principal', '', '', true)
ON CONFLICT DO NOTHING;
" 2>/dev/null || true
echo " ✓ Owner employee created (PIN: $OWNER_PIN)"
# ─── 9. Configure peers.json ─────────────────────────────────────────
echo "→ Configuring peers.json..."
# Get this machine's IP
LOCAL_IP=$(hostname -I | awk '{print $1}')
cat > "$INSTALL_DIR/pos/peers.json" << EOJSON
{
"instance_name": "$BUSINESS_NAME",
"instance_id": "$INSTANCE_ID",
"tenant_id": $TENANT_ID,
"peers": [],
"peer_timeout_seconds": 3,
"notes": "Add peer instances here: {\"name\": \"Refac B\", \"url\": \"http://192.168.X.Y:5001\", \"enabled\": true}"
}
EOJSON
echo " ✓ peers.json configured (local IP: $LOCAL_IP)"
# ─── 10. Create Gunicorn systemd service ─────────────────────────────
echo "→ Setting up Gunicorn service..."
cat > /etc/systemd/system/nexus-pos.service << EOSERVICE
[Unit]
Description=Nexus Autoparts POS
After=network.target postgresql.service
[Service]
User=root
WorkingDirectory=$INSTALL_DIR/pos
Environment="PATH=/usr/local/bin:/usr/bin"
ExecStart=/usr/local/bin/gunicorn -w 2 -b 0.0.0.0:5001 --timeout 120 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOSERVICE
systemctl daemon-reload
systemctl enable nexus-pos
# Don't start yet — let user verify config first
echo " ✓ Gunicorn service created (nexus-pos)"
# ─── Done ─────────────────────────────────────────────────────────────
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ SETUP COMPLETE ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
echo "Instance: $BUSINESS_NAME"
echo "Tenant DB: $TENANT_DB (ID: $TENANT_ID)"
echo "Owner PIN: $OWNER_PIN"
echo "Local IP: $LOCAL_IP"
echo ""
echo "To start the POS:"
echo " systemctl start nexus-pos"
echo " # or manually: cd $INSTALL_DIR/pos && gunicorn -w 2 -b 0.0.0.0:5001 app:app"
echo ""
echo "To add peers, edit: $INSTALL_DIR/pos/peers.json"
echo "To import inventory: python3 $INSTALL_DIR/scripts/import_inventory.py --tenant=$TENANT_ID --csv=inventario.csv"
echo ""
echo "Access: http://$LOCAL_IP:5001/pos/login"