214 lines
7.8 KiB
Python
214 lines
7.8 KiB
Python
#!/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()
|