segunda instancia white-label (clinica2) + fix de orden de menus en instalacion limpia

Infra (todo convive en la misma VPS, mismo addons_path read-only):
- infra/clinica2/: odoo.conf (puerto 8070, db clinica2_odoo, data_dir
  propio), odoo-clinica2.service (sin env vars de WACRM/Supabase/Hermes),
  nginx-vhost (80 con server_name clinica2.consultoria-as.com + 8880 LAN,
  /api/odoo -> 8070, /wacrm/ cortado con 403), postinstall.py (brand neutro,
  menu WACRM vacio, admin con password propio y must_change_password)
- docs/segunda-instancia.md: mapa completo de la instalacion, pasos para
  reproducir (instancia 3+), pendiente del hostname en dashboard del tunel
  (remotamente administrado), aislamiento de datos escenario B
- frontend/public/brand/: logos SVG neutros + favicon para la instancia 2

Fix de menus (aplica a todas las instalaciones limpias, una sola pasada):
- menu_skeen_root se mueve a skeen_pacientes (modulo raiz de la jerarquia);
  en bases existentes se transfirio el ownership del xmlid (ir_model_data)
  asi actualizar no duplica el menu
- menu_skeen_citas (padre) se declara en skeen_citas/views/menus.xml que
  carga antes que bloqueo/lista_espera; menu_views.xml conserva las entradas
  con action (cargan despues de cita_views, donde se define la accion)
- referencias actualizadas: pacientes(catalogos/recetas), monedero, ventas,
  inventario, visitas -> skeen_pacientes.menu_skeen_root

Verificacion: produccion (skeen_odoo) actualizada sin duplicar menus y API
intacta; instalacion limpia clinica2_odoo 9 modulos OK; 15/15 tests
end-to-end (brand neutro, login admin, WACRM oculto+403, datos vacios)
This commit is contained in:
2026-10-08 00:44:30 +00:00
parent 2f7cbe237e
commit 582c267ec0
19 changed files with 332 additions and 14 deletions

91
docs/segunda-instancia.md Normal file
View File

