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
This commit is contained in:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Assign categories to Strada inventory based on pcode from TODO.st01.
Creates categories if they don't exist and updates inventory.category_id.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_tenant_conn
# pcode → category name mapping (inferred from common auto parts codes)
PCODE_MAP = {
'SU': 'Suspensión',
'SO': 'Soportería',
'MA': 'Mangueras',
'EM': 'Empaques',
'HQ': 'Herramientas y Equipo',
'AM': 'Amortiguadores',
'IG': 'Ignición y Eléctrico',
'BD': 'Bandas y Correas',
'BL': 'Baleros',
'FI': 'Filtros',
'HE': 'Herramientas',
'PI': 'Pistones y Anillos',
'AW': 'Accesorios',
'BE': 'Baterías y Eléctrico',
'K0': 'Kit de Servicio',
'DF': 'Frenos (Discos)',
'RE': 'Refrigeración',
'PE': 'Perfiles y Sellos',
'BG': 'Bujías',
'TA': 'Toma de Agua',
'CV': 'Conversiones y Kits',
'CT': 'Clutch y Transmisión',
'CC': 'Cilindros y Componentes',
'OT': 'Otros',
'SF': 'Soportes y Bases',
'BT': 'Bujes y Terminales',
'HF': 'Herramientas y Ferretería',
'CR': 'Cremalleras y Dirección',
'CS': 'Cilindros de Freno',
'CF': 'Cables y Frenos',
'FG': 'Faros y Iluminación',
'TO': 'Tornillería',
'AO': 'Aceites y Lubricantes',
'BJ': 'Bujes',
'FR': 'Frenos',
'DR': 'Dirección y Rótulas',
'KT': 'Kits de Reparación',
'TT': 'Transmisión',
'BU': 'Bujes y Soportes',
'PC': 'Poleas y Componentes',
'CL': 'Clutch',
'PG': 'Pegamentos y Adhesivos',
'QU': 'Químicos',
'TA': 'Toma de Agua',
'VC': 'Válvulas y Controles',
'MV': 'Motoventiladores',
'AS': 'Aspas y Ventiladores',
'PO': 'Poleas',
'CM': 'Compresores y A/C',
'HE': 'Herramientas',
'EA': 'Enfriadores y Radiadores',
'CO': 'Conectores',
'CX': 'Conectores y Cables',
'TR': 'Tren Delantero',
'AB': 'Abrazaderas',
'FU': 'Fusibles',
'MR': 'Mangueras de Freno',
'TO': 'Tornillería',
'RA': 'Radiadores',
'MC': 'Mangueras y Conectores',
}
def assign_categories(tenant_id):
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Get or create categories
cur.execute("SELECT id, name FROM part_categories")
existing = {name: id for id, name in cur.fetchall()}
created = 0
category_map = {} # pcode -> category_id
for pcode, name in PCODE_MAP.items():
if name in existing:
category_map[pcode] = existing[name]
else:
cur.execute("INSERT INTO part_categories (name) VALUES (%s) RETURNING id", (name,))
cat_id = cur.fetchone()[0]
existing[name] = cat_id
category_map[pcode] = cat_id
created += 1
print(f"Categories: {len(existing)} total, {created} created")
# Update inventory items based on location (pcode) field
updated = 0
for pcode, cat_id in category_map.items():
cur.execute("""
UPDATE inventory
SET category_id = %s
WHERE location = %s AND category_id IS NULL
""", (cat_id, pcode))
updated += cur.rowcount
conn.commit()
cur.close()
conn.close()
print(f"Updated {updated:,} inventory items with categories")
# Show uncategorized items
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT location, COUNT(*) FROM inventory
WHERE category_id IS NULL AND location IS NOT NULL
GROUP BY location ORDER BY COUNT(*) DESC
""")
uncategorized = cur.fetchall()
if uncategorized:
print(f"\nUncategorized items by pcode:")
for pcode, cnt in uncategorized[:20]:
print(f" {pcode}: {cnt:,}")
cur.close()
conn.close()
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--tenant', type=int, required=True)
args = parser.parse_args()
assign_categories(args.tenant)

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Nexus Autoparts — Import Strada TODO.st01 Inventory
Parses fixed-width report and imports into tenant DB using PostgreSQL COPY.
Usage:
python3 import_estrada_st01.py --tenant=28 --file=/tmp/todo_st01.txt --branch=1
"""
import argparse
import csv
import io
import os
import sys
import tempfile
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn
# Margins applied to Cost A
MARGIN_PRICE_1 = 1.35 # mostrador / retail
MARGIN_PRICE_2 = 1.25 # taller / workshop
MARGIN_PRICE_3 = 1.15 # mayoreo / wholesale
def parse_number(val, default=0):
"""Parse a string that might be '$1,234.56' or '.000' or empty."""
if not val:
return default
cleaned = str(val).replace('$', '').replace(',', '').replace(' ', '').strip()
if cleaned == '.':
return default
try:
return float(cleaned)
except ValueError:
return default
def parse_st01_line(line):
"""Parse a single line from TODO.st01 fixed-width format."""
line = line.rstrip('\n')
# Skip short lines
if len(line) < 80:
return None
# Skip header/footer lines
if 'Page:' in line or 'General Inventory' in line or 'STRADA AUTO' in line:
return None
if 'Tienda:' in line and 'Page:' in line:
return None
if line.strip().startswith('Line Part Number'):
return None
if line.strip().startswith('----'):
return None
# Fixed-width columns based on header:
# Line Part Number Description Addl description Pcode Instk Min 1 Max 1 Cost A Cost B codes[1]
# ---- ---------------- ---------------- ---------------- ----- ------- ------ ------ ---------- ---------- --------
marca = line[0:5].strip()
part_number_raw = line[5:21].strip()
description = line[22:39].strip()
addl_desc = line[40:56].strip()
pcode = line[56:61].strip()
instk = line[62:70].strip()
min1 = line[71:77].strip()
max1 = line[78:84].strip()
cost_a = line[85:95].strip()
cost_b = line[96:106].strip()
codes = line[107:].strip() if len(line) > 107 else ''
if not part_number_raw:
return None
# Build composite part number: MARCA-NUMERO
# Some marcas are empty (fallback to pcode or generic)
marca_safe = marca if marca else 'GEN'
part_number = f"{marca_safe}-{part_number_raw}"
# Build name from description + additional description
name = description
if addl_desc and addl_desc != '?':
name = f"{description} {addl_desc}".strip()
if not name:
return None
cost = parse_number(cost_a, 0)
stock = int(parse_number(instk, 0))
min_stock = int(parse_number(min1, 0))
max_stock = int(parse_number(max1, 0))
# Calculate prices with margins
price_1 = round(cost * MARGIN_PRICE_1, 2) if cost > 0 else 0
price_2 = round(cost * MARGIN_PRICE_2, 2) if cost > 0 else 0
price_3 = round(cost * MARGIN_PRICE_3, 2) if cost > 0 else 0
return {
'part_number': part_number,
'name': name[:300], # truncate to schema limit
'brand': marca_safe,
'cost': cost,
'price_1': price_1,
'price_2': price_2,
'price_3': price_3,
'stock': stock,
'min_stock': min_stock,
'max_stock': max_stock,
'location': pcode,
'unit': 'PZA',
'tax_rate': 0.16,
}
def import_st01(tenant_id, file_path, branch_id=1):
print(f"=== Importing Strada inventory ===")
print(f"Tenant: {tenant_id}")
print(f"File: {file_path}")
print(f"Branch: {branch_id}")
print()
# Parse file
print("Parsing fixed-width file...")
parsed = []
skipped = 0
start_time = time.time()
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
row = parse_st01_line(line)
if row is None:
skipped += 1
continue
parsed.append(row)
parse_time = time.time() - start_time
print(f" Parsed: {len(parsed):,} rows")
print(f" Skipped: {skipped:,} lines (headers/empty)")
print(f" Time: {parse_time:.1f}s")
print()
if not parsed:
print("ERROR: No data rows found.")
return
# Show sample
print("Sample rows:")
for row in parsed[:5]:
print(f" {row['part_number']:20} | {row['name'][:40]:40} | stk={row['stock']:>4} | cost={row['cost']:>10.2f} | p1={row['price_1']:>10.2f}")
print()
# Connect to tenant DB
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Ensure branch exists
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
if not cur.fetchone():
print(f"ERROR: Branch {branch_id} does not exist. Creating default branch...")
cur.execute("INSERT INTO branches (name) VALUES ('Principal') RETURNING id")
branch_id = cur.fetchone()[0]
conn.commit()
print(f" Created branch id={branch_id}")
# Step 1: Create temp table and COPY data
print("Creating temp table...")
cur.execute("""
DROP TABLE IF EXISTS tmp_st01_import;
CREATE TEMP TABLE tmp_st01_import (
part_number VARCHAR(100),
name VARCHAR(300),
brand VARCHAR(100),
cost NUMERIC(12,2),
price_1 NUMERIC(12,2),
price_2 NUMERIC(12,2),
price_3 NUMERIC(12,2),
stock INTEGER,
min_stock INTEGER,
max_stock INTEGER,
location VARCHAR(50),
unit VARCHAR(20),
tax_rate NUMERIC(5,4)
);
""")
conn.commit()
print("COPY to temp table...")
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, lineterminator='\n')
for row in parsed:
writer.writerow([
row['part_number'],
row['name'],
row['brand'],
row['cost'],
row['price_1'],
row['price_2'],
row['price_3'],
row['stock'],
row['min_stock'],
row['max_stock'],
row['location'],
row['unit'],
row['tax_rate'],
])
csv_buffer.seek(0)
cur.copy_expert(
"""COPY tmp_st01_import (
part_number, name, brand, cost, price_1, price_2, price_3,
stock, min_stock, max_stock, location, unit, tax_rate
) FROM STDIN WITH (FORMAT CSV)""",
csv_buffer
)
conn.commit()
cur.execute("SELECT COUNT(*) FROM tmp_st01_import")
copied = cur.fetchone()[0]
print(f" Copied: {copied:,} rows")
print()
# Step 2: Insert into inventory
print("Inserting into inventory...")
start_time = time.time()
cur.execute("""
INSERT INTO inventory (
branch_id, part_number, name, brand,
cost, price_1, price_2, price_3,
min_stock, max_stock, location, unit, tax_rate, is_active
)
SELECT
%s, part_number, name, brand,
cost, price_1, price_2, price_3,
min_stock, max_stock, location, unit, tax_rate, TRUE
FROM tmp_st01_import
ON CONFLICT (branch_id, part_number) DO UPDATE SET
name = EXCLUDED.name,
brand = EXCLUDED.brand,
cost = EXCLUDED.cost,
price_1 = EXCLUDED.price_1,
price_2 = EXCLUDED.price_2,
price_3 = EXCLUDED.price_3,
min_stock = EXCLUDED.min_stock,
max_stock = EXCLUDED.max_stock,
location = EXCLUDED.location,
is_active = TRUE
""", (branch_id,))
conn.commit()
insert_time = time.time() - start_time
cur.execute("SELECT COUNT(*) FROM inventory WHERE branch_id = %s", (branch_id,))
total_inventory = cur.fetchone()[0]
print(f" Inventory rows: {total_inventory:,}")
print(f" Time: {insert_time:.1f}s")
print()
# Step 3: Create stock operations for items with stock > 0
print("Creating initial stock operations...")
start_time = time.time()
cur.execute("""
INSERT INTO inventory_operations (
inventory_id, branch_id, operation_type, quantity,
cost_at_time, reference_type, notes
)
SELECT
i.id,
i.branch_id,
'INITIAL',
t.stock,
i.cost,
'IMPORT',
'Importado desde TODO.st01'
FROM tmp_st01_import t
JOIN inventory i ON i.part_number = t.part_number AND i.branch_id = %s
WHERE t.stock > 0
""", (branch_id,))
conn.commit()
ops_time = time.time() - start_time
cur.execute("SELECT COUNT(*) FROM inventory_operations WHERE operation_type = 'INITIAL'")
total_ops = cur.fetchone()[0]
print(f" Stock operations created: {total_ops:,}")
print(f" Time: {ops_time:.1f}s")
print()
# Cleanup
cur.execute("DROP TABLE IF EXISTS tmp_st01_import")
conn.commit()
cur.close()
conn.close()
print("=== Import complete ===")
print(f"Total inventory items: {total_inventory:,}")
print(f"Items with stock > 0: {total_ops:,}")
def main():
parser = argparse.ArgumentParser(description='Import Strada TODO.st01 into Nexus tenant')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--file', required=True, help='Path to TODO.st01 file')
parser.add_argument('--branch', type=int, default=1, help='Branch ID (default: 1)')
args = parser.parse_args()
import_st01(args.tenant, args.file, args.branch)
if __name__ == '__main__':
main()

