#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Delta import SKEEN: trae citas y pacientes nuevos del legacy desde la última migración hasta hoy (+60 días de futuras), SIN duplicar. - Legacy: SOLO LECTURA (reusa login/fetch de extraer_skeen.py). - Citas: dedup por terna (partner_id, date, time). Existentes: actualiza state solo con información que el legacy sí expone (ver STATUS_MAP). El endpoint de agenda NO expone pagos, así que payment_state/amount_paid no se tocan. - Pacientes: crea solo legacy_id nuevo Y teléfono nuevo. Sin detalle clínico nuevo; usa caché detalle_pacientes.json para email/fecha_nac/sexo si existe. Uso: /root/odoo-venv/bin/python /root/migracion/delta_import.py """ import csv import html import json import re import sys import time import hashlib from datetime import datetime, timedelta from pathlib import Path sys.path.insert(0, '/root/migracion') sys.path.insert(0, '/root/skeen-odoo') import extraer_skeen as ex # login(), fetch_json(), paginate(), dt_params(), normalize_phone() import odoo from odoo import api, SUPERUSER_ID START = '2026-06-01' HOY = datetime.now().date() END = HOY + timedelta(days=60) STAMP = datetime.now().strftime('%Y%m%d_%H%M%S') OUTDIR = Path('/root/migracion/extraccion') # Leyenda de status del legacy (radios name="status" en /agenda): # 0 Normal, 1 No vino, 2 Canceló/Reagendó, 3 Canceló, 4/5 Lista de espera, # 6 No citar, 7 Vacaciones, 8 Horario de comida EXCLUDE_STATUS = {6, 7, 8} def map_state(status, confirmada, tiene_visita): """Estado Odoo según status legacy. None = no importar (bloques de agenda).""" 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' # Solo avanzar estados: nunca regresar done/arrived/etc. a confirmed/pending RANK = {'pending': 0, 'confirmed': 1, 'arrived': 2, 'in_progress': 3, 'done': 4, 'cancelled': 5, 'no_show': 5} def should_update(actual, nuevo): if nuevo == actual: return False if nuevo in ('cancelled', 'no_show'): return actual not in ('cancelled', 'no_show') return RANK.get(nuevo, 0) > RANK.get(actual, 0) def normalize_name(name): return ' '.join(str(name or '').split()).lower() def parse_time_hhmm(inicio): inicio = (inicio or '').strip() if len(inicio) == 4: return float(inicio[:2]) + float(inicio[2:]) / 60.0 if len(inicio) == 3: return float(inicio[0]) + float(inicio[1:]) / 60.0 return 9.0 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 log(msg): print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}', flush=True) def main(): # ---------- Conexión Odoo ---------- odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf']) db = odoo.sql_db.db_connect('skeen_odoo') cr = db.cursor() env = api.Environment(cr, SUPERUSER_ID, {}) Partner = env['res.partner'].sudo() Servicio = env['skeen.servicio'].sudo() CitaModel = env.registry['skeen.cita'] Cita = env['skeen.cita'].sudo() # Desactivar validación de solapamiento durante la importación original_check = CitaModel._check_availability CitaModel._check_availability = lambda self: None # ---------- Cachés Odoo ---------- log('Cargando cachés de Odoo...') partner_by_legacy = {} partner_by_phone = {} cr.execute("SELECT id, phone, legacy_id FROM res_partner WHERE is_patient = true AND active = true") for pid, phone, legacy_id in cr.fetchall(): if legacy_id: partner_by_legacy[str(legacy_id)] = pid if phone: partner_by_phone.setdefault(phone, pid) service_by_name = {} cr.execute("SELECT id, name FROM skeen_servicio") for sid, name in cr.fetchall(): if name: service_by_name.setdefault(normalize_name(name), sid) doctor_by_name = {} cr.execute("SELECT id, name FROM hr_employee") for eid, name in cr.fetchall(): if name: doctor_by_name.setdefault(normalize_name(name), eid) # Citas existentes en el rango (dedup + posibles actualizaciones) existing_citas = {} cr.execute( "SELECT id, partner_id, date, time, state FROM skeen_cita WHERE date >= %s AND date <= %s", (START, END.strftime('%Y-%m-%d'))) for cid, pid, d, t, state in cr.fetchall(): existing_citas[(pid, d.strftime('%Y-%m-%d'), round(float(t), 2))] = (cid, state) log(f' Pacientes: {len(partner_by_legacy)} legacy / {len(partner_by_phone)} teléfonos | ' f'Servicios: {len(service_by_name)} | Citas en rango: {len(existing_citas)}') medicos_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', } def get_or_create_service(name): name = name.strip() or 'Servicio genérico' key = normalize_name(name) if key in service_by_name: return service_by_name[key] code = 'HIST_' + hashlib.md5(key.encode('utf-8')).hexdigest()[:12] new_s = Servicio.create({'name': name, 'code': code, 'category': 'tratamiento', 'price': 0, 'duration_min': 30}) service_by_name[key] = new_s.id return new_s.id # ---------- Login legacy ---------- ex.login() # ---------- 1. Pacientes nuevos ---------- log('Descargando expedientes del legacy...') exp_rows = ex.paginate('/expedientes/json', lambda s, l: ex.dt_params(s, l), 'expedientes') detail_cache = ex.load_detail_cache() pacientes_nuevos = 0 omitidos_legacy = 0 omitidos_phone = 0 omitidos_invalid = 0 delta_pacientes = [] for r in exp_rows: legacy_id = str(r.get('id') or '').strip() nombre = (r.get('nombre') or '').strip() if not legacy_id or not nombre or nombre.upper().startswith('. . NO CITAR'): omitidos_invalid += 1 continue if legacy_id in partner_by_legacy: omitidos_legacy += 1 continue phone = ex.normalize_phone((r.get('telefono') or '').strip()) if phone and phone in partner_by_phone: omitidos_phone += 1 continue if not phone: omitidos_invalid += 1 continue detail = detail_cache.get(legacy_id, {}) email = (detail.get('email') or '').strip() if email.lower() in ('no@no.com', 'no@no'): email = '' medico = (r.get('medico_principal') or '').strip() doctor_id = doctor_by_name.get(normalize_name(medico)) if medico else False vals = { 'name': nombre, 'phone': phone, 'legacy_id': legacy_id, 'is_patient': True, 'source': 'other', # el Selection no admite 'legacy' 'is_vip': bool(r.get('vip')), } if email: vals['email'] = email if detail.get('fecha_nacimiento'): vals['birth_date'] = detail['fecha_nacimiento'] if detail.get('sexo'): vals['gender'] = detail['sexo'] if doctor_id: vals['primary_doctor_id'] = doctor_id if (r.get('notas') or '').strip(): vals['patient_comments'] = r['notas'].strip() Partner.create(vals) pacientes_nuevos += 1 partner_by_legacy[legacy_id] = True # placeholder, id real no necesario abajo if phone: partner_by_phone[phone] = True delta_pacientes.append({'legacy_id': legacy_id, 'nombre': nombre, 'telefono': phone, 'folio': r.get('folio', ''), 'vip': '1' if r.get('vip') else '0'}) if pacientes_nuevos % 50 == 0: cr.commit() log(f' {pacientes_nuevos} pacientes nuevos...') cr.commit() log(f'Pacientes nuevos creados: {pacientes_nuevos} ' f'(omitidos: {omitidos_legacy} por legacy_id, {omitidos_phone} por teléfono, {omitidos_invalid} inválidos)') # Recargar mapa legacy_id → id real (los nuevos ya están en BD) cr.execute("SELECT id, legacy_id FROM res_partner WHERE legacy_id IS NOT NULL AND legacy_id != ''") partner_by_legacy = {str(lid): pid for pid, lid in cr.fetchall()} # ---------- 2. Citas del delta ---------- log(f'Descargando citas legacy {START} → {END}...') delta_rows = [] current = datetime.strptime(START, '%Y-%m-%d').date() while current <= END: ds = current.strftime('%Y-%m-%d') try: citas = ex.fetch_json(f'/agenda/citas/{ds}').get('citas', []) except Exception as e: log(f' ERROR {ds}: {e}') citas = [] for c in citas: delta_rows.append(c) current += timedelta(days=1) time.sleep(0.05) log(f'Citas legacy descargadas (crudo): {len(delta_rows)}') nuevas = 0 actualizadas = 0 omitidas_sin_paciente = 0 omitidas_estado = 0 ya_existian = 0 batch = [] csv_rows = [] for c in delta_rows: nombre = (c.get('nombre') or '').strip() if not nombre or nombre.upper().startswith('. . NO CITAR'): continue status = c.get('status') or 0 tiene_visita = bool(c.get('visitas_id')) state = map_state(status, c.get('confirmada'), tiene_visita) if state is None: omitidas_estado += 1 continue expediente_id = str(c.get('expedientes_id') or '').strip() partner_id = partner_by_legacy.get(expediente_id) if expediente_id else None if not partner_id: phone = ex.normalize_phone(c.get('telefono') or '') or ex.normalize_phone(c.get('whatsapp') or '') partner_id = partner_by_phone.get(phone) if phone else None if not partner_id: omitidas_sin_paciente += 1 continue fecha = (c.get('fecha') or '').strip() if not fecha: continue hora = round(parse_time_hhmm(c.get('inicio')), 2) key = (partner_id, fecha, hora) servicio_nombre = extract_service(c.get('titulo', ''), nombre) or 'Servicio genérico' medico_id = str(c.get('medicos_id') or '') doctor_nombre = medicos_map.get(medico_id, '') doctor_id = doctor_by_name.get(normalize_name(doctor_nombre)) if doctor_nombre else False notas = html.unescape(c.get('observaciones') or '') csv_rows.append({'legacy_id': c.get('id', ''), 'expedientes_id': expediente_id, 'paciente': nombre, 'servicio': servicio_nombre, 'fecha': fecha, 'hora': c.get('inicio', ''), 'estado': state}) if key in existing_citas: ya_existian += 1 cid, estado_actual = existing_citas[key] if should_update(estado_actual, state): Cita.browse(cid).write({'state': state}) actualizadas += 1 continue batch.append({ 'partner_id': partner_id, 'servicio_id': get_or_create_service(servicio_nombre), 'date': fecha, 'time': hora, 'state': state, 'payment_state': 'not_paid', 'amount_paid': 0, 'doctor_id': doctor_id, 'branch': 'rosarito', 'medium': 'onsite', 'notes': notas, }) existing_citas[key] = (None, state) # dedup dentro del mismo delta if len(batch) >= 200: Cita.create(batch) nuevas += len(batch) batch = [] cr.commit() log(f' {nuevas} citas nuevas...') if batch: Cita.create(batch) nuevas += len(batch) cr.commit() # ---------- Guardar CSVs del delta (archivos nuevos, no toca originales) ---------- if csv_rows: with open(OUTDIR / f'delta_citas_{STAMP}.csv', 'w', newline='', encoding='utf-8') as f: w = csv.DictWriter(f, fieldnames=['legacy_id', 'expedientes_id', 'paciente', 'servicio', 'fecha', 'hora', 'estado'], extrasaction='ignore') w.writeheader() w.writerows(csv_rows) if delta_pacientes: with open(OUTDIR / f'delta_pacientes_{STAMP}.csv', 'w', newline='', encoding='utf-8') as f: w = csv.DictWriter(f, fieldnames=['legacy_id', 'nombre', 'telefono', 'folio', 'vip']) w.writeheader() w.writerows(delta_pacientes) # Restaurar validación y cerrar CitaModel._check_availability = original_check cr.commit() cr.close() log('========== RESUMEN DELTA ==========') log(f'Pacientes nuevos: {pacientes_nuevos}') log(f'Citas nuevas: {nuevas}') log(f'Citas actualizadas: {actualizadas}') log(f'Citas ya existentes: {ya_existian}') log(f'Citas omitidas (sin paciente): {omitidas_sin_paciente}') log(f'Citas omitidas (bloques agenda): {omitidas_estado}') log(f'CSVs: extraccion/delta_citas_{STAMP}.csv / delta_pacientes_{STAMP}.csv') if __name__ == '__main__': main()