fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config

This commit is contained in:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Post-instalación de una instancia white-label de SKEEN.
Se ejecuta con el Python del venv de Odoo después de inicializar los módulos:
/root/odoo-venv/bin/python postinstall_clinica.py /ruta/config.json
El config.json trae: conf, db, nombre, dominio, pack, admin_login,
admin_password, con_wacrm, demo, report.
Hace, en orden:
1. Brand neutro (ir.config_parameter 'skeen.clinic.brand').
2. Oculta WACRM (skeen.menu_roles) salvo que con_wacrm sea True.
3. Renombra res.company principal.
4. Usuario admin del frontend con password propia + cambio obligatorio.
5. Siembra, SOLO si existen en runtime, los parámetros/modelos del nuevo
sistema de configuración: glosario (pack), skeen.clinic.settings,
skeen.sucursal, skeen.service.category. Lo que no exista queda como TODO
en el reporte; nunca aborta por eso.
6. Datos demo opcionales (--demo), marcados con '[DEMO]'/'DEMO-' para poder
limpiarlos luego con demo_limpiar.py.
No toca ninguna otra base de datos: todo corre sobre `db` del config.
"""
import datetime
import json
import sys
import traceback
# ---------------------------------------------------------------- packs giro
# (nombre, precio, duración_min, categoría) por giro. La categoría usa la
# selección de skeen.servicio: consulta | tratamiento | procedimiento | paquete.
PACKS = {
'generico': [
('Consulta general', 600, 30, 'consulta'),
('Consulta de seguimiento', 450, 30, 'consulta'),
('Tratamiento básico', 800, 45, 'tratamiento'),
('Tratamiento intensivo', 1200, 60, 'tratamiento'),
('Procedimiento menor', 950, 30, 'procedimiento'),
('Procedimiento mayor', 1800, 90, 'procedimiento'),
('Valoración inicial', 350, 20, 'consulta'),
('Terapia de mantenimiento', 700, 45, 'tratamiento'),
('Sesión express', 400, 20, 'tratamiento'),
('Paquete 5 sesiones', 3000, 45, 'paquete'),
],
'spa': [
('Masaje relajante 60 min', 900, 60, 'tratamiento'),
('Masaje descontracturante', 1100, 60, 'tratamiento'),
('Facial hidratante', 850, 50, 'tratamiento'),
('Facial antiedad', 1400, 60, 'tratamiento'),
('Exfoliación corporal', 750, 45, 'tratamiento'),
('Envoltura de algas', 1200, 60, 'tratamiento'),
('Drenaje linfático', 1000, 60, 'procedimiento'),
('Depilación con cera (pierna)', 500, 40, 'procedimiento'),
('Manicure spa', 450, 40, 'procedimiento'),
('Paquete 5 masajes', 4000, 60, 'paquete'),
],
'clinica': [
('Consulta médica general', 700, 30, 'consulta'),
('Consulta de especialidad', 950, 40, 'consulta'),
('Consulta de seguimiento', 500, 20, 'consulta'),
('Aplicación de medicamento', 350, 15, 'procedimiento'),
('Curación de herida', 450, 25, 'procedimiento'),
('Toma de muestra', 300, 15, 'procedimiento'),
('Terapia física', 800, 50, 'tratamiento'),
('Rehabilitación', 900, 60, 'tratamiento'),
('Valoración preoperatoria', 1100, 45, 'consulta'),
('Paquete 10 terapias', 6500, 50, 'paquete'),
],
'derma': [
('Consulta dermatológica', 900, 30, 'consulta'),
('Consulta de seguimiento', 600, 20, 'consulta'),
('Limpieza facial profunda', 950, 50, 'tratamiento'),
('Peeling químico', 1600, 45, 'tratamiento'),
('Microdermoabrasión', 1300, 45, 'tratamiento'),
('Depilación láser (zona pequeña)', 1200, 30, 'procedimiento'),
('Depilación láser (zona grande)', 2200, 45, 'procedimiento'),
('Mesoterapia facial', 1800, 40, 'procedimiento'),
('Crioterapia', 700, 20, 'procedimiento'),
('Paquete 5 limpiezas', 4000, 50, 'paquete'),
],
}
DEMO_PATIENTS = [
('Ana López Márquez', '6681230001', 'F'), ('Carlos Ruiz Peña', '6681230002', 'M'),
('María Fernanda Soto', '6681230003', 'F'), ('Jorge Alberto Nava', '6681230004', 'M'),
('Lucía Ramírez Ortega', '6681230005', 'F'), ('Pedro Sánchez Luna', '6681230006', 'M'),
('Sofía Herrera Cano', '6681230007', 'F'), ('Diego Morales Vega', '6681230008', 'M'),
('Valeria Castro Ibarra', '6681230009', 'F'), ('Andrés Gil Mendoza', '6681230010', 'M'),
('Renata Flores Cuevas', '6681230011', 'F'), ('Miguel Ángel Ponce', '6681230012', 'M'),
('Camila Ríos Delgado', '6681230013', 'F'), ('Fernando Ochoa Palma', '6681230014', 'M'),
('Isabela Vargas Rocha', '6681230015', 'F'),
]
DEMO_EMPLOYEES = [
('Dra. Elena Márquez', 'doctor', 10.0),
('Dr. Hugo Salinas', 'doctor', 8.0),
('Cosmet. Paula Rincón', 'cosmetologa', 12.0),
]
def load_env(conf_path, db_name):
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config as odoo_config
odoo_config.parse_config(['-c', conf_path, '-d', db_name,
'--no-http', '--log-level', 'error'])
odoo.service.server.load_server_wide_modules()
return odoo, odoo.registry(db_name)
def has_model(env, model):
try:
return model in env
except Exception:
return False
def write_first(record, candidates, value):
"""Escribe `value` en el primer campo de `candidates` que exista."""
for field in candidates:
if field in record._fields:
try:
record.write({field: value})
return field
except Exception:
continue
return None
def create_first(model, candidates, value, extra=None):
vals = {}
for field in candidates:
if field in model._fields:
vals[field] = value
break
vals.update(extra or {})
if not vals:
return None
return model.create(vals)
def seed_brand(ICP, cfg, report):
brand = {
'nombre': cfg['nombre'],
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'pie': cfg.get('pie') or 'Sistema de gestión',
}
ICP.set_param('skeen.clinic.brand', json.dumps(brand, ensure_ascii=False))
report['seeded'].append('skeen.clinic.brand')
def seed_menu_roles(ICP, cfg, report):
if cfg.get('con_wacrm'):
report['skipped'].append('skeen.menu_roles (se pidió --con-wacrm)')
return
roles = json.loads(ICP.get_param('skeen.menu_roles') or '{}')
roles['/wacrm/messages'] = []
roles['/wacrm/leads'] = []
ICP.set_param('skeen.menu_roles', json.dumps(roles, ensure_ascii=False))
report['seeded'].append('skeen.menu_roles (WACRM oculto)')
def seed_company(env, cfg, report):
company = env['res.company'].sudo().search([], limit=1, order='id')
if company:
company.write({'name': cfg['nombre']})
report['seeded'].append('res.company.name = %s' % cfg['nombre'])
def seed_admin(env, cfg, report):
User = env['skeen.frontend.user'].sudo()
admin = User.search([('login', '=', cfg['admin_login'])], limit=1)
if not admin:
vals = {'login': cfg['admin_login'], 'name': '%s Admin' % cfg['nombre'],
'role': 'admin', 'password_hash': 'pendiente'}
admin = User.create(vals)
report['seeded'].append('skeen.frontend.user %s (creado)' % cfg['admin_login'])
else:
report['seeded'].append('skeen.frontend.user %s (password propia)' % cfg['admin_login'])
admin.set_password(cfg['admin_password'])
if 'must_change_password' in admin._fields:
admin.write({'must_change_password': True})
# --------------------------------------------------------- config system v2
def seed_glossary(env, ICP, cfg, report):
"""Glosario con el pack elegido. Tolera los tres estados posibles:
- llave ir.config_parameter 'skeen.glosario' ya existe -> se actualiza;
- modelo 'skeen.glosario' existe -> se crea/actualiza un registro;
- ninguno de los dos existe -> TODO (no aborta)."""
pack = cfg.get('pack') or 'generico'
raw = ICP.get_param('skeen.glosario')
if raw is not None:
try:
data = json.loads(raw)
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
data['pack'] = pack
ICP.set_param('skeen.glosario', json.dumps(data, ensure_ascii=False))
report['seeded'].append("skeen.glosario.pack = %s" % pack)
return
if has_model(env, 'skeen.glosario'):
Model = env['skeen.glosario'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = create_first(Model, ('pack', 'name', 'nombre'), pack)
elif 'pack' in Model._fields:
rec.write({'pack': pack})
if rec:
report['seeded'].append("skeen.glosario -> pack %s" % pack)
return
report['todo'].append(
"glosario: no existe la llave ir.config_parameter 'skeen.glosario' ni "
"el modelo skeen.glosario; sembrar el pack '%s' cuando el otro cambio "
"esté desplegado" % pack)
def seed_clinic_settings(env, cfg, report):
if not has_model(env, 'skeen.clinic.settings'):
report['todo'].append(
"skeen.clinic.settings: modelo ausente; cuando exista, sembrar "
"nombre=%r, url_publica=https://%s, horario 08:00-19:00, moneda MXN"
% (cfg['nombre'], cfg['dominio']))
return
Model = env['skeen.clinic.settings'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = Model.create({})
url = 'https://%s' % cfg['dominio']
write_first(rec, ('name', 'nombre', 'nombre_comercial'), cfg['nombre'])
write_first(rec, ('url_publica', 'url', 'website'), url)
write_first(rec, ('moneda', 'currency', 'currency_name'), 'MXN')
# 08:00 - 19:00 en los campos típicos de horario
write_first(rec, ('hora_apertura', 'horario_apertura', 'open_time',
'hour_from'), 8.0)
write_first(rec, ('hora_cierre', 'horario_cierre', 'close_time',
'hour_to'), 19.0)
write_first(rec, ('apertura', 'hora_inicio', 'inicio'), '08:00')
write_first(rec, ('cierre', 'hora_fin', 'fin'), '19:00')
report['seeded'].append('skeen.clinic.settings (nombre, url, horario, MXN)')
def seed_sucursal(env, cfg, report):
if not has_model(env, 'skeen.sucursal'):
report['todo'].append(
"skeen.sucursal: modelo ausente; cuando exista, crear 1 sucursal "
"'default' con nombre=%r" % cfg['nombre'])
return
Model = env['skeen.sucursal'].sudo()
if Model.search([], limit=1):
report['skipped'].append('skeen.sucursal (ya hay sucursales)')
return
rec = create_first(Model, ('name', 'nombre'), cfg['nombre'])
if rec:
report['seeded'].append('skeen.sucursal (1 sucursal default)')
def seed_service_category(env, cfg, report):
if not has_model(env, 'skeen.service.category'):
report['todo'].append(
"skeen.service.category: modelo ausente; cuando exista, sembrar la "
"semilla de categorías del pack '%s'" % (cfg.get('pack') or 'generico'))
return
Model = env['skeen.service.category'].sudo()
cats = []
for _n, _p, _d, cat in PACKS.get(cfg.get('pack') or 'generico', PACKS['generico']):
if cat not in cats:
cats.append(cat)
created = 0
for cat in cats:
label = cat.capitalize()
if Model.search([('name', '=', label)], limit=1):
continue
if create_first(Model, ('name', 'nombre'), label):
created += 1
report['seeded'].append('skeen.service.category (%d nuevas)' % created)
# ------------------------------------------------------------------- demo
def _mark(env, model, field, value, ids):
try:
recs = env[model].sudo().browse(ids)
if field in env[model]._fields:
recs.write({field: value})
except Exception:
pass
def seed_demo(env, cfg, report):
pack = PACKS.get(cfg.get('pack') or 'generico', PACKS['generico'])
today = datetime.date.today()
week_start = today - datetime.timedelta(days=today.weekday())
ids = {'servicio': [], 'paciente': [], 'empleado': [], 'cita': [],
'paquete': [], 'monedero': []}
Servicio = env['skeen.servicio'].sudo()
Serv = []
for i, (name, price, dur, cat) in enumerate(pack, start=1):
rec = Servicio.create({
'name': name, 'code': 'DEMO-%02d' % i, 'price': price,
'duration_min': dur, 'category': cat, 'is_favorite': i <= 3,
})
Serv.append(rec)
ids['servicio'].append(rec.id)
Partner = env['res.partner'].sudo()
Pacientes = []
for i, (name, phone, gender) in enumerate(DEMO_PATIENTS):
vals = {'name': name, 'is_patient': True, 'phone': phone,
'mobile': phone, 'referred_by': 'DEMO',
'email': 'demo%02d@demo.local' % (i + 1)}
if 'gender' in Partner._fields:
vals['gender'] = 'female' if gender == 'F' else 'male'
try:
rec = Partner.create(vals)
except Exception:
rec = Partner.create({'name': name, 'is_patient': True,
'referred_by': 'DEMO'})
Pacientes.append(rec)
ids['paciente'].append(rec.id)
Employee = env['hr.employee'].sudo()
Empleados = []
for name, stype, comm in DEMO_EMPLOYEES:
vals = {'name': name, 'commission_pct': comm}
if 'staff_type' in Employee._fields:
vals['staff_type'] = stype
if 'notes' in Employee._fields:
vals['notes'] = '[DEMO]'
Empleados.append(Employee.create(vals))
ids['empleado'] = [e.id for e in Empleados]
Cita = env['skeen.cita'].sudo()
hours = [9.0, 10.0, 11.0, 12.0, 13.0, 16.0, 17.0, 18.0]
for i in range(20):
day = week_start + datetime.timedelta(days=i % 6)
serv = Serv[i % len(Serv)]
vals = {
'partner_id': Pacientes[i % len(Pacientes)].id,
'servicio_id': serv.id,
'date': day.isoformat(),
'time': hours[i % len(hours)],
'doctor_id': Empleados[i % len(Empleados)].id,
'state': 'confirmed' if i % 3 else 'pending',
'medium': 'onsite',
}
if 'notes' in Cita._fields:
vals['notes'] = '[DEMO]'
try:
c = Cita.create(vals)
ids['cita'].append(c.id)
except Exception:
report['warnings'].append('demo: no se pudo crear cita %d' % i)
# Paquetes + alguna compra
Paquete = env['skeen.paquete'].sudo()
for i, serv in enumerate(Serv[:3]):
try:
p = Paquete.create({
'name': 'Paquete 5 %s' % serv.name, 'code': 'DEMO-P%02d' % (i + 1),
'service_id': serv.id, 'sessions': 5,
'price': serv.price * 4, 'validity_days': 180,
})
ids['paquete'].append(p.id)
if 'skeen.paquete.compra' in env:
env['skeen.paquete.compra'].sudo().create({
'partner_id': Pacientes[i].id, 'paquete_id': p.id,
'date': today.isoformat(), 'sessions_total': 5,
'price': p.price, 'user_login': 'demo',
})
except Exception as e:
report['warnings'].append('demo: paquete %d: %s' % (i, e))
# Monedero con saldo para algunos pacientes
Monedero = env['skeen.monedero'].sudo()
for i in range(5):
try:
m = Monedero.create({'partner_id': Pacientes[i].id,
'points': (i + 1) * 50})
ids['monedero'].append(m.id)
if 'skeen.monedero.transaction' in env:
env['skeen.monedero.transaction'].sudo().create({
'monedero_id': m.id, 'type': 'accrual',
'points': (i + 1) * 50,
'description': 'Saldo demo', 'operator': 'demo',
})
except Exception as e:
report['warnings'].append('demo: monedero %d: %s' % (i, e))
report['demo'] = ids
report['seeded'].append('datos demo (%s)' % ', '.join(
'%s:%d' % (k, len(v)) for k, v in ids.items()))
def main():
cfg = json.load(open(sys.argv[1], encoding='utf-8'))
report = {'seeded': [], 'skipped': [], 'warnings': [], 'todo': [], 'demo': {}}
odoo, registry = load_env(cfg['conf'], cfg['db'])
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
steps = [
lambda: seed_brand(ICP, cfg, report),
lambda: seed_menu_roles(ICP, cfg, report),
lambda: seed_company(env, cfg, report),
lambda: seed_admin(env, cfg, report),
lambda: seed_glossary(env, ICP, cfg, report),
lambda: seed_clinic_settings(env, cfg, report),
lambda: seed_sucursal(env, cfg, report),
lambda: seed_service_category(env, cfg, report),
]
for step in steps:
try:
step()
except Exception as e:
report['warnings'].append('%s: %s' % (step, e))
if cfg.get('demo'):
try:
seed_demo(env, cfg, report)
if any(report['demo'].values()):
ICP.set_param('skeen.demo.ids',
json.dumps(report['demo'], ensure_ascii=False))
except Exception as e:
report['warnings'].append('demo: %s' % e)
cr.commit()
print('POST-INSTALL OK')
print(json.dumps(report, ensure_ascii=False, indent=2))
if cfg.get('report'):
with open(cfg['report'], 'w', encoding='utf-8') as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)