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:
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()
|
||||
Reference in New Issue
Block a user