- 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
358 lines
13 KiB
Python
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()
|