439
scripts/import_pdf_catalog.py Executable file
View File

@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""
Import aftermarket parts catalog from PDF into Nexus Autoparts DB.
Usage:
# Extract and preview (generates CSV for review)
python3 scripts/import_pdf_catalog.py extract catalogo_bosch.pdf "BOSCH" --output bosch_preview.csv
# Import after reviewing CSV
python3 scripts/import_pdf_catalog.py import bosch_preview.csv "BOSCH"
The CSV should have columns:
part_number, name, price_usd, applications
Applications column (optional): comma-separated vehicle descriptions like:
"TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019"
If applications is empty, the part will be created but not linked to vehicles.
"""
import os
import sys
import re
import csv
import json
import argparse
import subprocess
import psycopg2
from pathlib import Path
# Add parent to path for config imports
sys.path.insert(0, str(Path(__file__).parent.parent / "pos"))
MASTER_DB_URL = os.environ.get("MASTER_DB_URL", "postgresql://postgres@localhost/nexus_autoparts")
def get_db_conn():
return psycopg2.connect(MASTER_DB_URL)
def pdf_to_text(pdf_path):
"""Extract text from PDF using pdftotext (preserves layout)."""
result = subprocess.run(
["pdftotext", "-layout", pdf_path, "-"],
capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f"pdftotext failed: {result.stderr}")
return result.stdout
def extract_lines_fuzzy(text, min_cols=2):
"""
Heuristic table extractor.
Looks for lines that have:
- A part number pattern (alphanumeric with dashes/slashes, 3+ chars)
- Some description text
Returns list of dicts with raw columns.
"""
rows = []
lines = text.splitlines()
# Part number patterns: BOSCH 0 986 AF1 041, MOOG K80001, NGK BKR6E, etc.
part_number_patterns = [
re.compile(r'\b[0-9A-Z]{3,}(?:[-\s/][0-9A-Z]+){1,}\b'), # codes with separators
re.compile(r'\b[A-Z]{1,3}\d{3,}[A-Z0-9]*\b'), # MOOG K80001, NGK BKR6E
re.compile(r'\b\d{3,}[A-Z]{1,3}\d+\b'), # 123ABC45
]
for line in lines:
line = line.strip()
if len(line) < 10:
continue
# Try to find a part number
part_number = None
for pat in part_number_patterns:
m = pat.search(line)
if m:
part_number = m.group(0).strip()
break
if not part_number:
continue
# Split line by 2+ spaces to get columns
cols = [c.strip() for c in re.split(r'\s{2,}', line) if c.strip()]
if len(cols) < min_cols:
continue
# Heuristic: part number is usually first or second column
# The rest is description, possibly with price at the end
name_parts = []
price = None
for col in cols:
if col == part_number:
continue
# Price detection
price_m = re.match(r'^\$?([0-9]{1,6}(?:\.[0-9]{1,2})?)$', col.replace(',', ''))
if price_m and not price:
price = float(price_m.group(1))
continue
name_parts.append(col)
name = ' '.join(name_parts) if name_parts else part_number
# Clean up name
name = re.sub(r'\s+', ' ', name).strip()
if len(name) < 3:
name = part_number
rows.append({
'part_number': part_number,
'name': name,
'price_usd': price,
'applications': '',
'raw': line,
})
return rows
def preview_rows(rows, limit=20):
print(f"\nExtracted {len(rows)} candidate rows. First {limit}:")
print("-" * 100)
for i, r in enumerate(rows[:limit]):
print(f"{i+1}. PN: {r['part_number'][:30]:30s} | Name: {r['name'][:50]:50s} | Price: {r['price_usd']}")
print("-" * 100)
def save_csv(rows, path):
with open(path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['part_number', 'name', 'price_usd', 'applications'])
writer.writeheader()
for r in rows:
writer.writerow({
'part_number': r['part_number'],
'name': r['name'],
'price_usd': r['price_usd'] or '',
'applications': r['applications'],
})
print(f"Saved preview to {path}")
def load_csv(path):
rows = []
with open(path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
price = row.get('price_usd', '')
try:
price = float(price) if price else None
except ValueError:
price = None
rows.append({
'part_number': row.get('part_number', '').strip(),
'name': row.get('name', '').strip(),
'price_usd': price,
'applications': row.get('applications', '').strip(),
})
return rows
def resolve_manufacturer(cur, name):
"""Get or create manufacturer. Returns id_manufacture."""
cur.execute(
"SELECT id_manufacture FROM manufacturers WHERE UPPER(name_manufacture) = UPPER(%s)",
(name,)
)
row = cur.fetchone()
if row:
return row[0]
# Insert new manufacturer
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
(name.upper() if len(name) <= 6 else name,)
)
return cur.fetchone()[0]
def resolve_or_create_part(cur, oem_part_number, name):
"""
parts.oem_part_number has UNIQUE index.
If it exists, return id_part. If not, insert.
"""
cur.execute(
"SELECT id_part, name_part FROM parts WHERE oem_part_number = %s",
(oem_part_number,)
)
row = cur.fetchone()
if row:
return row[0]
# Need a group_id. Use 'General' group as default.
cur.execute("SELECT id_part_group FROM part_groups WHERE name_part_group = 'General' LIMIT 1")
grow = cur.fetchone()
group_id = grow[0] if grow else None
cur.execute(
"""
INSERT INTO parts (oem_part_number, name_part, group_id)
VALUES (%s, %s, %s)
RETURNING id_part
""",
(oem_part_number, name, group_id)
)
return cur.fetchone()[0]
def parse_applications(app_text):
"""
Parse text like 'TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019'
into list of (brand, model, year_from, year_to).
"""
if not app_text:
return []
results = []
# Split by commas or slashes
entries = re.split(r'[,;/]', app_text)
for entry in entries:
entry = entry.strip()
if not entry:
continue
# Pattern: BRAND MODEL YEAR-YEAR or BRAND MODEL YEAR
m = re.match(
r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30}?)\s+(\d{4})(?:\s*-\s*(\d{4}))?$',
entry.upper().strip()
)
if m:
brand = m.group(1).strip()
model = m.group(2).strip()
year_from = int(m.group(3))
year_to = int(m.group(4)) if m.group(4) else year_from
results.append((brand, model, year_from, year_to))
else:
# Try looser pattern: just BRAND MODEL
m2 = re.match(r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30})$', entry.upper().strip())
if m2:
results.append((m2.group(1).strip(), m2.group(2).strip(), None, None))
return results
def resolve_mye_ids(cur, brand_name, model_name, year_from, year_to):
"""Find MYE ids matching brand/model/year range."""
myes = []
# Find brand
cur.execute("SELECT id_brand FROM brands WHERE UPPER(name_brand) = UPPER(%s)", (brand_name,))
brow = cur.fetchone()
if not brow:
return myes
brand_id = brow[0]
# Find model (fuzzy)
cur.execute(
"""
SELECT id_model, name_model FROM models
WHERE brand_id = %s AND UPPER(name_model) LIKE UPPER(%s)
ORDER BY name_model
LIMIT 5
""",
(brand_id, f"%{model_name}%")
)
models = cur.fetchall()
if not models:
return myes
# Use first match
model_id = models[0][0]
# Find MYEs for year range
if year_from and year_to:
cur.execute(
"""
SELECT mye.id_mye FROM model_year_engine mye
JOIN years y ON y.id_year = mye.year_id
WHERE mye.model_id = %s AND y.year_car BETWEEN %s AND %s
""",
(model_id, year_from, year_to)
)
elif year_from:
cur.execute(
"""
SELECT mye.id_mye FROM model_year_engine mye
JOIN years y ON y.id_year = mye.year_id
WHERE mye.model_id = %s AND y.year_car = %s
""",
(model_id, year_from)
)
else:
cur.execute(
"SELECT id_mye FROM model_year_engine WHERE model_id = %s",
(model_id,)
)
myes = [r[0] for r in cur.fetchall()]
return myes
def import_rows(rows, manufacturer_name, dry_run=False):
conn = get_db_conn()
cur = conn.cursor()
try:
manufacturer_id = resolve_manufacturer(cur, manufacturer_name)
print(f"Manufacturer '{manufacturer_name}' → id={manufacturer_id}")
inserted_parts = 0
inserted_am = 0
linked_vehicles = 0
skipped = 0
for i, row in enumerate(rows):
pn = row['part_number']
name = row['name'] or pn
price = row['price_usd']
if not pn:
skipped += 1
continue
if dry_run:
print(f" [DRY] {pn} | {name[:40]} | ${price}")
continue
# 1. Ensure part exists in parts table
part_id = resolve_or_create_part(cur, pn, name)
# 2. Insert/upsert aftermarket_parts
cur.execute(
"""
SELECT id_aftermarket_parts FROM aftermarket_parts
WHERE part_number = %s AND manufacturer_id = %s
""",
(pn, manufacturer_id)
)
existing = cur.fetchone()
if existing:
# Update
cur.execute(
"""
UPDATE aftermarket_parts
SET name_aftermarket_parts = %s,
price_usd = COALESCE(%s, price_usd),
oem_part_id = %s
WHERE id_aftermarket_parts = %s
""",
(name, price, part_id, existing[0])
)
else:
cur.execute(
"""
INSERT INTO aftermarket_parts
(oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, price_usd)
VALUES (%s, %s, %s, %s, %s)
""",
(part_id, manufacturer_id, pn, name, price)
)
inserted_am += 1
inserted_parts += 1
# 3. Link vehicles if applications provided
apps = row.get('applications', '')
if apps:
parsed = parse_applications(apps)
for brand, model, yf, yt in parsed:
myes = resolve_mye_ids(cur, brand, model, yf, yt)
for mye_id in myes:
cur.execute(
"""
INSERT INTO vehicle_parts (part_id, model_year_engine_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(part_id, mye_id)
)
linked_vehicles += 1
if (i + 1) % 100 == 0:
print(f" ... processed {i+1}/{len(rows)}")
conn.commit()
print(f"\nDone!")
print(f" Parts processed: {inserted_parts}")
print(f" Aftermarket parts inserted/updated: {inserted_am}")
print(f" Vehicle links created: {linked_vehicles}")
print(f" Skipped (no PN): {skipped}")
except Exception as e:
conn.rollback()
raise
finally:
cur.close()
conn.close()
def main():
parser = argparse.ArgumentParser(description='Import aftermarket catalog from PDF')
subparsers = parser.add_subparsers(dest='command')
# Extract command
ext = subparsers.add_parser('extract', help='Extract PDF to preview CSV')
ext.add_argument('pdf', help='Path to PDF file')
ext.add_argument('manufacturer', help='Manufacturer name')
ext.add_argument('--output', '-o', default='catalog_preview.csv', help='Output CSV path')
# Import command
imp = subparsers.add_parser('import', help='Import reviewed CSV to DB')
imp.add_argument('csv', help='Path to reviewed CSV')
imp.add_argument('manufacturer', help='Manufacturer name')
imp.add_argument('--dry-run', action='store_true', help='Preview without writing to DB')
args = parser.parse_args()
if args.command == 'extract':
print(f"Extracting {args.pdf}...")
text = pdf_to_text(args.pdf)
rows = extract_lines_fuzzy(text)
preview_rows(rows)
save_csv(rows, args.output)
print(f"\nNext step: Review {args.output}, add 'applications' column if needed,")
print(f"then run: python3 scripts/import_pdf_catalog.py import {args.output} '{args.manufacturer}'")
elif args.command == 'import':
rows = load_csv(args.csv)
print(f"Loaded {len(rows)} rows from {args.csv}")
import_rows(rows, args.manufacturer, dry_run=args.dry_run)
else:
parser.print_help()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Mass QWEN vehicle compatibility processor for Strada.
Processes inventory items in batches of 5 with parallel workers.
Usage:
python3 qwen_batch_compat.py --tenant=28 --batch-size=5 --workers=10 --checkpoint=qwen_progress.json
"""
import argparse
import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn, get_master_conn
from services.qwen_fitment import get_vehicle_fitment
DEFAULT_CHECKPOINT_FILE = '/tmp/qwen_progress.json'
PROGRESS_LOCK = Lock()
def load_checkpoint(checkpoint_file):
if os.path.exists(checkpoint_file):
with open(checkpoint_file, 'r') as f:
return set(json.load(f))
return set()
def save_checkpoint(checkpoint_file, processed_ids):
with PROGRESS_LOCK:
with open(checkpoint_file, 'w') as f:
json.dump(list(processed_ids), f)
def get_pending_items(tenant_id, processed_ids, limit=None):
"""Get inventory items that haven't been processed yet."""
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Get items without vehicle compatibility records
if processed_ids:
placeholders = ','.join(['%s'] * len(processed_ids))
query = f"""
SELECT id, part_number, name, brand
FROM inventory
WHERE id NOT IN ({placeholders})
AND is_active = true
ORDER BY id
"""
cur.execute(query, tuple(processed_ids))
else:
cur.execute("""
SELECT id, part_number, name, brand
FROM inventory
WHERE is_active = true
ORDER BY id
""")
rows = cur.fetchall()
cur.close()
conn.close()
if limit:
rows = rows[:limit]
return rows
def process_single_item(item_id, part_number, name, brand):
"""Process one item with QWEN."""
try:
result = get_vehicle_fitment(part_number, name, brand)
return {
'item_id': item_id,
'part_number': part_number,
'success': True,
'vehicles': result.get('vehicles', []),
'confidence': result.get('confidence', 0),
'notes': result.get('notes', ''),
}
except Exception as exc:
return {
'item_id': item_id,
'part_number': part_number,
'success': False,
'error': str(exc),
}
def save_results(tenant_id, results):
"""Save QWEN results to inventory_vehicle_compat."""
if not results:
return 0
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
saved = 0
for result in results:
if not result.get('success'):
continue
item_id = result['item_id']
vehicles = result.get('vehicles', [])[:200] # Limit to 200 vehicles per item
confidence = result.get('confidence', 0)
for v in vehicles:
mye_id = v.get('mye_id')
if mye_id:
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, make, model, year, created_at)
VALUES (%s, %s, 'qwen_ai', %s, %s, %s, %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (item_id, mye_id, confidence, v.get('make'), v.get('model'), v.get('year')))
else:
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, make, model, year, engine, engine_code, created_at)
VALUES (%s, NULL, 'qwen_ai', %s, %s, %s, %s, %s, %s, NOW())
""", (item_id, confidence, v.get('make'), v.get('model'), v.get('year'), v.get('engine'), v.get('engine_code')))
saved += 1
conn.commit()
cur.close()
conn.close()
return saved
def main():
parser = argparse.ArgumentParser(description='Batch QWEN vehicle compatibility processor')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--workers', type=int, default=10, help='Number of parallel workers')
parser.add_argument('--limit', type=int, default=None, help='Max items to process (None = all)')
parser.add_argument('--checkpoint-file', default=DEFAULT_CHECKPOINT_FILE, help='Progress checkpoint file')
args = parser.parse_args()
checkpoint_file = args.checkpoint_file
print(f"=== QWEN Batch Compatibility Processor ===", flush=True)
print(f"Tenant: {args.tenant}", flush=True)
print(f"Workers: {args.workers}", flush=True)
print(f"Checkpoint: {args.checkpoint_file}", flush=True)
print(flush=True)
# Load checkpoint
processed_ids = load_checkpoint(checkpoint_file)
print(f"Previously processed: {len(processed_ids):,} items", flush=True)
# Get pending items
pending = get_pending_items(args.tenant, processed_ids, args.limit)
print(f"Pending items: {len(pending):,}", flush=True)
if not pending:
print("Nothing to process!", flush=True)
return
# Process with thread pool
total = len(pending)
completed = 0
success_count = 0
fail_count = 0
total_vehicles = 0
start_time = time.time()
with ThreadPoolExecutor(max_workers=args.workers) as executor:
future_to_item = {
executor.submit(process_single_item, item[0], item[1], item[2], item[3]): item
for item in pending
}
batch_results = []
batch_size = 50 # Save to DB every N items
for future in as_completed(future_to_item):
item = future_to_item[future]
try:
result = future.result(timeout=180)
batch_results.append(result)
if result['success']:
success_count += 1
total_vehicles += len(result.get('vehicles', []))
else:
fail_count += 1
processed_ids.add(result['item_id'])
except Exception as exc:
fail_count += 1
processed_ids.add(item[0])
print(f"Worker exception for {item[1]}: {exc}")
completed += 1
# Save checkpoint and DB batch periodically
if len(batch_results) >= batch_size:
save_checkpoint(checkpoint_file, list(processed_ids))
saved = save_results(args.tenant, batch_results)
batch_results = []
elapsed = time.time() - start_time
rate = completed / elapsed if elapsed > 0 else 0
eta = (total - completed) / rate if rate > 0 else 0
print(f" Progress: {completed:,}/{total:,} ({100*completed/total:.1f}%) | "
f"Success: {success_count} | Fail: {fail_count} | "
f"Vehicles: {total_vehicles} | "
f"Rate: {rate:.1f} items/min | ETA: {eta/60:.0f} min", flush=True)
# Final save
if batch_results:
save_results(args.tenant, batch_results)
save_checkpoint(checkpoint_file, list(processed_ids))
elapsed = time.time() - start_time
print(f"\n=== Complete ===", flush=True)
print(f"Processed: {completed:,}", flush=True)
print(f"Success: {success_count}", flush=True)
print(f"Failed: {fail_count}", flush=True)
print(f"Total vehicles found: {total_vehicles}", flush=True)
print(f"Elapsed: {elapsed/3600:.1f} hours", flush=True)
print(f"Avg rate: {completed/(elapsed/60):.1f} items/min", flush=True)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Nexus Autoparts — Sync ALL Strada inventory to marketplace (backfill 133k items).
Extends warehouse_inventory with seller listings for parts that don't match
the OEM catalog. Items that DO match keep their part_id; unmatched items
get part_id=NULL with seller_part_number / seller_part_name populated.
Usage:
export MASTER_DB_URL="postgresql://user:pass@localhost/nexus_autoparts"
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@localhost/{db_name}"
python3 sync_estrada_marketplace_full.py --tenant=28 --bodega=7 --branch=1
Safe to re-run: uses UPSERT semantics.
"""
import argparse
import csv
import io
import os
import sys
import tempfile
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn
BATCH_SIZE = 5000
def get_master_conn():
from config import MASTER_DB_URL
return psycopg2.connect(MASTER_DB_URL)
def load_catalog_maps(master_conn):
"""Pre-load OEM part_number → part_id and cross-reference maps."""
cur = master_conn.cursor()
print("[1/5] Loading OEM catalog...")
cur.execute("SELECT id_part, oem_part_number FROM parts")
oem_map = {}
for row in cur:
pn = row[1].strip() if row[1] else ''
if pn:
oem_map[pn] = row[0]
print("[2/5] Loading cross-references...")
cur.execute("SELECT cross_reference_number, part_id FROM part_cross_references")
cross_map = {}
for row in cur:
pn = row[0].strip() if row[0] else ''
if pn:
cross_map[pn] = row[1]
cur.close()
print(f" OEM parts: {len(oem_map):,} | Cross-references: {len(cross_map):,}")
return oem_map, cross_map
def read_tenant_inventory(tenant_conn, branch_id):
"""Read all active inventory items from tenant DB."""
cur = tenant_conn.cursor()
print("[3/5] Reading tenant inventory...")
# Join with categories to get category name
cur.execute("""
SELECT
i.id,
i.part_number,
i.name,
i.price_1,
COALESCE(iss.stock, 0) AS stock,
c.name AS category_name
FROM inventory i
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
LEFT JOIN part_categories c ON c.id = i.category_id
WHERE i.is_active = true
AND (i.branch_id = %s OR %s IS NULL)
ORDER BY i.id
""", (branch_id, branch_id))
items = []
for row in cur:
items.append({
'id': row[0],
'part_number': (row[1] or '').strip(),
'name': (row[2] or '').strip(),
'price': float(row[3] or 0),
'stock': int(row[4] or 0),
'category': (row[5] or '').strip(),
})
cur.close()
print(f" Tenant items: {len(items):,}")
return items
def classify_items(items, oem_map, cross_map):
"""Split items into OEM-matched and seller listings."""
matched = []
seller = []
for it in items:
pn = it['part_number']
if not pn:
continue
# Try exact match on OEM or cross-reference
part_id = oem_map.get(pn)
if not part_id:
# Try without brand prefix (e.g. "4S-86050" → "86050")
raw = pn.split('-', 1)[1] if '-' in pn else pn
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
if part_id:
matched.append({**it, 'part_id': part_id})
else:
seller.append(it)
print(f" Matched (OEM): {len(matched):,} | Seller listings: {len(seller):,}")
return matched, seller
def sync_to_warehouse(master_conn, bodega_id, matched, seller):
"""Bulk upsert into warehouse_inventory using COPY."""
cur = master_conn.cursor()
# Create temp table matching warehouse_inventory structure
cur.execute("""
CREATE TEMP TABLE tmp_wi (
user_id INT,
part_id INT,
seller_part_number VARCHAR(100),
seller_part_name VARCHAR(300),
seller_category VARCHAR(100),
tenant_inventory_id INT,
price NUMERIC(12,2),
stock_quantity INT,
min_order_quantity INT DEFAULT 1,
warehouse_location VARCHAR(100) DEFAULT 'Principal',
bodega_id INT,
currency VARCHAR(3) DEFAULT 'MXN'
) ON COMMIT DROP
""")
print("[4/5] Preparing batches...")
buffer = io.StringIO()
writer = csv.writer(buffer, lineterminator='\n',
quoting=csv.QUOTE_MINIMAL)
total = 0
for it in matched + seller:
part_id = it.get('part_id')
seller_pn = None if part_id else it['part_number']
seller_name = None if part_id else (it['name'] or it['part_number'])
seller_cat = None if part_id else it['category']
writer.writerow([
1, # user_id (legacy FK, must match existing rows for bodega 7)
part_id, # part_id (NULL for seller listings)
seller_pn, # seller_part_number
seller_name, # seller_part_name
seller_cat, # seller_category
it['id'], # tenant_inventory_id
it['price'], # price
max(0, it['stock']), # stock_quantity
1, # min_order_quantity
'Principal', # warehouse_location
bodega_id, # bodega_id
'MXN', # currency
])
total += 1
if total % BATCH_SIZE == 0:
buffer.seek(0)
cur.copy_expert("""
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV, NULL '')
""", buffer)
buffer = io.StringIO()
writer = csv.writer(buffer, lineterminator='\n',
quoting=csv.QUOTE_MINIMAL)
print(f" Buffered {total:,} rows...")
# Final batch
if buffer.tell() > 0:
buffer.seek(0)
cur.copy_expert("""
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV, NULL '')
""", buffer)
print(f"[5/5] Upserting {total:,} rows into warehouse_inventory...")
# --- Update existing OEM-matched rows ---
cur.execute("""
UPDATE warehouse_inventory wi
SET
price = tmp.price,
stock_quantity = tmp.stock_quantity,
user_id = tmp.user_id,
currency = tmp.currency,
updated_at = NOW()
FROM tmp_wi tmp
WHERE wi.bodega_id = tmp.bodega_id
AND wi.part_id = tmp.part_id
AND wi.warehouse_location = tmp.warehouse_location
AND tmp.part_id IS NOT NULL
""")
matched_updated = cur.rowcount
# --- Insert new OEM-matched rows ---
cur.execute("""
INSERT INTO warehouse_inventory (
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
)
SELECT
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
FROM tmp_wi tmp
WHERE part_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM warehouse_inventory wi
WHERE wi.bodega_id = tmp.bodega_id
AND wi.part_id = tmp.part_id
AND wi.warehouse_location = tmp.warehouse_location
)
""")
matched_inserted = cur.rowcount
# --- Update existing seller listings ---
cur.execute("""
UPDATE warehouse_inventory wi
SET
price = tmp.price,
stock_quantity = tmp.stock_quantity,
seller_part_name = tmp.seller_part_name,
seller_category = tmp.seller_category,
user_id = tmp.user_id,
currency = tmp.currency,
updated_at = NOW()
FROM tmp_wi tmp
WHERE wi.bodega_id = tmp.bodega_id
AND wi.seller_part_number = tmp.seller_part_number
AND wi.warehouse_location = tmp.warehouse_location
AND tmp.part_id IS NULL
""")
seller_updated = cur.rowcount
# --- Insert new seller listings ---
cur.execute("""
INSERT INTO warehouse_inventory (
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
)
SELECT
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
FROM tmp_wi tmp
WHERE part_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM warehouse_inventory wi
WHERE wi.bodega_id = tmp.bodega_id
AND wi.seller_part_number = tmp.seller_part_number
AND wi.warehouse_location = tmp.warehouse_location
)
""")
seller_inserted = cur.rowcount
matched_upserted = matched_updated + matched_inserted
seller_upserted = seller_updated + seller_inserted
master_conn.commit()
cur.close()
print(f"\n✓ Done!")
print(f" OEM matched upserted: {matched_upserted:,}")
print(f" Seller listings upserted: {seller_upserted:,}")
print(f" Total: {matched_upserted + seller_upserted:,}")
def main():
parser = argparse.ArgumentParser(description='Sync tenant inventory to marketplace')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--bodega', type=int, required=True, help='Bodega ID in master DB')
parser.add_argument('--branch', type=int, default=1, help='Branch ID filter (default all)')
args = parser.parse_args()
start = time.time()
print(f"=== Sync tenant {args.tenant} → bodega {args.bodega} ===\n")
master_conn = get_master_conn()
tenant_conn = get_tenant_conn(args.tenant)
try:
oem_map, cross_map = load_catalog_maps(master_conn)
items = read_tenant_inventory(tenant_conn, args.branch)
matched, seller = classify_items(items, oem_map, cross_map)
sync_to_warehouse(master_conn, args.bodega, matched, seller)
finally:
tenant_conn.close()
master_conn.close()
elapsed = time.time() - start
print(f"\nElapsed: {elapsed:.1f}s")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Sync Strada inventory to marketplace warehouse_inventory.
Matches by OEM part_number and cross-references.
"""
import os, sys, io
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_tenant_conn, get_master_conn
BODEGA_ID = 7
USER_ID = 1 # admin user in master DB
CURRENCY = 'MXN'
def main():
tenant_conn = get_tenant_conn(28)
tenant_cur = tenant_conn.cursor()
# Get active inventory with stock
tenant_cur.execute("""
SELECT i.id, i.part_number, i.price_1, i.cost, COALESCE(iss.stock, 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
WHERE i.is_active = true
""")
items = {r[0]: {'pn': r[1], 'price': r[2], 'cost': r[3], 'stock': r[4]} for r in tenant_cur.fetchall()}
print(f"Active inventory: {len(items)}")
tenant_cur.close()
tenant_conn.close()
# Get catalog mappings from master
master = get_master_conn()
master_cur = master.cursor()
master_cur.execute("SELECT id_part, oem_part_number FROM parts WHERE oem_part_number IS NOT NULL")
oem_map = {r[1]: r[0] for r in master_cur.fetchall()}
master_cur.execute("SELECT part_id, cross_reference_number FROM part_cross_references")
cross_map = {r[1]: r[0] for r in master_cur.fetchall()}
print(f"OEM parts: {len(oem_map)}, Cross-references: {len(cross_map)}")
# Build match list
matched = []
seen_parts = set()
for item_id, data in items.items():
pn = data['pn']
raw = pn.split('-', 1)[1] if '-' in pn else pn
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
if part_id and part_id not in seen_parts:
seen_parts.add(part_id)
price = data['price'] or data['cost'] or 0
stock = data['stock'] or 0
matched.append((USER_ID, part_id, price, stock, 1, 'Principal', BODEGA_ID, CURRENCY))
print(f"Matched items: {len(matched)}")
if not matched:
print("Nothing to sync")
return
# Bulk insert via COPY
csv_buffer = io.StringIO()
for row in matched:
csv_buffer.write(','.join(str(c) for c in row) + '\n')
csv_buffer.seek(0)
master_cur.copy_expert(
"""COPY warehouse_inventory (user_id, part_id, price, stock_quantity, min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV)""",
csv_buffer
)
master.commit()
master_cur.close()
master.close()
print(f"Synced {len(matched)} items to warehouse_inventory")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,262 @@
# Guion de Video — Introducción a Nexus POS
## Módulos: Inventario + Punto de Venta
**Duración estimada:** 45 minutos
**Formato:** Screen recording con voz en off
**Resolución:** 1920×1080 (16:9)
**Público:** Dueños de refaccionarias, mostradores, administradores
---
## ESCENA 0 — INTRO / HOOK (0:000:25)
**Visual:**
- Fade in desde negro.
- Logo de Nexus Autoparts centrado.
- Transición rápida: montaje de 34 tomas del sistema en acción (búsqueda de pieza, ticket imprimiéndose, gráfica de ventas).
**Narración (voz en off, tono energético):**
> "¿Todavía controlas tu inventario con hojas de Excel o cuadernos?
> Pierdes piezas, no sabes qué te queda en bodega y en la caja nunca cuadra.
> Te presento Nexus POS: el sistema diseñado específicamente para refaccionarias.
> En los próximos minutos vas a ver cómo administrar tu inventario y vender desde cualquier dispositivo."
**Nota técnica:**
- Música de fondo: upbeat corporativo, volumen bajo durante la narración.
- Lower third: "Nexus POS — Inventario & Punto de Venta".
---
## ESCENA 1 — INVENTARIO: PANEL GENERAL (0:250:55)
**Visual:**
- Login rápido (no mostrar credenciales reales, usar cuenta demo).
- Entrar al módulo **Inventario**.
- Mostrar el dashboard de inventario: badges superiores (Total de piezas, Stock bajo, Sin stock, Valor total).
**Narración:**
> "Empecemos con el corazón de tu negocio: el inventario.
> Desde el panel principal tienes el pulso completo de tu refaccionaria.
> Cuántas piezas tienes, cuáles están por acabarse y el valor total de tu stock… todo al instante."
**Nota técnica:**
- Usar transición suave (slide lateral) al entrar al módulo.
- Zoom sutil sobre los badges superiores para enfatizar los números.
---
## ESCENA 2 — INVENTARIO: ALTA RÁPIDA DE PRODUCTO (0:551:35)
**Visual:**
- Clic en "Nuevo Artículo".
- Llenar formulario: Número de parte, nombre, marca, stock inicial, precios.
- Destacar el campo **Código de barras** (se genera automáticamente).
- Guardar. Aparece toast de éxito: "Artículo creado".
**Narración:**
> "Dar de alta una pieza es cosa de segundos.
> Capturas el número de parte, la descripción, la marca… y el sistema genera automáticamente un código de barras.
> Tú decides el stock inicial y hasta tres niveles de precio.
> Guardas… y listo, la pieza ya está en el sistema."
**Nota técnica:**
- Escribir rápido pero legible (o usar autofill demo).
- Resaltar con cursor el código de barras generado.
- Toast de éxito debe ser visible al menos 1.5 segundos.
---
## ESCENA 3 — INVENTARIO: COMPATIBILIDAD CON VEHÍCULOS (1:352:05)
**Visual:**
- Abrir la ficha del artículo recién creado.
- Clic en pestaña **"Compatibilidad"**.
- Clic en "Auto-Match con IA (QWEN)".
- Mostrar spinner de carga breve (34 segundos), luego la lista de vehículos aparece (marca, modelo, año, motor).
- Señalar la etiqueta "IA" en color naranja/azul.
**Narración:**
> "Aquí viene lo potente.
> No sabes exactamente a qué carros le queda esta pieza… pero la inteligencia artificial sí.
> Con un solo clic, el sistema busca en segundos todos los vehículos compatibles: marca, modelo, año y motor.
> Todo se guarda automáticamente para que tu mostrador lo consulte al instante."
**Nota técnica:**
- Usar una pieza con resultados claros (ejemplo: filtro de aceite PH8A o bujía BKR5EYA).
- Si el auto-match tarda mucho en producción, usar un clip grabado previamente o acelerar 2×.
- Enfatizar la etiqueta "IA" con un círculo o flecha.
---
## ESCENA 4 — INVENTARIO: ENTRADAS, TRASPASOS Y AJUSTES (2:052:40)
**Visual:**
- Volver al listado de inventario.
- Clic en "Entrada" sobre una fila de stock (simular compra a proveedor: cantidad 10, costo $150).
- Cambiar a pestaña **"Traspasos"**, mostrar lista de movimientos entre sucursales.
- Cambiar a pestaña **"Ajustes"**, mostrar ajuste de inventario.
**Narración:**
> "El inventario se mueve constantemente.
> Recibiste mercancía del proveedor — registras la entrada en un clic.
> Necesitas enviar 5 piezas a la otra sucursal — traspaso directo.
> Y si encuentras una diferencia física contra el sistema, haces un ajuste con comentario.
> Todo queda auditado: quién, cuándo y por qué."
**Nota técnica:**
- Usar transición rápida entre pestañas (corte duro o fade).
- Mostrar al menos 23 filas en cada tabla para que se vea real.
---
## ESCENA 5 — POS: APERTURA DE CAJA (2:403:05)
**Visual:**
- Transición al módulo **Punto de Venta**.
- Mostrar barra superior: "Sin caja abierta".
- Clic en "Abrir Caja", llenar número de caja y monto inicial ($500).
- Guardar. La barra cambia a "Caja #1 abierta".
**Narración:**
> "Pasemos a la venta.
> Antes de vender, abres tu caja: le pones número y efectivo inicial.
> El sistema no te deja facturar sin caja abierta, así que siempre hay control.
> Una vez abierta, la barra te recuerda en todo momento en qué caja estás."
**Nota técnica:**
- Transición limpia: slide desde abajo o fade.
- Close-up en la barra de estado antes y después de abrir la caja.
---
## ESCENA 6 — POS: VENTA RÁPIDA (3:053:45)
**Visual:**
- Buscar producto por número de parte (escanear o teclear).
- El producto aparece en el carrito con imagen, descripción y precio.
- Agregar una segunda pieza al carrito.
- Clic en **"Cobrar"**.
- Modal de pago: mostrar total, cliente, descuento.
- Dividir pago: $200 efectivo, resto transferencia.
- Clic "Finalizar Venta". Ticket/recibo aparece en pantalla.
**Narración:**
> "Vender es tan simple como buscar la pieza… puede ser por número de parte o escaneando el código de barras.
> El carrito muestra todo claro: qué vendes, cuánto cuesta y cuánto llevas.
> Al cobrar, aceptas efectivo, transferencia, tarjeta… o una combinación de todo.
> Finalizas… y el ticket se genera al instante para el cliente."
**Nota técnica:**
- Usar búsqueda rápida (autocomplete visible).
- Animar el producto "volando" al carrito (si el sistema lo tiene) o simple highlight.
- Mostrar claramente el ticket final con logo y datos de la empresa.
---
## ESCENA 7 — POS: CORTE Z Y CIERRE DE CAJA (3:454:10)
**Visual:**
- Clic en "Corte Z" en la barra de estado.
- Modal aparece con resumen: ventas por método de pago, entradas, salidas, efectivo esperado.
- Ingresar efectivo real en caja ($1,247.50).
- El sistema calcula diferencia automáticamente.
- Clic "Cerrar Caja". Confirmación.
**Narración:**
> "Al final del turno, haces tu corte.
> El sistema te dice exactamente cuánto deberías tener en caja: ventas, entradas, salidas… todo desglosado.
> Tú cuentas el efectivo real, lo capturas y si hay diferencia, la ves inmediatamente.
> Cierras caja y listo: turno terminado sin sorpresas."
**Nota técnica:**
- Hacer scroll dentro del modal para mostrar todos los totales.
- Enfatizar el número de "diferencia" (positivo o negativo) con color.
---
## ESCENA 8 — CIERRE / CALL TO ACTION (4:104:45)
**Visual:**
- Volver al dashboard principal.
- Montaje rápido de pantallas vistas: inventario, auto-match, carrito, corte Z.
- Fade a pantalla final: datos de contacto / sitio web / QR.
**Narración (tono inspirador):**
> "Desde la primera pieza que entra hasta el último ticket del día, Nexus POS te acompaña.
- Inventario inteligente con IA, punto de venta rápido y cortes de caja que sí cuadran.
- Todo en un solo sistema, accesible desde tu computadora, tablet o celular.
- Agenda tu demo hoy y lleva tu refaccionaria al siguiente nivel."
**Texto en pantalla (overlay):**
- "¿Listo para modernizar tu refaccionaria?"
- Botón/CTA: "Agenda tu demo gratis"
- Web: nexusautoparts.com.mx
- WhatsApp: [número]
**Nota técnica:**
- Música sube de volumen en la pantalla final.
- Logo + CTA deben permanecer 57 segundos.
---
## CHECKLIST TÉCNICO PARA GRABACIÓN
| Ítem | Especificación |
|------|----------------|
| **Resolución** | 1920×1080 mínimo |
| **FPS** | 30 fps (60 fps opcional para animaciones suaves) |
| **Audio** | Voz en off clara, sin eco. Música royalty-free bajo |
| **Cursor** | Usar cursor grande y resaltado (Halo o similar) |
| **Transiciones** | Slide lateral 0.3s o fade 0.2s. Nada exagerado |
| **Datos demo** | Usar piezas reales: BKR5EYA, PH8A, BP6ES. Cliente: "Juan Pérez" |
| **Cuenta** | Usar usuario demo; NUNCA mostrar contraseñas reales |
| **Limpieza** | Cerrar notificaciones del OS antes de grabar |
---
## DATOS DE PRUEBA RECOMENDADOS PARA LA GRABACIÓN
### Producto 1 (alta en vivo)
- Número de parte: `PH8A`
- Nombre: `Filtro de aceite Motorcraft`
- Marca: `Motorcraft`
- Stock inicial: `12`
- Precio 1: `$185.00`
### Producto 2 (venta rápida)
- Número de parte: `BKR5EYA`
- Nombre: `Bujía NGK`
- Precio: `$145.00`
### Cliente demo
- Nombre: `Juan Pérez`
- Teléfono: `555-123-4567`
### Caja
- Caja #1
- Apertura: `$500.00`
- Cierre: `$1,247.50`
---
## NOTAS POST-PRODUCCIÓN
1. **Subtítulos:** Agregar subtítulos en español (accesibilidad + redes sociales sin audio).
2. **Capítulos:** YouTube chapters recomendados:
- 0:00 Intro
- 0:25 Inventario
- 0:55 Alta de producto
- 1:35 Compatibilidad con IA
- 2:05 Movimientos de inventario
- 2:40 Punto de Venta
- 3:05 Venta rápida
- 3:45 Corte de caja
- 4:10 Demo y contacto
3. **Thumbnail:** Split screen — mitad inventario, mitad POS. Texto: "Control total de tu refaccionaria".
4. **Formatos de exportación:**
- MP4 H.264 (YouTube / web)
- Vertical 9:16 recorte para Reels/TikTok (60 seg version)
---
*Guion generado para Nexus Autoparts — Módulos Inventario & POS*
*Versión 1.0 — 2026-05-18*