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,212 @@
# -*- coding: utf-8 -*-
"""Glosario configurable de la clínica (terminología white-label).
Solo lee/escribe el parámetro `skeen.glosario` (JSON). No hay modelo propio:
el pack base y los overrides se resuelven en memoria.
Estructura guardada:
{"pack": "clinica", "overrides": {"patient_singular": "Huésped"}}
Resolución:
terms = PACKS[pack] (o 'clinica' si el pack no existe) + overrides
El pack 'custom' es válido: parte del pack 'clinica' y aplica los overrides.
Así el endpoint siempre devuelve las 24 claves canónicas resueltas.
"""
import json
GLOSARIO_KEY = 'skeen.glosario'
DEFAULT_PACK = 'clinica'
CUSTOM_PACK = 'custom'
# Las 24 claves canónicas, en el orden del brief (contrato del frontend).
GLOSARIO_KEYS = (
'patient_singular', 'patient_plural',
'appointment_singular', 'appointment_plural',
'visit_singular', 'visit_plural',
'record_singular', 'record_plural',
'doctor_singular', 'doctor_plural',
'staff_singular', 'staff_plural',
'branch_singular', 'branch_plural',
'room_singular', 'room_plural',
'service_singular', 'service_plural',
'package_singular', 'package_plural',
'prescription_singular', 'prescription_plural',
'treatment_singular', 'treatment_plural',
)
# Nombre legible de cada pack para el selector de configuración.
PACK_LABELS = {
'clinica': 'Clínica',
'spa': 'Spa / Wellness',
'estetica': 'Estética',
'dental': 'Dental',
}
PACKS = {
'clinica': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Visita',
'visit_plural': 'Visitas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Médico',
'doctor_plural': 'Médicos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'spa': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Reserva',
'appointment_plural': 'Reservas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Terapeuta',
'doctor_plural': 'Terapeutas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Sala',
'room_plural': 'Salas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Recomendación',
'prescription_plural': 'Recomendaciones',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'estetica': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Especialista',
'doctor_plural': 'Especialistas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'dental': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Consulta',
'visit_plural': 'Consultas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Odontólogo',
'doctor_plural': 'Odontólogos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Consultorio',
'room_plural': 'Consultorios',
'service_singular': 'Tratamiento',
'service_plural': 'Tratamientos',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Procedimiento',
'treatment_plural': 'Procedimientos',
},
}
VALID_PACKS = tuple(PACKS) + (CUSTOM_PACK,)
def pack_terms(pack):
"""Términos base de un pack (copia). 'custom' parte del pack por defecto."""
base = PACKS.get(pack) or PACKS[DEFAULT_PACK]
return dict(base)
def get_config(env):
"""Config vigente: {'pack': str, 'overrides': {clave: valor}}."""
raw = env['ir.config_parameter'].sudo().get_param(GLOSARIO_KEY)
pack = DEFAULT_PACK
overrides = {}
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
p = guardado.get('pack')
if isinstance(p, str) and p:
pack = p
o = guardado.get('overrides')
if isinstance(o, dict):
overrides = {k: str(v) for k, v in o.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
return {'pack': pack, 'overrides': overrides}
def resolve(env):
"""(pack, terms): las 24 claves resueltas (pack + overrides)."""
cfg = get_config(env)
terms = pack_terms(cfg['pack'])
terms.update(cfg['overrides'])
return cfg['pack'], terms
def set_config(env, pack=None, overrides=None):
"""Guarda pack/overrides. None = conserva lo actual."""
actual = get_config(env)
nuevo_pack = actual['pack'] if pack is None else (str(pack).strip() or DEFAULT_PACK)
nuevos_overrides = actual['overrides']
if overrides is not None:
if not isinstance(overrides, dict):
raise ValueError('overrides debe ser un objeto')
nuevos_overrides = {k: str(v).strip() for k, v in overrides.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
env['ir.config_parameter'].sudo().set_param(
GLOSARIO_KEY,
json.dumps({'pack': nuevo_pack, 'overrides': nuevos_overrides},
ensure_ascii=False))
return get_config(env)
def list_packs():
"""Packs disponibles con nombre legible."""
return [{'code': code, 'name': PACK_LABELS.get(code, code.capitalize())}
for code in VALID_PACKS]