importacion historico legacy completo: citas con estados reales + 24,497 ventas con pagos (cuadre al centavo 48/48 meses); method points en skeen_pagos + sin acumular al canjear; scripts y doc de migracion
This commit is contained in:
877
migracion/importar_concentrado_full.py
Normal file
877
migracion/importar_concentrado_full.py
Normal file
@@ -0,0 +1,877 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Importa citas y ventas/pagos del legacy SKEEN a Odoo 16 (idempotente).
|
||||
|
||||
Fuentes (ya extraídas, solo lectura):
|
||||
- extraccion/concentrado/citas_full.csv 57,030 citas legacy (legacy_id)
|
||||
- extraccion/concentrado/concentrado_full.csv 29,467 filas cobradas (17 col + mes)
|
||||
- extraccion/concentrado/ventas_full.json 19,278 ventas json (expedientes_id por folio)
|
||||
- extraccion/citas.csv CSV viejo (46,456 citas, tiene legacy_id)
|
||||
|
||||
Parte A — Citas:
|
||||
A1. Backfill de skeen_cita.legacy_id cruzando el CSV viejo y citas_full
|
||||
por (teléfono normalizado, fecha, hora) contra las citas existentes.
|
||||
A2. Upsert desde citas_full: existe → corrige state/doctor/notes/duration;
|
||||
no existe → crea (partner por teléfono, servicio normalizado o HIST_).
|
||||
A3. Recálculo de computes datetime_start/time_str (patrón recalcular_citas.py).
|
||||
|
||||
Parte B — Ventas + pagos (concentrado_full.csv):
|
||||
Una skeen.venta por FOLIO (los folios repetidos son varios conceptos del
|
||||
mismo ticket → varias líneas), 1 skeen.pago por método×moneda > 0.
|
||||
Join partner: folio → ventas_full.json expedientes_id → res.partner.legacy_id;
|
||||
fallback por nombre normalizado. Sin match → se cuenta y se salta.
|
||||
|
||||
Idempotencia: citas por legacy_id, ventas por legacy_id (= folio numérico).
|
||||
Re-ejecutable: lo ya importado se salta o se vuelve a aplicar lo mismo.
|
||||
|
||||
Uso:
|
||||
/root/odoo-venv/bin/python /root/migracion/importar_concentrado_full.py \
|
||||
--db skeen_odoo [--citas-only] [--ventas-only] [--dry-run] [--limit N]
|
||||
|
||||
La instancia de producción (8069/8070) no se toca: el script arranca el
|
||||
bootstrap del ORM sin HTTP ni cron, igual que importar_resto_rapido.py.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, '/root/skeen-odoo')
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
EXTRACCION = Path('/root/migracion/extraccion')
|
||||
CITAS_FULL = EXTRACCION / 'concentrado' / 'citas_full.csv'
|
||||
CONCENTRADO = EXTRACCION / 'concentrado' / 'concentrado_full.csv'
|
||||
VENTAS_JSON = EXTRACCION / 'concentrado' / 'ventas_full.json'
|
||||
CITAS_OLD = EXTRACCION / 'citas.csv'
|
||||
|
||||
MESES_ES = {
|
||||
'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04',
|
||||
'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08',
|
||||
'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12',
|
||||
}
|
||||
|
||||
# medicos_id del legacy -> nombre del médico en hr.employee (los únicos
|
||||
# deducidos con evidencia: postprocesar_citas.py, importar_visitas.py y el
|
||||
# rescate del 8-sep). El resto de ids legacy quedan sin resolver → NULL.
|
||||
MEDICOS_ID_MAP = {
|
||||
'32': 'Dra. Alejandra Ramos',
|
||||
'33': 'Dra. Lidia Martinez',
|
||||
'46': 'Dra. Fernanda Cerecer',
|
||||
'49': 'RODRIGUEZ FRIDA',
|
||||
'54': 'Dr. Benjamín Adrián',
|
||||
'79': 'XIMENA',
|
||||
'80': 'ELY',
|
||||
}
|
||||
|
||||
METODOS = ('efectivo', 'tarjeta', 'transferencia', 'saldo_a_favor', 'monedero')
|
||||
PAGO_METHOD_MAP = {
|
||||
'efectivo': 'cash',
|
||||
'tarjeta': 'card',
|
||||
'transferencia': 'transfer',
|
||||
'saldo_a_favor': 'saldo_favor',
|
||||
'monedero': 'points',
|
||||
}
|
||||
CONCEPTO_CATS = (
|
||||
'SERVICIOS MEDICOS', 'PROCEDIMIENTOS COSMETICOS', 'TECNOLOGIA MEDICA',
|
||||
'COSMETOLOGIA BASICA', 'COSMETOLOGIA AVANZADA',
|
||||
)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)
|
||||
|
||||
|
||||
def strip_accents(s):
|
||||
return ''.join(c for c in unicodedata.normalize('NFD', s or '')
|
||||
if unicodedata.category(c) != 'Mn')
|
||||
|
||||
|
||||
def norm(s):
|
||||
"""Normalización con acentos (para matchear contra claves viejas del pipeline)."""
|
||||
return ' '.join(str(s or '').split()).lower()
|
||||
|
||||
|
||||
def norm_deacc(s):
|
||||
"""Normalización sin acentos (robustez para datos legacy con/sin tilde)."""
|
||||
return strip_accents(norm(s))
|
||||
|
||||
|
||||
def phone_key(p):
|
||||
"""Clave de teléfono: dígitos, sin prefijo 52 (10 dígitos canónicos)."""
|
||||
d = re.sub(r'\D', '', p or '')
|
||||
if len(d) == 12 and d.startswith('52'):
|
||||
return d[2:]
|
||||
if len(d) == 11 and d.startswith('52'):
|
||||
return d[2:]
|
||||
return d
|
||||
|
||||
|
||||
def phone_valid(p):
|
||||
return len(phone_key(p)) >= 6
|
||||
|
||||
|
||||
def phone_store(p):
|
||||
"""Formato de alta consistente con la migración previa (prefijo 52)."""
|
||||
d = re.sub(r'\D', '', p or '')
|
||||
if len(d) == 10:
|
||||
return '52' + d
|
||||
return d
|
||||
|
||||
|
||||
def hora_a_float(h):
|
||||
try:
|
||||
hh, mm = (h or '').split(':')[:2]
|
||||
return float(hh) + float(mm) / 60.0
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def duracion_min(hora, hora_fin):
|
||||
hi, hf = hora_a_float(hora), hora_a_float(hora_fin)
|
||||
if hi is None or hf is None:
|
||||
return None
|
||||
d = int(round((hf - hi) * 60))
|
||||
return d if 5 <= d <= 480 else None
|
||||
|
||||
|
||||
def extract_service(text, patient_name):
|
||||
"""Quita prefijo 'PACIENTE - ' y sufijo '(N sesión)' (pipeline viejo,
|
||||
más segunda pasada tolerante a acentos/doble espacio)."""
|
||||
text = html.unescape(text or '').strip()
|
||||
pat = ' '.join((patient_name or '').split())
|
||||
if pat:
|
||||
text = re.sub(r'^' + re.escape(pat) + r'\s*[-–—]\s*', '', text,
|
||||
flags=re.IGNORECASE)
|
||||
m = re.match(r'^(.*?)\s*[-–—]\s*(.+)$', text, flags=re.DOTALL)
|
||||
if m and norm_deacc(m.group(1)) == norm_deacc(pat):
|
||||
text = m.group(2)
|
||||
text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '',
|
||||
text, flags=re.IGNORECASE)
|
||||
return ' '.join(text.split()).strip()
|
||||
|
||||
|
||||
def concepto_service_name(concepto):
|
||||
"""Concepto del concentrado → nombre de servicio limpio."""
|
||||
c = html.unescape(concepto or '').strip()
|
||||
c = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '',
|
||||
c, flags=re.IGNORECASE)
|
||||
up = strip_accents(c).upper()
|
||||
for cat in CONCEPTO_CATS:
|
||||
if up.startswith(cat):
|
||||
c = c[len(cat):].strip()
|
||||
break
|
||||
c = re.sub(r'\s+\d+\s*(sesión|sesiones|unidad|unidades)$', '', c,
|
||||
flags=re.IGNORECASE)
|
||||
c = re.sub(r'\s+\d+\s*(sesion|sesiones|unidad|unidades)$', '', c,
|
||||
flags=re.IGNORECASE)
|
||||
return ' '.join(c.split()).strip()
|
||||
|
||||
|
||||
def parse_fecha_es(s):
|
||||
"""'10/Agosto/2026 04:21 pm' -> ('2026-08-10', '2026-08-10 16:21:00')."""
|
||||
m = re.match(r'(\d{1,2})/([A-Za-zÁÉÍÓÚáéíóúñÑ]+)/(\d{4})\s*(.*)$',
|
||||
str(s or '').strip())
|
||||
if not m:
|
||||
return None, None
|
||||
dia, mes, anio, resto = m.groups()
|
||||
mn = MESES_ES.get(mes.lower())
|
||||
if not mn:
|
||||
return None, None
|
||||
fecha = f'{anio}-{mn}-{int(dia):02d}'
|
||||
hora = '00:00:00'
|
||||
mh = re.match(r'(\d{1,2}):(\d{2})\s*(am|pm)', resto.strip(), flags=re.IGNORECASE)
|
||||
if mh:
|
||||
h, mi, ap = mh.groups()
|
||||
h = int(h) % 12 + (12 if ap.lower() == 'pm' else 0)
|
||||
hora = f'{h:02d}:{mi}:00'
|
||||
return fecha, f'{fecha} {hora}'
|
||||
|
||||
|
||||
def is_garbage_name(nombre):
|
||||
n = (nombre or '').strip()
|
||||
if not n or n in ('.', '. .', '-', 'N/A'):
|
||||
return True
|
||||
return 'NO CITAR' in n.upper()
|
||||
|
||||
|
||||
class Importer:
|
||||
def __init__(self, env, cr, dry_run=False, limit=None):
|
||||
self.env = env
|
||||
self.cr = cr
|
||||
self.dry_run = dry_run
|
||||
self.limit = limit
|
||||
self.stats = Counter()
|
||||
|
||||
# ---------------- cachés ----------------
|
||||
def cargar_caches(self):
|
||||
cr = self.cr
|
||||
self.p_by_phone, self.p_by_name, self.p_by_legacy = {}, {}, {}
|
||||
cr.execute("SELECT id, phone, name, legacy_id FROM res_partner"
|
||||
" WHERE is_patient = true")
|
||||
for pid, phone, name, lid in cr.fetchall():
|
||||
k = phone_key(phone)
|
||||
if len(k) >= 6:
|
||||
self.p_by_phone.setdefault(k, pid)
|
||||
if name:
|
||||
self.p_by_name.setdefault(norm_deacc(name), pid)
|
||||
if lid is not None:
|
||||
self.p_by_legacy[str(lid)] = pid
|
||||
|
||||
# Servicios: clave estilo pipeline viejo (con acento) + sin acento
|
||||
self.svc_by_key = {}
|
||||
cr.execute("SELECT id, name, code FROM skeen_servicio")
|
||||
self.hist_codes = set()
|
||||
for sid, name, code in cr.fetchall():
|
||||
if code and code.startswith('HIST_'):
|
||||
self.hist_codes.add(code)
|
||||
if name:
|
||||
self.svc_by_key.setdefault(norm(name), sid)
|
||||
self.svc_by_key.setdefault(norm_deacc(name), sid)
|
||||
|
||||
# Empleados: activos preferidos sobre inactivos de igual nombre
|
||||
self.emp_by_name = {}
|
||||
cr.execute("SELECT id, name, active FROM hr_employee ORDER BY active DESC, id")
|
||||
empleados = []
|
||||
for eid, name, active in cr.fetchall():
|
||||
empleados.append((eid, name or '', active))
|
||||
if name and norm_deacc(name) not in self.emp_by_name:
|
||||
self.emp_by_name[norm_deacc(name)] = eid
|
||||
self.emp_tokens = []
|
||||
for eid, name, active in empleados:
|
||||
toks = [t for t in norm_deacc(name).split()
|
||||
if len(t) > 3 and t not in ('dra.', 'dr.')]
|
||||
self.emp_tokens.append((eid, name, set(toks), active))
|
||||
|
||||
# Empleados fantasma numéricos ("34".."51")
|
||||
cr.execute("SELECT id FROM hr_employee WHERE name ~ '^[0-9]+$'")
|
||||
self.ghost_emp_ids = {r[0] for r in cr.fetchall()}
|
||||
|
||||
self.cita_by_legacy = {}
|
||||
cr.execute("SELECT id, legacy_id, state, doctor_id, notes, duration, servicio_id"
|
||||
" FROM skeen_cita WHERE legacy_id IS NOT NULL")
|
||||
for cid, lid, state, doc, notes, dur, svc in cr.fetchall():
|
||||
self.cita_by_legacy[lid] = (cid, state, doc, notes, dur, svc)
|
||||
|
||||
self.venta_by_legacy = {}
|
||||
cr.execute("SELECT id, legacy_id FROM skeen_venta WHERE legacy_id IS NOT NULL")
|
||||
for vid, lid in cr.fetchall():
|
||||
self.venta_by_legacy[lid] = vid
|
||||
|
||||
# Usuarios del frontend (vendedor -> login)
|
||||
self.login_by_name = {}
|
||||
cr.execute("SELECT login, name FROM skeen_frontend_user")
|
||||
for login, name in cr.fetchall():
|
||||
if name and norm_deacc(name) not in self.login_by_name:
|
||||
self.login_by_name[norm_deacc(name)] = login
|
||||
|
||||
# Monedas
|
||||
self.cur_usd = self.env.ref('base.USD').id
|
||||
self.cur_mxn = self.env.ref('base.MXN').id
|
||||
|
||||
# Mapa médicos legacy -> employee id (con verificación de nombre)
|
||||
self.medicos_emp = {}
|
||||
for mid, nombre in MEDICOS_ID_MAP.items():
|
||||
eid = self.match_employee(nombre)
|
||||
if eid:
|
||||
self.medicos_emp[mid] = eid
|
||||
else:
|
||||
log(f' !! médico del mapa sin empleado: {mid}={nombre}')
|
||||
|
||||
log(f' Pacientes: {len(self.p_by_phone)} tel / {len(self.p_by_name)} nombre / {len(self.p_by_legacy)} legacy')
|
||||
log(f' Servicios: {len(self.svc_by_key)} claves | Fantasmas: {sorted(self.ghost_emp_ids)}')
|
||||
log(f' Citas con legacy ya: {len(self.cita_by_legacy)} | Ventas con legacy ya: {len(self.venta_by_legacy)}')
|
||||
log(f' Médicos resueltos: {self.medicos_emp}')
|
||||
|
||||
def match_employee(self, texto):
|
||||
"""Match por nombre normalizado; fallback por tokens (todos presentes)."""
|
||||
t = norm_deacc(texto)
|
||||
if not t:
|
||||
return None
|
||||
if t in self.emp_by_name:
|
||||
return self.emp_by_name[t]
|
||||
best = None
|
||||
for eid, name, toks, active in self.emp_tokens:
|
||||
if toks and all(tok in t for tok in toks):
|
||||
if best is None or len(toks) > best[1]:
|
||||
best = (eid, len(toks))
|
||||
return best[0] if best else None
|
||||
|
||||
def resolve_service(self, nombre):
|
||||
return self.svc_by_key.get(norm(nombre)) or self.svc_by_key.get(norm_deacc(nombre))
|
||||
|
||||
def get_or_create_service(self, nombre, prefijo='HIST_'):
|
||||
"""Reutiliza o crea servicio histórico (precio 0, 30 min). Devuelve id."""
|
||||
nombre = (nombre or '').strip()
|
||||
if not nombre or nombre == '-':
|
||||
nombre = 'Servicio histórico'
|
||||
sid = self.resolve_service(nombre)
|
||||
if sid:
|
||||
return sid, False
|
||||
key = norm_deacc(nombre)
|
||||
code = f'{prefijo}{__import__("hashlib").md5(key.encode("utf-8")).hexdigest()[:12]}'
|
||||
if code in self.hist_codes:
|
||||
cr = self.cr
|
||||
cr.execute("SELECT id FROM skeen_servicio WHERE code = %s", (code,))
|
||||
row = cr.fetchone()
|
||||
if row:
|
||||
self.svc_by_key[norm(nombre)] = row[0]
|
||||
self.svc_by_key[norm_deacc(nombre)] = row[0]
|
||||
return row[0], False
|
||||
if self.dry_run:
|
||||
return None, True
|
||||
sid = self.env['skeen.servicio'].sudo().create({
|
||||
'name': nombre,
|
||||
'code': code,
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
}).id
|
||||
self.hist_codes.add(code)
|
||||
self.svc_by_key[norm(nombre)] = sid
|
||||
self.svc_by_key[norm_deacc(nombre)] = sid
|
||||
return sid, True
|
||||
|
||||
def get_or_create_partner(self, telefono, nombre):
|
||||
k = phone_key(telefono)
|
||||
pid = self.p_by_phone.get(k)
|
||||
if pid:
|
||||
return pid, False
|
||||
if self.dry_run:
|
||||
return None, True
|
||||
pid = self.env['res.partner'].sudo().create({
|
||||
'name': nombre.strip() or 'Paciente sin nombre',
|
||||
'is_patient': True,
|
||||
'phone': phone_store(telefono),
|
||||
}).id
|
||||
self.p_by_phone[k] = pid
|
||||
self.p_by_name.setdefault(norm_deacc(nombre), pid)
|
||||
return pid, True
|
||||
|
||||
# ================= PARTE A =================
|
||||
def parte_a1_backfill(self):
|
||||
log('A1. Backfill de legacy_id en citas existentes...')
|
||||
# Índice legacy por (tel, fecha, hora): primero CSV viejo, luego full
|
||||
idx = defaultdict(list)
|
||||
|
||||
def add_rows(path, extra_pass):
|
||||
if not path.exists():
|
||||
return 0
|
||||
n = 0
|
||||
with open(path, newline='', encoding='utf-8') as f:
|
||||
for r in csv.DictReader(f):
|
||||
tel = phone_key(r.get('paciente_telefono'))
|
||||
hf = hora_a_float(r.get('hora'))
|
||||
fecha = (r.get('fecha') or '').strip()
|
||||
if not tel or hf is None or not fecha:
|
||||
continue
|
||||
try:
|
||||
lid = int(r['legacy_id'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
svc = extract_service(r.get('servicio_nombre', ''),
|
||||
r.get('paciente_nombre', ''))
|
||||
idx[(tel, fecha, round(hf, 2))].append((lid, norm_deacc(svc)))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
n_old = add_rows(CITAS_OLD, False)
|
||||
n_full = add_rows(CITAS_FULL, True)
|
||||
log(f' Filas indexadas: {n_old} (viejo) + {n_full} (full), {len(idx)} claves')
|
||||
|
||||
# Citas actuales sin legacy_id, indexadas por la misma clave
|
||||
self.cr.execute("""
|
||||
SELECT c.id, p.phone, c.date, c.time, s.name
|
||||
FROM skeen_cita c
|
||||
JOIN res_partner p ON p.id = c.partner_id
|
||||
LEFT JOIN skeen_servicio s ON s.id = c.servicio_id
|
||||
WHERE c.legacy_id IS NULL
|
||||
""")
|
||||
db_idx = defaultdict(list)
|
||||
for cid, phone, date, timef, svcname in self.cr.fetchall():
|
||||
k = (phone_key(phone or ''), str(date), round(float(timef or 0), 2))
|
||||
db_idx[k].append((cid, norm_deacc(svcname or '')))
|
||||
|
||||
asignados, ambiguos, sin_match_csv = 0, 0, 0
|
||||
updates = []
|
||||
for k, dbs in db_idx.items():
|
||||
cands = idx.get(k)
|
||||
if not cands:
|
||||
sin_match_csv += len(dbs)
|
||||
continue
|
||||
usados_csv, usados_db = set(), set()
|
||||
# 1) match por nombre de servicio
|
||||
for i, (lid, svc) in enumerate(cands):
|
||||
for j, (cid, dsvc) in enumerate(dbs):
|
||||
if i in usados_csv or j in usados_db:
|
||||
continue
|
||||
if svc and dsvc and svc == dsvc:
|
||||
updates.append((cid, lid))
|
||||
usados_csv.add(i)
|
||||
usados_db.add(j)
|
||||
# 2) el resto, por orden
|
||||
for i, (lid, svc) in enumerate(cands):
|
||||
if i in usados_csv:
|
||||
continue
|
||||
for j, (cid, dsvc) in enumerate(dbs):
|
||||
if j in usados_db:
|
||||
continue
|
||||
updates.append((cid, lid))
|
||||
usados_csv.add(i)
|
||||
usados_db.add(j)
|
||||
break
|
||||
# dedup: un legacy_id solo se asigna una vez (el primero gana)
|
||||
vistos_lid, finales = set(), []
|
||||
for cid, lid in sorted(updates):
|
||||
if lid in vistos_lid:
|
||||
ambiguos += 1
|
||||
continue
|
||||
vistos_lid.add(lid)
|
||||
finales.append((cid, lid))
|
||||
asignados = len(finales)
|
||||
self.stats['a1_backfill_asignados'] = asignados
|
||||
self.stats['a1_backfill_ambiguos'] = ambiguos
|
||||
self.stats['a1_sin_match_csv'] = sin_match_csv
|
||||
log(f' Pares cita↔legacy: {asignados} (descartados por dup: {ambiguos});'
|
||||
f' citas sin contraparte en CSV: {sin_match_csv}')
|
||||
if not self.dry_run and finales:
|
||||
self.cr.executemany(
|
||||
'UPDATE skeen_cita SET legacy_id = %s WHERE id = %s',
|
||||
[(lid, cid) for cid, lid in finales])
|
||||
self.cr.commit()
|
||||
return asignados
|
||||
|
||||
def parte_a2_upsert(self):
|
||||
log('A2. Upsert de citas desde citas_full.csv...')
|
||||
with open(CITAS_FULL, newline='', encoding='utf-8') as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
if self.limit:
|
||||
rows = rows[:self.limit]
|
||||
|
||||
# Recargar caché de citas con legacy (incluye las del backfill A1)
|
||||
self.cita_by_legacy = {}
|
||||
self.cr.execute("SELECT id, legacy_id, state, doctor_id, notes, duration"
|
||||
" FROM skeen_cita WHERE legacy_id IS NOT NULL")
|
||||
for cid, lid, state, doc, notes, dur in self.cr.fetchall():
|
||||
self.cita_by_legacy[lid] = (cid, state, doc, notes, dur)
|
||||
|
||||
Cita = self.env['skeen.cita'].sudo().with_context(tracking_disable=True)
|
||||
ModeloCita = self.env.registry['skeen.cita']
|
||||
# Desactivar validaciones que bloquean carga histórica (se restauran)
|
||||
orig = {m: getattr(ModeloCita, m) for m in (
|
||||
'_check_availability', '_check_bloqueos', '_check_horario_medico',
|
||||
'_check_doctor_activo')}
|
||||
for m in orig:
|
||||
setattr(ModeloCita, m, lambda self: None)
|
||||
|
||||
batch, batch_size = [], 500
|
||||
try:
|
||||
for r in rows:
|
||||
self.stats['a2_total'] += 1
|
||||
lid = int(r['legacy_id'])
|
||||
estado = (r['estado_mapeado'] or '').strip() or 'pending'
|
||||
med_id = (r['medicos_id'] or '').strip()
|
||||
nombre = r['paciente_nombre']
|
||||
basura = is_garbage_name(nombre)
|
||||
tel_ok = phone_valid(r['paciente_telefono'])
|
||||
|
||||
existente = self.cita_by_legacy.get(lid)
|
||||
if existente:
|
||||
cid, state_cur, doc_cur, notes_cur, dur_cur = existente
|
||||
sets = {}
|
||||
if estado and estado != state_cur:
|
||||
sets['state'] = estado
|
||||
emp = self.medicos_emp.get(med_id)
|
||||
if emp and (doc_cur is None or doc_cur in self.ghost_emp_ids):
|
||||
# fantasma/NULL -> médico real resuelto
|
||||
sets['doctor_id'] = emp
|
||||
self.stats['a2_doctor_corregido'] += 1
|
||||
elif not emp and med_id not in ('0', '') \
|
||||
and doc_cur in self.ghost_emp_ids:
|
||||
# médico legacy sin resolver: se anula el fantasma
|
||||
sets['doctor_id'] = None
|
||||
self.stats['a2_doctor_fantasma_anulado'] += 1
|
||||
if not emp and med_id not in ('0', ''):
|
||||
self.stats.setdefault(
|
||||
'a2_medicos_sin_resolver', Counter())[med_id] += 1
|
||||
obs = (r['observaciones'] or '').strip()
|
||||
if obs and obs != (notes_cur or '').strip():
|
||||
sets['notes'] = obs
|
||||
dur = duracion_min(r['hora'], r['hora_fin'])
|
||||
if dur and not dur_cur:
|
||||
sets['duration'] = dur
|
||||
if sets:
|
||||
self.stats['a2_actualizadas'] += 1
|
||||
if not self.dry_run:
|
||||
campos = ', '.join(f'{k} = %s' for k in sets)
|
||||
self.cr.execute(
|
||||
f'UPDATE skeen_cita SET {campos} WHERE id = %s',
|
||||
tuple(sets.values()) + (cid,))
|
||||
else:
|
||||
self.stats['a2_sin_cambios'] += 1
|
||||
continue
|
||||
|
||||
# ---- crear ----
|
||||
if basura:
|
||||
self.stats['a2_skip_nombre_basura'] += 1
|
||||
continue
|
||||
if not tel_ok:
|
||||
self.stats['a2_skip_telefono_invalido'] += 1
|
||||
continue
|
||||
partner_id, creado = self.get_or_create_partner(
|
||||
r['paciente_telefono'], nombre)
|
||||
if creado:
|
||||
self.stats['a2_partners_creados'] += 1
|
||||
if not partner_id:
|
||||
self.stats['a2_skip_error'] += 1
|
||||
continue
|
||||
svc_name = extract_service(r['servicio_nombre'], nombre)
|
||||
if not svc_name:
|
||||
m = re.match(r'^(.*?)\s*[-–—]\s*(.+)$',
|
||||
html.unescape(r['titulo_raw'] or ''), flags=re.DOTALL)
|
||||
svc_name = extract_service(m.group(2) if m else '', '') or 'Servicio histórico'
|
||||
servicio_id, svc_creado = self.get_or_create_service(svc_name, 'HIST_')
|
||||
if svc_creado:
|
||||
self.stats['a2_servicios_creados'] += 1
|
||||
emp = self.medicos_emp.get(med_id)
|
||||
if med_id not in ('0', '') and not emp:
|
||||
self.stats.setdefault('a2_medicos_sin_resolver', Counter())[med_id] += 1
|
||||
tf = hora_a_float(r['hora']) or 9.0
|
||||
vals = {
|
||||
'legacy_id': lid,
|
||||
'partner_id': partner_id,
|
||||
'servicio_id': servicio_id,
|
||||
'date': r['fecha'],
|
||||
'time': tf,
|
||||
'state': estado,
|
||||
'doctor_id': emp,
|
||||
'branch': 'rosarito',
|
||||
'medium': 'onsite',
|
||||
'payment_state': 'not_paid',
|
||||
'amount_paid': 0,
|
||||
'notes': (r['observaciones'] or '').strip(),
|
||||
}
|
||||
dur = duracion_min(r['hora'], r['hora_fin'])
|
||||
if dur:
|
||||
vals['duration'] = dur
|
||||
batch.append(vals)
|
||||
self.stats['a2_creadas'] += 1
|
||||
if len(batch) >= batch_size:
|
||||
if not self.dry_run:
|
||||
try:
|
||||
Cita.create(batch)
|
||||
self.cr.commit()
|
||||
except Exception as e:
|
||||
self.cr.rollback()
|
||||
self._crecar_uno_a_uno(Cita, batch)
|
||||
batch = []
|
||||
log(f" creadas {self.stats['a2_creadas']}...")
|
||||
if batch and not self.dry_run:
|
||||
try:
|
||||
Cita.create(batch)
|
||||
self.cr.commit()
|
||||
except Exception:
|
||||
self.cr.rollback()
|
||||
self._crecar_uno_a_uno(Cita, batch)
|
||||
finally:
|
||||
for m, fn in orig.items():
|
||||
setattr(ModeloCita, m, fn)
|
||||
if not self.dry_run:
|
||||
self.cr.commit()
|
||||
log(f" citas: {dict((k, v) for k, v in self.stats.items() if k.startswith('a2_') and not isinstance(v, Counter))}")
|
||||
sin_res = self.stats.get('a2_medicos_sin_resolver')
|
||||
if sin_res:
|
||||
log(f' medicos_id sin resolver (citas): {dict(sin_res)}')
|
||||
|
||||
def _crecar_uno_a_uno(self, Modelo, batch):
|
||||
"""Aísla registros que rompen un lote (el resto ya se descartó)."""
|
||||
for vals in batch:
|
||||
try:
|
||||
Modelo.create([vals])
|
||||
self.cr.commit()
|
||||
except Exception as e:
|
||||
self.cr.rollback()
|
||||
self.stats['a2_errores_create'] += 1
|
||||
self.stats.setdefault('a2_errores_ej', []).append(
|
||||
str(vals.get('legacy_id')) + ': ' + str(e)[:120])
|
||||
|
||||
def parte_a3_recalc(self):
|
||||
if self.dry_run:
|
||||
return
|
||||
log('A3. Recalculando computes de citas...')
|
||||
Citas = self.env['skeen.cita'].sudo().with_context(tracking_disable=True)
|
||||
todas = Citas.search([])
|
||||
todas._compute_datetime()
|
||||
todas._compute_time_str()
|
||||
self.cr.commit()
|
||||
nulls = Citas.search_count([('datetime_start', '=', False)])
|
||||
log(f' datetime_start NULL tras recálculo: {nulls}')
|
||||
self.stats['a3_datetime_null'] = nulls
|
||||
|
||||
# ================= PARTE B =================
|
||||
def _cargar_folios_json(self):
|
||||
with open(VENTAS_JSON, encoding='utf-8') as f:
|
||||
ventas = json.load(f)
|
||||
folio2exp, folios_cancelados = {}, set()
|
||||
for v in ventas:
|
||||
for c in (v.get('comprobantes') or {}).values():
|
||||
f2 = (c.get('folio') or '').strip()
|
||||
if not f2:
|
||||
continue
|
||||
if c.get('cancelado'):
|
||||
folios_cancelados.add(f2)
|
||||
if f2 not in folio2exp and v.get('expedientes_id'):
|
||||
folio2exp[f2] = v['expedientes_id']
|
||||
return folio2exp, folios_cancelados
|
||||
|
||||
def parte_b(self):
|
||||
log('B. Importando ventas + pagos desde concentrado_full.csv...')
|
||||
with open(CONCENTRADO, newline='', encoding='utf-8') as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
if self.limit:
|
||||
rows = rows[:self.limit]
|
||||
|
||||
folio2exp, folios_cancelados = self._cargar_folios_json()
|
||||
self.stats['b_folios_json'] = len(folio2exp)
|
||||
self.stats['b_folios_cancelados_json'] = len(folios_cancelados)
|
||||
|
||||
# agrupar filas por folio (mismo ticket = varias líneas)
|
||||
grupos = defaultdict(list)
|
||||
orden = []
|
||||
for r in rows:
|
||||
f = r['folio'].strip()
|
||||
if f not in grupos:
|
||||
orden.append(f)
|
||||
grupos[f].append(r)
|
||||
|
||||
Venta = self.env['skeen.venta'].sudo().with_context(tracking_disable=True)
|
||||
Pago = self.env['skeen.pago'].sudo().with_context(tracking_disable=True)
|
||||
|
||||
vbatch, pbatch = [], []
|
||||
for folio in orden:
|
||||
g = grupos[folio]
|
||||
m = re.match(r'^[A-Za-z]*(\d+)$', folio)
|
||||
if not m:
|
||||
self.stats['b_skip_folio_raro'] += 1
|
||||
continue
|
||||
lid = int(m.group(1))
|
||||
if lid in self.venta_by_legacy:
|
||||
self.stats['b_skip_ya_existe'] += 1
|
||||
continue
|
||||
|
||||
# filas con monto; si todas son cero se salta el ticket
|
||||
def monto_fila(r):
|
||||
return sum(float(r[f'{mm}_{c}'] or 0)
|
||||
for mm in METODOS for c in ('usd', 'mn'))
|
||||
filas = [r for r in g if monto_fila(r) > 0]
|
||||
if not filas:
|
||||
self.stats['b_skip_todo_cero'] += len(g)
|
||||
continue
|
||||
|
||||
rep = filas[0]
|
||||
fecha, fecha_dt = parse_fecha_es(rep['fecha'])
|
||||
if not fecha:
|
||||
self.stats['b_skip_fecha_mala'] += 1
|
||||
continue
|
||||
|
||||
# partner: join folio -> expedientes_id -> legacy; fallback nombre
|
||||
exp = folio2exp.get(folio)
|
||||
partner_id = self.p_by_legacy.get(str(exp)) if exp is not None else None
|
||||
via = 'expedientes_id'
|
||||
if not partner_id:
|
||||
partner_id = self.p_by_name.get(norm_deacc(rep['nombre_paciente']))
|
||||
via = 'nombre'
|
||||
if not partner_id:
|
||||
self.stats['b_skip_sin_paciente'] += 1
|
||||
self.stats.setdefault('b_sin_paciente_folios', []).append(folio)
|
||||
continue
|
||||
if via == 'expedientes_id':
|
||||
self.stats['b_match_expedientes'] += 1
|
||||
else:
|
||||
self.stats['b_match_nombre'] += 1
|
||||
|
||||
total = round(sum(monto_fila(r) for r in filas), 2)
|
||||
|
||||
# desglose pagos por método x moneda
|
||||
pagos = {}
|
||||
for mm in METODOS:
|
||||
for c in ('usd', 'mn'):
|
||||
s = round(sum(float(r[f'{mm}_{c}'] or 0) for r in filas), 2)
|
||||
if s > 0:
|
||||
pagos[(mm, c)] = s
|
||||
metodos_usados = {mm for (mm, c) in pagos}
|
||||
|
||||
vendedor = (rep['vendedor'] or '').strip()
|
||||
user_login = self.login_by_name.get(norm_deacc(vendedor), vendedor or False)
|
||||
|
||||
cancelada = folio in folios_cancelados
|
||||
lineas = []
|
||||
for r in filas:
|
||||
sname = concepto_service_name(r['concepto'])
|
||||
sid, _ = self.get_or_create_service(sname, 'HISTV_')
|
||||
if not sid:
|
||||
self.stats['b_skip_error'] += 1
|
||||
lineas = None
|
||||
break
|
||||
lineas.append((0, 0, {
|
||||
'service_id': sid,
|
||||
'description': sname,
|
||||
'quantity': 1,
|
||||
'price_unit': round(monto_fila(r), 2),
|
||||
'prescribed_by_id': self.match_employee(r.get('atendido_por') or ''),
|
||||
}))
|
||||
if lineas is None:
|
||||
continue
|
||||
self.stats['b_lineas'] += len(lineas)
|
||||
|
||||
vals = {
|
||||
'name': folio,
|
||||
'legacy_id': lid,
|
||||
'partner_id': partner_id,
|
||||
'date': fecha,
|
||||
'amount_paid': 0 if cancelada else total,
|
||||
'state': 'cancelled' if cancelada else 'paid',
|
||||
'user_login': user_login,
|
||||
'payment_method': 'mixed' if len(metodos_usados) > 1 else (
|
||||
PAGO_METHOD_MAP[list(metodos_usados)[0]] if metodos_usados else 'none'),
|
||||
'line_ids': lineas,
|
||||
'notes': f"Importado de concentrado legacy ({rep['mes']})",
|
||||
}
|
||||
vbatch.append((vals, pagos, partner_id, fecha_dt, cancelada))
|
||||
self.stats['b_creadas'] += 1
|
||||
|
||||
if len(vbatch) >= 300:
|
||||
self._flush_ventas(Venta, Pago, vbatch, pbatch)
|
||||
vbatch, pbatch = [], []
|
||||
log(f" ventas {self.stats['b_creadas']} (skip: sin paciente {self.stats['b_skip_sin_paciente']}, ya existe {self.stats['b_skip_ya_existe']}, $0 {self.stats['b_skip_todo_cero']})...")
|
||||
if vbatch:
|
||||
self._flush_ventas(Venta, Pago, vbatch, pbatch)
|
||||
log(f" ventas: creadas {self.stats['b_creadas']}, ya existían {self.stats['b_skip_ya_existe']},"
|
||||
f" sin paciente {self.stats['b_skip_sin_paciente']}, todo-cero {self.stats['b_skip_todo_cero']}")
|
||||
log(f" match paciente: expedientes {self.stats['b_match_expedientes']}, nombre {self.stats['b_match_nombre']}")
|
||||
log(f" pagos creados {self.stats['b_pagos_creados']} | líneas {self.stats['b_lineas']}"
|
||||
f" | servicios HISTV_ creados {self.stats['b_servicios_creados']}")
|
||||
|
||||
def _flush_ventas(self, Venta, Pago, vbatch, pbatch):
|
||||
if self.dry_run:
|
||||
return
|
||||
try:
|
||||
creadas = Venta.create([v for v, _, _, _, _ in vbatch])
|
||||
self.cr.commit()
|
||||
except Exception as e:
|
||||
self.cr.rollback()
|
||||
if len(vbatch) == 1:
|
||||
self.stats['b_errores_create'] += 1
|
||||
self.stats.setdefault('b_errores_ej', []).append(
|
||||
str(vbatch[0][0].get('name')) + ': ' + str(e)[:150])
|
||||
return
|
||||
log(f' !! lote de {len(vbatch)} ventas falló ({str(e)[:100]}); reintento uno a uno')
|
||||
for item in vbatch:
|
||||
self._flush_ventas(Venta, Pago, [item], pbatch)
|
||||
return
|
||||
for venta, (vals, pagos, partner_id, fecha_dt, cancelada) in zip(creadas, vbatch):
|
||||
self.venta_by_legacy[vals['legacy_id']] = venta.id
|
||||
if cancelada:
|
||||
continue
|
||||
for (mm, cur), amount in pagos.items():
|
||||
pbatch.append({
|
||||
'venta_id': venta.id,
|
||||
'partner_id': partner_id,
|
||||
'amount': amount,
|
||||
'currency_id': self.cur_usd if cur == 'usd' else self.cur_mxn,
|
||||
'payment_method': PAGO_METHOD_MAP[mm],
|
||||
'state': 'completed',
|
||||
'payment_date': fecha_dt,
|
||||
})
|
||||
self.stats['b_pagos_creados'] += 1
|
||||
if pbatch:
|
||||
try:
|
||||
Pago.create(pbatch)
|
||||
pbatch.clear()
|
||||
self.cr.commit()
|
||||
except Exception as e:
|
||||
self.cr.rollback()
|
||||
self.stats['b_errores_pagos'] += len(pbatch)
|
||||
self.stats.setdefault('b_errores_ej', []).append('pagos: ' + str(e)[:150])
|
||||
pbatch.clear()
|
||||
|
||||
# ================= resumen =================
|
||||
def imprimir_resumen(self):
|
||||
log('========== RESUMEN ==========')
|
||||
for k in sorted(self.stats):
|
||||
v = self.stats[k]
|
||||
if isinstance(v, Counter):
|
||||
log(f' {k}: {dict(v)}')
|
||||
elif isinstance(v, list):
|
||||
log(f' {k}: {len(v)} (ej: {v[:8]})')
|
||||
else:
|
||||
log(f' {k}: {v}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Importación idempotente concentrado SKEEN')
|
||||
parser.add_argument('--db', default='skeen_odoo', help='Base de datos destino')
|
||||
parser.add_argument('--odoo-config', default='/root/skeen-odoo/odoo.conf')
|
||||
parser.add_argument('--citas-only', action='store_true')
|
||||
parser.add_argument('--ventas-only', action='store_true')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--limit', type=int, default=None)
|
||||
parser.add_argument('--permitir-produccion', action='store_true',
|
||||
help='Requerido para escribir en skeen_odoo/clinica2_odoo')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.db in ('skeen_odoo', 'clinica2_odoo') and not args.dry_run \
|
||||
and not args.permitir_produccion:
|
||||
# Seguridad: la corrida real contra producción exige el flag explícito.
|
||||
log(f'REFUSADO: --db={args.db} es producción. Usa una copia (p. ej. skeen_import_test)'
|
||||
f' o añade --permitir-produccion si es lo que realmente quieres.')
|
||||
sys.exit(2)
|
||||
|
||||
log(f'Conectando a {args.db} (dry_run={args.dry_run}, limit={args.limit})...')
|
||||
odoo.tools.config.parse_config(['-c', args.odoo_config, '--no-http',
|
||||
'--log-level', 'error'])
|
||||
db = odoo.sql_db.db_connect(args.db)
|
||||
cr = db.cursor()
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# 'points' no está en el selection de skeen.pago (lag del addon) pero el
|
||||
# endpoint concentrado sí agrupa points->monedero. Se extiende el selection
|
||||
# en runtime (sin tocar addons) para registrar pagos de monedero reales.
|
||||
campo = env['skeen.pago']._fields['payment_method']
|
||||
if ('points', 'Monedero') not in campo.selection and \
|
||||
not any(s[0] == 'points' for s in campo.selection):
|
||||
campo.selection.append(('points', 'Monedero'))
|
||||
|
||||
imp = Importer(env, cr, dry_run=args.dry_run, limit=args.limit)
|
||||
t0 = time.time()
|
||||
try:
|
||||
imp.cargar_caches()
|
||||
do_citas = not args.ventas_only
|
||||
do_ventas = not args.citas_only
|
||||
if do_citas:
|
||||
imp.parte_a1_backfill()
|
||||
imp.parte_a2_upsert()
|
||||
imp.parte_a3_recalc()
|
||||
if do_ventas:
|
||||
imp.parte_b()
|
||||
imp.imprimir_resumen()
|
||||
if not args.dry_run:
|
||||
cr.commit()
|
||||
log(f'Listo en {time.time() - t0:.0f}s')
|
||||
except Exception:
|
||||
cr.rollback()
|
||||
raise
|
||||
finally:
|
||||
cr.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
213
migracion/x_full_citas.py
Normal file
213
migracion/x_full_citas.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Extracción FULL read-only de citas del legacy SKEEN, día por día.
|
||||
|
||||
Rango: 2022-11-01 → 2026-10-10 (reanudable: guarda progreso por fecha).
|
||||
Salida: /root/migracion/extraccion/concentrado/citas_full.csv
|
||||
columnas: legacy_id, expedientes_id, paciente_telefono, paciente_nombre,
|
||||
servicio_codigo, servicio_nombre, fecha, hora, hora_fin, status,
|
||||
confirmada, visitas_id, medicos_id, observaciones, estado_mapeado,
|
||||
titulo_raw (titulo_raw = título crudo del endpoint, para fidelidad)
|
||||
|
||||
Leyenda de estado (la misma de delta_import.py):
|
||||
status 6/7/8 → bloques de agenda, NO se escriben
|
||||
status 1 → no_show | 2/3 → cancelled
|
||||
visitas_id > 0 → done | confirmada=1 → confirmed | resto → pending
|
||||
|
||||
Hallazgos del endpoint /agenda/citas/{fecha} (probe 2026-10-10):
|
||||
- NUNCA expone 'expedientes_id' (columna siempre vacía; en el CSV viejo
|
||||
solo 602/45782 filas la tenían, heredadas de otra fuente).
|
||||
- inicio/fin vienen como "HHMM" de 4 dígitos (raro 3).
|
||||
- status vistos: 0,1,2,3,4,6 (4/5 = lista de espera → caen en la ruta
|
||||
visita/confirmada/pending, igual que delta_import).
|
||||
- observaciones/titulo traen entidades HTML (ó etc.) → se decodifican
|
||||
con html.unescape; titulo_raw conserva el original.
|
||||
|
||||
Uso: /root/odoo-venv/bin/python /root/migracion/x_full_citas.py [--start YYYY-MM-DD] [--end YYYY-MM-DD]
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, '/root/migracion')
|
||||
import extraer_skeen as ex # login(), normalize_phone()
|
||||
|
||||
OUTDIR = Path('/root/migracion/extraccion/concentrado')
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
CSV_PATH = OUTDIR / 'citas_full.csv'
|
||||
PROGRESS_PATH = OUTDIR / 'citas_full.progress'
|
||||
ERROR_PATH = OUTDIR / 'errores.txt'
|
||||
|
||||
FIELDS = ['legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre',
|
||||
'servicio_codigo', 'servicio_nombre', 'fecha', 'hora', 'hora_fin',
|
||||
'status', 'confirmada', 'visitas_id', 'medicos_id', 'observaciones',
|
||||
'estado_mapeado', 'titulo_raw']
|
||||
|
||||
EXCLUDE_STATUS = {6, 7, 8}
|
||||
SLEEP = 0.35
|
||||
|
||||
|
||||
def map_state(status, confirmada, tiene_visita):
|
||||
if status in EXCLUDE_STATUS:
|
||||
return None
|
||||
if status == 1:
|
||||
return 'no_show'
|
||||
if status in (2, 3):
|
||||
return 'cancelled'
|
||||
if tiene_visita:
|
||||
return 'done'
|
||||
return 'confirmed' if confirmada else 'pending'
|
||||
|
||||
|
||||
def extract_service(text, patient_name):
|
||||
text = html.unescape(text or '')
|
||||
if patient_name:
|
||||
text = re.sub(r'^' + re.escape(patient_name) + r'\s*[-–—]\s*', '', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', text, flags=re.IGNORECASE)
|
||||
return ' '.join(text.split()).strip()
|
||||
|
||||
|
||||
def hhmm(v):
|
||||
v = str(v or '').strip()
|
||||
if len(v) == 4:
|
||||
return f'{v[:2]}:{v[2:]}'
|
||||
if len(v) == 3:
|
||||
return f'0{v[0]}:{v[1:]}'
|
||||
return ''
|
||||
|
||||
|
||||
def fetch_day(ds, max_attempts=5):
|
||||
"""GET /agenda/citas/{ds} con re-login ante expiración y backoff ante 5xx/timeout."""
|
||||
url = f'{ex.BASE}/agenda/citas/{ds}'
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
r = ex.session.get(url, headers=headers, timeout=60)
|
||||
if '/login' in r.url:
|
||||
ex.log(f'{ds}: sesión expirada (redirect /login), re-login...')
|
||||
ex.login()
|
||||
continue
|
||||
if r.status_code >= 500:
|
||||
raise requests.HTTPError(f'{r.status_code}')
|
||||
if 'json' not in (r.headers.get('Content-Type') or ''):
|
||||
ex.log(f'{ds}: respuesta no-JSON ({r.headers.get("Content-Type")}), re-login...')
|
||||
ex.login()
|
||||
continue
|
||||
return r.json().get('citas', [])
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
wait = 2 ** attempt
|
||||
ex.log(f'{ds}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)')
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f'{ds}: imposible descargar tras {max_attempts} intentos')
|
||||
|
||||
|
||||
def load_progress():
|
||||
if PROGRESS_PATH.exists():
|
||||
txt = PROGRESS_PATH.read_text().strip()
|
||||
if txt:
|
||||
try:
|
||||
return datetime.strptime(txt, '%Y-%m-%d').date() + timedelta(days=1)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--start', default='2022-11-01')
|
||||
ap.add_argument('--end', default='2026-10-10')
|
||||
args = ap.parse_args()
|
||||
|
||||
start = datetime.strptime(args.start, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(args.end, '%Y-%m-%d').date()
|
||||
|
||||
resumed = load_progress()
|
||||
if resumed and resumed > start:
|
||||
ex.log(f'Reanudando desde {resumed} (progreso previo)')
|
||||
start = resumed
|
||||
|
||||
resume_append = CSV_PATH.exists() and resumed is not None
|
||||
fout = open(CSV_PATH, 'a' if resume_append else 'w', newline='', encoding='utf-8')
|
||||
writer = csv.DictWriter(fout, fieldnames=FIELDS, extrasaction='ignore')
|
||||
if not resume_append:
|
||||
writer.writeheader()
|
||||
fout.flush()
|
||||
|
||||
ex.login()
|
||||
|
||||
raw_status = Counter()
|
||||
mapped = Counter()
|
||||
n_rows = 0
|
||||
n_days_with = 0
|
||||
n_excluded = 0
|
||||
current = start
|
||||
while current <= end:
|
||||
ds = current.strftime('%Y-%m-%d')
|
||||
try:
|
||||
citas = fetch_day(ds)
|
||||
except Exception as e:
|
||||
with open(ERROR_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(f'[citas] {ds}: {e}\n')
|
||||
ex.log(f'ERROR {ds}: {e} → registrado en errores.txt, continúo')
|
||||
citas = []
|
||||
if citas:
|
||||
n_days_with += 1
|
||||
for c in citas:
|
||||
status = c.get('status') or 0
|
||||
raw_status[status] += 1
|
||||
state = map_state(status, c.get('confirmada'), bool(c.get('visitas_id')))
|
||||
if state is None:
|
||||
n_excluded += 1
|
||||
continue
|
||||
nombre = ' '.join((c.get('nombre') or '').split())
|
||||
telefono = ex.normalize_phone(c.get('telefono') or '')
|
||||
if not telefono:
|
||||
telefono = ex.normalize_phone(c.get('whatsapp') or '')
|
||||
titulo_raw = c.get('titulo') or ''
|
||||
writer.writerow({
|
||||
'legacy_id': c.get('id', ''),
|
||||
'expedientes_id': '',
|
||||
'paciente_telefono': telefono,
|
||||
'paciente_nombre': nombre,
|
||||
'servicio_codigo': '',
|
||||
'servicio_nombre': extract_service(titulo_raw, nombre),
|
||||
'fecha': c.get('fecha') or ds,
|
||||
'hora': hhmm(c.get('inicio')),
|
||||
'hora_fin': hhmm(c.get('fin')),
|
||||
'status': status,
|
||||
'confirmada': c.get('confirmada') or 0,
|
||||
'visitas_id': c.get('visitas_id') or 0,
|
||||
'medicos_id': c.get('medicos_id') or 0,
|
||||
'observaciones': html.unescape(c.get('observaciones') or ''),
|
||||
'estado_mapeado': state,
|
||||
'titulo_raw': titulo_raw,
|
||||
})
|
||||
mapped[state] += 1
|
||||
n_rows += 1
|
||||
fout.flush()
|
||||
PROGRESS_PATH.write_text(ds)
|
||||
if citas or current.day == 1:
|
||||
ex.log(f'{ds}: {len(citas)} citas | filas CSV acumuladas: {n_rows}')
|
||||
current += timedelta(days=1)
|
||||
time.sleep(SLEEP)
|
||||
|
||||
fout.close()
|
||||
ex.log('========== RESUMEN CITAS ==========')
|
||||
ex.log(f'Período procesado: {start} → {end}')
|
||||
ex.log(f'Días con citas: {n_days_with}')
|
||||
ex.log(f'Filas escritas: {n_rows} (bloques agenda excluidos: {n_excluded})')
|
||||
ex.log(f'Status crudos: {dict(sorted(raw_status.items(), key=lambda kv: str(kv[0])))}')
|
||||
ex.log(f'Estados mapeados: {dict(mapped)}')
|
||||
ex.log(f'CSV: {CSV_PATH}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
241
migracion/x_full_concentrado.py
Normal file
241
migracion/x_full_concentrado.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Descarga mensual del reporte concentrado del legacy SKEEN (SOLO LECTURA).
|
||||
|
||||
Por cada mes 2022-11 → 2026-10 hace POST a /reportes/concentrado/excel
|
||||
(mismo formulario que la vista HTML del reporte; no modifica datos) y guarda
|
||||
el Excel en extraccion/concentrado/concentrado_mensual/concentrado_YYYY-MM.xlsx.
|
||||
Luego combina todo en concentrado_full.csv (17 columnas del reporte + mes).
|
||||
|
||||
Quirk del Excel generado por el legacy: <pageMargins left="" right="" .../>
|
||||
con cadenas vacías → openpyxl 3.1.5 revienta ("expected <class 'float'>").
|
||||
Se parchea el XML al guardar (márgenes por defecto), quedando el .xlsx en
|
||||
disco ya abrible.
|
||||
|
||||
El Excel trae 4 filas de encabezado (título, rango, vacía, cabeceras de las
|
||||
17 columnas) y desde la fila 5 los datos. No trae fila de totales (las
|
||||
totales solo salen en el HTML, tabla resumen).
|
||||
|
||||
Uso: /root/odoo-venv/bin/python /root/migracion/x_full_concentrado.py
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
sys.path.insert(0, '/root/migracion')
|
||||
import extraer_skeen as ex # login(), MESES_ES
|
||||
|
||||
OUTDIR = Path('/root/migracion/extraccion/concentrado')
|
||||
MENSUAL = OUTDIR / 'concentrado_mensual'
|
||||
MENSUAL.mkdir(parents=True, exist_ok=True)
|
||||
CSV_PATH = OUTDIR / 'concentrado_full.csv'
|
||||
ERROR_PATH = OUTDIR / 'errores.txt'
|
||||
|
||||
MESES = ['2022-11', '2022-12'] + [f'{y}-{m:02d}' for y in range(2023, 2027) for m in range(1, 13)]
|
||||
MESES = [m for m in MESES if '2022-11' <= m <= '2026-10']
|
||||
SLEEP = 1.2
|
||||
|
||||
COLS = ['folio', 'fecha', 'nombre_paciente', 'folio_dr', 'atendido_por', 'vendedor',
|
||||
'concepto', 'efectivo_usd', 'efectivo_mn', 'tarjeta_usd', 'tarjeta_mn',
|
||||
'transferencia_usd', 'transferencia_mn', 'saldo_a_favor_usd', 'saldo_a_favor_mn',
|
||||
'monedero_usd', 'monedero_mn', 'mes']
|
||||
|
||||
MARG_RE = re.compile(r'<pageMargins left="" right="" top="" bottom=""')
|
||||
|
||||
|
||||
def patch_xlsx(data: bytes) -> bytes:
|
||||
zin = zipfile.ZipFile(io.BytesIO(data))
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
buf = zin.read(item.filename)
|
||||
if item.filename.startswith('xl/worksheets/') and buf.lstrip().startswith(b'<'):
|
||||
txt = buf.decode('utf-8')
|
||||
txt = MARG_RE.sub('<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75"', txt)
|
||||
buf = txt.encode('utf-8')
|
||||
zout.writestr(item, buf)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def get_token():
|
||||
r = ex.session.get(f'{ex.BASE}/reportes/concentrado', timeout=30)
|
||||
if '/login' in r.url:
|
||||
ex.login()
|
||||
r = ex.session.get(f'{ex.BASE}/reportes/concentrado', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
inp = soup.find('input', {'name': '_token'})
|
||||
if not inp:
|
||||
raise RuntimeError('no se encontró _token en /reportes/concentrado')
|
||||
return inp['value']
|
||||
|
||||
|
||||
def download_month(ym, token, max_attempts=5):
|
||||
y, m = map(int, ym.split('-'))
|
||||
import calendar
|
||||
ini = f'{ym}-01'
|
||||
fin = f'{ym}-{calendar.monthrange(y, m)[1]:02d}'
|
||||
payload = {'_token': token, 'fecha_pago': json.dumps({'start': ini, 'end': fin})}
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
r = ex.session.post(f'{ex.BASE}/reportes/concentrado/excel', data=payload, timeout=120)
|
||||
if '/login' in r.url:
|
||||
ex.log(f'{ym}: sesión expirada, re-login...')
|
||||
ex.login()
|
||||
token = get_token()
|
||||
payload['_token'] = token
|
||||
continue
|
||||
if r.status_code >= 500:
|
||||
raise requests.HTTPError(f'{r.status_code}')
|
||||
ct = r.headers.get('Content-Type') or ''
|
||||
if 'spreadsheetml' not in ct and 'octet-stream' not in ct:
|
||||
raise ValueError(f'respuesta no-Excel: ct={ct} url={r.url} bytes={len(r.content)}')
|
||||
return patch_xlsx(r.content)
|
||||
except Exception as e:
|
||||
wait = 2 ** attempt
|
||||
ex.log(f'{ym}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)')
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f'{ym}: imposible descargar tras {max_attempts} intentos')
|
||||
|
||||
|
||||
def parse_xlsx(path, ym):
|
||||
wb = openpyxl.load_workbook(path, read_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
wb.close()
|
||||
# localizar fila de cabecera (col A == 'Folio')
|
||||
hdr_idx = None
|
||||
for i, row in enumerate(rows[:10]):
|
||||
if row and str(row[0]).strip() == 'Folio':
|
||||
hdr_idx = i
|
||||
break
|
||||
if hdr_idx is None:
|
||||
raise ValueError(f'{path}: fila de cabecera no encontrada')
|
||||
header = [str(h).strip() if h is not None else '' for h in rows[hdr_idx]]
|
||||
out = []
|
||||
for row in rows[hdr_idx + 1:]:
|
||||
if not row or row[0] is None or str(row[0]).strip() == '':
|
||||
continue
|
||||
vals = list(row) + [None] * (17 - len(row))
|
||||
d = {COLS[i]: vals[i] for i in range(17)}
|
||||
a = str(d['folio']).strip().lower()
|
||||
if a.startswith('total'):
|
||||
continue # por si algún mes trae fila de totales
|
||||
d['mes'] = ym
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def parse_fecha_es(s):
|
||||
m = re.match(r'(\d{1,2})/([A-Za-zÁÉÍÓÚáéíóúñ]+)/(\d{4})', str(s or '').strip())
|
||||
if not m:
|
||||
return None
|
||||
dia, mes, anio = m.groups()
|
||||
mn = ex.MESES_ES.get(mes.lower())
|
||||
if not mn:
|
||||
return None
|
||||
return f'{anio}-{mn}-{int(dia):02d}'
|
||||
|
||||
|
||||
def main():
|
||||
ex.login()
|
||||
token = get_token()
|
||||
|
||||
failed = []
|
||||
row_counts = {}
|
||||
all_rows = []
|
||||
for ym in MESES:
|
||||
dest = MENSUAL / f'concentrado_{ym}.xlsx'
|
||||
if dest.exists() and dest.stat().st_size > 2000:
|
||||
ex.log(f'{ym}: ya existe {dest.name} ({dest.stat().st_size} bytes), reutilizo')
|
||||
else:
|
||||
try:
|
||||
data = download_month(ym, token)
|
||||
except Exception as e:
|
||||
with open(ERROR_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(f'[concentrado] {ym}: {e}\n')
|
||||
ex.log(f'ERROR {ym}: {e} → registrado en errores.txt, continúo')
|
||||
failed.append(ym)
|
||||
time.sleep(SLEEP)
|
||||
continue
|
||||
dest.write_bytes(data)
|
||||
ex.log(f'{ym}: guardado {dest.name} ({len(data)} bytes)')
|
||||
time.sleep(SLEEP)
|
||||
try:
|
||||
rows = parse_xlsx(dest, ym)
|
||||
row_counts[ym] = len(rows)
|
||||
all_rows.extend(rows)
|
||||
except Exception as e:
|
||||
with open(ERROR_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(f'[concentrado-parse] {ym}: {e}\n')
|
||||
ex.log(f'ERROR parse {ym}: {e}')
|
||||
failed.append(ym)
|
||||
|
||||
with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f:
|
||||
w = csv.DictWriter(f, fieldnames=COLS, extrasaction='ignore')
|
||||
w.writeheader()
|
||||
for d in all_rows:
|
||||
w.writerow({k: ('' if d.get(k) is None else d.get(k)) for k in COLS})
|
||||
|
||||
ex.log('========== RESUMEN CONCENTRADO ==========')
|
||||
ex.log(f'Meses pedidos: {len(MESES)} | descargados/parseados: {len(row_counts)} | fallados: {failed}')
|
||||
ex.log(f'Filas totales concentrado_full.csv: {len(all_rows)}')
|
||||
by_year = defaultdict(int)
|
||||
for d in all_rows:
|
||||
by_year[str(d['mes'])[:4]] += 1
|
||||
ex.log(f'Filas por año: {dict(sorted(by_year.items()))}')
|
||||
ex.log(f'CSV: {CSV_PATH}')
|
||||
|
||||
# ---- Verificación cruzada: semana 2026-08-01..07 del Excel vs HTML ya guardado ----
|
||||
probe = OUTDIR / 'probe_concentrado.html'
|
||||
if probe.exists():
|
||||
soup = BeautifulSoup(probe.read_text(encoding='utf-8'), 'html.parser')
|
||||
tabs = soup.find_all('table')
|
||||
sums_html = {}
|
||||
if len(tabs) >= 1:
|
||||
for cell in tabs[0].find_all('tr')[1].find_all('td'):
|
||||
txt = cell.get_text(strip=True)
|
||||
mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*USD', txt)
|
||||
if mm:
|
||||
sums_html[mm.group(1)] = float(mm.group(2).replace(',', ''))
|
||||
for cell in tabs[0].find_all('tr')[2].find_all('td'):
|
||||
txt = cell.get_text(strip=True)
|
||||
mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*M\.N\.', txt)
|
||||
if mm:
|
||||
sums_html[mm.group(1) + ' MN'] = float(mm.group(2).replace(',', ''))
|
||||
mine = defaultdict(float)
|
||||
for d in all_rows:
|
||||
f = parse_fecha_es(d['fecha'])
|
||||
if f and '2026-08-01' <= f <= '2026-08-07':
|
||||
mine['Efectivo'] += float(d['efectivo_usd'] or 0)
|
||||
mine['Efectivo MN'] += float(d['efectivo_mn'] or 0)
|
||||
mine['Tarjeta'] += float(d['tarjeta_usd'] or 0)
|
||||
mine['Tarjeta MN'] += float(d['tarjeta_mn'] or 0)
|
||||
mine['Transferencia'] += float(d['transferencia_usd'] or 0)
|
||||
mine['Transferencia MN'] += float(d['transferencia_mn'] or 0)
|
||||
mine['Saldo a favor'] += float(d['saldo_a_favor_usd'] or 0)
|
||||
mine['Saldo a favor MN'] += float(d['saldo_a_favor_mn'] or 0)
|
||||
mine['Monedero electrónico'] += float(d['monedero_usd'] or 0)
|
||||
mine['Monedero electrónico MN'] += float(d['monedero_mn'] or 0)
|
||||
ex.log('---- Cross-check semana 2026-08-01..07 (Excel mensual vs HTML) ----')
|
||||
ok = True
|
||||
for k in sorted(set(sums_html) | set(mine)):
|
||||
a, b = sums_html.get(k, 0.0), round(mine.get(k, 0.0), 2)
|
||||
match = abs(a - b) < 0.01
|
||||
ok = ok and match
|
||||
ex.log(f' {k}: HTML={a:>12,.2f} Excel={b:>12,.2f} {"OK" if match else "DIFF"}')
|
||||
ex.log(f' RESULTADO: {"TODO COINCIDE" if ok else "HAY DIFERENCIAS"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
197
migracion/x_full_ventas.py
Normal file
197
migracion/x_full_ventas.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Extracción FULL read-only de ventas del legacy SKEEN por mes natural.
|
||||
|
||||
Rango: 2022-11 → 2026-10 (un request /ventas/json/{ini}/{fin} por mes).
|
||||
Salida (en /root/migracion/extraccion/concentrado/):
|
||||
- ventas_full.json : lista de ventas crudas (dedup por id), tal cual las
|
||||
devuelve el endpoint + _meses_vistos.
|
||||
- ventas_full.csv : una fila por (venta, concepto), con campos de pago
|
||||
principales aplanados y el desglose completo de
|
||||
métodos de pago del concepto en JSON.
|
||||
|
||||
IMPORTANTE sobre el endpoint (verificado con probes 2026-10-10):
|
||||
- /ventas/json/{inicio}/{fin} filtra por FECHA DE PAGO (fecha del
|
||||
comprobante), NO por fecha de creación de la venta. Una venta creada en
|
||||
mayo con pago en agosto aparece en el rango de agosto. Una venta con
|
||||
pagos parciales en 2 meses aparece en AMBOS (por eso el CSV viejo tenía
|
||||
2,224 legacy_id duplicados). Aquí se dedup por id conservando la
|
||||
aparición del ÚLTIMO mes (el estado de pago más acumulado).
|
||||
- Campos que el endpoint SÍ expone (keys observadas 2022 y 2026, idénticas):
|
||||
tipo, id, created_at, status, expedientes_id, nombre, doctor, motivo,
|
||||
subtotal, pagado, adeudo, comprobantes, conceptos, consumibles,
|
||||
count_pagos, puede_ver_montos, cancelada, motivo_cancelacion,
|
||||
sucursal_externa, sucursal_titulo
|
||||
- Campos que el endpoint NO expone (sorpresas): folio de venta (solo folio
|
||||
de comprobante), cantidad/precio_unitario en conceptos (el concepto trae
|
||||
titulo + subtotal + desglose de pagos, NO cantidad ni precio unitario),
|
||||
sucursal en la mayoría (sucursal_titulo suele venir vacío), y los
|
||||
montos a nivel venta (subtotal/pagado) suelen venir null — el dinero
|
||||
está a nivel concepto y comprobante.
|
||||
- conceptos[] keys: status, titulo, moneda (0=MN 1=USD), subtotal, pagado,
|
||||
pagado_dolares, pagado_pesos, pagado_tarjeta_mn, pagado_tarjeta_usd,
|
||||
pagado_metodos{pesos,dolares,tarjeta_mn,tarjeta_usd,metodos_pago{...}}
|
||||
- comprobantes: dict {id: {id, created_at, status, cancelado, folio}}
|
||||
|
||||
Uso: /root/odoo-venv/bin/python /root/migracion/x_full_ventas.py
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, '/root/migracion')
|
||||
import extraer_skeen as ex # login(), fetch_json(), dt_params()
|
||||
|
||||
OUTDIR = Path('/root/migracion/extraccion/concentrado')
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
JSON_PATH = OUTDIR / 'ventas_full.json'
|
||||
CSV_PATH = OUTDIR / 'ventas_full.csv'
|
||||
ERROR_PATH = OUTDIR / 'errores.txt'
|
||||
|
||||
MESES = ['2022-11', '2022-12'] + [f'{y}-{m:02d}' for y in range(2023, 2027) for m in range(1, 13)]
|
||||
MESES = [m for m in MESES if '2022-11' <= m <= '2026-10']
|
||||
SLEEP = 1.0
|
||||
|
||||
CSV_FIELDS = ['legacy_id', 'expedientes_id', 'paciente_nombre', 'doctor', 'fecha_creacion',
|
||||
'mes_pago', 'cancelada', 'motivo_cancelacion', 'tipo', 'status_venta',
|
||||
'subtotal_venta', 'pagado_venta', 'adeudo_usd', 'adeudo_mn', 'count_pagos',
|
||||
'comprobantes', 'concepto_idx', 'concepto_status', 'concepto_titulo',
|
||||
'concepto_moneda', 'concepto_subtotal', 'concepto_pagado',
|
||||
'concepto_pagado_dolares', 'concepto_pagado_pesos',
|
||||
'concepto_pagado_tarjeta_usd', 'concepto_pagado_tarjeta_mn',
|
||||
'concepto_pagado_metodos_json', 'motivo', 'sucursal_titulo']
|
||||
|
||||
|
||||
def last_day(ym):
|
||||
y, m = map(int, ym.split('-'))
|
||||
nxt = date(y + (m == 12), 1 if m == 12 else m + 1, 1)
|
||||
return (nxt.toordinal() - 1)
|
||||
|
||||
|
||||
def fetch_month(ym, max_attempts=5):
|
||||
y, m = map(int, ym.split('-'))
|
||||
ini = date(y, m, 1)
|
||||
fin = date.fromordinal(last_day(ym))
|
||||
params = ex.dt_params(0, 10000)
|
||||
url = f'/ventas/json/{ini.isoformat()}/{fin.isoformat()}'
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
data = ex.fetch_json(url, params)
|
||||
return data.get('data', []), data.get('recordsTotal')
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if 'login' in msg.lower():
|
||||
ex.log(f'{ym}: sesión expirada, re-login...')
|
||||
ex.login()
|
||||
continue
|
||||
wait = 2 ** attempt
|
||||
ex.log(f'{ym}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)')
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f'{ym}: imposible descargar tras {max_attempts} intentos')
|
||||
|
||||
|
||||
def main():
|
||||
ex.login()
|
||||
|
||||
seen = {} # id -> (orden de mes, record) ; el último mes gana
|
||||
order = [] # ids en orden de primera aparición
|
||||
month_counts = {}
|
||||
failed = []
|
||||
for ym in MESES:
|
||||
try:
|
||||
rows, total = fetch_month(ym)
|
||||
except Exception as e:
|
||||
with open(ERROR_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(f'[ventas] {ym}: {e}\n')
|
||||
ex.log(f'ERROR {ym}: {e} → registrado, continúo')
|
||||
failed.append(ym)
|
||||
time.sleep(SLEEP)
|
||||
continue
|
||||
month_counts[ym] = len(rows)
|
||||
for r in rows:
|
||||
rid = r.get('id')
|
||||
if rid not in seen:
|
||||
order.append(rid)
|
||||
rec = dict(r)
|
||||
rec['_mes_pago'] = ym
|
||||
rec['_meses_vistos'] = sorted(set(seen[rid][1].get('_meses_vistos', [seen[rid][1]['_mes_pago']]) + [ym])) if rid in seen else [ym]
|
||||
seen[rid] = (len(order), rec)
|
||||
ex.log(f'{ym}: {len(rows)} ventas (recordsTotal={total})')
|
||||
time.sleep(SLEEP)
|
||||
|
||||
sales = [seen[rid][1] for rid in order]
|
||||
|
||||
# JSON crudo dedup
|
||||
with open(JSON_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(sales, f, ensure_ascii=False, indent=1)
|
||||
ex.log(f'JSON: {JSON_PATH} ({len(sales)} ventas únicas)')
|
||||
|
||||
# CSV aplanado por concepto
|
||||
n_rows = 0
|
||||
with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f:
|
||||
w = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction='ignore')
|
||||
w.writeheader()
|
||||
for r in sales:
|
||||
conceptos = r.get('conceptos') or []
|
||||
if not conceptos:
|
||||
conceptos = [{}]
|
||||
adeudo = r.get('adeudo') or [0, 0]
|
||||
comps = r.get('comprobantes') or {}
|
||||
comp_txt = '; '.join(
|
||||
f"{c.get('folio','')}|{str(c.get('created_at',''))[:10]}|cancelado={c.get('cancelado')}"
|
||||
for c in comps.values())
|
||||
base = {
|
||||
'legacy_id': r.get('id', ''),
|
||||
'expedientes_id': r.get('expedientes_id') or '',
|
||||
'paciente_nombre': r.get('nombre') or '',
|
||||
'doctor': r.get('doctor') or '',
|
||||
'fecha_creacion': (r.get('created_at') or '')[:10],
|
||||
'mes_pago': r.get('_mes_pago', ''),
|
||||
'cancelada': r.get('cancelada') or 0,
|
||||
'motivo_cancelacion': r.get('motivo_cancelacion') or '',
|
||||
'tipo': r.get('tipo') or '',
|
||||
'status_venta': r.get('status') or 0,
|
||||
'subtotal_venta': r.get('subtotal') if r.get('subtotal') is not None else '',
|
||||
'pagado_venta': r.get('pagado') if r.get('pagado') is not None else '',
|
||||
'adeudo_usd': adeudo[0] if len(adeudo) > 0 else '',
|
||||
'adeudo_mn': adeudo[1] if len(adeudo) > 1 else '',
|
||||
'count_pagos': r.get('count_pagos') or 0,
|
||||
'comprobantes': comp_txt,
|
||||
'motivo': r.get('motivo') or '',
|
||||
'sucursal_titulo': r.get('sucursal_titulo') or '',
|
||||
}
|
||||
for i, c in enumerate(conceptos):
|
||||
row = dict(base)
|
||||
row.update({
|
||||
'concepto_idx': i,
|
||||
'concepto_status': c.get('status') if c else '',
|
||||
'concepto_titulo': c.get('titulo') or '',
|
||||
'concepto_moneda': c.get('moneda') if c is not None and 'moneda' in c else '',
|
||||
'concepto_subtotal': c.get('subtotal') if c else '',
|
||||
'concepto_pagado': c.get('pagado') if c else '',
|
||||
'concepto_pagado_dolares': c.get('pagado_dolares') if c else '',
|
||||
'concepto_pagado_pesos': c.get('pagado_pesos') if c else '',
|
||||
'concepto_pagado_tarjeta_usd': c.get('pagado_tarjeta_usd') if c else '',
|
||||
'concepto_pagado_tarjeta_mn': c.get('pagado_tarjeta_mn') if c else '',
|
||||
'concepto_pagado_metodos_json': json.dumps(c.get('pagado_metodos'), ensure_ascii=False) if c else '',
|
||||
})
|
||||
w.writerow(row)
|
||||
n_rows += 1
|
||||
|
||||
ex.log('========== RESUMEN VENTAS ==========')
|
||||
ex.log(f'Meses pedidos: {len(MESES)} | ok: {len(month_counts)} | fallados: {failed}')
|
||||
ex.log(f'Ventas únicas (dedup por id): {len(sales)}')
|
||||
ex.log(f'Filas CSV (venta×concepto): {n_rows}')
|
||||
canc = Counter(r.get('cancelada') for r in sales)
|
||||
ex.log(f'cancelada distribution: {dict(canc)}')
|
||||
ex.log(f'CSV: {CSV_PATH}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user