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
This commit is contained in:
42
docs/migracion-concentrado.md
Normal file
42
docs/migracion-concentrado.md
Normal file
@@ -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`).
|
||||||
877
migracion/importar_concentrado_full.py
Normal file
877
migracion/importar_concentrado_full.py
Normal file
@@ -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()
|
||||||
213
migracion/x_full_citas.py
Normal file
213
migracion/x_full_citas.py
Normal file
@@ -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()
|
||||||
241
migracion/x_full_concentrado.py
Normal file
241
migracion/x_full_concentrado.py
Normal file
@@ -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: <pageMargins left="" right="" .../>
|
||||||
|
con cadenas vacías → openpyxl 3.1.5 revienta ("expected <class 'float'>").
|
||||||
|
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'<pageMargins left="" right="" top="" bottom=""')
|
||||||
|
|
||||||
|
|
||||||
|
def patch_xlsx(data: bytes) -> 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('<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75"', txt)
|
||||||
|
buf = txt.encode('utf-8')
|
||||||
|
zout.writestr(item, buf)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
r = ex.session.get(f'{ex.BASE}/reportes/concentrado', timeout=30)
|
||||||
|
if '/login' in r.url:
|
||||||
|
ex.login()
|
||||||
|
r = ex.session.get(f'{ex.BASE}/reportes/concentrado', timeout=30)
|
||||||
|
soup = BeautifulSoup(r.text, 'html.parser')
|
||||||
|
inp = soup.find('input', {'name': '_token'})
|
||||||
|
if not inp:
|
||||||
|
raise RuntimeError('no se encontró _token en /reportes/concentrado')
|
||||||
|
return inp['value']
|
||||||
|
|
||||||
|
|
||||||
|
def download_month(ym, token, max_attempts=5):
|
||||||
|
y, m = map(int, ym.split('-'))
|
||||||
|
import calendar
|
||||||
|
ini = f'{ym}-01'
|
||||||
|
fin = f'{ym}-{calendar.monthrange(y, m)[1]:02d}'
|
||||||
|
payload = {'_token': token, 'fecha_pago': json.dumps({'start': ini, 'end': fin})}
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
r = ex.session.post(f'{ex.BASE}/reportes/concentrado/excel', data=payload, timeout=120)
|
||||||
|
if '/login' in r.url:
|
||||||
|
ex.log(f'{ym}: sesión expirada, re-login...')
|
||||||
|
ex.login()
|
||||||
|
token = get_token()
|
||||||
|
payload['_token'] = token
|
||||||
|
continue
|
||||||
|
if r.status_code >= 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()
|
||||||
197
migracion/x_full_ventas.py
Normal file
197
migracion/x_full_ventas.py
Normal file
@@ -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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'SKEEN Pagos',
|
'name': 'SKEEN Pagos',
|
||||||
'version': '1.0.1',
|
'version': '1.0.2',
|
||||||
'category': 'Healthcare',
|
'category': 'Healthcare',
|
||||||
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
201
odoo-addons/skeen_pagos/pago.py
Normal file
201
odoo-addons/skeen_pagos/pago.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user