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