@@ -0,0 +1,91 @@
# Segunda instancia white-label (sin branding SKEEN)
Segunda clínica sobre el mismo código y la misma VPS. La marca no vive en el
código: sale del parámetro `skeen.clinic.brand` (ver `GET /skeen/frontend/v1/brand`),
así que esta y futuras instancias son solo infra + config.
## Mapa de la instalación
| Componente | SKEEN (instancia 1) | Clínica 2 (instancia white-label) |
|---|---|---|
| Odoo HTTP | 8069 | **8070** |
| Odoo longpolling | 8072 | **8073** |
| Base de datos | `skeen_odoo` | **`clinica2_odoo`** |
| Conf | `/root/skeen-odoo/odoo.conf` | **`/root/clinica2-odoo.conf`** |
| Systemd | `odoo-skeen.service` | **`odoo-clinica2.service`** |
| Frontend | `/var/www/skeen-frontend` | **`/var/www/clinica2-frontend`** |
| Nginx | vhost `skeen-crm` (80) | **vhost `clinica2` (80 + 8880)** |
| Acceso LAN | http://192.168.10.114 | **http://192.168.10.114:8880** |
| Acceso público | skeen.consultoria-as.com | **clinica2.consultoria-as.com (pendiente: dashboard del túnel)** |
| WhatsApp (WACRM) | integrado | **oculto + bloqueado (escenario B)** |
`addons_path` se comparte (read-only): `/root/skeen-odoo/addons`. Un solo
código, N bases de datos.
## Cómo se levantó (orden de pasos)
Los archivos canónicos están en `infra/clinica2/`:
1. `odoo.conf` — copia del de SKEEN con `db_name=clinica2_odoo`, `http_port=8070`,
`longpolling_port=8073`, `data_dir=/var/lib/odoo-clinica2`.
2. Crear la base (el rol `skeen` no tiene CREATEDB; se crea como `postgres`):
`CREATE DATABASE clinica2_odoo OWNER skeen ...`
3. Instalar módulos limpio (sin demo):
`odoo-bin -c /root/clinica2-odoo.conf -d clinica2_odoo --without-demo=all --init=skeen_whatsapp,skeen_chat,skeen_visitas --stop-after-init --no-http`
(skeen_whatsapp arrastra citas/pacientes/ventas/pagos/monedero; visitas
arrastra inventario).
4. `postinstall.py` — brand neutro (`skeen.clinic.brand`), menú WACRM vacío
para todos (`skeen.menu_roles`), password de admin propio +
`must_change_password`, empresa renombrada.
5. `systemctl enable --now odoo-clinica2`.
6. `nginx-vhost` → `/etc/nginx/sites-enabled/clinica2` + `nginx -t && reload`.
7. Frontend: mismo build de SKEEN (`rsync -a dist/ /var/www/clinica2-frontend/`)
más `public/brand/` (logos SVG neutros + favicon, versionados en el repo).
## Pendiente para acceso público (único paso manual)
El túnel cloudflared corre con `--token` (remotamente administrado): el
`config.yml` local NO aplica. En el dashboard de Cloudflare (Zero Trust →
Networks → Tunnels → f2517233…) agregar el ingress:
```yaml
- hostname: clinica2.consultoria-as.com
service: http://localhost:80
```
El vhost nginx ya escucha en el 80 con ese `server_name`; al agregar el
hostname el túnel enruta solo. El 8880 queda como acceso LAN permanente.
## Aislamiento de datos (escenario B: sin WhatsApp)
- Sin `SUPABASE_URL` / `WACRM_API_*` / `HERMES_BRIDGE_URL` en la unit systemd
(el backend respondería "Falta configuración").
- Aun así, el backend tiene fallback a `localhost:3000`; por eso el vhost
nginx corta `/api/odoo/skeen/frontend/v1/wacrm/` con `return 403;` — doble
candado, la instancia 2 no puede leer ni escribir las conversaciones de
SKEEN aunque alguien llame la API a mano.
- Menú WACRM oculto para todos los roles vía matriz (`/wacrm/*: []`).
Para activar WhatsApp en la instancia 2 (escenario A) se necesita: número
propio de WhatsApp Business + decisión de WACRM multi-clínica (estimar aparte).
## Fix de menús que esta fase destapó (aplica a TODAS las instancias)
Las vistas XML referenciaban `skeen_citas.menu_skeen_root`, pero `skeen_citas`
depende de `skeen_pacientes`: en instalación limpia de una sola pasada el
xmlid aún no existe y la carga falla (la base de SKEEN se construyó por fases
y por eso nunca explotó). Se movió el menú raíz a `skeen_pacientes` (módulo
raíz de la jerarquía) y todas las referencias apuntan a
`skeen_pacientes.menu_skeen_root`. En bases existentes se transfirió el
ownership del xmlid (`UPDATE ir_model_data SET module='skeen_pacientes' …`),
así actualizar no duplica el menú. Mismo tratamiento para el menú padre de
Citas (`skeen_citas/views/menus.xml` carga antes que bloqueos/lista de espera).
## Operación habitual
- Logs: `tail -f /tmp/odoo2.log` (instancia 2) vs `/tmp/odoo.log` (SKEEN).
- Ambas instancias comparten código: un `git pull` + rebuild afecta el build
de ambas solo si se vuelven a copiar los `dist/` (cada uno tiene el suyo).
- Para una tercera clínica: repetir pasos 1–7 con `clinica3` (puertos 8074/8075,
db `clinica3_odoo`, vhost propio) y setear su brand desde
Configuración → Clínica o con `PUT /brand`.

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect x="2" y="2" width="44" height="44" rx="10" fill="#1f3a4d"/>
<path d="M24 13v22M13 24h22" stroke="#ffffff" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48" width="220" height="48">
<rect x="2" y="8" width="32" height="32" rx="8" fill="#ffffff"/>
<path d="M18 15v18M9 24h18" stroke="#4f7c9b" stroke-width="6" stroke-linecap="round"/>
<text x="42" y="31" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#ffffff" letter-spacing="2">CLÍNICA</text>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48" width="220" height="48">
<rect x="2" y="8" width="32" height="32" rx="8" fill="#1f3a4d"/>
<path d="M18 15v18M9 24h18" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
<text x="42" y="31" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#1f3a4d" letter-spacing="2">CLÍNICA</text>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,47 @@
# Segunda instancia (white-label) — frontend React + API Odoo en puerto 8070.
#
# Acceso LAN inmediato: http://192.168.10.114:8880
# Acceso publico: requiere agregar el hostname en el dashboard del tunel
# cloudflared (remotamente administrado, sin config local):
# ingress: - hostname: clinica2.consultoria-as.com
# service: http://localhost:80
server {
listen 80;
listen 8880;
server_name clinica2.consultoria-as.com;
# Frontend React (mismo build que SKEEN; el brand sale del API /brand)
location / {
root /var/www/clinica2-frontend;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# Odoo API para el frontend
location /api/odoo/ {
proxy_pass http://127.0.0.1:8070/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Backend Odoo (administracion directa, opcional)
location /web/ {
proxy_pass http://127.0.0.1:8070/web/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Escenario B: la instancia 2 no tiene WhatsApp. Aunque el backend tenga
# fallback a localhost:3000, aqui se corta la ruta: 403 fijo, sin excepciones.
location /api/odoo/skeen/frontend/v1/wacrm/ {
return 403;
}
}

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Odoo CLINICA2 (skeen-odoo venv, instancia white-label)
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/skeen-odoo
# Sin SUPABASE_URL / WACRM_API_* / HERMES_BRIDGE_URL a proposito: la instancia
# 2 arranca en escenario B (sin WhatsApp). Los endpoints WACRM responden
# "Falta configuracion" en vez de tocar la instancia compartida de SKEEN.
ExecStart=/root/odoo-venv/bin/python /root/skeen-odoo/odoo-bin -c /root/clinica2-odoo.conf --logfile=/tmp/odoo2.log
Restart=always
RestartSec=5
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target

55
infra/clinica2/odoo.conf Normal file
View File

@@ -0,0 +1,55 @@
[options]
addons_path = /root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons
admin_passwd = skeen_admin_2026
csv_internal_sep = ,
data_dir = /var/lib/odoo-clinica2
db_host = localhost
db_maxconn = 64
db_name = clinica2_odoo
db_password = skeen_dev_2026
db_port = 5432
db_sslmode = prefer
db_user = skeen
demo = {}
email_from = False
http_enable = True
http_interface =
http_port = 8070
import_partial =
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
limit_time_cpu = 60
limit_time_real = 120
limit_time_real_cron = -1
list_db = True
log_db = False
log_db_level = warning
log_handler = :INFO
log_level = info
logfile =
longpolling_port = 8073
max_cron_threads = 2
osv_memory_age_limit = False
osv_memory_count_limit = False
pg_path =
pidfile =
proxy_mode = True
reportgz = False
screencasts =
screenshots = /tmp/odoo_screenshots_clinica2
server_wide_modules = base,web
smtp_password = False
smtp_port = 25
smtp_server = localhost
smtp_ssl = False
smtp_ssl_certificate_filename = False
smtp_ssl_private_key_filename = False
smtp_user = False
test_file =
test_tags = None
translate_modules = ['all']
unaccent = False
upgrade_path =
without_demo = False
workers = 0

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Post-instalación de la instancia clinica2_odoo (white-label, sin WACRM).
- Brand neutro (skeen.clinic.brand) apuntando a logos genericos servidos por
el nginx de la instancia (no SKEEN).
- skeen.menu_roles con /wacrm/* en lista vacia: modulo WhatsApp oculto para
todos (escenario B: la clinica nueva aun no tiene numero de WhatsApp).
- Admin con password distinto a la instancia SKEEN y must_change_password.
- Empresa principal renombrada al nombre neutro.
"""
import json
import sys
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config
config.parse_config([
'-c', '/root/clinica2-odoo.conf',
'--no-http',
'--log-level', 'error',
])
odoo.service.server.load_server_wide_modules()
registry = odoo.registry(config['db_name'])
BRAND = {
'nombre': 'Clínica',
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'pie': 'Sistema de gestión clínica',
}
ADMIN_PASSWORD = 'Clinica2Admin2026!'
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
# 1) Brand neutro
ICP.set_param('skeen.clinic.brand', json.dumps(BRAND, ensure_ascii=False))
print('brand:', ICP.get_param('skeen.clinic.brand'))
# 2) Ocultar WACRM para todos (lista vacia = ningun rol)
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))
print('menu_roles wacrm:', roles.get('/wacrm/messages'), roles.get('/wacrm/leads'))
# 3) Admin: password propio de la instancia + cambio forzado
admin = env['skeen.frontend.user'].sudo().search([('login', '=', 'admin')], limit=1)
admin.set_password(ADMIN_PASSWORD)
admin.write({'must_change_password': True})
print('admin:', admin.login, 'must_change_password:', admin.must_change_password)
# 4) Empresa principal renombrada (el backend Odoo la muestra)
company = env['res.company'].sudo().search([], limit=1, order='id')
if company:
company.write({'name': 'Clínica'})
print('company:', company.name)
cr.commit()
print('POST-INSTALL OK')

View File

@@ -14,6 +14,8 @@
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
# Menú padre primero: las vistas de abajo cuelgan de él.
'views/menus.xml',
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/lista_espera_views.xml',

View File

@@ -1,10 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menú principal -->
<menuitem id="menu_skeen_root" name="SKEEN" sequence="10"/>
<!-- Menú Citas -->
<menuitem id="menu_skeen_citas" name="Citas" parent="menu_skeen_root" sequence="10"/>
<!-- Menú Citas y sus entradas con acción. El padre (menu_skeen_citas) se
declara en menus.xml, que carga antes que las vistas que lo usan. -->
<menuitem id="menu_skeen_citas_list" name="Todas las Citas" parent="menu_skeen_citas"
action="action_skeen_cita_tree" sequence="10"/>
</odoo>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menú padre de Citas, declarado ANTES que las vistas que cuelgan de él
(bloqueos, lista de espera). Sin action a propósito: las entradas con
acción viven en menu_views.xml, que carga después de cita_views.xml
(donde se define action_skeen_cita_tree). -->
<!-- Menú Citas -->
<menuitem id="menu_skeen_citas" name="Citas" parent="skeen_pacientes.menu_skeen_root" sequence="10"/>
</odoo>

View File

@@ -133,7 +133,7 @@
</record>
<!-- Menús (bajo el menú raíz SKEEN) -->
<menuitem id="menu_skeen_inventario" name="Inventario" parent="skeen_citas.menu_skeen_root" sequence="40"/>
<menuitem id="menu_skeen_inventario" name="Inventario" parent="skeen_pacientes.menu_skeen_root" sequence="40"/>
<menuitem id="menu_skeen_inventario_items" name="Artículos" parent="menu_skeen_inventario"
action="action_skeen_inventario_item" sequence="10"/>
<menuitem id="menu_skeen_inventario_moves" name="Movimientos" parent="menu_skeen_inventario"

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menú Monedero -->
<menuitem id="menu_skeen_monedero" name="Monedero" parent="skeen_citas.menu_skeen_root" sequence="20"/>
<menuitem id="menu_skeen_monedero" name="Monedero" parent="skeen_pacientes.menu_skeen_root" sequence="20"/>
<menuitem id="menu_skeen_monedero_list" name="Monederos" parent="menu_skeen_monedero"
action="action_skeen_monedero_tree" sequence="10"/>

View File

@@ -13,6 +13,10 @@
'depends': ['base', 'contacts', 'hr'],
'data': [
'security/ir.model.access.csv',
# El menú raíz se declara primero: las vistas de abajo lo usan como
# parent y en una instalación limpia este módulo carga antes que
# skeen_citas (dueño histórico del xmlid).
'views/menu_views.xml',
'views/paciente_views.xml',
'views/receta_views.xml',
'views/catalogos_views.xml',

View File

@@ -69,8 +69,8 @@
</record>
<!-- Menús (bajo el menú raíz SKEEN de skeen_citas) -->
<menuitem id="menu_skeen_diagnosticos" name="Diagnósticos" parent="skeen_citas.menu_skeen_root"
<menuitem id="menu_skeen_diagnosticos" name="Diagnósticos" parent="skeen_pacientes.menu_skeen_root"
action="action_skeen_diagnostico_tree" sequence="17"/>
<menuitem id="menu_skeen_procedimientos" name="Procedimientos" parent="skeen_citas.menu_skeen_root"
<menuitem id="menu_skeen_procedimientos" name="Procedimientos" parent="skeen_pacientes.menu_skeen_root"
action="action_skeen_procedimiento_tree" sequence="18"/>
</odoo>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menú raíz de la aplicación. Vive en skeen_pacientes (el módulo raíz de
la jerarquía: todos los demás módulos skeen_* dependen de él, directa o
transitivamente) para que exista antes que cualquier otro menú que lo
use como parent en una instalación limpia de una sola pasada.
Las referencias cruzadas usan el xmlid completo
skeen_pacientes.menu_skeen_root. En bases existentes el ownership del
xmlid se transfirió de skeen_citas a skeen_pacientes (ver migración
ir_model_data), así que actualizar no duplica el menú. -->
<menuitem id="menu_skeen_root" name="SKEEN" sequence="10"/>
</odoo>

View File

@@ -39,6 +39,6 @@
</record>
<!-- Menú Recetas (bajo el menú raíz SKEEN de skeen_citas) -->
<menuitem id="menu_skeen_recetas" name="Recetas" parent="skeen_citas.menu_skeen_root"
<menuitem id="menu_skeen_recetas" name="Recetas" parent="skeen_pacientes.menu_skeen_root"
action="action_skeen_receta_tree" sequence="16"/>
</odoo>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_citas.menu_skeen_root" sequence="30" />
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_pacientes.menu_skeen_root" sequence="30" />
<menuitem id="menu_skeen_venta" name="Ventas" parent="menu_skeen_ventas_root" action="action_skeen_venta" sequence="10" />
<menuitem id="menu_skeen_corte_caja" name="Cortes de Caja" parent="menu_skeen_ventas_root" action="action_skeen_corte_caja" sequence="20" />
<menuitem id="menu_skeen_paquete" name="Paquetes" parent="menu_skeen_ventas_root" action="action_skeen_paquete" sequence="30" />

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menú Visitas (bajo el menú raíz SKEEN de skeen_citas) -->
<menuitem id="menu_skeen_visitas" name="Visitas" parent="skeen_citas.menu_skeen_root" sequence="15"/>
<menuitem id="menu_skeen_visitas" name="Visitas" parent="skeen_pacientes.menu_skeen_root" sequence="15"/>
<menuitem id="menu_skeen_visitas_list" name="Todas las Visitas" parent="menu_skeen_visitas"
action="action_skeen_visita_tree" sequence="10"/>
<menuitem id="menu_skeen_visitas_hoy" name="Visitas de Hoy" parent="menu_skeen_visitas"