- 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
239 lines
8.3 KiB
Python
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()
|