feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- 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).
This commit is contained in:
442
scripts/import_rached_workshop.py
Normal file
442
scripts/import_rached_workshop.py
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user