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