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:
79
scripts/capture_rached_network.js
Normal file
79
scripts/capture_rached_network.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
function apiLogin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify({
|
||||
grant_type: 'password',
|
||||
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
client_id: 2,
|
||||
username: 'IVAN@flechasyventiladores-rached.com',
|
||||
password: 'Nexus01'
|
||||
});
|
||||
const req = http.request({
|
||||
hostname: 'appapi.flechasyventiladores-rached.com',
|
||||
path: '/api/seguridad/login',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const outDir = path.resolve(__dirname, '../data/rached_import/har');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const loginResp = await apiLogin();
|
||||
const sesion = JSON.stringify(loginResp.datos);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const allRequests = [];
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request();
|
||||
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
|
||||
|
||||
const routes = [
|
||||
'/administracion/servicios/ordenesservicio',
|
||||
'/administracion/catalogos/clientes',
|
||||
'/administracion/catalogos/articulos',
|
||||
'/administracion/catalogos/mecanicos',
|
||||
'/administracion/catalogos/sucursales',
|
||||
'/administracion/catalogos/motociclistas',
|
||||
'/administracion/catalogos/viasentrega',
|
||||
'/administracion/catalogos/telefonostipos',
|
||||
'/administracion/catalogos/direccionestipos',
|
||||
];
|
||||
for (const r of routes) {
|
||||
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
|
||||
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
|
||||
await browser.close();
|
||||
console.log('Captured', allRequests.length, 'total requests');
|
||||
})();
|
||||
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()
|
||||
32
scripts/inspect_rached_after_login.js
Normal file
32
scripts/inspect_rached_after_login.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('correo').removeAttribute('pattern');
|
||||
document.getElementById('password').removeAttribute('pattern');
|
||||
});
|
||||
await page.fill('#correo', 'IVAN@flechasyventiladores-rached.com');
|
||||
await page.fill('#password', 'Nexus01');
|
||||
await page.evaluate(() => {
|
||||
['correo','password'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login_filled.png', fullPage: true });
|
||||
fs.writeFileSync('/home/Autopartes/data/rached_import/login_filled.html', await page.content());
|
||||
// click enabled submit button if any
|
||||
await page.click('button[color="primary"]');
|
||||
await page.waitForTimeout(8000);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/after_login.png', fullPage: true });
|
||||
fs.writeFileSync('/home/Autopartes/data/rached_import/after_login.html', await page.content());
|
||||
console.log('done');
|
||||
await browser.close();
|
||||
})();
|
||||
14
scripts/inspect_rached_login.js
Normal file
14
scripts/inspect_rached_login.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(5000);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login.png', fullPage: true });
|
||||
fs.writeFileSync('/home/Autopartes/data/rached_import/login.html', await page.content());
|
||||
console.log('done');
|
||||
await browser.close();
|
||||
})();
|
||||
175
scripts/scrape_rached_catalogs.py
Normal file
175
scripts/scrape_rached_catalogs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrape Rached legacy catalog endpoints discovered via UI."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||
USER = 'IVAN@flechasyventiladores-rached.com'
|
||||
PASS = 'Nexus01'
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
|
||||
def login():
|
||||
url = f'{BASE_API}/api/seguridad/login'
|
||||
payload = {
|
||||
'grant_type': 'password',
|
||||
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
'client_id': 2,
|
||||
'username': USER,
|
||||
'password': PASS,
|
||||
}
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get('mensaje'):
|
||||
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||
return data['datos']['token']
|
||||
|
||||
|
||||
def api_headers(token):
|
||||
return {**HEADERS, 'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def fetch_get(token, path, params=None):
|
||||
url = f'{BASE_API}{path}'
|
||||
resp = requests.get(url, headers=api_headers(token), params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_post(token, path, payload=None, params=None):
|
||||
url = f'{BASE_API}{path}'
|
||||
resp = requests.post(url, headers=api_headers(token), json=payload or {}, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def save(name, data):
|
||||
path = OUT_DIR / f'{name}.json'
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
return path
|
||||
|
||||
|
||||
def fetch_list_all(token, path, method='GET', payload=None):
|
||||
"""Fetch a list endpoint that may return either a list or Laravel pagination."""
|
||||
all_items = []
|
||||
page = 1
|
||||
while True:
|
||||
try:
|
||||
if method == 'GET':
|
||||
data = fetch_get(token, f'{path}?page={page}')
|
||||
else:
|
||||
data = fetch_post(token, f'{path}?page={page}', payload)
|
||||
except Exception as e:
|
||||
print(f' {path} page {page} error: {e}')
|
||||
break
|
||||
datos = data.get('datos', {})
|
||||
if isinstance(datos, list):
|
||||
# Non-paginated full list
|
||||
all_items.extend(datos)
|
||||
print(f' {path}: +{len(datos)} (total {len(all_items)})')
|
||||
break
|
||||
items = datos.get('data', [])
|
||||
all_items.extend(items)
|
||||
print(f' {path} page {page}: +{len(items)} (total {len(all_items)}/{datos.get("total", "?")})')
|
||||
if datos.get('current_page', 1) >= datos.get('last_page', 1):
|
||||
break
|
||||
page += 1
|
||||
time.sleep(0.6)
|
||||
return all_items
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = OUT_DIR / 'scrape_catalogs.log'
|
||||
|
||||
def log(msg):
|
||||
print(msg)
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(msg + '\n')
|
||||
|
||||
log('Logging in...')
|
||||
token = login()
|
||||
log('Logged in.')
|
||||
|
||||
# Simple catalog endpoints (GET, single page or paginated)
|
||||
simple_gets = {
|
||||
'catalog_sucursales': '/api/catalogos/sucursales',
|
||||
'catalog_viasentrega': '/api/catalogos/viasentrega/listado',
|
||||
'catalog_order_statuses': '/api/catalogos/ordenesservicioestatus/listado',
|
||||
'catalog_item_statuses': '/api/catalogos/ordenesserviciodetalleestatus/listado',
|
||||
'catalog_directas': '/api/catalogos/ordenesDirectas',
|
||||
'catalog_telefonostipos': '/api/catalogos/telefonostipos/listado',
|
||||
'catalog_direccionestipos': '/api/catalogos/direccionestipos/listado',
|
||||
}
|
||||
for name, path in simple_gets.items():
|
||||
try:
|
||||
data = fetch_get(token, path)
|
||||
save(name, data)
|
||||
count = len(data.get('datos', []) if isinstance(data.get('datos'), list) else [])
|
||||
log(f'{name}: {count} items')
|
||||
except Exception as e:
|
||||
log(f'{name} error: {e}')
|
||||
time.sleep(0.6)
|
||||
|
||||
# List endpoints (GET or POST)
|
||||
list_gets = {
|
||||
'catalog_articulos': '/api/catalogos/articulos/listado',
|
||||
'catalog_mecanicos': '/api/catalogos/mecanicos/listado',
|
||||
'catalog_motociclistas': '/api/catalogos/motociclistas/listado',
|
||||
}
|
||||
for name, path in list_gets.items():
|
||||
items = fetch_list_all(token, path, method='GET')
|
||||
save(name, items)
|
||||
log(f'{name}: {len(items)} items')
|
||||
|
||||
# Client list
|
||||
try:
|
||||
client_items = fetch_list_all(token, '/api/catalogos/clientes/listado', method='POST',
|
||||
payload={'taller': '', 'nombreCompleto': '', 'idClienteEstatus': '-1'})
|
||||
save('catalog_clientes', client_items)
|
||||
log(f'catalog_clientes: {len(client_items)} items')
|
||||
|
||||
# Fetch full client details for clients referenced in orders
|
||||
orders_path = OUT_DIR / 'orders_list.json'
|
||||
if orders_path.exists():
|
||||
order_ids = {o['idCliente'] for o in json.loads(orders_path.read_text(encoding='utf-8')) if o.get('idCliente')}
|
||||
log(f'Fetching details for {len(order_ids)} unique order clients...')
|
||||
client_details = {}
|
||||
for idx, cid in enumerate(sorted(order_ids), 1):
|
||||
detail_path = OUT_DIR / 'client_details' / f'client_{cid}.json'
|
||||
if detail_path.exists():
|
||||
client_details[cid] = json.loads(detail_path.read_text(encoding='utf-8'))
|
||||
continue
|
||||
try:
|
||||
data = fetch_get(token, f'/api/catalogos/clientes/{cid}')
|
||||
client_details[cid] = data.get('datos')
|
||||
detail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
except Exception as e:
|
||||
log(f' client {cid} detail error: {e}')
|
||||
if idx % 30 == 0:
|
||||
time.sleep(1)
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
save('catalog_client_details', client_details)
|
||||
log(f'catalog_client_details: {len(client_details)} items')
|
||||
except Exception as e:
|
||||
log(f'catalog_clientes error: {e}')
|
||||
|
||||
log('Done.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
85
scripts/scrape_rached_client_details.py
Normal file
85
scripts/scrape_rached_client_details.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch full client details for clients referenced in Rached service orders."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||
PASS = 'Nexus01'
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
}
|
||||
|
||||
|
||||
def login():
|
||||
url = f'{BASE_API}/api/seguridad/login'
|
||||
payload = {
|
||||
'grant_type': 'password',
|
||||
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
'client_id': 2,
|
||||
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||
'password': PASS,
|
||||
}
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get('mensaje'):
|
||||
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||
return data['datos']['token']
|
||||
|
||||
|
||||
def fetch_client(token, cid):
|
||||
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
|
||||
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
detail_dir = OUT_DIR / 'client_details'
|
||||
detail_dir.mkdir(exist_ok=True)
|
||||
|
||||
# collect unique client ids from order details
|
||||
client_ids = set()
|
||||
for f in OUT_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)
|
||||
|
||||
print(f'Found {len(client_ids)} unique clients from order details')
|
||||
token = login()
|
||||
print('Logged in')
|
||||
|
||||
results = {}
|
||||
for idx, cid in enumerate(sorted(client_ids), 1):
|
||||
out_path = detail_dir / f'client_{cid}.json'
|
||||
if out_path.exists():
|
||||
data = json.loads(out_path.read_text(encoding='utf-8'))
|
||||
else:
|
||||
try:
|
||||
data = fetch_client(token, cid)
|
||||
except Exception as e:
|
||||
print(f'[{idx}/{len(client_ids)}] client {cid} error: {e}')
|
||||
continue
|
||||
out_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
results[cid] = data.get('datos')
|
||||
print(f'[{idx}/{len(client_ids)}] client {cid} saved')
|
||||
time.sleep(0.4 if idx % 30 else 1.5)
|
||||
|
||||
summary_path = OUT_DIR / 'catalog_client_details.json'
|
||||
summary_path.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
print(f'Saved {len(results)} client details to {summary_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
91
scripts/scrape_rached_client_details_retry.py
Normal file
91
scripts/scrape_rached_client_details_retry.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Retry fetching missing Rached client details with slower rate."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||
PASS = 'Nexus01'
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
}
|
||||
|
||||
|
||||
def login():
|
||||
url = f'{BASE_API}/api/seguridad/login'
|
||||
payload = {
|
||||
'grant_type': 'password',
|
||||
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
'client_id': 2,
|
||||
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||
'password': PASS,
|
||||
}
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get('mensaje'):
|
||||
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||
return data['datos']['token']
|
||||
|
||||
|
||||
def fetch_client(token, cid, attempts=5):
|
||||
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
|
||||
for attempt in range(attempts):
|
||||
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
|
||||
if resp.status_code == 429:
|
||||
wait = 5 + attempt * 3
|
||||
print(f' client {cid} 429, waiting {wait}s')
|
||||
time.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
raise RuntimeError(f'client {cid} rate limited')
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
detail_dir = OUT_DIR / 'client_details'
|
||||
detail_dir.mkdir(exist_ok=True)
|
||||
|
||||
needed = set()
|
||||
for f in OUT_DIR.glob('details/order_*.json'):
|
||||
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
||||
cid = d.get('idCliente')
|
||||
if cid:
|
||||
needed.add(cid)
|
||||
|
||||
saved = set(int(p.name.split('_')[1].split('.')[0]) for p in detail_dir.glob('client_*.json'))
|
||||
missing = sorted(needed - saved)
|
||||
print(f'Need {len(needed)}, have {len(saved)}, missing {len(missing)}')
|
||||
if not missing:
|
||||
return
|
||||
|
||||
token = login()
|
||||
results = json.loads((OUT_DIR / 'catalog_client_details.json').read_text(encoding='utf-8')) if (OUT_DIR / 'catalog_client_details.json').exists() else {}
|
||||
|
||||
for idx, cid in enumerate(missing, 1):
|
||||
try:
|
||||
data = fetch_client(token, cid)
|
||||
except Exception as e:
|
||||
print(f'[{idx}/{len(missing)}] client {cid} final error: {e}')
|
||||
continue
|
||||
detail_path = detail_dir / f'client_{cid}.json'
|
||||
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
results[str(cid)] = data.get('datos')
|
||||
print(f'[{idx}/{len(missing)}] client {cid} saved')
|
||||
time.sleep(1.2)
|
||||
|
||||
(OUT_DIR / 'catalog_client_details.json').write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
print(f'Saved {len(results)} client details')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
162
scripts/scrape_rached_workshop.js
Normal file
162
scripts/scrape_rached_workshop.js
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scrape service orders from Rached's legacy web app.
|
||||
* Run with:
|
||||
* RACHED_USER='IVAN' RACHED_PASS='Nexus01' node scripts/scrape_rached_workshop.js
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT_DIR = process.env.OUT_DIR || path.join(__dirname, '..', 'data', 'rached_import');
|
||||
const USER = process.env.RACHED_USER || 'IVAN';
|
||||
const PASS = process.env.RACHED_PASS || 'Nexus01';
|
||||
const BASE_APP = 'http://app.flechasyventiladores-rached.com';
|
||||
const BASE_API = 'http://appapi.flechasyventiladores-rached.com';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1440, height: 900 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// ── LOGIN ──
|
||||
console.log('Logging in...');
|
||||
await page.goto(`${BASE_APP}/login`);
|
||||
await page.waitForSelector('#correo', { timeout: 10000 });
|
||||
await page.fill('#correo', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('button:has-text("Aceptar")');
|
||||
await page.waitForURL('**/administracion/**', { timeout: 20000 });
|
||||
console.log('Logged in.');
|
||||
|
||||
// ── ORDERS LIST ──
|
||||
console.log('Navigating to orders list...');
|
||||
const allOrders = [];
|
||||
let pageNum = 1;
|
||||
let respPromise = page.waitForResponse(
|
||||
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await page.goto(`${BASE_APP}/administracion/servicios/ordenesservicio`);
|
||||
while (true) {
|
||||
if (pageNum > 1) {
|
||||
const nextBtn = page.locator('button.mat-paginator-navigation-next');
|
||||
const isDisabled = await nextBtn.evaluate(el => el.disabled).catch(() => true);
|
||||
if (isDisabled) {
|
||||
console.log('No more pages.');
|
||||
break;
|
||||
}
|
||||
respPromise = page.waitForResponse(
|
||||
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await nextBtn.click();
|
||||
}
|
||||
const resp = await respPromise;
|
||||
const payload = await resp.json();
|
||||
const datos = payload.datos || {};
|
||||
const orders = datos.data || [];
|
||||
allOrders.push(...orders);
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, `orders_page_${pageNum}.json`),
|
||||
JSON.stringify(payload, null, 2)
|
||||
);
|
||||
console.log(`Page ${pageNum}: ${orders.length} orders (total ${allOrders.length}/${datos.total || '?'})`);
|
||||
if (datos.current_page >= datos.last_page) break;
|
||||
pageNum++;
|
||||
await sleep(800);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, 'orders_list.json'),
|
||||
JSON.stringify(allOrders, null, 2)
|
||||
);
|
||||
console.log(`Collected ${allOrders.length} orders summary.`);
|
||||
await page.waitForSelector('mat-row', { timeout: 10000 });
|
||||
await sleep(3000);
|
||||
|
||||
if (!allOrders.length) {
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── ORDER DETAILS ──
|
||||
const detailDir = path.join(OUT_DIR, 'details');
|
||||
fs.mkdirSync(detailDir, { recursive: true });
|
||||
|
||||
let targetId = null;
|
||||
await page.route('**/api/servicios/ordenes/*', async (route, request) => {
|
||||
if (targetId && request.url().match(/\/ordenes\/\d+$/)) {
|
||||
const url = new URL(request.url());
|
||||
const parts = url.pathname.split('/');
|
||||
parts[parts.length - 1] = String(targetId);
|
||||
url.pathname = parts.join('/');
|
||||
await route.continue({ url: url.toString() });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
async function clickFirstEditButton() {
|
||||
await page.evaluate(() => {
|
||||
const row = document.querySelector('mat-row');
|
||||
if (!row) return;
|
||||
const btn = row.querySelector('button.mat-icon-button i.fa-edit');
|
||||
if (btn) btn.closest('button').click();
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parseInt(process.env.LIMIT || '0', 10);
|
||||
const ordersToFetch = limit > 0 ? allOrders.slice(0, limit) : allOrders;
|
||||
for (let i = 0; i < ordersToFetch.length; i++) {
|
||||
const o = ordersToFetch[i];
|
||||
targetId = o.id;
|
||||
const detailPath = path.join(detailDir, `order_${targetId}.json`);
|
||||
if (fs.existsSync(detailPath)) {
|
||||
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} (already saved) `);
|
||||
continue;
|
||||
}
|
||||
let attempts = 0;
|
||||
while (attempts < 3) {
|
||||
attempts++;
|
||||
try {
|
||||
const respPromise = page.waitForResponse(
|
||||
r => r.url().includes(`/api/servicios/ordenes/${targetId}`) && r.status() === 200,
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await clickFirstEditButton();
|
||||
const resp = await respPromise;
|
||||
const detail = await resp.json();
|
||||
fs.writeFileSync(
|
||||
path.join(detailDir, `order_${targetId}.json`),
|
||||
JSON.stringify(detail, null, 2)
|
||||
);
|
||||
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} `);
|
||||
// close modal if opened
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await sleep(300);
|
||||
break;
|
||||
} catch (e) {
|
||||
console.error(`\nError fetching detail for ${targetId} (attempt ${attempts}):`, e.message);
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('\nDetail extraction complete.');
|
||||
|
||||
await browser.close();
|
||||
console.log(`Output saved to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
144
scripts/scrape_rached_workshop.py
Normal file
144
scripts/scrape_rached_workshop.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrape service orders from Rached legacy web app using their internal API."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
BASE_APP = 'http://app.flechasyventiladores-rached.com'
|
||||
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
|
||||
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
|
||||
USER = os.environ.get('RACHED_USER', 'IVAN')
|
||||
PASS = os.environ.get('RACHED_PASS', 'Nexus01')
|
||||
LIMIT = int(os.environ.get('LIMIT', '0'))
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': BASE_APP,
|
||||
'Referer': f'{BASE_APP}/',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
|
||||
def login():
|
||||
url = f'{BASE_API}/api/seguridad/login'
|
||||
payload = {
|
||||
'grant_type': 'password',
|
||||
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
'client_id': 2,
|
||||
'username': 'IVAN@flechasyventiladores-rached.com',
|
||||
'password': PASS,
|
||||
}
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get('mensaje'):
|
||||
raise RuntimeError(f'Login failed: {data["mensaje"]}')
|
||||
return data['datos']['token']
|
||||
|
||||
|
||||
def api_headers(token):
|
||||
return {**HEADERS, 'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def fetch_list(token, page):
|
||||
url = f'{BASE_API}/api/servicios/ordenes/listado?page={page}'
|
||||
resp = requests.post(url, headers=api_headers(token))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_detail(token, order_id):
|
||||
url = f'{BASE_API}/api/servicios/ordenes/{order_id}'
|
||||
resp = requests.get(url, headers=api_headers(token))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = OUT_DIR / 'scrape_py.log'
|
||||
|
||||
def log(msg):
|
||||
print(msg)
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(msg + '\n')
|
||||
|
||||
log('Logging in...')
|
||||
token = login()
|
||||
log('Logged in.')
|
||||
|
||||
# ── List ──
|
||||
list_path = OUT_DIR / 'orders_list.json'
|
||||
if list_path.exists():
|
||||
all_orders = json.loads(list_path.read_text(encoding='utf-8'))
|
||||
log(f'Loaded {len(all_orders)} orders from existing list.')
|
||||
else:
|
||||
all_orders = []
|
||||
page = 1
|
||||
while True:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
data = fetch_list(token, page)
|
||||
break
|
||||
except Exception as e:
|
||||
log(f'List page {page} attempt {attempt+1} error: {e}')
|
||||
time.sleep(2)
|
||||
else:
|
||||
log(f'Failed to fetch list page {page} after 3 attempts.')
|
||||
break
|
||||
|
||||
datos = data.get('datos', {})
|
||||
orders = datos.get('data', [])
|
||||
all_orders.extend(orders)
|
||||
(OUT_DIR / f'orders_page_{page}.json').write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding='utf-8'
|
||||
)
|
||||
log(f'Page {page}: {len(orders)} orders (total {len(all_orders)}/{datos.get("total", "?")})')
|
||||
if datos.get('current_page', 1) >= datos.get('last_page', 1):
|
||||
break
|
||||
page += 1
|
||||
time.sleep(1)
|
||||
|
||||
list_path.write_text(json.dumps(all_orders, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
log(f'Collected {len(all_orders)} orders summary.')
|
||||
|
||||
if not all_orders:
|
||||
log('No orders to fetch details for.')
|
||||
return
|
||||
|
||||
# ── Details ──
|
||||
detail_dir = OUT_DIR / 'details'
|
||||
detail_dir.mkdir(exist_ok=True)
|
||||
|
||||
target_orders = all_orders[:LIMIT] if LIMIT > 0 else all_orders
|
||||
total = len(target_orders)
|
||||
for i, o in enumerate(target_orders, start=1):
|
||||
oid = o['id']
|
||||
detail_path = detail_dir / f'order_{oid}.json'
|
||||
if detail_path.exists():
|
||||
log(f'[{i}/{total}] {oid} already saved')
|
||||
continue
|
||||
for attempt in range(3):
|
||||
try:
|
||||
data = fetch_detail(token, oid)
|
||||
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
log(f'[{i}/{total}] {oid} saved')
|
||||
break
|
||||
except Exception as e:
|
||||
log(f'[{i}/{total}] {oid} attempt {attempt+1} error: {e}')
|
||||
time.sleep(2)
|
||||
else:
|
||||
log(f'[{i}/{total}] {oid} FAILED after 3 attempts')
|
||||
time.sleep(1)
|
||||
|
||||
log('Done.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
25
scripts/test_cashier_pos.js
Normal file
25
scripts/test_cashier_pos.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="33"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||
await page.goto('/pos/sale');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/cashier_pos_default.png', fullPage: true });
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
37
scripts/test_counter_dashboard.js
Normal file
37
scripts/test_counter_dashboard.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="32"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||
await page.goto('/pos/catalog');
|
||||
await page.waitForTimeout(1500);
|
||||
const info = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
bodyClass: document.body.className,
|
||||
scripts: Array.from(document.querySelectorAll('script[src]')).map(s => s.src).filter(s => s.includes('sidebar') || s.includes('app-init'))
|
||||
}));
|
||||
console.log('Page info:', JSON.stringify(info, null, 2));
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_sidebar_catalog.png', fullPage: true });
|
||||
|
||||
// Try dashboard
|
||||
await page.goto('/pos/dashboard');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_dashboard_redirect.png', fullPage: true });
|
||||
console.log('URL after dashboard:', page.url());
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
31
scripts/test_inventory_entrada.js
Normal file
31
scripts/test_inventory_entrada.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="1"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||
await page.goto('/pos/inventory');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.click('button:has-text("Entradas")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('button:has-text("Nueva Entrada")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.fill('#purchaseItemSearch', 'CVBT2170');
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/inventory_entrada_alias.png', fullPage: true });
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
25
scripts/test_pos_counter_price.js
Normal file
25
scripts/test_pos_counter_price.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="31"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||
await page.goto('/pos/sale');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/pos_counter_debug.png', fullPage: true });
|
||||
console.log('URL:', page.url());
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
28
scripts/test_reports_cortes.js
Normal file
28
scripts/test_reports_cortes.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="1"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
|
||||
await page.goto('/pos/reports');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.click('button:has-text("Cortes de caja")');
|
||||
await page.waitForTimeout(1500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/reports_cortes_tab.png', fullPage: true });
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
35
scripts/test_workshop_counter.js
Normal file
35
scripts/test_workshop_counter.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { errors.push('CONSOLE(' + msg.type() + '): ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="29"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_list.png', fullPage: true });
|
||||
|
||||
await page.click('button:has-text("Ver")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_detail.png', fullPage: true });
|
||||
await page.click('button:has-text("Cerrar")');
|
||||
await page.waitForTimeout(300);
|
||||
await page.click('#btnNewOrder');
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_new.png', fullPage: true });
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
34
scripts/test_workshop_ui.js
Normal file
34
scripts/test_workshop_ui.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
|
||||
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
|
||||
|
||||
await page.goto('/pos/login2?tenant=31');
|
||||
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
|
||||
await page.click('.user-avatar-btn[data-id="2"]');
|
||||
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
|
||||
await page.click('#btn-login');
|
||||
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_list.png', fullPage: true });
|
||||
|
||||
await page.click('button:has-text("Ver")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_detail.png', fullPage: true });
|
||||
|
||||
await page.click('button:has-text("Artículos"), .so-tabs__btn:has-text("Artículos")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_articles.png', fullPage: true });
|
||||
|
||||
console.log('Errors:', errors.length);
|
||||
errors.forEach(e => console.log(' -', e));
|
||||
await browser.close();
|
||||
})();
|
||||
Reference in New Issue
Block a user