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
This commit is contained in:
238
migracion/importar_visitas.py
Normal file
238
migracion/importar_visitas.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user