#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Scripts de migración de datos del sistema Laravel de SKEEN a Odoo 17. Uso: 1. Exportar CSVs del sistema Laravel (pacientes, servicios, productos, médicos, etc.) o generar CSVs con el formato esperado. 2. Colocar los archivos en /root/migracion/datos/ 3. Ejecutar: source /root/odoo-venv/bin/activate && python3 /root/migracion/importar_a_odoo.py Estructura esperada de CSVs: - pacientes.csv: nombre, apellido_paterno, apellido_materno, telefono, email, fecha_nacimiento, sexo, medico_principal, etiquetas, vip, fuente - servicios.csv: codigo, nombre, categoria, precio, duracion_min, descripcion, color, grupo, favorito, activo - productos.csv: codigo, nombre, categoria, descripcion, precio, inventario_minimo, activo - medicos.csv: nombre, apellido_paterno, apellido_materno, telefono, email, especialidad, rol - usuarios.csv: nombre, login, email, password, rol, activo - citas.csv: paciente_telefono, paciente_nombre, servicio_codigo, fecha, hora, estado, doctor_nombre, sucursal, medio, notas, estado_pago, monto_pagado - ventas.csv: paciente_telefono, fecha, servicio_codigo, cantidad, precio_unitario, descuento, impuesto, estado, notas - pagos.csv: paciente_telefono, cita_referencia, monto, metodo, estado, fecha_pago - adeudos.csv: paciente_telefono, concepto, monto, moneda, fecha_vencimiento - monederos.csv: telefono, puntos, activo El script es idempotente: busca por teléfono / código y actualiza si ya existe. """ import csv import os import re import sys import argparse from pathlib import Path # Odoo bootstrap ODOO_PATH = Path('/root/skeen-odoo') sys.path.insert(0, str(ODOO_PATH)) import odoo from odoo import api, fields, SUPERUSER_ID DATA_DIR = Path('/root/migracion/datos') def _normalize_name(name): return ' '.join(str(name).split()) def get_or_create_doctor(env, doctor_name): """Busca o crea un empleado (médico/especialista).""" name = _normalize_name(doctor_name or '') if not name: return False # Normalizar títulos para búsqueda clean = name.lower().replace('dra.', '').replace('dr.', '').replace('dr ', '').replace('dra ', '').strip() doctor = env['hr.employee'].sudo().search([ '|', ('name', 'ilike', name), ('name', 'ilike', clean) ], limit=1) if not doctor: doctor = env['hr.employee'].sudo().create({'name': name}) return doctor def get_or_create_service(env, service_code='', service_name=''): """Busca servicio por código o nombre; si no existe crea uno genérico.""" service_code = (service_code or '').strip() service_name = _normalize_name(service_name or '') servicio = False if service_code: servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', service_code)], limit=1) if not servicio and service_name: servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('name', 'ilike', service_name)], limit=1) if not servicio and service_name: # Crear servicio genérico basado en el nombre histórico import hashlib name_hash = hashlib.md5(service_name.encode('utf-8')).hexdigest()[:12] code = f'HIST_{name_hash}' servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1) if not servicio: servicio = env['skeen.servicio'].sudo().create({ 'name': service_name, 'code': code, 'category': 'tratamiento', 'price': 0, 'duration_min': 30, }) if not servicio: servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'GENERICO')], limit=1) if not servicio: servicio = env['skeen.servicio'].sudo().create({ 'name': 'Servicio genérico', 'code': 'GENERICO', 'category': 'tratamiento', 'price': 0, 'duration_min': 30, }) return servicio def get_or_create_patient(env, phone, name, **vals): """Busca o crea un paciente por teléfono.""" partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if partner: return partner create_vals = { 'name': name, 'phone': phone, 'is_patient': True, } create_vals.update(vals) return env['res.partner'].sudo().create(create_vals) def importar_pacientes(env, filepath): """Importa pacientes desde CSV con expediente clínico completo.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: name = ' '.join(filter(None, [ row.get('nombre', ''), row.get('apellido_paterno', ''), row.get('apellido_materno', '') ])).strip() phone = (row.get('telefono') or '').strip() if not name or not phone: continue vals = { 'email': row.get('email', ''), } source = (row.get('fuente') or 'other').strip().lower() source_map = { 'whatsapp': 'whatsapp', 'facebook': 'facebook', 'instagram': 'instagram', 'google': 'google', 'referral': 'referral', 'walkin': 'walkin', 'other': 'other', } vals['source'] = source_map.get(source, 'other') if row.get('vip', '').lower() in ('1', 'true', 'si', 'sí'): vals['is_vip'] = True if row.get('fecha_nacimiento'): vals['birth_date'] = row.get('fecha_nacimiento') if row.get('sexo'): vals['gender'] = row.get('sexo') # Datos personales extendidos if row.get('lugar_nacimiento'): vals['birthplace'] = row.get('lugar_nacimiento') if row.get('empleo'): vals['occupation'] = row.get('empleo') if row.get('estado_civil'): vals['marital_status'] = row.get('estado_civil') if row.get('contacto_emergencia'): vals['emergency_contact'] = row.get('contacto_emergencia') if row.get('telefono_emergencia'): vals['emergency_phone'] = row.get('telefono_emergencia') if row.get('telefono_casa'): vals['home_phone'] = row.get('telefono_casa') if row.get('direccion'): vals['address_notes'] = row.get('direccion') if row.get('recomendado_por'): vals['referred_by'] = row.get('recomendado_por') if row.get('comentarios'): vals['patient_comments'] = row.get('comentarios') # Historia clínica try: hijos = int(row.get('hijos', 0) or 0) vals['children_count'] = hijos except Exception: vals['children_count'] = 0 vals['is_pregnant'] = row.get('embarazada', '').lower() in ('1', 'true', 'si', 'sí') vals['is_breastfeeding'] = row.get('lactancia', '').lower() in ('1', 'true', 'si', 'sí') vals['uses_contraceptives'] = row.get('anticonceptivos', '').lower() in ('1', 'true', 'si', 'sí') vals['kidney_problems'] = row.get('problemas_rinon', '').lower() in ('1', 'true', 'si', 'sí') vals['back_pain'] = row.get('problemas_espalda', '').lower() in ('1', 'true', 'si', 'sí') vals['heart_disease'] = row.get('problemas_cardiacos', '').lower() in ('1', 'true', 'si', 'sí') vals['respiratory_problems'] = row.get('problemas_respiratorios', '').lower() in ('1', 'true', 'si', 'sí') vals['blood_pressure'] = ( row.get('alta_presion', '').lower() in ('1', 'true', 'si', 'sí') or row.get('baja_presion', '').lower() in ('1', 'true', 'si', 'sí') ) vals['diabetes'] = row.get('diabetes', '').lower() in ('1', 'true', 'si', 'sí') vals['thyroid'] = ( row.get('hipertiroidismo', '').lower() in ('1', 'true', 'si', 'sí') or row.get('hipotiroidismo', '').lower() in ('1', 'true', 'si', 'sí') ) vals['colitis'] = row.get('colitis', '').lower() in ('1', 'true', 'si', 'sí') vals['constipation'] = row.get('estrenimiento', '').lower() in ('1', 'true', 'si', 'sí') vals['liver_problems'] = row.get('problemas_higado', '').lower() in ('1', 'true', 'si', 'sí') vals['surgeries'] = row.get('cirugias', '').lower() in ('1', 'true', 'si', 'sí') vals['varicose_veins'] = row.get('varices', '').lower() in ('1', 'true', 'si', 'sí') vals['migraine'] = row.get('migrana', '').lower() in ('1', 'true', 'si', 'sí') vals['faints_with_needles'] = row.get('desmaya_agujas', '').lower() in ('1', 'true', 'si', 'sí') if row.get('alergias'): vals['allergies'] = row.get('alergias') if row.get('medicamentos'): vals['current_medication'] = row.get('medicamentos') if row.get('tratamientos_previos'): vals['medical_history'] = row.get('tratamientos_previos') medico_nombre = row.get('medico_principal', '').strip() if medico_nombre: medico = get_or_create_doctor(env, medico_nombre) if medico: vals['primary_doctor_id'] = medico.id etiquetas = row.get('etiquetas', '').strip() if etiquetas: tag_names = [t.strip() for t in etiquetas.split(',') if t.strip()] tags = env['skeen.patient.tag'].sudo() tag_ids = [] for tag_name in tag_names: tag = tags.search([('name', '=', tag_name)], limit=1) if not tag: tag = tags.create({'name': tag_name}) tag_ids.append(tag.id) vals['tag_ids'] = [(6, 0, tag_ids)] get_or_create_patient(env, phone, name, **vals) count += 1 if count % 500 == 0: env.cr.commit() print(f' pacientes: {count} procesados') env.cr.commit() return count def importar_servicios(env, filepath): """Importa catálogo de 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 = set() with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 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: continue seen.add(code) existing = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1) if existing: continue cat = category_map.get((row.get('categoria') or '').strip().lower(), 'tratamiento') env['skeen.servicio'].sudo().create({ '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', ''), 'active': row.get('activo', '1').lower() in ('1', 'true', 'si'), }) count += 1 if count % 100 == 0: env.cr.commit() print(f' servicios: {count} procesados') env.cr.commit() return count def importar_productos(env, filepath): """Importa productos / inventario.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: name = (row.get('nombre') or '').strip() if not name: continue # Crear como product.template básico de Odoo + campos custom si aplica existing = env['product.template'].sudo().search([('name', '=', name)], limit=1) if existing: continue env['product.template'].sudo().create({ 'name': name, 'list_price': float(row.get('precio', 0) or 0), 'type': 'product', 'default_code': row.get('codigo', ''), 'description_sale': row.get('descripcion', ''), }) count += 1 return count def importar_medicos(env, filepath): """Importa médicos/cosmetólogos como empleados.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: name = ' '.join(filter(None, [ row.get('nombre', ''), row.get('apellido_paterno', ''), row.get('apellido_materno', '') ])).strip() if not name: continue existing = env['hr.employee'].sudo().search([('name', '=', name)], limit=1) if existing: continue env['hr.employee'].sudo().create({ 'name': name, 'work_phone': row.get('telefono', ''), 'work_email': row.get('email', ''), 'job_title': row.get('especialidad', ''), }) count += 1 return count def importar_monederos(env, filepath): """Importa saldos de monedero por teléfono de paciente.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: phone = (row.get('telefono') or '').strip() points = int(float(row.get('puntos', 0) or 0)) if not phone: continue partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if not partner: partner = get_or_create_patient(env, phone, 'Paciente ' + phone) monedero = env['skeen.monedero'].sudo().search([ ('partner_id', '=', partner.id) ], limit=1) if monedero: monedero.write({'points': points}) else: env['skeen.monedero'].sudo().create({ 'partner_id': partner.id, 'points': points, }) count += 1 return count def _parse_time(value): """Convierte '09:30' o '9.5' a float de horas.""" 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 importar_citas(env, filepath): """Importa citas históricas y futuras.""" # Desactivar temporalmente la validación de solapamiento para la migración masiva CitaModel = env.registry['skeen.cita'] original_constraint = CitaModel._check_availability CitaModel._check_availability = lambda self: None try: with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: phone = (row.get('paciente_telefono') or '').strip() name = (row.get('paciente_nombre') or '').strip() date_str = (row.get('fecha') or '').strip() time_str = (row.get('hora') or '').strip() if not phone or not date_str: continue partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if not partner: partner = get_or_create_patient(env, phone, name or 'Paciente ' + phone) servicio = get_or_create_service( env, row.get('servicio_codigo', ''), row.get('servicio_nombre', '') ) state_map = { 'pendiente': 'pending', 'confirmada': 'confirmed', 'llego': 'arrived', 'en_progreso': 'in_progress', 'completada': 'done', 'cancelada': 'cancelled', 'no_show': 'no_show', } state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed') payment_state_map = { 'no_pagado': 'not_paid', 'parcial': 'partial', 'pagado': 'paid', } payment_state = payment_state_map.get((row.get('estado_pago') or '').strip().lower(), 'not_paid') doctor = get_or_create_doctor(env, row.get('doctor_nombre', '')) 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' vals = { 'partner_id': partner.id, 'servicio_id': servicio.id, 'date': date_str, 'time': _parse_time(time_str), 'state': state, 'payment_state': payment_state, 'amount_paid': float(row.get('monto_pagado', 0) or 0), 'doctor_id': doctor.id if doctor else False, 'branch': branch, 'medium': medium, 'notes': row.get('notas', ''), } env['skeen.cita'].sudo().create(vals) count += 1 if count % 500 == 0: env.cr.commit() print(f' citas: {count} procesadas') env.cr.commit() return count finally: CitaModel._check_availability = original_constraint def importar_ventas(env, filepath): """Importa ventas históricas.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: phone = (row.get('paciente_telefono') or '').strip() if not phone: continue partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if not partner: partner = get_or_create_patient(env, phone, 'Paciente ' + phone) servicio = get_or_create_service( env, row.get('servicio_codigo', ''), row.get('servicio_nombre', '') ) qty = float(row.get('cantidad', 1) or 1) price = float(row.get('precio_unitario', servicio.price) or servicio.price) discount = float(row.get('descuento', 0) or 0) tax = float(row.get('impuesto', 0) or 0) state_map = { 'borrador': 'draft', 'confirmada': 'confirmed', 'pagada': 'paid', 'parcial': 'partial', 'cancelada': 'cancelled', } state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed') venta = env['skeen.venta'].sudo().create({ 'partner_id': partner.id, 'date': row.get('fecha') or fields.Date.today(), 'discount': discount, 'tax': tax, 'state': state, 'notes': row.get('notas', ''), 'line_ids': [(0, 0, { 'service_id': servicio.id, 'description': servicio.name, 'quantity': qty, 'price_unit': price, })], }) # Si está pagada, marcar como pagada para que acumule puntos if state == 'paid': venta.action_mark_paid() count += 1 if count % 500 == 0: env.cr.commit() print(f' ventas: {count} procesadas') env.cr.commit() return count def importar_pagos(env, filepath): """Importa pagos históricos vinculados a pacientes y citas.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: phone = (row.get('paciente_telefono') or '').strip() amount = float(row.get('monto', 0) or 0) if not phone or amount <= 0: continue partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if not partner: partner = get_or_create_patient(env, phone, 'Paciente ' + phone) cita_ref = (row.get('cita_referencia') or '').strip() cita_id = False if cita_ref: cita = env['skeen.cita'].sudo().search([('name', '=', cita_ref)], limit=1) cita_id = cita.id if cita else False method_map = { 'efectivo': 'cash', 'tarjeta': 'card', 'transferencia': 'transfer', 'stripe': 'stripe', 'mercadopago': 'mercadopago', } method = method_map.get((row.get('metodo') or '').strip().lower(), 'cash') state_map = { 'pendiente': 'pending', 'procesando': 'processing', 'completado': 'completed', 'fallido': 'failed', 'reembolsado': 'refunded', } state = state_map.get((row.get('estado') or '').strip().lower(), 'completed') vals = { 'partner_id': partner.id, 'cita_id': cita_id, 'amount': amount, 'payment_method': method, 'state': state, } if row.get('fecha_pago'): vals['payment_date'] = row.get('fecha_pago') pago = env['skeen.pago'].sudo().create(vals) if state == 'completed': pago.action_confirm_payment() count += 1 return count def importar_adeudos(env, filepath): """Importa adeudos como ventas en estado draft/con adeudo.""" with open(filepath, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) count = 0 for row in reader: phone = (row.get('telefono') or '').strip() concepto = (row.get('concepto') or 'Adeudo').strip() monto = float(row.get('monto', 0) or 0) if not phone or monto <= 0: continue partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1) if not partner: partner = get_or_create_patient(env, phone, 'Paciente ' + phone) # Crear un servicio genérico de adeudo si no existe servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'ADEUDO')], limit=1) if not servicio: servicio = env['skeen.servicio'].sudo().create({ 'name': 'Adeudo histórico', 'code': 'ADEUDO', 'category': 'consulta', 'price': 0, 'duration_min': 0, }) env['skeen.venta'].sudo().create({ 'partner_id': partner.id, 'date': row.get('fecha_vencimiento') or fields.Date.today(), 'state': 'confirmed', 'notes': concepto, 'line_ids': [(0, 0, { 'service_id': servicio.id, 'description': concepto, 'quantity': 1, 'price_unit': monto, })], }) count += 1 return count def main(): parser = argparse.ArgumentParser(description='Importar datos de SKEEN a Odoo') parser.add_argument('--odoo-config', default='/root/skeen-odoo/odoo.conf') parser.add_argument('--database', default='skeen_odoo') args = parser.parse_args() odoo.tools.config.parse_config(['-c', args.odoo_config]) db = odoo.sql_db.db_connect(args.database) with db.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) resultados = {} pacientes_path = DATA_DIR / 'pacientes.csv' if pacientes_path.exists(): resultados['pacientes'] = importar_pacientes(env, pacientes_path) servicios_path = DATA_DIR / 'servicios.csv' if servicios_path.exists(): resultados['servicios'] = importar_servicios(env, servicios_path) productos_path = DATA_DIR / 'productos.csv' if productos_path.exists(): resultados['productos'] = importar_productos(env, productos_path) medicos_path = DATA_DIR / 'medicos.csv' if medicos_path.exists(): resultados['medicos'] = importar_medicos(env, medicos_path) citas_path = DATA_DIR / 'citas.csv' if citas_path.exists(): resultados['citas'] = importar_citas(env, citas_path) ventas_path = DATA_DIR / 'ventas.csv' if ventas_path.exists(): resultados['ventas'] = importar_ventas(env, ventas_path) pagos_path = DATA_DIR / 'pagos.csv' if pagos_path.exists(): resultados['pagos'] = importar_pagos(env, pagos_path) adeudos_path = DATA_DIR / 'adeudos.csv' if adeudos_path.exists(): resultados['adeudos'] = importar_adeudos(env, adeudos_path) monederos_path = DATA_DIR / 'monederos.csv' if monederos_path.exists(): resultados['monederos'] = importar_monederos(env, monederos_path) cr.commit() print('Importación completada:') for k, v in resultados.items(): print(f' {k}: {v} registros') if __name__ == '__main__': main()