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:
2026-10-10 10:27:29 +00:00
parent 2523635716
commit 4842552574
7 changed files with 1772 additions and 1 deletions

View 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
View 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 (&oacute; 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()

View 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
View 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()