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