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:
2026-10-10 10:27:29 +00:00
parent 2523635716
commit 4842552574
7 changed files with 1772 additions and 1 deletions

View 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()