878 lines
36 KiB
Python
878 lines
36 KiB
Python
#!/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()
|