Files
SKEEN-Proyecto/migracion/importar_visitas.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

239 lines
8.3 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Importa las VISITAS del sistema legacy SKEEN a skeen.visita (idempotente, dedup por legacy_id).
- Legacy SOLO LECTURA. Crudo guardado en extraccion/visitas_legacy_YYYYMMDD_HHMMSS.json.
- date_start: created_at interpretado en America/Tijuana (convertido a UTC para que
los filtros del frontend por fecha caigan en el día correcto).
- doctor_id: matching por tokens (sin acentos) de medico_principal contra hr.employee.
- cosmetologa_id: mapa fijo de ids legacy conocidos ({79: XIMENA, 80: ELY}); otros → False
(el legacy no expone endpoint de catálogo — /medicos/json no existe).
- state = 'completada' para todas (los status legacy son del flujo viejo y no mapean limpio).
Uso: /root/odoo-venv/bin/python /root/migracion/importar_visitas.py
"""
import html
import json
import sys
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
sys.path.insert(0, '/root/migracion')
sys.path.insert(0, '/root/skeen-odoo')
import extraer_skeen as ex # login(), fetch_json()
import odoo
from odoo import api, SUPERUSER_ID
TZ_LOCAL = ZoneInfo('America/Tijuana')
STAMP = datetime.now().strftime('%Y%m%d_%H%M%S')
RAW_PATH = Path(f'/root/migracion/extraccion/visitas_legacy_{STAMP}.json')
# Cosmetólogas conocidas del legacy (id → nombre en hr.employee)
COSMETOLOGAS_LEGACY = {79: 'XIMENA', 80: 'ELY'}
def log(msg):
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}', flush=True)
def strip_accents(s):
return ''.join(c for c in unicodedata.normalize('NFD', s or '') if unicodedata.category(c) != 'Mn')
def norm(s):
return ' '.join(strip_accents(str(s or '')).lower().split())
def visitas_params(start, length):
"""Params DataTables completos (el endpoint da 500 si faltan)"""
p = {
'draw': 1, 'start': start, 'length': length,
'search[value]': '', 'search[regex]': 'false',
'fecha_registro': '', 'medicos_id': '', 'cosmetologos_id': '',
'order[0][column]': '0', 'order[0][dir]': 'desc',
'_': int(time.time() * 1000),
}
for i in range(10):
p[f'columns[{i}][data]'] = str(i)
p[f'columns[{i}][searchable]'] = 'true'
p[f'columns[{i}][orderable]'] = 'true'
p[f'columns[{i}][search][value]'] = ''
p[f'columns[{i}][search][regex]'] = 'false'
return p
def main():
# ---------- 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, {})
Visita = env['skeen.visita'].sudo()
log('Cargando cachés de Odoo...')
partner_by_legacy = {}
cr.execute("SELECT id, legacy_id FROM res_partner WHERE legacy_id IS NOT NULL AND legacy_id != ''")
for pid, lid in cr.fetchall():
partner_by_legacy[str(lid)] = pid
existing_visitas = set()
cr.execute("SELECT legacy_id FROM skeen_visita WHERE legacy_id IS NOT NULL AND legacy_id != ''")
existing_visitas = {r[0] for r in cr.fetchall()}
service_by_name = {}
cr.execute("SELECT id, name FROM skeen_servicio")
for sid, name in cr.fetchall():
if name:
service_by_name.setdefault(norm(name), sid)
employees = []
cr.execute("SELECT id, name FROM hr_employee")
for eid, name in cr.fetchall():
employees.append((eid, name or ''))
# tokens significativos por empleado (sin dr/dra ni tokens cortos)
emp_tokens = []
for eid, name in employees:
toks = [t for t in norm(name).split() if len(t) > 3 and t not in ('dra.', 'dr.')]
if toks:
emp_tokens.append((eid, name, set(toks)))
log(f' Partners con legacy_id: {len(partner_by_legacy)} | Visitas ya importadas: {len(existing_visitas)} | Empleados: {len(employees)}')
def match_doctor(texto):
t = norm(texto)
if not t:
return False
best = False
for eid, name, toks in emp_tokens:
if toks and all(tok in t for tok in toks):
# preferir el match con más tokens (más específico)
if not best or len(toks) > best[1]:
best = (eid, len(toks))
return best[0] if best else False
employee_by_name = {norm(name): eid for eid, name in employees}
cosmetologa_map = {}
for cid, nombre in COSMETOLOGAS_LEGACY.items():
eid = employee_by_name.get(norm(nombre))
if eid:
cosmetologa_map[cid] = eid
log(f' Cosmetólogas mapeadas: {cosmetologa_map}')
# ---------- Legacy ----------
ex.login()
log('Descargando visitas del legacy...')
rows = []
start = 0
page_size = 500
while True:
data = ex.fetch_json('/visitas/json', visitas_params(start, page_size))
chunk = data.get('data', [])
if not chunk:
break
rows.extend(chunk)
total = data.get('recordsTotal') or 0
log(f' página start={start}: {len(chunk)} (acumulado {len(rows)}/{total})')
if len(chunk) < page_size:
break
start += page_size
time.sleep(0.3)
log(f'Total visitas legacy: {len(rows)}')
with open(RAW_PATH, 'w', encoding='utf-8') as f:
json.dump(rows, f, ensure_ascii=False)
log(f'Crudo guardado en {RAW_PATH}')
# ---------- Importación ----------
importadas = 0
omitidas_sin_paciente = 0
omitidas_existentes = 0
errores = 0
batch = []
for r in rows:
legacy_id = str(r.get('id') or '').strip()
if not legacy_id:
continue
if legacy_id in existing_visitas:
omitidas_existentes += 1
continue
exp_id = str(r.get('expedientes_id') or '').strip()
partner_id = partner_by_legacy.get(exp_id)
if not partner_id:
omitidas_sin_paciente += 1
continue
# created_at legacy es hora local Tijuana → UTC
created = (r.get('created_at') or '').strip()
try:
dt_local = datetime.strptime(created, '%Y-%m-%d %H:%M:%S').replace(tzinfo=TZ_LOCAL)
date_start = dt_local.astimezone(ZoneInfo('UTC')).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
date_start = created or False
motivo = html.unescape(r.get('otro_motivo') or '').strip()
conceptos = r.get('conceptos') or []
partes = []
for c in conceptos:
titulo = html.unescape(c.get('titulo') or '').strip()
subtotal = c.get('subtotal') or ''
partes.append(f'{titulo} — ${subtotal}' if subtotal else titulo)
notas = f"Conceptos: {'; '.join(partes)}" if partes else ''
cosmetologa_id = cosmetologa_map.get(r.get('cosmetologos_id') or 0, False)
# Servicio: match exacto normalizado del primer concepto (no crea servicios)
servicio_id = False
if conceptos:
servicio_id = service_by_name.get(norm(html.unescape(conceptos[0].get('titulo') or '')), False)
batch.append({
'legacy_id': legacy_id,
'partner_id': partner_id,
'doctor_id': match_doctor(r.get('medico_principal')),
'cosmetologa_id': cosmetologa_id,
'servicio_id': servicio_id,
'date_start': date_start,
'motivo': motivo,
'notas': notas,
'state': 'completada',
})
existing_visitas.add(legacy_id) # dedup dentro del mismo lote
if len(batch) >= 500:
try:
Visita.create(batch)
importadas += len(batch)
batch = []
cr.commit()
log(f' {importadas} visitas importadas...')
except Exception as e:
cr.rollback()
log(f' ERROR en lote: {e}')
errores += len(batch)
batch = []
if batch:
try:
Visita.create(batch)
importadas += len(batch)
cr.commit()
except Exception as e:
cr.rollback()
log(f' ERROR en lote final: {e}')
errores += len(batch)
cr.close()
log('========== RESUMEN VISITAS ==========')
log(f'Importadas: {importadas}')
log(f'Ya existían (legacy_id): {omitidas_existentes}')
log(f'Omitidas sin paciente: {omitidas_sin_paciente}')
log(f'Errores: {errores}')
if __name__ == '__main__':
main()