Files
SKEEN-Proyecto/migracion/importar_concentrado_full.py

878 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()