#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Importador de servicios e inventario para SKEEN Derma Experts. Uso: python3 importar_servicios_inventario.py \ --servicios servicios.csv \ --inventario inventario.csv \ [--purge-citas] \ [--generar-hermes] Formato CSV servicios: name,code,category,price,duration_min,package_price,package_sessions,package_notes,description,service_group,color,is_favorite category: consulta | tratamiento | procedimiento | paquete price: precio por sesion (float) package_price: precio total del paquete (float, 0 si no aplica) package_sessions: numero de sesiones del paquete (int, 0 si no aplica) Formato CSV inventario: name,kind,sku,category,unit,qty,qty_optimal,qty_min,cost,expiry_date,notes kind: producto | consumible expiry_date: opcional, formato YYYY-MM-DD """ import argparse import csv import json import os import sys import xmlrpc.client from datetime import datetime ODOO_URL = os.environ.get('ODOO_URL', 'http://localhost:8069') ODOO_DB = os.environ.get('ODOO_DB', 'skeen_odoo') ODOO_USER = os.environ.get('ODOO_USER', 'admin') ODOO_PASSWORD = os.environ.get('ODOO_PASSWORD', 'skeen_admin_2026') def conectar(): common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common') uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {}) if not uid: raise Exception("Autenticacion fallida. Verifica ODOO_USER / ODOO_PASSWORD.") models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object') return uid, models def leer_csv(path): if not path or not os.path.exists(path): return [] with open(path, 'r', encoding='utf-8-sig', newline='') as f: reader = csv.DictReader(f) rows = [row for row in reader] return rows def _run_sql(command): """Ejecuta un comando SQL via psql como usuario postgres.""" import subprocess full = f"sudo -u postgres psql -d {ODOO_DB} -c \"{command}\"" result = subprocess.run(full, shell=True, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"SQL error: {result.stderr}\n{result.stdout}") return result.stdout def limpiar_inventario(models, uid, use_sql=False): if use_sql: print("[INVENTARIO] Eliminando movimientos e items via SQL...") _run_sql("TRUNCATE TABLE skeen_inventario_move RESTART IDENTITY CASCADE") _run_sql("TRUNCATE TABLE skeen_inventario_item RESTART IDENTITY CASCADE") print(" -> Inventario limpio") return print("[INVENTARIO] Eliminando movimientos...") move_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.move', 'search', [[]]) if move_ids: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.move', 'unlink', [move_ids]) print(f" -> Eliminados {len(move_ids)} movimientos") print("[INVENTARIO] Eliminando items...") item_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.item', 'search', [[]]) if item_ids: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.item', 'unlink', [item_ids]) print(f" -> Eliminados {len(item_ids)} items") def limpiar_servicios(models, uid, purge_citas=False, use_sql=False): if use_sql and purge_citas: print("[SERVICIOS] Eliminando citas y servicios via SQL...") _run_sql("TRUNCATE TABLE skeen_cita RESTART IDENTITY CASCADE") _run_sql("TRUNCATE TABLE skeen_service_tag_skeen_servicio_rel RESTART IDENTITY CASCADE") _run_sql("TRUNCATE TABLE skeen_service_tag RESTART IDENTITY CASCADE") _run_sql("TRUNCATE TABLE skeen_servicio RESTART IDENTITY CASCADE") print(" -> Servicios y citas limpios") return if purge_citas: print("[SERVICIOS] Eliminando citas existentes...") cita_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.cita', 'search', [[]]) if cita_ids: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.cita', 'unlink', [cita_ids]) print(f" -> Eliminadas {len(cita_ids)} citas") print("[SERVICIOS] Eliminando servicios...") servicio_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'search', [[]]) if servicio_ids: if purge_citas: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'unlink', [servicio_ids]) print(f" -> Eliminados {len(servicio_ids)} servicios") else: # Intentar eliminar uno a uno; los que tengan citas fallaran eliminados = 0 fallidos = [] for sid in servicio_ids: try: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'unlink', [[sid]]) eliminados += 1 except Exception as e: serv = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'read', [[sid], ['name', 'code']]) fallidos.append((sid, serv[0] if serv else {}, str(e))) print(f" -> Eliminados {eliminados} servicios") if fallidos: print(f" -> No se pudieron eliminar {len(fallidos)} servicios (tienen citas/historial):") for sid, info, err in fallidos[:10]: print(f" - {info.get('name', sid)} ({info.get('code', '')}): {err[:120]}") print("[SERVICIOS] Eliminando tags de servicios...") tag_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.service.tag', 'search', [[]]) if tag_ids: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.service.tag', 'unlink', [tag_ids]) print(f" -> Eliminados {len(tag_ids)} tags") def parse_float(val, default=0.0): if val is None: return default val = str(val).strip().replace(',', '').replace('$', '') if val == '': return default try: return float(val) except ValueError: return default def parse_int(val, default=0): if val is None: return default val = str(val).strip() if val == '': return default try: return int(float(val)) except ValueError: return default def importar_servicios(models, uid, rows): print(f"[SERVICIOS] Importando {len(rows)} servicios...") creados = 0 for row in rows: name = (row.get('name') or '').strip() code = (row.get('code') or '').strip() if not name or not code: print(f" -> Saltando fila sin nombre o codigo: {row}") continue category = (row.get('category') or 'tratamiento').strip().lower() if category not in ('consulta', 'tratamiento', 'procedimiento', 'paquete'): category = 'tratamiento' vals = { 'name': name, 'code': code, 'category': category, 'price': parse_float(row.get('price')), 'duration_min': parse_int(row.get('duration_min'), 30), 'package_price': parse_float(row.get('package_price')), 'package_sessions': parse_int(row.get('package_sessions')), 'package_notes': (row.get('package_notes') or '').strip(), 'description': (row.get('description') or '').strip(), 'service_group': (row.get('service_group') or '').strip(), 'color': (row.get('color') or '#1abc9c').strip(), 'is_favorite': str(row.get('is_favorite', '')).strip().lower() in ('1', 'true', 'si', 'sí', 'yes'), } # Si es categoria paquete pero no tiene sesiones, forzar consulta if category == 'paquete' and vals['package_sessions'] <= 0: vals['category'] = 'tratamiento' existing = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'search', [[['code', '=', code]]]) if existing: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'write', [existing, vals]) print(f" -> Actualizado: {name} ({code})") else: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'create', [vals]) creados += 1 print(f" -> Creado: {name} ({code})") print(f"[SERVICIOS] {creados} nuevos creados.") def importar_inventario(models, uid, rows): print(f"[INVENTARIO] Importando {len(rows)} items...") creados = 0 for row in rows: name = (row.get('name') or '').strip() if not name: continue kind = (row.get('kind') or 'producto').strip().lower() if kind not in ('producto', 'consumible'): kind = 'producto' sku = (row.get('sku') or '').strip() expiry = (row.get('expiry_date') or '').strip() vals = { 'name': name, 'kind': kind, 'sku': sku, 'category': (row.get('category') or '').strip(), 'unit': (row.get('unit') or 'pieza').strip(), 'qty': parse_float(row.get('qty')), 'qty_optimal': parse_float(row.get('qty_optimal')), 'qty_min': parse_float(row.get('qty_min')), 'cost': parse_float(row.get('cost')), 'notes': (row.get('notes') or '').strip(), } if expiry: try: datetime.strptime(expiry, '%Y-%m-%d') vals['expiry_date'] = expiry except ValueError: pass existing = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.item', 'search', [[['sku', '=', sku]]] if sku else [[['name', '=', name]]]) if existing: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.item', 'write', [existing, vals]) print(f" -> Actualizado: {name}") else: models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.inventario.item', 'create', [vals]) creados += 1 print(f" -> Creado: {name}") print(f"[INVENTARIO] {creados} nuevos items creados.") def generar_hermes_services(models, uid): print("[HERMES] Generando catalogo para skills...") servicios = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, 'skeen.servicio', 'search_read', [[['active', '=', True]]], {'fields': ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description']}) payload = { "clinica": { "nombre": "SKEEN Derma Experts", "direccion": "Blvd. Popotla 123, Playas de Rosarito, BC, Mexico", "telefono": "+52 (664) 123-4567", "horario": { "lunes_viernes": "9:00 - 18:00", "sabado": "9:00 - 14:00", "domingo": "Cerrado" } }, "servicios": [] } for s in servicios: payload['servicios'].append({ "id": s['code'].lower().replace(' ', '-'), "nombre": s['name'], "categoria": s['category'], "precio": s['price'], "duracion_min": s['duration_min'], "precio_paquete": s['package_price'] or 0, "sesiones_paquete": s['package_sessions'] or 0, "notas_paquete": s['package_notes'] or '', "descripcion": s['description'] or '', "contraindicaciones": [] }) paths = [ '/root/.hermes/skills/skeen-services.json', '/root/skeen-roadmap-hermes/hermes/skills/skeen-services.json', ] for p in paths: try: with open(p, 'w', encoding='utf-8') as f: json.dump(payload, f, ensure_ascii=False, indent=2) print(f" -> Escrito: {p}") except Exception as e: print(f" -> Error escribiendo {p}: {e}") def main(): parser = argparse.ArgumentParser(description='Importar servicios e inventario a Odoo') parser.add_argument('--servicios', help='CSV de servicios') parser.add_argument('--inventario', help='CSV de inventario') parser.add_argument('--purge-citas', action='store_true', help='Eliminar citas existentes antes de borrar servicios') parser.add_argument('--purge-sql', action='store_true', help='Usar TRUNCATE SQL directo para limpieza rapida (miles de registros)') parser.add_argument('--generar-hermes', action='store_true', help='Generar skeen-services.json para Hermes') parser.add_argument('--solo-hermes', action='store_true', help='Solo generar skeen-services.json desde Odoo actual') args = parser.parse_args() uid, models = conectar() print(f"[OK] Conectado a Odoo {ODOO_URL} como {ODOO_USER} (uid={uid})") if args.solo_hermes: generar_hermes_services(models, uid) return if not args.servicios and not args.inventario: print("[INFO] No se especificaron CSVs. Usa --servicios y/o --inventario.") return if args.inventario: limpiar_inventario(models, uid, use_sql=args.purge_sql) rows_inv = leer_csv(args.inventario) importar_inventario(models, uid, rows_inv) if args.servicios: limpiar_servicios(models, uid, purge_citas=args.purge_citas, use_sql=args.purge_sql) rows_srv = leer_csv(args.servicios) importar_servicios(models, uid, rows_srv) if args.generar_hermes: generar_hermes_services(models, uid) print("[OK] Proceso completado.") if __name__ == '__main__': main()