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

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