#!/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()