- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
443 lines
14 KiB
Python
443 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import psycopg2
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
|
|
TENANT_DB = 'tenant_refaccionaria_rached'
|
|
|
|
# Load DB URL from environment or use localhost defaults
|
|
DB_URL = os.environ.get(
|
|
'TENANT_DB_URL',
|
|
f'postgresql://postgres@localhost/{TENANT_DB}'
|
|
)
|
|
|
|
|
|
def normalize_name(s):
|
|
if not s:
|
|
return ''
|
|
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
|
|
|
|
|
|
def load_json(name):
|
|
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def load_detail_json(cid):
|
|
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
|
|
if path.exists():
|
|
with open(path, encoding='utf-8') as f:
|
|
return json.load(f)
|
|
return None
|
|
|
|
|
|
def connect():
|
|
return psycopg2.connect(DB_URL)
|
|
|
|
|
|
ORDER_STATUS_MAP = {
|
|
1: 'por_recolectar',
|
|
2: 'por_revisar',
|
|
3: 'en_revision',
|
|
4: 'revisada',
|
|
5: 'cotizada',
|
|
6: 'por_autorizar',
|
|
7: 'autorizada',
|
|
8: 'autorizacion_parcial',
|
|
9: 'cancelada',
|
|
10: 'en_reparacion',
|
|
11: 'reparada',
|
|
12: 'por_entregar',
|
|
13: 'enviado',
|
|
14: 'entregado',
|
|
15: 'por_facturar',
|
|
16: 'facturada',
|
|
17: 'por_enviar',
|
|
}
|
|
|
|
ITEM_STATUS_MAP = {
|
|
1: 'por_revisar',
|
|
2: 'revisando',
|
|
3: 'revisado',
|
|
4: 'cotizado',
|
|
5: 'por_autorizar',
|
|
6: 'autorizado',
|
|
7: 'cancelado',
|
|
8: 'en_reparacion',
|
|
9: 'reparado',
|
|
10: 'por_entregar',
|
|
11: 'enviado',
|
|
12: 'entregado',
|
|
13: 'por_enviar',
|
|
}
|
|
|
|
|
|
def get_default_branch(cur):
|
|
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
|
|
row = cur.fetchone()
|
|
if row:
|
|
return row[0]
|
|
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def ensure_branches(cur):
|
|
cur.execute("SELECT id, name FROM branches")
|
|
existing = {normalize_name(name): id for id, name in cur.fetchall()}
|
|
catalog = load_json('catalog_sucursales')['datos']
|
|
mapping = {}
|
|
for s in catalog:
|
|
name = (s.get('nombre') or '').strip()
|
|
# Skip the erroneous Rached branch
|
|
if name.upper() == 'CREMALLERAS':
|
|
continue
|
|
key = normalize_name(name)
|
|
if key in existing:
|
|
mapping[s['id']] = existing[key]
|
|
else:
|
|
cur.execute(
|
|
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
|
|
(s['nombre'],)
|
|
)
|
|
new_id = cur.fetchone()[0]
|
|
existing[key] = new_id
|
|
mapping[s['id']] = new_id
|
|
print(f"Created branch {s['nombre']} -> {new_id}")
|
|
return mapping
|
|
|
|
|
|
def ensure_customers(cur, branch_map):
|
|
# collect unique client ids referenced in orders
|
|
client_ids = set()
|
|
for f in DATA_DIR.glob('details/order_*.json'):
|
|
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
|
cid = d.get('idCliente')
|
|
if cid:
|
|
client_ids.add(cid)
|
|
|
|
# load existing customers by name
|
|
cur.execute("SELECT id, name FROM customers")
|
|
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
|
|
client_map = {}
|
|
default_branch = get_default_branch(cur)
|
|
for cid in sorted(client_ids):
|
|
detail = load_detail_json(cid)
|
|
datos = detail.get('datos', {}) if detail else {}
|
|
# Name: prefer 'taller' if meaningful, else full name
|
|
taller = (datos.get('taller') or '').strip()
|
|
nombre = (datos.get('nombre') or '').strip()
|
|
ap1 = (datos.get('primerApellido') or '').strip()
|
|
ap2 = (datos.get('segundoApellido') or '').strip()
|
|
if taller:
|
|
name = taller
|
|
else:
|
|
name = ' '.join([nombre, ap1, ap2]).strip()
|
|
if not name:
|
|
name = f"Cliente Rached {cid}"
|
|
|
|
if name.lower() in existing_by_name:
|
|
client_map[cid] = existing_by_name[name.lower()]
|
|
continue
|
|
|
|
# phone / address
|
|
phone = None
|
|
for t in datos.get('telefonos', []):
|
|
num = t.get('numero')
|
|
if num:
|
|
phone = str(num)
|
|
break
|
|
address = None
|
|
for drec in datos.get('direcciones', []):
|
|
address = drec.get('completa')
|
|
if address:
|
|
break
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
|
|
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
|
|
""",
|
|
(default_branch, name, phone, address)
|
|
)
|
|
new_id = cur.fetchone()[0]
|
|
existing_by_name[name.lower()] = new_id
|
|
client_map[cid] = new_id
|
|
print(f"Created customer {cid}: {name}")
|
|
return client_map
|
|
|
|
|
|
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
|
|
default_branch = get_default_branch(cur)
|
|
emp_map = {}
|
|
if existing_names is None:
|
|
cur.execute("SELECT id, name FROM employees")
|
|
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
for e in catalog:
|
|
name = (e.get('nombre') or '').strip()
|
|
if not name:
|
|
continue
|
|
key = name.lower()
|
|
if key in existing_names:
|
|
emp_map[e['id']] = existing_names[key]
|
|
continue
|
|
is_active = bool(e.get('activo', True))
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO employees (name, role, branch_id, is_active)
|
|
VALUES (%s, %s, %s, %s) RETURNING id
|
|
""",
|
|
(name, role, default_branch, is_active)
|
|
)
|
|
new_id = cur.fetchone()[0]
|
|
existing_names[key] = new_id
|
|
emp_map[e['id']] = new_id
|
|
print(f"Created employee {role} {e['id']}: {name}")
|
|
return emp_map
|
|
|
|
|
|
def ensure_couriers(cur, catalog, tenant_id=31):
|
|
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
|
|
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
courier_map = {}
|
|
for c in catalog:
|
|
name = (c.get('nombre') or '').strip()
|
|
if not name:
|
|
continue
|
|
key = name.lower()
|
|
if key in existing:
|
|
courier_map[c['id']] = existing[key]
|
|
continue
|
|
cur.execute(
|
|
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
|
(tenant_id, name, f"MOT-{c['id']}")
|
|
)
|
|
new_id = cur.fetchone()[0]
|
|
existing[key] = new_id
|
|
courier_map[c['id']] = new_id
|
|
print(f"Created courier {c['id']}: {name}")
|
|
return courier_map
|
|
|
|
|
|
def ensure_articles(cur, branch_map):
|
|
"""Rached articles are kept as free-text lines, not inventory products."""
|
|
catalog = load_json('catalog_articulos')
|
|
article_map = {}
|
|
for a in catalog:
|
|
name = (a.get('nombre') or '').strip()
|
|
if name:
|
|
article_map[a['id']] = name
|
|
return article_map
|
|
|
|
|
|
def ensure_users(cur, branch_map, order_details):
|
|
# Map Rached user ids to employees
|
|
default_branch = get_default_branch(cur)
|
|
cur.execute("SELECT id, name FROM employees")
|
|
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
user_map = {}
|
|
for d in order_details:
|
|
datos = d.get('datos', {})
|
|
uid = datos.get('idUsuario')
|
|
name = (datos.get('usuarioNombre') or '').strip()
|
|
if not uid or not name:
|
|
continue
|
|
key = name.lower()
|
|
if key in existing:
|
|
user_map[uid] = existing[key]
|
|
continue
|
|
cur.execute(
|
|
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
|
(name, 'workshop', default_branch)
|
|
)
|
|
new_id = cur.fetchone()[0]
|
|
existing[key] = new_id
|
|
user_map[uid] = new_id
|
|
print(f"Created user {uid}: {name}")
|
|
return user_map
|
|
|
|
|
|
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
|
|
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
|
|
imported = 0
|
|
skipped = 0
|
|
for f in order_files:
|
|
d = json.loads(f.read_text(encoding='utf-8'))
|
|
datos = d.get('datos', {})
|
|
order_number = datos.get('numero')
|
|
if not order_number:
|
|
continue
|
|
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
|
|
if cur.fetchone():
|
|
skipped += 1
|
|
continue
|
|
|
|
branch_id = branch_map.get(datos.get('idSucursal'))
|
|
customer_id = client_map.get(datos.get('idCliente'))
|
|
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
|
|
|
|
# delivery method
|
|
via = datos.get('idViaEntrega')
|
|
mot_rec = datos.get('idMotociclistaRecoleccion')
|
|
mot_ent = datos.get('idMotociclistaEntrega')
|
|
courier_id = None
|
|
if via == 1:
|
|
delivery_method = 'pickup'
|
|
elif via == 2:
|
|
if mot_ent:
|
|
delivery_method = 'courier'
|
|
courier_id = courier_map.get(mot_ent)
|
|
elif mot_rec:
|
|
delivery_method = 'courier'
|
|
courier_id = courier_map.get(mot_rec)
|
|
else:
|
|
delivery_method = 'delivery'
|
|
else:
|
|
delivery_method = None
|
|
|
|
# customer address/phone from order overrides if present? order detail only has ids.
|
|
# Prefill from customer record (already stored in customers)
|
|
workshop_name = None
|
|
customer_phone = None
|
|
customer_address = None
|
|
if customer_id:
|
|
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
workshop_name, customer_phone, customer_address = row
|
|
|
|
created_by = user_map.get(datos.get('idUsuario'))
|
|
fecha = datos.get('fecha')
|
|
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO service_orders
|
|
(tenant_id, branch_id, customer_id, order_number, status,
|
|
workshop_name, customer_address, customer_phone, vehicle_description,
|
|
reception_notes, estimated_cost, final_cost,
|
|
delivery_method, courier_id, is_direct, requires_invoice,
|
|
created_by, created_at, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
31, # tenant_id for Rached
|
|
branch_id,
|
|
customer_id,
|
|
order_number,
|
|
status,
|
|
workshop_name,
|
|
customer_address,
|
|
customer_phone,
|
|
datos.get('vehiculo'),
|
|
datos.get('observaciones'),
|
|
datos.get('presupuesto') or 0,
|
|
datos.get('total') or 0,
|
|
delivery_method,
|
|
courier_id,
|
|
bool(datos.get('ordenDirecta')),
|
|
bool(datos.get('requiereFactura')),
|
|
created_by,
|
|
created_at,
|
|
created_at,
|
|
)
|
|
)
|
|
so_id = cur.fetchone()[0]
|
|
|
|
# status history
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""",
|
|
(so_id, status, created_by, 'Importado desde app Rached', created_at)
|
|
)
|
|
|
|
# items (free-text lines; not linked to inventory)
|
|
for it in datos.get('detalles', []):
|
|
articulo = it.get('articulo', {})
|
|
art_id = articulo.get('id')
|
|
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
|
|
mech_id = mech_map.get(it.get('idMecanico'))
|
|
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
|
|
qty = it.get('cantidad', 1)
|
|
price = it.get('precio') or 0
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO service_order_items
|
|
(service_order_id, inventory_id, part_number, name, quantity,
|
|
unit_cost, unit_price, status, mechanic_id, observations)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
so_id,
|
|
None,
|
|
None,
|
|
art_name,
|
|
qty,
|
|
price,
|
|
price,
|
|
item_status,
|
|
mech_id,
|
|
it.get('observaciones'),
|
|
)
|
|
)
|
|
|
|
imported += 1
|
|
if imported % 100 == 0:
|
|
print(f"Imported {imported} orders...")
|
|
|
|
return imported, skipped
|
|
|
|
|
|
def main():
|
|
print(f"Importing Rached workshop data into {TENANT_DB}")
|
|
conn = connect()
|
|
cur = conn.cursor()
|
|
try:
|
|
branch_map = ensure_branches(cur)
|
|
print(f"Branch map: {branch_map}")
|
|
|
|
client_map = ensure_customers(cur, branch_map)
|
|
print(f"Customers to import: {len(client_map)}")
|
|
|
|
mech_catalog = load_json('catalog_mecanicos')
|
|
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
|
|
print(f"Mechanics map: {len(mech_map)}")
|
|
|
|
courier_catalog = load_json('catalog_motociclistas')
|
|
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
|
|
print(f"Couriers map: {len(courier_map)}")
|
|
|
|
article_map = ensure_articles(cur, branch_map)
|
|
print(f"Articles map: {len(article_map)}")
|
|
|
|
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
|
|
user_map = ensure_users(cur, branch_map, order_details)
|
|
print(f"Users map: {len(user_map)}")
|
|
|
|
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
|
|
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
|
|
|
|
conn.commit()
|
|
except Exception as e:
|
|
conn.rollback()
|
|
print(f"ERROR: {e}")
|
|
raise
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|