Files
SKEEN-Proyecto/migracion/delta_import.py
Consultoría Alcaraz Salazar 01f6007e30 Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
2026-08-13 23:30:08 +00:00

358 lines
13 KiB
Python

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