#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Importador rápido de servicios, citas, ventas y monederos SKEEN a Odoo.""" import csv import sys import re import html import hashlib from pathlib import Path ODOO_PATH = Path('/root/skeen-odoo') sys.path.insert(0, str(ODOO_PATH)) import odoo from odoo import api, SUPERUSER_ID DATA_DIR = Path('/root/migracion/datos') def normalize_bool(v): return str(v).lower() in ('1', 'true', 'si', 'sí') def parse_time(value): if not value: return 9.0 value = str(value).strip() if ':' in value: h, m = value.split(':')[:2] return float(h) + float(m) / 60.0 try: return float(value) except ValueError: return 9.0 def extract_service(text, patient_name): """Extrae el nombre del servicio del campo servicio_nombre.""" text = html.unescape(text or '') if patient_name: text = re.sub(r'^' + re.escape(patient_name) + r'\s*[-–—]\s*', '', text, flags=re.IGNORECASE) text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', text, flags=re.IGNORECASE) text = ' '.join(text.split()) return text.strip() def normalize_name(name): return ' '.join(str(name).split()).lower() def main(): odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf']) db = odoo.sql_db.db_connect('skeen_odoo') cr = db.cursor() env = api.Environment(cr, SUPERUSER_ID, {}) Partner = env['res.partner'].sudo() Servicio = env['skeen.servicio'].sudo() Employee = env['hr.employee'].sudo() CitaModel = env.registry['skeen.cita'] Cita = env['skeen.cita'].sudo() Venta = env['skeen.venta'].sudo() Monedero = env['skeen.monedero'].sudo() MonederoTx = env['skeen.monedero.transaction'].sudo() # Cachés en memoria (SQL directo para velocidad) print('Cargando cachés...') partner_by_phone = {} partner_by_name = {} partner_by_legacy = {} cr.execute("SELECT id, phone, name, legacy_id FROM res_partner WHERE is_patient = true") for pid, phone, name, legacy_id in cr.fetchall(): if phone: partner_by_phone.setdefault(phone, pid) if name: partner_by_name.setdefault(normalize_name(name), pid) if legacy_id: partner_by_legacy[legacy_id] = pid service_by_code = {} service_by_name = {} cr.execute("SELECT id, code, name FROM skeen_servicio") for sid, code, name in cr.fetchall(): if code: service_by_code[code] = sid if name: service_by_name[normalize_name(name)] = sid doctor_by_name = {} cr.execute("SELECT id, name FROM hr_employee") for eid, name in cr.fetchall(): if name: doctor_by_name[normalize_name(name)] = eid print(f' Pacientes: {len(partner_by_phone)} por teléfono, {len(partner_by_name)} por nombre') print(f' Servicios: {len(service_by_name)}') print(f' Doctores: {len(doctor_by_name)}') skipped_reasons = {'no_patient': 0, 'no_service': 0, 'no_date': 0} def resolve_partner(row, phone_field='paciente_telefono', name_field='paciente_nombre', legacy_field='expedientes_id'): legacy_id = (row.get(legacy_field) or '').strip() if legacy_id and legacy_id in partner_by_legacy: return partner_by_legacy[legacy_id] phone = (row.get(phone_field) or '').strip() if phone and phone in partner_by_phone: return partner_by_phone[phone] name = normalize_name(row.get(name_field, '')) if name and name in partner_by_name: return partner_by_name[name] return False # Desactivar validación de solapamiento de citas original_check = CitaModel._check_availability CitaModel._check_availability = lambda self: None resultados = {} # 1. Servicios print('Importando servicios...') category_map = { 'consulta': 'consulta', 'tratamiento': 'tratamiento', 'procedimiento': 'procedimiento', 'paquete': 'paquete', 'servicios_medicos': 'consulta', 'procedimientos_cosmeticos': 'procedimiento', 'tecnologia_medica': 'tratamiento', 'cosmetologia_basica': 'tratamiento', 'cosmetologia_avanzada': 'tratamiento', } seen_codes = set() batch = [] count = 0 with open(DATA_DIR / 'servicios.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: code = (row.get('codigo') or '').strip() name = (row.get('nombre') or '').strip() if not code or not name or code in seen_codes: continue seen_codes.add(code) if code in service_by_code: continue cat = category_map.get((row.get('categoria') or '').strip().lower(), 'tratamiento') batch.append({ 'name': name, 'code': code, 'category': cat, 'price': float(row.get('precio', 0) or 0), 'duration_min': int(row.get('duracion_min', 30) or 30), 'description': row.get('descripcion', ''), 'color': row.get('color', '#1abc9c'), 'service_group': row.get('grupo', ''), 'is_favorite': normalize_bool(row.get('favorito')), 'active': normalize_bool(row.get('activo', '1')), }) if len(batch) >= 100: created = Servicio.create(batch) count += len(created) cr.commit() batch = [] if batch: created = Servicio.create(batch) count += len(created) cr.commit() # Recargar caché de servicios (SQL directo) service_by_code = {} service_by_name = {} cr.execute("SELECT id, code, name FROM skeen_servicio") for sid, code, name in cr.fetchall(): if code: service_by_code[code] = sid if name: service_by_name[normalize_name(name)] = sid resultados['servicios'] = count print(f' {count} servicios creados') # Helper: obtener o crear servicio por nombre service_name_cache = dict(service_by_name) def get_or_create_service_by_name(name): name = name.strip() if not name: name = 'Servicio genérico' key = normalize_name(name) if key in service_name_cache: return service_name_cache[key] # Crear nuevo servicio code_hash = hashlib.md5(key.encode('utf-8')).hexdigest()[:12] code = f'HIST_{code_hash}' new_s = Servicio.create({ 'name': name, 'code': code, 'category': 'tratamiento', 'price': 0, 'duration_min': 30, }) service_name_cache[key] = new_s.id return new_s.id # 2. Citas print('Importando citas...') state_map = { 'pendiente': 'pending', 'confirmada': 'confirmed', 'llego': 'arrived', 'en_progreso': 'in_progress', 'completada': 'done', 'cancelada': 'cancelled', 'no_show': 'no_show', } payment_state_map = { 'no_pagado': 'not_paid', 'parcial': 'partial', 'pagado': 'paid', } batch = [] count = 0 skipped = 0 BATCH_SIZE = 500 with open(DATA_DIR / 'citas.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: partner_id = resolve_partner(row) date_str = (row.get('fecha') or '').strip() if not partner_id: skipped += 1 skipped_reasons['no_patient'] += 1 continue if not date_str: skipped += 1 skipped_reasons['no_date'] += 1 continue service_name = extract_service(row.get('servicio_nombre', ''), row.get('paciente_nombre', '')) if not service_name: service_name = row.get('servicio_nombre', '').strip() or 'Servicio genérico' servicio_id = get_or_create_service_by_name(service_name) doctor_name = (row.get('doctor_nombre') or '').strip() doctor_id = doctor_by_name.get(normalize_name(doctor_name)) if doctor_name else False branch = 'rosarito' if (row.get('sucursal') or '').strip().lower() in ('tijuana', 'tj'): branch = 'tijuana' medium = 'onsite' if (row.get('medio') or '').strip().lower() in ('videollamada', 'video', 'remota'): medium = 'videocall' state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed') payment_state = payment_state_map.get((row.get('estado_pago') or '').strip().lower(), 'not_paid') batch.append({ 'partner_id': partner_id, 'servicio_id': servicio_id, 'date': date_str, 'time': parse_time(row.get('hora')), 'state': state, 'payment_state': payment_state, 'amount_paid': float(row.get('monto_pagado', 0) or 0), 'doctor_id': doctor_id, 'branch': branch, 'medium': medium, 'notes': row.get('notas', ''), }) if len(batch) >= BATCH_SIZE: Cita.create(batch) count += len(batch) batch = [] cr.commit() print(f' {count} citas creadas') if batch: Cita.create(batch) count += len(batch) cr.commit() resultados['citas'] = count print(f' {count} citas creadas, {skipped} omitidas ({skipped_reasons})') # 3. Ventas print('Importando ventas...') state_map_venta = { 'borrador': 'draft', 'confirmada': 'confirmed', 'pagada': 'paid', 'parcial': 'partial', 'cancelada': 'cancelled', } count = 0 skipped = 0 batch = [] BATCH_SIZE = 500 with open(DATA_DIR / 'ventas.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: partner_id = resolve_partner(row) if not partner_id: skipped += 1 skipped_reasons['no_patient'] += 1 continue service_name = extract_service(row.get('servicio_nombre', ''), row.get('paciente_nombre', '')) if not service_name: service_name = row.get('servicio_nombre', '').strip() or 'Servicio genérico' servicio_id = get_or_create_service_by_name(service_name) qty = float(row.get('cantidad', 1) or 1) price = float(row.get('precio_unitario', 0) or 0) discount = float(row.get('descuento', 0) or 0) tax = float(row.get('impuesto', 0) or 0) state = state_map_venta.get((row.get('estado') or '').strip().lower(), 'confirmed') date = row.get('fecha') or odoo.fields.Date.today() batch.append({ 'partner_id': partner_id, 'date': date, 'discount': discount, 'tax': tax, 'state': state, 'notes': row.get('notas', ''), 'line_ids': [(0, 0, { 'service_id': servicio_id, 'description': service_name or 'Servicio', 'quantity': qty, 'price_unit': price, })], }) if len(batch) >= BATCH_SIZE: Venta.create(batch) count += len(batch) batch = [] cr.commit() print(f' {count} ventas creadas') if batch: Venta.create(batch) count += len(batch) cr.commit() resultados['ventas'] = count print(f' {count} ventas creadas, {skipped} omitidas') # 4. Monederos print('Importando monederos...') count = 0 skipped = 0 batch_monedero = [] batch_tx = [] BATCH_SIZE = 500 with open(DATA_DIR / 'monederos.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: phone = (row.get('telefono') or '').strip() points = int(float(row.get('puntos', 0) or 0)) if not phone or phone not in partner_by_phone: skipped += 1 continue partner_id = partner_by_phone[phone] existing = Monedero.search([('partner_id', '=', partner_id)], limit=1) if existing: existing.write({'points': points}) count += 1 continue batch_monedero.append({ 'partner_id': partner_id, 'points': points, }) if len(batch_monedero) >= BATCH_SIZE: created = Monedero.create(batch_monedero) for m in created: batch_tx.append({ 'monedero_id': m.id, 'type': 'adjustment', 'points': m.points, 'description': 'Saldo inicial migrado de SKEEN', 'reference': 'MIGRACION', }) if batch_tx: MonederoTx.create(batch_tx) batch_tx = [] count += len(batch_monedero) batch_monedero = [] cr.commit() print(f' {count} monederos creados') if batch_monedero: created = Monedero.create(batch_monedero) for m in created: batch_tx.append({ 'monedero_id': m.id, 'type': 'adjustment', 'points': m.points, 'description': 'Saldo inicial migrado de SKEEN', 'reference': 'MIGRACION', }) if batch_tx: MonederoTx.create(batch_tx) count += len(batch_monedero) cr.commit() resultados['monederos'] = count print(f' {count} monederos creados, {skipped} omitidos') # Restaurar validación CitaModel._check_availability = original_check cr.commit() cr.close() print('Importación completada:') for k, v in resultados.items(): print(f' {k}: {v} registros') if __name__ == '__main__': main()