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:
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()
|
||||
Reference in New Issue
Block a user