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:
138
scripts/assign_categories_estrada.py
Normal file
138
scripts/assign_categories_estrada.py
Normal 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)
|
||||
310
scripts/import_estrada_st01.py
Normal file
310
scripts/import_estrada_st01.py
Normal 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
439
scripts/import_pdf_catalog.py
Executable 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()
|
||||
231
scripts/qwen_batch_compat.py
Normal file
231
scripts/qwen_batch_compat.py
Normal 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()
|
||||
321
scripts/sync_estrada_marketplace_full.py
Normal file
321
scripts/sync_estrada_marketplace_full.py
Normal 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()
|
||||
82
scripts/sync_estrada_to_marketplace.py
Normal file
82
scripts/sync_estrada_to_marketplace.py
Normal 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()
|
||||
262
scripts/video_intro_inventario_pos.md
Normal file
262
scripts/video_intro_inventario_pos.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Guion de Video — Introducción a Nexus POS
|
||||
## Módulos: Inventario + Punto de Venta
|
||||
**Duración estimada:** 4–5 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:00–0:25)
|
||||
|
||||
**Visual:**
|
||||
- Fade in desde negro.
|
||||
- Logo de Nexus Autoparts centrado.
|
||||
- Transición rápida: montaje de 3–4 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:25–0: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:55–1: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:35–2: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 (3–4 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:05–2: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 2–3 filas en cada tabla para que se vea real.
|
||||
|
||||
---
|
||||
|
||||
## ESCENA 5 — POS: APERTURA DE CAJA (2:40–3: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:05–3: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:45–4: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:10–4: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 5–7 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*
|
||||
Reference in New Issue
Block a user