#!/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()