From 4842552574094fcf1a9ef5303a50556b471221af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sat, 10 Oct 2026 10:27:29 +0000 Subject: [PATCH] importacion historico legacy completo: citas con estados reales + 24,497 ventas con pagos (cuadre al centavo 48/48 meses); method points en skeen_pagos + sin acumular al canjear; scripts y doc de migracion --- docs/migracion-concentrado.md | 42 ++ migracion/importar_concentrado_full.py | 877 ++++++++++++++++++++++++ migracion/x_full_citas.py | 213 ++++++ migracion/x_full_concentrado.py | 241 +++++++ migracion/x_full_ventas.py | 197 ++++++ odoo-addons/skeen_pagos/__manifest__.py | 2 +- odoo-addons/skeen_pagos/pago.py | 201 ++++++ 7 files changed, 1772 insertions(+), 1 deletion(-) create mode 100644 docs/migracion-concentrado.md create mode 100644 migracion/importar_concentrado_full.py create mode 100644 migracion/x_full_citas.py create mode 100644 migracion/x_full_concentrado.py create mode 100644 migracion/x_full_ventas.py create mode 100644 odoo-addons/skeen_pagos/pago.py diff --git a/docs/migracion-concentrado.md b/docs/migracion-concentrado.md new file mode 100644 index 0000000..50100da --- /dev/null +++ b/docs/migracion-concentrado.md @@ -0,0 +1,42 @@ +# Migración del histórico legacy (citas + ventas/pagos) — 2026-10-10 + +Reextracción completa de la plataforma legacy (`sistema.skeenmx.app`, solo lectura) e +importación idempotente a `skeen_odoo`. Alimenta el reporte **Concentrado** (recreación +fiel al legacy: una fila por venta, desglose por método de pago × moneda). + +## Datasets extraídos (origen: `/root/migracion/extraccion/concentrado/`) + +| Archivo | Contenido | +|---|---| +| `citas_full.csv` | 57,030 citas 2022-11-07 → 2026-10-10, con `status` real del legacy (done 21,369 · cancelled 16,546 · pending 11,622 · confirmed 4,274 · no_show 3,219). Reanudable (`citas_full.progress`). | +| `concentrado_full.csv` | 29,467 filas = 48 meses del reporte concentrado legacy (Excel mensual en `concentrado_mensual/`), verificado al centavo vs el HTML del legacy. | +| `ventas_full.json` / `ventas_full.csv` | 19,278 ventas únicas con `expedientes_id` (join a pacientes vía `res.partner.legacy_id`) y desglose de pagos. | + +Scripts: `x_full_citas.py`, `x_full_ventas.py`, `x_full_concentrado.py` (extracción, +reusan `extraer_skeen.login()`; correr con `/root/odoo-venv/bin/python`). + +## Importación + +`importar_concentrado_full.py` — idempotente (upsert por `legacy_id`; re-ejecutable): + +```bash +/root/odoo-venv/bin/python /root/migracion/importar_concentrado_full.py --db skeen_odoo --permitir-produccion +``` + +Resultado en producción (2026-10-10): +- Citas: 39,565 `legacy_id` retroados, 39,564 actualizadas con estado real, 9,009 creadas, + 8,456 filas basura omitidas (NO CITAR / teléfonos inválidos). 7,351 doctores fantasma + numéricos anulados; sin resolver (doctor NULL): id 34–39, 44, 51 (~8,350 citas históricas). +- Ventas: 24,497 creadas (96.8% de folios con monto), 26,983 pagos. Cuadra **al centavo + por mes, método y moneda** contra el legacy (48/48 meses). +- Omitidos: 799 folios ($1.29M, 2.5% del dinero) cuyos pacientes (`expedientes_id`) nunca + se migraron a Odoo; 139 filas todo-$0 (sesiones de paquete). +- Las 20,127 ventas del pipeline de julio-2026 se eliminaron: eran duplicadas del mismo + histórico, sin pagos y con montos corruptos (p. ej. cantidad 126 × $12,600). + +## Requiere + +- Columna `legacy_id` en `skeen_cita` y `skeen_venta` (agregada 2026-10-10, v1.0.1). +- Métodos de pago `saldo_favor` y `points` en `skeen.pago` (v1.0.2). +- Backups previos en `/root/backups/skeen_odoo_pre_import_prod_20261010_100903.dump` + (y `skeen_preimport_20261010_095311.dump`). diff --git a/migracion/importar_concentrado_full.py b/migracion/importar_concentrado_full.py new file mode 100644 index 0000000..abadcc1 --- /dev/null +++ b/migracion/importar_concentrado_full.py @@ -0,0 +1,877 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Importa citas y ventas/pagos del legacy SKEEN a Odoo 16 (idempotente). + +Fuentes (ya extraídas, solo lectura): + - extraccion/concentrado/citas_full.csv 57,030 citas legacy (legacy_id) + - extraccion/concentrado/concentrado_full.csv 29,467 filas cobradas (17 col + mes) + - extraccion/concentrado/ventas_full.json 19,278 ventas json (expedientes_id por folio) + - extraccion/citas.csv CSV viejo (46,456 citas, tiene legacy_id) + +Parte A — Citas: + A1. Backfill de skeen_cita.legacy_id cruzando el CSV viejo y citas_full + por (teléfono normalizado, fecha, hora) contra las citas existentes. + A2. Upsert desde citas_full: existe → corrige state/doctor/notes/duration; + no existe → crea (partner por teléfono, servicio normalizado o HIST_). + A3. Recálculo de computes datetime_start/time_str (patrón recalcular_citas.py). + +Parte B — Ventas + pagos (concentrado_full.csv): + Una skeen.venta por FOLIO (los folios repetidos son varios conceptos del + mismo ticket → varias líneas), 1 skeen.pago por método×moneda > 0. + Join partner: folio → ventas_full.json expedientes_id → res.partner.legacy_id; + fallback por nombre normalizado. Sin match → se cuenta y se salta. + +Idempotencia: citas por legacy_id, ventas por legacy_id (= folio numérico). +Re-ejecutable: lo ya importado se salta o se vuelve a aplicar lo mismo. + +Uso: + /root/odoo-venv/bin/python /root/migracion/importar_concentrado_full.py \ + --db skeen_odoo [--citas-only] [--ventas-only] [--dry-run] [--limit N] + +La instancia de producción (8069/8070) no se toca: el script arranca el +bootstrap del ORM sin HTTP ni cron, igual que importar_resto_rapido.py. +""" +import argparse +import csv +import html +import json +import re +import sys +import time +import unicodedata +from collections import Counter, defaultdict +from pathlib import Path + +sys.path.insert(0, '/root/skeen-odoo') + +import odoo +from odoo import api, SUPERUSER_ID + +EXTRACCION = Path('/root/migracion/extraccion') +CITAS_FULL = EXTRACCION / 'concentrado' / 'citas_full.csv' +CONCENTRADO = EXTRACCION / 'concentrado' / 'concentrado_full.csv' +VENTAS_JSON = EXTRACCION / 'concentrado' / 'ventas_full.json' +CITAS_OLD = EXTRACCION / 'citas.csv' + +MESES_ES = { + 'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04', + 'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08', + 'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12', +} + +# medicos_id del legacy -> nombre del médico en hr.employee (los únicos +# deducidos con evidencia: postprocesar_citas.py, importar_visitas.py y el +# rescate del 8-sep). El resto de ids legacy quedan sin resolver → NULL. +MEDICOS_ID_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', +} + +METODOS = ('efectivo', 'tarjeta', 'transferencia', 'saldo_a_favor', 'monedero') +PAGO_METHOD_MAP = { + 'efectivo': 'cash', + 'tarjeta': 'card', + 'transferencia': 'transfer', + 'saldo_a_favor': 'saldo_favor', + 'monedero': 'points', +} +CONCEPTO_CATS = ( + 'SERVICIOS MEDICOS', 'PROCEDIMIENTOS COSMETICOS', 'TECNOLOGIA MEDICA', + 'COSMETOLOGIA BASICA', 'COSMETOLOGIA AVANZADA', +) + + +def log(msg): + print(f'[{time.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): + """Normalización con acentos (para matchear contra claves viejas del pipeline).""" + return ' '.join(str(s or '').split()).lower() + + +def norm_deacc(s): + """Normalización sin acentos (robustez para datos legacy con/sin tilde).""" + return strip_accents(norm(s)) + + +def phone_key(p): + """Clave de teléfono: dígitos, sin prefijo 52 (10 dígitos canónicos).""" + d = re.sub(r'\D', '', p or '') + if len(d) == 12 and d.startswith('52'): + return d[2:] + if len(d) == 11 and d.startswith('52'): + return d[2:] + return d + + +def phone_valid(p): + return len(phone_key(p)) >= 6 + + +def phone_store(p): + """Formato de alta consistente con la migración previa (prefijo 52).""" + d = re.sub(r'\D', '', p or '') + if len(d) == 10: + return '52' + d + return d + + +def hora_a_float(h): + try: + hh, mm = (h or '').split(':')[:2] + return float(hh) + float(mm) / 60.0 + except Exception: + return None + + +def duracion_min(hora, hora_fin): + hi, hf = hora_a_float(hora), hora_a_float(hora_fin) + if hi is None or hf is None: + return None + d = int(round((hf - hi) * 60)) + return d if 5 <= d <= 480 else None + + +def extract_service(text, patient_name): + """Quita prefijo 'PACIENTE - ' y sufijo '(N sesión)' (pipeline viejo, + más segunda pasada tolerante a acentos/doble espacio).""" + text = html.unescape(text or '').strip() + pat = ' '.join((patient_name or '').split()) + if pat: + text = re.sub(r'^' + re.escape(pat) + r'\s*[-–—]\s*', '', text, + flags=re.IGNORECASE) + m = re.match(r'^(.*?)\s*[-–—]\s*(.+)$', text, flags=re.DOTALL) + if m and norm_deacc(m.group(1)) == norm_deacc(pat): + text = m.group(2) + text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', + text, flags=re.IGNORECASE) + return ' '.join(text.split()).strip() + + +def concepto_service_name(concepto): + """Concepto del concentrado → nombre de servicio limpio.""" + c = html.unescape(concepto or '').strip() + c = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', + c, flags=re.IGNORECASE) + up = strip_accents(c).upper() + for cat in CONCEPTO_CATS: + if up.startswith(cat): + c = c[len(cat):].strip() + break + c = re.sub(r'\s+\d+\s*(sesión|sesiones|unidad|unidades)$', '', c, + flags=re.IGNORECASE) + c = re.sub(r'\s+\d+\s*(sesion|sesiones|unidad|unidades)$', '', c, + flags=re.IGNORECASE) + return ' '.join(c.split()).strip() + + +def parse_fecha_es(s): + """'10/Agosto/2026 04:21 pm' -> ('2026-08-10', '2026-08-10 16:21:00').""" + m = re.match(r'(\d{1,2})/([A-Za-zÁÉÍÓÚáéíóúñÑ]+)/(\d{4})\s*(.*)$', + str(s or '').strip()) + if not m: + return None, None + dia, mes, anio, resto = m.groups() + mn = MESES_ES.get(mes.lower()) + if not mn: + return None, None + fecha = f'{anio}-{mn}-{int(dia):02d}' + hora = '00:00:00' + mh = re.match(r'(\d{1,2}):(\d{2})\s*(am|pm)', resto.strip(), flags=re.IGNORECASE) + if mh: + h, mi, ap = mh.groups() + h = int(h) % 12 + (12 if ap.lower() == 'pm' else 0) + hora = f'{h:02d}:{mi}:00' + return fecha, f'{fecha} {hora}' + + +def is_garbage_name(nombre): + n = (nombre or '').strip() + if not n or n in ('.', '. .', '-', 'N/A'): + return True + return 'NO CITAR' in n.upper() + + +class Importer: + def __init__(self, env, cr, dry_run=False, limit=None): + self.env = env + self.cr = cr + self.dry_run = dry_run + self.limit = limit + self.stats = Counter() + + # ---------------- cachés ---------------- + def cargar_caches(self): + cr = self.cr + self.p_by_phone, self.p_by_name, self.p_by_legacy = {}, {}, {} + cr.execute("SELECT id, phone, name, legacy_id FROM res_partner" + " WHERE is_patient = true") + for pid, phone, name, lid in cr.fetchall(): + k = phone_key(phone) + if len(k) >= 6: + self.p_by_phone.setdefault(k, pid) + if name: + self.p_by_name.setdefault(norm_deacc(name), pid) + if lid is not None: + self.p_by_legacy[str(lid)] = pid + + # Servicios: clave estilo pipeline viejo (con acento) + sin acento + self.svc_by_key = {} + cr.execute("SELECT id, name, code FROM skeen_servicio") + self.hist_codes = set() + for sid, name, code in cr.fetchall(): + if code and code.startswith('HIST_'): + self.hist_codes.add(code) + if name: + self.svc_by_key.setdefault(norm(name), sid) + self.svc_by_key.setdefault(norm_deacc(name), sid) + + # Empleados: activos preferidos sobre inactivos de igual nombre + self.emp_by_name = {} + cr.execute("SELECT id, name, active FROM hr_employee ORDER BY active DESC, id") + empleados = [] + for eid, name, active in cr.fetchall(): + empleados.append((eid, name or '', active)) + if name and norm_deacc(name) not in self.emp_by_name: + self.emp_by_name[norm_deacc(name)] = eid + self.emp_tokens = [] + for eid, name, active in empleados: + toks = [t for t in norm_deacc(name).split() + if len(t) > 3 and t not in ('dra.', 'dr.')] + self.emp_tokens.append((eid, name, set(toks), active)) + + # Empleados fantasma numéricos ("34".."51") + cr.execute("SELECT id FROM hr_employee WHERE name ~ '^[0-9]+$'") + self.ghost_emp_ids = {r[0] for r in cr.fetchall()} + + self.cita_by_legacy = {} + cr.execute("SELECT id, legacy_id, state, doctor_id, notes, duration, servicio_id" + " FROM skeen_cita WHERE legacy_id IS NOT NULL") + for cid, lid, state, doc, notes, dur, svc in cr.fetchall(): + self.cita_by_legacy[lid] = (cid, state, doc, notes, dur, svc) + + self.venta_by_legacy = {} + cr.execute("SELECT id, legacy_id FROM skeen_venta WHERE legacy_id IS NOT NULL") + for vid, lid in cr.fetchall(): + self.venta_by_legacy[lid] = vid + + # Usuarios del frontend (vendedor -> login) + self.login_by_name = {} + cr.execute("SELECT login, name FROM skeen_frontend_user") + for login, name in cr.fetchall(): + if name and norm_deacc(name) not in self.login_by_name: + self.login_by_name[norm_deacc(name)] = login + + # Monedas + self.cur_usd = self.env.ref('base.USD').id + self.cur_mxn = self.env.ref('base.MXN').id + + # Mapa médicos legacy -> employee id (con verificación de nombre) + self.medicos_emp = {} + for mid, nombre in MEDICOS_ID_MAP.items(): + eid = self.match_employee(nombre) + if eid: + self.medicos_emp[mid] = eid + else: + log(f' !! médico del mapa sin empleado: {mid}={nombre}') + + log(f' Pacientes: {len(self.p_by_phone)} tel / {len(self.p_by_name)} nombre / {len(self.p_by_legacy)} legacy') + log(f' Servicios: {len(self.svc_by_key)} claves | Fantasmas: {sorted(self.ghost_emp_ids)}') + log(f' Citas con legacy ya: {len(self.cita_by_legacy)} | Ventas con legacy ya: {len(self.venta_by_legacy)}') + log(f' Médicos resueltos: {self.medicos_emp}') + + def match_employee(self, texto): + """Match por nombre normalizado; fallback por tokens (todos presentes).""" + t = norm_deacc(texto) + if not t: + return None + if t in self.emp_by_name: + return self.emp_by_name[t] + best = None + for eid, name, toks, active in self.emp_tokens: + if toks and all(tok in t for tok in toks): + if best is None or len(toks) > best[1]: + best = (eid, len(toks)) + return best[0] if best else None + + def resolve_service(self, nombre): + return self.svc_by_key.get(norm(nombre)) or self.svc_by_key.get(norm_deacc(nombre)) + + def get_or_create_service(self, nombre, prefijo='HIST_'): + """Reutiliza o crea servicio histórico (precio 0, 30 min). Devuelve id.""" + nombre = (nombre or '').strip() + if not nombre or nombre == '-': + nombre = 'Servicio histórico' + sid = self.resolve_service(nombre) + if sid: + return sid, False + key = norm_deacc(nombre) + code = f'{prefijo}{__import__("hashlib").md5(key.encode("utf-8")).hexdigest()[:12]}' + if code in self.hist_codes: + cr = self.cr + cr.execute("SELECT id FROM skeen_servicio WHERE code = %s", (code,)) + row = cr.fetchone() + if row: + self.svc_by_key[norm(nombre)] = row[0] + self.svc_by_key[norm_deacc(nombre)] = row[0] + return row[0], False + if self.dry_run: + return None, True + sid = self.env['skeen.servicio'].sudo().create({ + 'name': nombre, + 'code': code, + 'category': 'tratamiento', + 'price': 0, + 'duration_min': 30, + }).id + self.hist_codes.add(code) + self.svc_by_key[norm(nombre)] = sid + self.svc_by_key[norm_deacc(nombre)] = sid + return sid, True + + def get_or_create_partner(self, telefono, nombre): + k = phone_key(telefono) + pid = self.p_by_phone.get(k) + if pid: + return pid, False + if self.dry_run: + return None, True + pid = self.env['res.partner'].sudo().create({ + 'name': nombre.strip() or 'Paciente sin nombre', + 'is_patient': True, + 'phone': phone_store(telefono), + }).id + self.p_by_phone[k] = pid + self.p_by_name.setdefault(norm_deacc(nombre), pid) + return pid, True + + # ================= PARTE A ================= + def parte_a1_backfill(self): + log('A1. Backfill de legacy_id en citas existentes...') + # Índice legacy por (tel, fecha, hora): primero CSV viejo, luego full + idx = defaultdict(list) + + def add_rows(path, extra_pass): + if not path.exists(): + return 0 + n = 0 + with open(path, newline='', encoding='utf-8') as f: + for r in csv.DictReader(f): + tel = phone_key(r.get('paciente_telefono')) + hf = hora_a_float(r.get('hora')) + fecha = (r.get('fecha') or '').strip() + if not tel or hf is None or not fecha: + continue + try: + lid = int(r['legacy_id']) + except (TypeError, ValueError): + continue + svc = extract_service(r.get('servicio_nombre', ''), + r.get('paciente_nombre', '')) + idx[(tel, fecha, round(hf, 2))].append((lid, norm_deacc(svc))) + n += 1 + return n + + n_old = add_rows(CITAS_OLD, False) + n_full = add_rows(CITAS_FULL, True) + log(f' Filas indexadas: {n_old} (viejo) + {n_full} (full), {len(idx)} claves') + + # Citas actuales sin legacy_id, indexadas por la misma clave + self.cr.execute(""" + SELECT c.id, p.phone, c.date, c.time, s.name + FROM skeen_cita c + JOIN res_partner p ON p.id = c.partner_id + LEFT JOIN skeen_servicio s ON s.id = c.servicio_id + WHERE c.legacy_id IS NULL + """) + db_idx = defaultdict(list) + for cid, phone, date, timef, svcname in self.cr.fetchall(): + k = (phone_key(phone or ''), str(date), round(float(timef or 0), 2)) + db_idx[k].append((cid, norm_deacc(svcname or ''))) + + asignados, ambiguos, sin_match_csv = 0, 0, 0 + updates = [] + for k, dbs in db_idx.items(): + cands = idx.get(k) + if not cands: + sin_match_csv += len(dbs) + continue + usados_csv, usados_db = set(), set() + # 1) match por nombre de servicio + for i, (lid, svc) in enumerate(cands): + for j, (cid, dsvc) in enumerate(dbs): + if i in usados_csv or j in usados_db: + continue + if svc and dsvc and svc == dsvc: + updates.append((cid, lid)) + usados_csv.add(i) + usados_db.add(j) + # 2) el resto, por orden + for i, (lid, svc) in enumerate(cands): + if i in usados_csv: + continue + for j, (cid, dsvc) in enumerate(dbs): + if j in usados_db: + continue + updates.append((cid, lid)) + usados_csv.add(i) + usados_db.add(j) + break + # dedup: un legacy_id solo se asigna una vez (el primero gana) + vistos_lid, finales = set(), [] + for cid, lid in sorted(updates): + if lid in vistos_lid: + ambiguos += 1 + continue + vistos_lid.add(lid) + finales.append((cid, lid)) + asignados = len(finales) + self.stats['a1_backfill_asignados'] = asignados + self.stats['a1_backfill_ambiguos'] = ambiguos + self.stats['a1_sin_match_csv'] = sin_match_csv + log(f' Pares cita↔legacy: {asignados} (descartados por dup: {ambiguos});' + f' citas sin contraparte en CSV: {sin_match_csv}') + if not self.dry_run and finales: + self.cr.executemany( + 'UPDATE skeen_cita SET legacy_id = %s WHERE id = %s', + [(lid, cid) for cid, lid in finales]) + self.cr.commit() + return asignados + + def parte_a2_upsert(self): + log('A2. Upsert de citas desde citas_full.csv...') + with open(CITAS_FULL, newline='', encoding='utf-8') as f: + rows = list(csv.DictReader(f)) + if self.limit: + rows = rows[:self.limit] + + # Recargar caché de citas con legacy (incluye las del backfill A1) + self.cita_by_legacy = {} + self.cr.execute("SELECT id, legacy_id, state, doctor_id, notes, duration" + " FROM skeen_cita WHERE legacy_id IS NOT NULL") + for cid, lid, state, doc, notes, dur in self.cr.fetchall(): + self.cita_by_legacy[lid] = (cid, state, doc, notes, dur) + + Cita = self.env['skeen.cita'].sudo().with_context(tracking_disable=True) + ModeloCita = self.env.registry['skeen.cita'] + # Desactivar validaciones que bloquean carga histórica (se restauran) + orig = {m: getattr(ModeloCita, m) for m in ( + '_check_availability', '_check_bloqueos', '_check_horario_medico', + '_check_doctor_activo')} + for m in orig: + setattr(ModeloCita, m, lambda self: None) + + batch, batch_size = [], 500 + try: + for r in rows: + self.stats['a2_total'] += 1 + lid = int(r['legacy_id']) + estado = (r['estado_mapeado'] or '').strip() or 'pending' + med_id = (r['medicos_id'] or '').strip() + nombre = r['paciente_nombre'] + basura = is_garbage_name(nombre) + tel_ok = phone_valid(r['paciente_telefono']) + + existente = self.cita_by_legacy.get(lid) + if existente: + cid, state_cur, doc_cur, notes_cur, dur_cur = existente + sets = {} + if estado and estado != state_cur: + sets['state'] = estado + emp = self.medicos_emp.get(med_id) + if emp and (doc_cur is None or doc_cur in self.ghost_emp_ids): + # fantasma/NULL -> médico real resuelto + sets['doctor_id'] = emp + self.stats['a2_doctor_corregido'] += 1 + elif not emp and med_id not in ('0', '') \ + and doc_cur in self.ghost_emp_ids: + # médico legacy sin resolver: se anula el fantasma + sets['doctor_id'] = None + self.stats['a2_doctor_fantasma_anulado'] += 1 + if not emp and med_id not in ('0', ''): + self.stats.setdefault( + 'a2_medicos_sin_resolver', Counter())[med_id] += 1 + obs = (r['observaciones'] or '').strip() + if obs and obs != (notes_cur or '').strip(): + sets['notes'] = obs + dur = duracion_min(r['hora'], r['hora_fin']) + if dur and not dur_cur: + sets['duration'] = dur + if sets: + self.stats['a2_actualizadas'] += 1 + if not self.dry_run: + campos = ', '.join(f'{k} = %s' for k in sets) + self.cr.execute( + f'UPDATE skeen_cita SET {campos} WHERE id = %s', + tuple(sets.values()) + (cid,)) + else: + self.stats['a2_sin_cambios'] += 1 + continue + + # ---- crear ---- + if basura: + self.stats['a2_skip_nombre_basura'] += 1 + continue + if not tel_ok: + self.stats['a2_skip_telefono_invalido'] += 1 + continue + partner_id, creado = self.get_or_create_partner( + r['paciente_telefono'], nombre) + if creado: + self.stats['a2_partners_creados'] += 1 + if not partner_id: + self.stats['a2_skip_error'] += 1 + continue + svc_name = extract_service(r['servicio_nombre'], nombre) + if not svc_name: + m = re.match(r'^(.*?)\s*[-–—]\s*(.+)$', + html.unescape(r['titulo_raw'] or ''), flags=re.DOTALL) + svc_name = extract_service(m.group(2) if m else '', '') or 'Servicio histórico' + servicio_id, svc_creado = self.get_or_create_service(svc_name, 'HIST_') + if svc_creado: + self.stats['a2_servicios_creados'] += 1 + emp = self.medicos_emp.get(med_id) + if med_id not in ('0', '') and not emp: + self.stats.setdefault('a2_medicos_sin_resolver', Counter())[med_id] += 1 + tf = hora_a_float(r['hora']) or 9.0 + vals = { + 'legacy_id': lid, + 'partner_id': partner_id, + 'servicio_id': servicio_id, + 'date': r['fecha'], + 'time': tf, + 'state': estado, + 'doctor_id': emp, + 'branch': 'rosarito', + 'medium': 'onsite', + 'payment_state': 'not_paid', + 'amount_paid': 0, + 'notes': (r['observaciones'] or '').strip(), + } + dur = duracion_min(r['hora'], r['hora_fin']) + if dur: + vals['duration'] = dur + batch.append(vals) + self.stats['a2_creadas'] += 1 + if len(batch) >= batch_size: + if not self.dry_run: + try: + Cita.create(batch) + self.cr.commit() + except Exception as e: + self.cr.rollback() + self._crecar_uno_a_uno(Cita, batch) + batch = [] + log(f" creadas {self.stats['a2_creadas']}...") + if batch and not self.dry_run: + try: + Cita.create(batch) + self.cr.commit() + except Exception: + self.cr.rollback() + self._crecar_uno_a_uno(Cita, batch) + finally: + for m, fn in orig.items(): + setattr(ModeloCita, m, fn) + if not self.dry_run: + self.cr.commit() + log(f" citas: {dict((k, v) for k, v in self.stats.items() if k.startswith('a2_') and not isinstance(v, Counter))}") + sin_res = self.stats.get('a2_medicos_sin_resolver') + if sin_res: + log(f' medicos_id sin resolver (citas): {dict(sin_res)}') + + def _crecar_uno_a_uno(self, Modelo, batch): + """Aísla registros que rompen un lote (el resto ya se descartó).""" + for vals in batch: + try: + Modelo.create([vals]) + self.cr.commit() + except Exception as e: + self.cr.rollback() + self.stats['a2_errores_create'] += 1 + self.stats.setdefault('a2_errores_ej', []).append( + str(vals.get('legacy_id')) + ': ' + str(e)[:120]) + + def parte_a3_recalc(self): + if self.dry_run: + return + log('A3. Recalculando computes de citas...') + Citas = self.env['skeen.cita'].sudo().with_context(tracking_disable=True) + todas = Citas.search([]) + todas._compute_datetime() + todas._compute_time_str() + self.cr.commit() + nulls = Citas.search_count([('datetime_start', '=', False)]) + log(f' datetime_start NULL tras recálculo: {nulls}') + self.stats['a3_datetime_null'] = nulls + + # ================= PARTE B ================= + def _cargar_folios_json(self): + with open(VENTAS_JSON, encoding='utf-8') as f: + ventas = json.load(f) + folio2exp, folios_cancelados = {}, set() + for v in ventas: + for c in (v.get('comprobantes') or {}).values(): + f2 = (c.get('folio') or '').strip() + if not f2: + continue + if c.get('cancelado'): + folios_cancelados.add(f2) + if f2 not in folio2exp and v.get('expedientes_id'): + folio2exp[f2] = v['expedientes_id'] + return folio2exp, folios_cancelados + + def parte_b(self): + log('B. Importando ventas + pagos desde concentrado_full.csv...') + with open(CONCENTRADO, newline='', encoding='utf-8') as f: + rows = list(csv.DictReader(f)) + if self.limit: + rows = rows[:self.limit] + + folio2exp, folios_cancelados = self._cargar_folios_json() + self.stats['b_folios_json'] = len(folio2exp) + self.stats['b_folios_cancelados_json'] = len(folios_cancelados) + + # agrupar filas por folio (mismo ticket = varias líneas) + grupos = defaultdict(list) + orden = [] + for r in rows: + f = r['folio'].strip() + if f not in grupos: + orden.append(f) + grupos[f].append(r) + + Venta = self.env['skeen.venta'].sudo().with_context(tracking_disable=True) + Pago = self.env['skeen.pago'].sudo().with_context(tracking_disable=True) + + vbatch, pbatch = [], [] + for folio in orden: + g = grupos[folio] + m = re.match(r'^[A-Za-z]*(\d+)$', folio) + if not m: + self.stats['b_skip_folio_raro'] += 1 + continue + lid = int(m.group(1)) + if lid in self.venta_by_legacy: + self.stats['b_skip_ya_existe'] += 1 + continue + + # filas con monto; si todas son cero se salta el ticket + def monto_fila(r): + return sum(float(r[f'{mm}_{c}'] or 0) + for mm in METODOS for c in ('usd', 'mn')) + filas = [r for r in g if monto_fila(r) > 0] + if not filas: + self.stats['b_skip_todo_cero'] += len(g) + continue + + rep = filas[0] + fecha, fecha_dt = parse_fecha_es(rep['fecha']) + if not fecha: + self.stats['b_skip_fecha_mala'] += 1 + continue + + # partner: join folio -> expedientes_id -> legacy; fallback nombre + exp = folio2exp.get(folio) + partner_id = self.p_by_legacy.get(str(exp)) if exp is not None else None + via = 'expedientes_id' + if not partner_id: + partner_id = self.p_by_name.get(norm_deacc(rep['nombre_paciente'])) + via = 'nombre' + if not partner_id: + self.stats['b_skip_sin_paciente'] += 1 + self.stats.setdefault('b_sin_paciente_folios', []).append(folio) + continue + if via == 'expedientes_id': + self.stats['b_match_expedientes'] += 1 + else: + self.stats['b_match_nombre'] += 1 + + total = round(sum(monto_fila(r) for r in filas), 2) + + # desglose pagos por método x moneda + pagos = {} + for mm in METODOS: + for c in ('usd', 'mn'): + s = round(sum(float(r[f'{mm}_{c}'] or 0) for r in filas), 2) + if s > 0: + pagos[(mm, c)] = s + metodos_usados = {mm for (mm, c) in pagos} + + vendedor = (rep['vendedor'] or '').strip() + user_login = self.login_by_name.get(norm_deacc(vendedor), vendedor or False) + + cancelada = folio in folios_cancelados + lineas = [] + for r in filas: + sname = concepto_service_name(r['concepto']) + sid, _ = self.get_or_create_service(sname, 'HISTV_') + if not sid: + self.stats['b_skip_error'] += 1 + lineas = None + break + lineas.append((0, 0, { + 'service_id': sid, + 'description': sname, + 'quantity': 1, + 'price_unit': round(monto_fila(r), 2), + 'prescribed_by_id': self.match_employee(r.get('atendido_por') or ''), + })) + if lineas is None: + continue + self.stats['b_lineas'] += len(lineas) + + vals = { + 'name': folio, + 'legacy_id': lid, + 'partner_id': partner_id, + 'date': fecha, + 'amount_paid': 0 if cancelada else total, + 'state': 'cancelled' if cancelada else 'paid', + 'user_login': user_login, + 'payment_method': 'mixed' if len(metodos_usados) > 1 else ( + PAGO_METHOD_MAP[list(metodos_usados)[0]] if metodos_usados else 'none'), + 'line_ids': lineas, + 'notes': f"Importado de concentrado legacy ({rep['mes']})", + } + vbatch.append((vals, pagos, partner_id, fecha_dt, cancelada)) + self.stats['b_creadas'] += 1 + + if len(vbatch) >= 300: + self._flush_ventas(Venta, Pago, vbatch, pbatch) + vbatch, pbatch = [], [] + log(f" ventas {self.stats['b_creadas']} (skip: sin paciente {self.stats['b_skip_sin_paciente']}, ya existe {self.stats['b_skip_ya_existe']}, $0 {self.stats['b_skip_todo_cero']})...") + if vbatch: + self._flush_ventas(Venta, Pago, vbatch, pbatch) + log(f" ventas: creadas {self.stats['b_creadas']}, ya existían {self.stats['b_skip_ya_existe']}," + f" sin paciente {self.stats['b_skip_sin_paciente']}, todo-cero {self.stats['b_skip_todo_cero']}") + log(f" match paciente: expedientes {self.stats['b_match_expedientes']}, nombre {self.stats['b_match_nombre']}") + log(f" pagos creados {self.stats['b_pagos_creados']} | líneas {self.stats['b_lineas']}" + f" | servicios HISTV_ creados {self.stats['b_servicios_creados']}") + + def _flush_ventas(self, Venta, Pago, vbatch, pbatch): + if self.dry_run: + return + try: + creadas = Venta.create([v for v, _, _, _, _ in vbatch]) + self.cr.commit() + except Exception as e: + self.cr.rollback() + if len(vbatch) == 1: + self.stats['b_errores_create'] += 1 + self.stats.setdefault('b_errores_ej', []).append( + str(vbatch[0][0].get('name')) + ': ' + str(e)[:150]) + return + log(f' !! lote de {len(vbatch)} ventas falló ({str(e)[:100]}); reintento uno a uno') + for item in vbatch: + self._flush_ventas(Venta, Pago, [item], pbatch) + return + for venta, (vals, pagos, partner_id, fecha_dt, cancelada) in zip(creadas, vbatch): + self.venta_by_legacy[vals['legacy_id']] = venta.id + if cancelada: + continue + for (mm, cur), amount in pagos.items(): + pbatch.append({ + 'venta_id': venta.id, + 'partner_id': partner_id, + 'amount': amount, + 'currency_id': self.cur_usd if cur == 'usd' else self.cur_mxn, + 'payment_method': PAGO_METHOD_MAP[mm], + 'state': 'completed', + 'payment_date': fecha_dt, + }) + self.stats['b_pagos_creados'] += 1 + if pbatch: + try: + Pago.create(pbatch) + pbatch.clear() + self.cr.commit() + except Exception as e: + self.cr.rollback() + self.stats['b_errores_pagos'] += len(pbatch) + self.stats.setdefault('b_errores_ej', []).append('pagos: ' + str(e)[:150]) + pbatch.clear() + + # ================= resumen ================= + def imprimir_resumen(self): + log('========== RESUMEN ==========') + for k in sorted(self.stats): + v = self.stats[k] + if isinstance(v, Counter): + log(f' {k}: {dict(v)}') + elif isinstance(v, list): + log(f' {k}: {len(v)} (ej: {v[:8]})') + else: + log(f' {k}: {v}') + + +def main(): + parser = argparse.ArgumentParser(description='Importación idempotente concentrado SKEEN') + parser.add_argument('--db', default='skeen_odoo', help='Base de datos destino') + parser.add_argument('--odoo-config', default='/root/skeen-odoo/odoo.conf') + parser.add_argument('--citas-only', action='store_true') + parser.add_argument('--ventas-only', action='store_true') + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--limit', type=int, default=None) + parser.add_argument('--permitir-produccion', action='store_true', + help='Requerido para escribir en skeen_odoo/clinica2_odoo') + args = parser.parse_args() + + if args.db in ('skeen_odoo', 'clinica2_odoo') and not args.dry_run \ + and not args.permitir_produccion: + # Seguridad: la corrida real contra producción exige el flag explícito. + log(f'REFUSADO: --db={args.db} es producción. Usa una copia (p. ej. skeen_import_test)' + f' o añade --permitir-produccion si es lo que realmente quieres.') + sys.exit(2) + + log(f'Conectando a {args.db} (dry_run={args.dry_run}, limit={args.limit})...') + odoo.tools.config.parse_config(['-c', args.odoo_config, '--no-http', + '--log-level', 'error']) + db = odoo.sql_db.db_connect(args.db) + cr = db.cursor() + env = api.Environment(cr, SUPERUSER_ID, {}) + + # 'points' no está en el selection de skeen.pago (lag del addon) pero el + # endpoint concentrado sí agrupa points->monedero. Se extiende el selection + # en runtime (sin tocar addons) para registrar pagos de monedero reales. + campo = env['skeen.pago']._fields['payment_method'] + if ('points', 'Monedero') not in campo.selection and \ + not any(s[0] == 'points' for s in campo.selection): + campo.selection.append(('points', 'Monedero')) + + imp = Importer(env, cr, dry_run=args.dry_run, limit=args.limit) + t0 = time.time() + try: + imp.cargar_caches() + do_citas = not args.ventas_only + do_ventas = not args.citas_only + if do_citas: + imp.parte_a1_backfill() + imp.parte_a2_upsert() + imp.parte_a3_recalc() + if do_ventas: + imp.parte_b() + imp.imprimir_resumen() + if not args.dry_run: + cr.commit() + log(f'Listo en {time.time() - t0:.0f}s') + except Exception: + cr.rollback() + raise + finally: + cr.close() + + +if __name__ == '__main__': + main() diff --git a/migracion/x_full_citas.py b/migracion/x_full_citas.py new file mode 100644 index 0000000..ab21641 --- /dev/null +++ b/migracion/x_full_citas.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Extracción FULL read-only de citas del legacy SKEEN, día por día. + +Rango: 2022-11-01 → 2026-10-10 (reanudable: guarda progreso por fecha). +Salida: /root/migracion/extraccion/concentrado/citas_full.csv + columnas: legacy_id, expedientes_id, paciente_telefono, paciente_nombre, + servicio_codigo, servicio_nombre, fecha, hora, hora_fin, status, + confirmada, visitas_id, medicos_id, observaciones, estado_mapeado, + titulo_raw (titulo_raw = título crudo del endpoint, para fidelidad) + +Leyenda de estado (la misma de delta_import.py): + status 6/7/8 → bloques de agenda, NO se escriben + status 1 → no_show | 2/3 → cancelled + visitas_id > 0 → done | confirmada=1 → confirmed | resto → pending + +Hallazgos del endpoint /agenda/citas/{fecha} (probe 2026-10-10): + - NUNCA expone 'expedientes_id' (columna siempre vacía; en el CSV viejo + solo 602/45782 filas la tenían, heredadas de otra fuente). + - inicio/fin vienen como "HHMM" de 4 dígitos (raro 3). + - status vistos: 0,1,2,3,4,6 (4/5 = lista de espera → caen en la ruta + visita/confirmada/pending, igual que delta_import). + - observaciones/titulo traen entidades HTML (ó etc.) → se decodifican + con html.unescape; titulo_raw conserva el original. + +Uso: /root/odoo-venv/bin/python /root/migracion/x_full_citas.py [--start YYYY-MM-DD] [--end YYYY-MM-DD] +""" +import argparse +import csv +import html +import re +import sys +import time +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path + +import requests + +sys.path.insert(0, '/root/migracion') +import extraer_skeen as ex # login(), normalize_phone() + +OUTDIR = Path('/root/migracion/extraccion/concentrado') +OUTDIR.mkdir(parents=True, exist_ok=True) +CSV_PATH = OUTDIR / 'citas_full.csv' +PROGRESS_PATH = OUTDIR / 'citas_full.progress' +ERROR_PATH = OUTDIR / 'errores.txt' + +FIELDS = ['legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre', + 'servicio_codigo', 'servicio_nombre', 'fecha', 'hora', 'hora_fin', + 'status', 'confirmada', 'visitas_id', 'medicos_id', 'observaciones', + 'estado_mapeado', 'titulo_raw'] + +EXCLUDE_STATUS = {6, 7, 8} +SLEEP = 0.35 + + +def map_state(status, confirmada, tiene_visita): + 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' + + +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 hhmm(v): + v = str(v or '').strip() + if len(v) == 4: + return f'{v[:2]}:{v[2:]}' + if len(v) == 3: + return f'0{v[0]}:{v[1:]}' + return '' + + +def fetch_day(ds, max_attempts=5): + """GET /agenda/citas/{ds} con re-login ante expiración y backoff ante 5xx/timeout.""" + url = f'{ex.BASE}/agenda/citas/{ds}' + headers = {'X-Requested-With': 'XMLHttpRequest'} + for attempt in range(1, max_attempts + 1): + try: + r = ex.session.get(url, headers=headers, timeout=60) + if '/login' in r.url: + ex.log(f'{ds}: sesión expirada (redirect /login), re-login...') + ex.login() + continue + if r.status_code >= 500: + raise requests.HTTPError(f'{r.status_code}') + if 'json' not in (r.headers.get('Content-Type') or ''): + ex.log(f'{ds}: respuesta no-JSON ({r.headers.get("Content-Type")}), re-login...') + ex.login() + continue + return r.json().get('citas', []) + except (requests.RequestException, ValueError) as e: + wait = 2 ** attempt + ex.log(f'{ds}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)') + time.sleep(wait) + raise RuntimeError(f'{ds}: imposible descargar tras {max_attempts} intentos') + + +def load_progress(): + if PROGRESS_PATH.exists(): + txt = PROGRESS_PATH.read_text().strip() + if txt: + try: + return datetime.strptime(txt, '%Y-%m-%d').date() + timedelta(days=1) + except ValueError: + pass + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--start', default='2022-11-01') + ap.add_argument('--end', default='2026-10-10') + args = ap.parse_args() + + start = datetime.strptime(args.start, '%Y-%m-%d').date() + end = datetime.strptime(args.end, '%Y-%m-%d').date() + + resumed = load_progress() + if resumed and resumed > start: + ex.log(f'Reanudando desde {resumed} (progreso previo)') + start = resumed + + resume_append = CSV_PATH.exists() and resumed is not None + fout = open(CSV_PATH, 'a' if resume_append else 'w', newline='', encoding='utf-8') + writer = csv.DictWriter(fout, fieldnames=FIELDS, extrasaction='ignore') + if not resume_append: + writer.writeheader() + fout.flush() + + ex.login() + + raw_status = Counter() + mapped = Counter() + n_rows = 0 + n_days_with = 0 + n_excluded = 0 + current = start + while current <= end: + ds = current.strftime('%Y-%m-%d') + try: + citas = fetch_day(ds) + except Exception as e: + with open(ERROR_PATH, 'a', encoding='utf-8') as f: + f.write(f'[citas] {ds}: {e}\n') + ex.log(f'ERROR {ds}: {e} → registrado en errores.txt, continúo') + citas = [] + if citas: + n_days_with += 1 + for c in citas: + status = c.get('status') or 0 + raw_status[status] += 1 + state = map_state(status, c.get('confirmada'), bool(c.get('visitas_id'))) + if state is None: + n_excluded += 1 + continue + nombre = ' '.join((c.get('nombre') or '').split()) + telefono = ex.normalize_phone(c.get('telefono') or '') + if not telefono: + telefono = ex.normalize_phone(c.get('whatsapp') or '') + titulo_raw = c.get('titulo') or '' + writer.writerow({ + 'legacy_id': c.get('id', ''), + 'expedientes_id': '', + 'paciente_telefono': telefono, + 'paciente_nombre': nombre, + 'servicio_codigo': '', + 'servicio_nombre': extract_service(titulo_raw, nombre), + 'fecha': c.get('fecha') or ds, + 'hora': hhmm(c.get('inicio')), + 'hora_fin': hhmm(c.get('fin')), + 'status': status, + 'confirmada': c.get('confirmada') or 0, + 'visitas_id': c.get('visitas_id') or 0, + 'medicos_id': c.get('medicos_id') or 0, + 'observaciones': html.unescape(c.get('observaciones') or ''), + 'estado_mapeado': state, + 'titulo_raw': titulo_raw, + }) + mapped[state] += 1 + n_rows += 1 + fout.flush() + PROGRESS_PATH.write_text(ds) + if citas or current.day == 1: + ex.log(f'{ds}: {len(citas)} citas | filas CSV acumuladas: {n_rows}') + current += timedelta(days=1) + time.sleep(SLEEP) + + fout.close() + ex.log('========== RESUMEN CITAS ==========') + ex.log(f'Período procesado: {start} → {end}') + ex.log(f'Días con citas: {n_days_with}') + ex.log(f'Filas escritas: {n_rows} (bloques agenda excluidos: {n_excluded})') + ex.log(f'Status crudos: {dict(sorted(raw_status.items(), key=lambda kv: str(kv[0])))}') + ex.log(f'Estados mapeados: {dict(mapped)}') + ex.log(f'CSV: {CSV_PATH}') + + +if __name__ == '__main__': + main() diff --git a/migracion/x_full_concentrado.py b/migracion/x_full_concentrado.py new file mode 100644 index 0000000..09cfe64 --- /dev/null +++ b/migracion/x_full_concentrado.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Descarga mensual del reporte concentrado del legacy SKEEN (SOLO LECTURA). + +Por cada mes 2022-11 → 2026-10 hace POST a /reportes/concentrado/excel +(mismo formulario que la vista HTML del reporte; no modifica datos) y guarda +el Excel en extraccion/concentrado/concentrado_mensual/concentrado_YYYY-MM.xlsx. +Luego combina todo en concentrado_full.csv (17 columnas del reporte + mes). + +Quirk del Excel generado por el legacy: +con cadenas vacías → openpyxl 3.1.5 revienta ("expected "). +Se parchea el XML al guardar (márgenes por defecto), quedando el .xlsx en +disco ya abrible. + +El Excel trae 4 filas de encabezado (título, rango, vacía, cabeceras de las +17 columnas) y desde la fila 5 los datos. No trae fila de totales (las +totales solo salen en el HTML, tabla resumen). + +Uso: /root/odoo-venv/bin/python /root/migracion/x_full_concentrado.py +""" +import csv +import io +import json +import re +import sys +import time +import zipfile +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +import openpyxl +import requests +from bs4 import BeautifulSoup + +sys.path.insert(0, '/root/migracion') +import extraer_skeen as ex # login(), MESES_ES + +OUTDIR = Path('/root/migracion/extraccion/concentrado') +MENSUAL = OUTDIR / 'concentrado_mensual' +MENSUAL.mkdir(parents=True, exist_ok=True) +CSV_PATH = OUTDIR / 'concentrado_full.csv' +ERROR_PATH = OUTDIR / 'errores.txt' + +MESES = ['2022-11', '2022-12'] + [f'{y}-{m:02d}' for y in range(2023, 2027) for m in range(1, 13)] +MESES = [m for m in MESES if '2022-11' <= m <= '2026-10'] +SLEEP = 1.2 + +COLS = ['folio', 'fecha', 'nombre_paciente', 'folio_dr', 'atendido_por', 'vendedor', + 'concepto', 'efectivo_usd', 'efectivo_mn', 'tarjeta_usd', 'tarjeta_mn', + 'transferencia_usd', 'transferencia_mn', 'saldo_a_favor_usd', 'saldo_a_favor_mn', + 'monedero_usd', 'monedero_mn', 'mes'] + +MARG_RE = re.compile(r' bytes: + zin = zipfile.ZipFile(io.BytesIO(data)) + out = io.BytesIO() + with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + buf = zin.read(item.filename) + if item.filename.startswith('xl/worksheets/') and buf.lstrip().startswith(b'<'): + txt = buf.decode('utf-8') + txt = MARG_RE.sub('= 500: + raise requests.HTTPError(f'{r.status_code}') + ct = r.headers.get('Content-Type') or '' + if 'spreadsheetml' not in ct and 'octet-stream' not in ct: + raise ValueError(f'respuesta no-Excel: ct={ct} url={r.url} bytes={len(r.content)}') + return patch_xlsx(r.content) + except Exception as e: + wait = 2 ** attempt + ex.log(f'{ym}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)') + time.sleep(wait) + raise RuntimeError(f'{ym}: imposible descargar tras {max_attempts} intentos') + + +def parse_xlsx(path, ym): + wb = openpyxl.load_workbook(path, read_only=True) + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + wb.close() + # localizar fila de cabecera (col A == 'Folio') + hdr_idx = None + for i, row in enumerate(rows[:10]): + if row and str(row[0]).strip() == 'Folio': + hdr_idx = i + break + if hdr_idx is None: + raise ValueError(f'{path}: fila de cabecera no encontrada') + header = [str(h).strip() if h is not None else '' for h in rows[hdr_idx]] + out = [] + for row in rows[hdr_idx + 1:]: + if not row or row[0] is None or str(row[0]).strip() == '': + continue + vals = list(row) + [None] * (17 - len(row)) + d = {COLS[i]: vals[i] for i in range(17)} + a = str(d['folio']).strip().lower() + if a.startswith('total'): + continue # por si algún mes trae fila de totales + d['mes'] = ym + out.append(d) + return out + + +def parse_fecha_es(s): + m = re.match(r'(\d{1,2})/([A-Za-zÁÉÍÓÚáéíóúñ]+)/(\d{4})', str(s or '').strip()) + if not m: + return None + dia, mes, anio = m.groups() + mn = ex.MESES_ES.get(mes.lower()) + if not mn: + return None + return f'{anio}-{mn}-{int(dia):02d}' + + +def main(): + ex.login() + token = get_token() + + failed = [] + row_counts = {} + all_rows = [] + for ym in MESES: + dest = MENSUAL / f'concentrado_{ym}.xlsx' + if dest.exists() and dest.stat().st_size > 2000: + ex.log(f'{ym}: ya existe {dest.name} ({dest.stat().st_size} bytes), reutilizo') + else: + try: + data = download_month(ym, token) + except Exception as e: + with open(ERROR_PATH, 'a', encoding='utf-8') as f: + f.write(f'[concentrado] {ym}: {e}\n') + ex.log(f'ERROR {ym}: {e} → registrado en errores.txt, continúo') + failed.append(ym) + time.sleep(SLEEP) + continue + dest.write_bytes(data) + ex.log(f'{ym}: guardado {dest.name} ({len(data)} bytes)') + time.sleep(SLEEP) + try: + rows = parse_xlsx(dest, ym) + row_counts[ym] = len(rows) + all_rows.extend(rows) + except Exception as e: + with open(ERROR_PATH, 'a', encoding='utf-8') as f: + f.write(f'[concentrado-parse] {ym}: {e}\n') + ex.log(f'ERROR parse {ym}: {e}') + failed.append(ym) + + with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f: + w = csv.DictWriter(f, fieldnames=COLS, extrasaction='ignore') + w.writeheader() + for d in all_rows: + w.writerow({k: ('' if d.get(k) is None else d.get(k)) for k in COLS}) + + ex.log('========== RESUMEN CONCENTRADO ==========') + ex.log(f'Meses pedidos: {len(MESES)} | descargados/parseados: {len(row_counts)} | fallados: {failed}') + ex.log(f'Filas totales concentrado_full.csv: {len(all_rows)}') + by_year = defaultdict(int) + for d in all_rows: + by_year[str(d['mes'])[:4]] += 1 + ex.log(f'Filas por año: {dict(sorted(by_year.items()))}') + ex.log(f'CSV: {CSV_PATH}') + + # ---- Verificación cruzada: semana 2026-08-01..07 del Excel vs HTML ya guardado ---- + probe = OUTDIR / 'probe_concentrado.html' + if probe.exists(): + soup = BeautifulSoup(probe.read_text(encoding='utf-8'), 'html.parser') + tabs = soup.find_all('table') + sums_html = {} + if len(tabs) >= 1: + for cell in tabs[0].find_all('tr')[1].find_all('td'): + txt = cell.get_text(strip=True) + mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*USD', txt) + if mm: + sums_html[mm.group(1)] = float(mm.group(2).replace(',', '')) + for cell in tabs[0].find_all('tr')[2].find_all('td'): + txt = cell.get_text(strip=True) + mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*M\.N\.', txt) + if mm: + sums_html[mm.group(1) + ' MN'] = float(mm.group(2).replace(',', '')) + mine = defaultdict(float) + for d in all_rows: + f = parse_fecha_es(d['fecha']) + if f and '2026-08-01' <= f <= '2026-08-07': + mine['Efectivo'] += float(d['efectivo_usd'] or 0) + mine['Efectivo MN'] += float(d['efectivo_mn'] or 0) + mine['Tarjeta'] += float(d['tarjeta_usd'] or 0) + mine['Tarjeta MN'] += float(d['tarjeta_mn'] or 0) + mine['Transferencia'] += float(d['transferencia_usd'] or 0) + mine['Transferencia MN'] += float(d['transferencia_mn'] or 0) + mine['Saldo a favor'] += float(d['saldo_a_favor_usd'] or 0) + mine['Saldo a favor MN'] += float(d['saldo_a_favor_mn'] or 0) + mine['Monedero electrónico'] += float(d['monedero_usd'] or 0) + mine['Monedero electrónico MN'] += float(d['monedero_mn'] or 0) + ex.log('---- Cross-check semana 2026-08-01..07 (Excel mensual vs HTML) ----') + ok = True + for k in sorted(set(sums_html) | set(mine)): + a, b = sums_html.get(k, 0.0), round(mine.get(k, 0.0), 2) + match = abs(a - b) < 0.01 + ok = ok and match + ex.log(f' {k}: HTML={a:>12,.2f} Excel={b:>12,.2f} {"OK" if match else "DIFF"}') + ex.log(f' RESULTADO: {"TODO COINCIDE" if ok else "HAY DIFERENCIAS"}') + + +if __name__ == '__main__': + main() diff --git a/migracion/x_full_ventas.py b/migracion/x_full_ventas.py new file mode 100644 index 0000000..38e165f --- /dev/null +++ b/migracion/x_full_ventas.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Extracción FULL read-only de ventas del legacy SKEEN por mes natural. + +Rango: 2022-11 → 2026-10 (un request /ventas/json/{ini}/{fin} por mes). +Salida (en /root/migracion/extraccion/concentrado/): + - ventas_full.json : lista de ventas crudas (dedup por id), tal cual las + devuelve el endpoint + _meses_vistos. + - ventas_full.csv : una fila por (venta, concepto), con campos de pago + principales aplanados y el desglose completo de + métodos de pago del concepto en JSON. + +IMPORTANTE sobre el endpoint (verificado con probes 2026-10-10): + - /ventas/json/{inicio}/{fin} filtra por FECHA DE PAGO (fecha del + comprobante), NO por fecha de creación de la venta. Una venta creada en + mayo con pago en agosto aparece en el rango de agosto. Una venta con + pagos parciales en 2 meses aparece en AMBOS (por eso el CSV viejo tenía + 2,224 legacy_id duplicados). Aquí se dedup por id conservando la + aparición del ÚLTIMO mes (el estado de pago más acumulado). + - Campos que el endpoint SÍ expone (keys observadas 2022 y 2026, idénticas): + tipo, id, created_at, status, expedientes_id, nombre, doctor, motivo, + subtotal, pagado, adeudo, comprobantes, conceptos, consumibles, + count_pagos, puede_ver_montos, cancelada, motivo_cancelacion, + sucursal_externa, sucursal_titulo + - Campos que el endpoint NO expone (sorpresas): folio de venta (solo folio + de comprobante), cantidad/precio_unitario en conceptos (el concepto trae + titulo + subtotal + desglose de pagos, NO cantidad ni precio unitario), + sucursal en la mayoría (sucursal_titulo suele venir vacío), y los + montos a nivel venta (subtotal/pagado) suelen venir null — el dinero + está a nivel concepto y comprobante. + - conceptos[] keys: status, titulo, moneda (0=MN 1=USD), subtotal, pagado, + pagado_dolares, pagado_pesos, pagado_tarjeta_mn, pagado_tarjeta_usd, + pagado_metodos{pesos,dolares,tarjeta_mn,tarjeta_usd,metodos_pago{...}} + - comprobantes: dict {id: {id, created_at, status, cancelado, folio}} + +Uso: /root/odoo-venv/bin/python /root/migracion/x_full_ventas.py +""" +import csv +import json +import sys +import time +from collections import Counter +from datetime import date +from pathlib import Path + +import requests + +sys.path.insert(0, '/root/migracion') +import extraer_skeen as ex # login(), fetch_json(), dt_params() + +OUTDIR = Path('/root/migracion/extraccion/concentrado') +OUTDIR.mkdir(parents=True, exist_ok=True) +JSON_PATH = OUTDIR / 'ventas_full.json' +CSV_PATH = OUTDIR / 'ventas_full.csv' +ERROR_PATH = OUTDIR / 'errores.txt' + +MESES = ['2022-11', '2022-12'] + [f'{y}-{m:02d}' for y in range(2023, 2027) for m in range(1, 13)] +MESES = [m for m in MESES if '2022-11' <= m <= '2026-10'] +SLEEP = 1.0 + +CSV_FIELDS = ['legacy_id', 'expedientes_id', 'paciente_nombre', 'doctor', 'fecha_creacion', + 'mes_pago', 'cancelada', 'motivo_cancelacion', 'tipo', 'status_venta', + 'subtotal_venta', 'pagado_venta', 'adeudo_usd', 'adeudo_mn', 'count_pagos', + 'comprobantes', 'concepto_idx', 'concepto_status', 'concepto_titulo', + 'concepto_moneda', 'concepto_subtotal', 'concepto_pagado', + 'concepto_pagado_dolares', 'concepto_pagado_pesos', + 'concepto_pagado_tarjeta_usd', 'concepto_pagado_tarjeta_mn', + 'concepto_pagado_metodos_json', 'motivo', 'sucursal_titulo'] + + +def last_day(ym): + y, m = map(int, ym.split('-')) + nxt = date(y + (m == 12), 1 if m == 12 else m + 1, 1) + return (nxt.toordinal() - 1) + + +def fetch_month(ym, max_attempts=5): + y, m = map(int, ym.split('-')) + ini = date(y, m, 1) + fin = date.fromordinal(last_day(ym)) + params = ex.dt_params(0, 10000) + url = f'/ventas/json/{ini.isoformat()}/{fin.isoformat()}' + for attempt in range(1, max_attempts + 1): + try: + data = ex.fetch_json(url, params) + return data.get('data', []), data.get('recordsTotal') + except Exception as e: + msg = str(e) + if 'login' in msg.lower(): + ex.log(f'{ym}: sesión expirada, re-login...') + ex.login() + continue + wait = 2 ** attempt + ex.log(f'{ym}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)') + time.sleep(wait) + raise RuntimeError(f'{ym}: imposible descargar tras {max_attempts} intentos') + + +def main(): + ex.login() + + seen = {} # id -> (orden de mes, record) ; el último mes gana + order = [] # ids en orden de primera aparición + month_counts = {} + failed = [] + for ym in MESES: + try: + rows, total = fetch_month(ym) + except Exception as e: + with open(ERROR_PATH, 'a', encoding='utf-8') as f: + f.write(f'[ventas] {ym}: {e}\n') + ex.log(f'ERROR {ym}: {e} → registrado, continúo') + failed.append(ym) + time.sleep(SLEEP) + continue + month_counts[ym] = len(rows) + for r in rows: + rid = r.get('id') + if rid not in seen: + order.append(rid) + rec = dict(r) + rec['_mes_pago'] = ym + rec['_meses_vistos'] = sorted(set(seen[rid][1].get('_meses_vistos', [seen[rid][1]['_mes_pago']]) + [ym])) if rid in seen else [ym] + seen[rid] = (len(order), rec) + ex.log(f'{ym}: {len(rows)} ventas (recordsTotal={total})') + time.sleep(SLEEP) + + sales = [seen[rid][1] for rid in order] + + # JSON crudo dedup + with open(JSON_PATH, 'w', encoding='utf-8') as f: + json.dump(sales, f, ensure_ascii=False, indent=1) + ex.log(f'JSON: {JSON_PATH} ({len(sales)} ventas únicas)') + + # CSV aplanado por concepto + n_rows = 0 + with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f: + w = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction='ignore') + w.writeheader() + for r in sales: + conceptos = r.get('conceptos') or [] + if not conceptos: + conceptos = [{}] + adeudo = r.get('adeudo') or [0, 0] + comps = r.get('comprobantes') or {} + comp_txt = '; '.join( + f"{c.get('folio','')}|{str(c.get('created_at',''))[:10]}|cancelado={c.get('cancelado')}" + for c in comps.values()) + base = { + 'legacy_id': r.get('id', ''), + 'expedientes_id': r.get('expedientes_id') or '', + 'paciente_nombre': r.get('nombre') or '', + 'doctor': r.get('doctor') or '', + 'fecha_creacion': (r.get('created_at') or '')[:10], + 'mes_pago': r.get('_mes_pago', ''), + 'cancelada': r.get('cancelada') or 0, + 'motivo_cancelacion': r.get('motivo_cancelacion') or '', + 'tipo': r.get('tipo') or '', + 'status_venta': r.get('status') or 0, + 'subtotal_venta': r.get('subtotal') if r.get('subtotal') is not None else '', + 'pagado_venta': r.get('pagado') if r.get('pagado') is not None else '', + 'adeudo_usd': adeudo[0] if len(adeudo) > 0 else '', + 'adeudo_mn': adeudo[1] if len(adeudo) > 1 else '', + 'count_pagos': r.get('count_pagos') or 0, + 'comprobantes': comp_txt, + 'motivo': r.get('motivo') or '', + 'sucursal_titulo': r.get('sucursal_titulo') or '', + } + for i, c in enumerate(conceptos): + row = dict(base) + row.update({ + 'concepto_idx': i, + 'concepto_status': c.get('status') if c else '', + 'concepto_titulo': c.get('titulo') or '', + 'concepto_moneda': c.get('moneda') if c is not None and 'moneda' in c else '', + 'concepto_subtotal': c.get('subtotal') if c else '', + 'concepto_pagado': c.get('pagado') if c else '', + 'concepto_pagado_dolares': c.get('pagado_dolares') if c else '', + 'concepto_pagado_pesos': c.get('pagado_pesos') if c else '', + 'concepto_pagado_tarjeta_usd': c.get('pagado_tarjeta_usd') if c else '', + 'concepto_pagado_tarjeta_mn': c.get('pagado_tarjeta_mn') if c else '', + 'concepto_pagado_metodos_json': json.dumps(c.get('pagado_metodos'), ensure_ascii=False) if c else '', + }) + w.writerow(row) + n_rows += 1 + + ex.log('========== RESUMEN VENTAS ==========') + ex.log(f'Meses pedidos: {len(MESES)} | ok: {len(month_counts)} | fallados: {failed}') + ex.log(f'Ventas únicas (dedup por id): {len(sales)}') + ex.log(f'Filas CSV (venta×concepto): {n_rows}') + canc = Counter(r.get('cancelada') for r in sales) + ex.log(f'cancelada distribution: {dict(canc)}') + ex.log(f'CSV: {CSV_PATH}') + + +if __name__ == '__main__': + main() diff --git a/odoo-addons/skeen_pagos/__manifest__.py b/odoo-addons/skeen_pagos/__manifest__.py index e329459..eca883c 100644 --- a/odoo-addons/skeen_pagos/__manifest__.py +++ b/odoo-addons/skeen_pagos/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'SKEEN Pagos', - 'version': '1.0.1', + 'version': '1.0.2', 'category': 'Healthcare', 'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN', 'description': """ diff --git a/odoo-addons/skeen_pagos/pago.py b/odoo-addons/skeen_pagos/pago.py new file mode 100644 index 0000000..1988f15 --- /dev/null +++ b/odoo-addons/skeen_pagos/pago.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +import requests +import json + + +class SkeenPago(models.Model): + _name = 'skeen.pago' + _description = 'Pago de Servicio' + _order = 'create_date desc' + + # Referencia + name = fields.Char(string='Referencia', readonly=True, copy=False) + + # Paciente y Cita + partner_id = fields.Many2one('res.partner', string='Paciente', required=True) + cita_id = fields.Many2one('skeen.cita', string='Cita') + + # Monto + amount = fields.Float(string='Monto', required=True) + currency_id = fields.Many2one('res.currency', string='Moneda', default=lambda self: self.env.ref('base.MXN')) + + # Método de pago + payment_method = fields.Selection([ + ('stripe', 'Stripe'), + ('mercadopago', 'MercadoPago'), + ('cash', 'Efectivo'), + ('card', 'Tarjeta'), + ('transfer', 'Transferencia'), + ('saldo_favor', 'Saldo a favor'), + ('points', 'Monedero'), + ], string='Método de Pago', required=True) + + # Estado + state = fields.Selection([ + ('pending', 'Pendiente'), + ('processing', 'Procesando'), + ('completed', 'Completado'), + ('failed', 'Fallido'), + ('refunded', 'Reembolsado'), + ], string='Estado', default='pending') + + # Datos del proveedor de pago + provider_reference = fields.Char(string='Referencia Proveedor') + provider_response = fields.Text(string='Respuesta Proveedor') + + # URLs + payment_url = fields.Char(string='URL de Pago') + success_url = fields.Char(string='URL de Éxito') + cancel_url = fields.Char(string='URL de Cancelación') + + # Fechas + create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now) + payment_date = fields.Datetime(string='Fecha de Pago') + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('name'): + vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001' + return super(SkeenPago, self).create(vals_list) + + def action_generate_stripe_link(self): + """Generar link de pago Stripe""" + self.ensure_one() + stripe_key = self.env['ir.config_parameter'].sudo().get_param('skeen.stripe_secret_key') + if not stripe_key: + raise ValidationError(_('Stripe no está configurado!')) + + # Crear sesión de checkout + headers = { + 'Authorization': f'Bearer {stripe_key}', + 'Content-Type': 'application/x-www-form-urlencoded', + } + data = { + 'payment_method_types[]': 'card', + 'line_items[0][price_data][currency]': 'mxn', + 'line_items[0][price_data][product_data][name]': f'Servicio SKEEN - {self.cita_id.servicio_id.name}', + 'line_items[0][price_data][unit_amount]': int(self.amount * 100), + 'line_items[0][quantity]': 1, + 'mode': 'payment', + 'success_url': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}', + 'cancel_url': f'https://app.skeen.mx/pagos/cancelado?ref={self.name}', + 'metadata[payment_id]': str(self.id), + 'metadata[patient_phone]': self.partner_id.phone or '', + } + + response = requests.post('https://api.stripe.com/v1/checkout/sessions', headers=headers, data=data) + if response.status_code == 200: + session = response.json() + self.write({ + 'payment_url': session['url'], + 'provider_reference': session['id'], + 'provider_response': json.dumps(session), + 'state': 'processing', + }) + else: + self.write({ + 'provider_response': response.text, + 'state': 'failed', + }) + + return True + + def action_generate_mercadopago_link(self): + """Generar link de pago MercadoPago""" + self.ensure_one() + mp_token = self.env['ir.config_parameter'].sudo().get_param('skeen.mercadopago_access_token') + if not mp_token: + raise ValidationError(_('MercadoPago no está configurado!')) + + headers = { + 'Authorization': f'Bearer {mp_token}', + 'Content-Type': 'application/json', + } + data = { + 'items': [{ + 'title': f'Servicio SKEEN - {self.cita_id.servicio_id.name}', + 'quantity': 1, + 'unit_price': int(self.amount), + }], + 'back_urls': { + 'success': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}', + 'failure': f'https://app.skeen.mx/pagos/fallido?ref={self.name}', + 'pending': f'https://app.skeen.mx/pagos/pendiente?ref={self.name}', + }, + 'notification_url': 'https://skeen.mx/webhooks/mercadopago', + 'metadata': { + 'payment_id': str(self.id), + 'patient_phone': self.partner_id.phone or '', + }, + } + + response = requests.post('https://api.mercadopago.com/checkout/preferences', headers=headers, json=data) + if response.status_code == 201: + preference = response.json() + self.write({ + 'payment_url': preference['init_point'], + 'provider_reference': preference['id'], + 'provider_response': json.dumps(preference), + 'state': 'processing', + }) + else: + self.write({ + 'provider_response': response.text, + 'state': 'failed', + }) + + return True + + def action_confirm_payment(self): + """Confirmar pago recibido""" + self.ensure_one() + self.write({ + 'state': 'completed', + 'payment_date': fields.Datetime.now(), + }) + + # Actualizar estado de pago de la cita + if self.cita_id: + self.cita_id.write({ + 'payment_state': 'paid', + 'amount_paid': self.amount, + }) + + # Acumular puntos en monedero (excepto si el pago es canje de monedero) + if self.payment_method == 'points': + return True + monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1) + if not monedero: + monedero = self.env['skeen.monedero'].create({ + 'partner_id': self.partner_id.id, + }) + + points = int(self.amount / 10) # 1 punto por cada $10 + monedero.add_points( + points=points, + description=f'Pago de servicio - {self.cita_id.servicio_id.name if self.cita_id else "N/A"}', + reference=self.name, + ) + + return True + + def action_refund(self): + """Procesar reembolso""" + self.ensure_one() + self.write({'state': 'refunded'}) + + # Restar puntos del monedero + monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1) + if monedero: + points = int(self.amount / 10) + monedero.redeem_points( + points=points, + description=f'Reembolso - {self.name}', + reference=self.name, + ) + + return True