#!/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()