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:
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Citas',
|
||||
'version': '1.0.1',
|
||||
'version': '1.1.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
|
||||
'description': """
|
||||
@@ -14,6 +14,7 @@
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
'data/catalogos.xml',
|
||||
# Menú padre primero: las vistas de abajo cuelgan de él.
|
||||
'views/menus.xml',
|
||||
'views/cita_views.xml',
|
||||
|
||||
38
odoo-addons/skeen_citas/catalogs.py
Normal file
38
odoo-addons/skeen_citas/catalogs.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utilidades para los catálogos configurables (sucursales, categorías)."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def slugify(text):
|
||||
"""'Playa de Rosarito' -> 'playa-de-rosarito' (sin acentos, [a-z0-9-])."""
|
||||
text = unicodedata.normalize('NFKD', str(text or ''))
|
||||
text = text.encode('ascii', 'ignore').decode('ascii')
|
||||
text = re.sub(r'[^a-zA-Z0-9]+', '-', text).strip('-').lower()
|
||||
return text
|
||||
|
||||
|
||||
def unique_slug(model, base, exclude_id=None):
|
||||
"""Slug único para `model` (sufija -2, -3... si ya existe).
|
||||
|
||||
Cuenta también los registros inactivos: el `code` es único a nivel base de
|
||||
datos y las bajas son lógicas, así que un código usado por algo desactivado
|
||||
sigue ocupado.
|
||||
"""
|
||||
model = model.with_context(active_test=False)
|
||||
base = slugify(base) or 'item'
|
||||
domain = [('code', '=', base)]
|
||||
if exclude_id:
|
||||
domain.append(('id', '!=', exclude_id))
|
||||
if not model.search_count(domain):
|
||||
return base
|
||||
n = 2
|
||||
while True:
|
||||
cand = '%s-%d' % (base, n)
|
||||
domain = [('code', '=', cand)]
|
||||
if exclude_id:
|
||||
domain.append(('id', '!=', exclude_id))
|
||||
if not model.search_count(domain):
|
||||
return cand
|
||||
n += 1
|
||||
166
odoo-addons/skeen_citas/clinic_settings.py
Normal file
166
odoo-addons/skeen_citas/clinic_settings.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Ajustes de la clínica por instancia (white-label).
|
||||
|
||||
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
|
||||
datos solo existían en el localStorage del navegador de cada equipo; ahora son
|
||||
compartidos por toda la instancia y los consumen el frontend, la impresión de
|
||||
tickets y las URLs de pago.
|
||||
|
||||
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
|
||||
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
|
||||
"""
|
||||
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SETTINGS_KEY = 'skeen.clinic.settings'
|
||||
BRAND_KEY = 'skeen.clinic.brand'
|
||||
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
|
||||
|
||||
HORA_APERTURA_DEFAULT = '08:00'
|
||||
HORA_CIERRE_DEFAULT = '19:00'
|
||||
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
|
||||
MONEDA_DEFAULT = 'MXN'
|
||||
|
||||
# Campos del contrato del endpoint.
|
||||
CAMPOS = (
|
||||
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
|
||||
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
|
||||
'moneda_base', 'nota_ticket',
|
||||
)
|
||||
|
||||
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||
AGENDA_START_DEFAULT = 8.0
|
||||
AGENDA_END_DEFAULT = 19.0
|
||||
|
||||
|
||||
def brand_dict(env):
|
||||
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
|
||||
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
|
||||
if raw:
|
||||
try:
|
||||
guardado = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
guardado = None
|
||||
if isinstance(guardado, dict):
|
||||
return guardado
|
||||
return {}
|
||||
|
||||
|
||||
def brand_nombre(env):
|
||||
nombre = brand_dict(env).get('nombre')
|
||||
nombre = str(nombre).strip() if nombre is not None else ''
|
||||
return nombre or BRAND_NOMBRE_DEFAULT
|
||||
|
||||
|
||||
def get_settings(env):
|
||||
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
|
||||
out = {
|
||||
'nombre': brand_nombre(env),
|
||||
'telefono': '',
|
||||
'email': '',
|
||||
'direccion': '',
|
||||
'web': '',
|
||||
'rfc': '',
|
||||
'horario_apertura': HORA_APERTURA_DEFAULT,
|
||||
'horario_cierre': HORA_CIERRE_DEFAULT,
|
||||
'zona_horaria': ZONA_HORARIA_DEFAULT,
|
||||
'url_publica': '',
|
||||
'moneda_base': MONEDA_DEFAULT,
|
||||
'nota_ticket': '',
|
||||
}
|
||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||
if raw:
|
||||
try:
|
||||
guardado = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
guardado = None
|
||||
if isinstance(guardado, dict):
|
||||
for k in CAMPOS:
|
||||
if k in guardado and guardado[k] is not None:
|
||||
out[k] = str(guardado[k])
|
||||
return out
|
||||
|
||||
|
||||
def set_settings(env, vals):
|
||||
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
|
||||
if not isinstance(vals, dict):
|
||||
raise ValueError('Los ajustes deben ser un objeto')
|
||||
actual = get_settings(env)
|
||||
for k in CAMPOS:
|
||||
if k in vals and vals[k] is not None:
|
||||
actual[k] = str(vals[k]).strip()
|
||||
env['ir.config_parameter'].sudo().set_param(
|
||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||
return get_settings(env)
|
||||
|
||||
|
||||
def _hhmm_to_float(valor):
|
||||
"""'08:00' -> 8.0. None si el formato no es válido."""
|
||||
try:
|
||||
partes = str(valor).strip().split(':')
|
||||
h = int(partes[0])
|
||||
m = int(partes[1]) if len(partes) > 1 else 0
|
||||
if not (0 <= h <= 24 and 0 <= m <= 59):
|
||||
return None
|
||||
return h + m / 60.0
|
||||
except (ValueError, TypeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def agenda_bounds(env):
|
||||
"""(inicio, fin) de la agenda en horas float.
|
||||
|
||||
Lee horario_apertura/horario_cierre de los ajustes; si faltan o son
|
||||
incoherentes usa el horario de siempre (8:00-19:00), de modo que una
|
||||
instancia sin configurar se comporta igual que hoy.
|
||||
"""
|
||||
s = get_settings(env)
|
||||
inicio = _hhmm_to_float(s.get('horario_apertura'))
|
||||
fin = _hhmm_to_float(s.get('horario_cierre'))
|
||||
if inicio is None:
|
||||
inicio = AGENDA_START_DEFAULT
|
||||
if fin is None:
|
||||
fin = AGENDA_END_DEFAULT
|
||||
if fin <= inicio:
|
||||
return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT
|
||||
return inicio, fin
|
||||
|
||||
|
||||
def _es_url_privada(url):
|
||||
"""True si el host es local/privado (no sirve para URLs de pago públicas)."""
|
||||
try:
|
||||
host = (urlparse(url).hostname or '').lower()
|
||||
except ValueError:
|
||||
return True
|
||||
if not host or host in ('localhost', '127.0.0.1', '::1'):
|
||||
return True
|
||||
if host.startswith('10.') or host.startswith('192.168.'):
|
||||
return True
|
||||
if host.startswith('172.'):
|
||||
try:
|
||||
segundo = int(host.split('.')[1])
|
||||
except (IndexError, ValueError):
|
||||
return True
|
||||
if 16 <= segundo <= 31:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def url_publica(env, fallback=''):
|
||||
"""URL pública base de la instancia, sin barra final.
|
||||
|
||||
Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público)
|
||||
-> `fallback` (el valor que usaba el código antes). El último escalón
|
||||
preserva el comportamiento actual en instancias sin configurar.
|
||||
"""
|
||||
url = (get_settings(env).get('url_publica') or '').strip()
|
||||
if not url:
|
||||
url = str(brand_dict(env).get('url_publica') or '').strip()
|
||||
if not url:
|
||||
base = env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
|
||||
if base and not _es_url_privada(base):
|
||||
url = base
|
||||
if not url:
|
||||
url = fallback
|
||||
return (url or '').rstrip('/')
|
||||
49
odoo-addons/skeen_citas/data/catalogos.xml
Normal file
49
odoo-addons/skeen_citas/data/catalogos.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Catálogos semilla configurables (white-label).
|
||||
noupdate="1": se crean una sola vez; los cambios que haga la clínica
|
||||
(renombrar, desactivar, agregar) sobreviven a los upgrades.
|
||||
Los `code` coinciden con los valores que ya estaban hardcodeados en
|
||||
skeen.cita.branch y skeen.servicio.category, para no tocar los datos
|
||||
históricos. -->
|
||||
<data noupdate="1">
|
||||
<!-- Sucursales -->
|
||||
<record id="sucursal_rosarito" model="skeen.sucursal">
|
||||
<field name="name">Rosarito</field>
|
||||
<field name="code">rosarito</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_default" eval="True"/>
|
||||
</record>
|
||||
<record id="sucursal_tijuana" model="skeen.sucursal">
|
||||
<field name="name">Tijuana</field>
|
||||
<field name="code">tijuana</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Categorías de servicio -->
|
||||
<record id="service_category_consulta" model="skeen.service.category">
|
||||
<field name="name">Consulta</field>
|
||||
<field name="code">consulta</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_system" eval="True"/>
|
||||
</record>
|
||||
<record id="service_category_tratamiento" model="skeen.service.category">
|
||||
<field name="name">Tratamiento</field>
|
||||
<field name="code">tratamiento</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_system" eval="True"/>
|
||||
</record>
|
||||
<record id="service_category_procedimiento" model="skeen.service.category">
|
||||
<field name="name">Procedimiento</field>
|
||||
<field name="code">procedimiento</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="is_system" eval="True"/>
|
||||
</record>
|
||||
<record id="service_category_paquete" model="skeen.service.category">
|
||||
<field name="name">Paquete</field>
|
||||
<field name="code">paquete</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="is_system" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
212
odoo-addons/skeen_citas/glosario.py
Normal file
212
odoo-addons/skeen_citas/glosario.py
Normal 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]
|
||||
@@ -3,3 +3,5 @@
|
||||
from . import cita
|
||||
from . import bloqueo
|
||||
from . import lista_espera
|
||||
from . import sucursal
|
||||
from . import service_category
|
||||
|
||||
@@ -4,6 +4,8 @@ from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..clinic_settings import agenda_bounds
|
||||
|
||||
|
||||
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
|
||||
# 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado.
|
||||
@@ -34,13 +36,12 @@ class SkeenServicio(models.Model):
|
||||
package_sessions = fields.Integer(string='Sesiones en Paquete', default=0)
|
||||
package_notes = fields.Char(string='Notas del Paquete')
|
||||
|
||||
# Categoría
|
||||
category = fields.Selection([
|
||||
('consulta', 'Consulta'),
|
||||
('tratamiento', 'Tratamiento'),
|
||||
('procedimiento', 'Procedimiento'),
|
||||
('paquete', 'Paquete'),
|
||||
], string='Categoría', default='tratamiento', required=True)
|
||||
# Categoría (valores configurables en skeen.service.category; los códigos
|
||||
# semilla 'consulta', 'tratamiento', 'procedimiento' y 'paquete' conservan
|
||||
# el comportamiento y los lookups actuales del backend)
|
||||
category = fields.Selection(
|
||||
selection='_get_category_selection',
|
||||
string='Categoría', default='tratamiento', required=True)
|
||||
|
||||
# Visual / agrupación
|
||||
color = fields.Char(string='Color', default='#1abc9c')
|
||||
@@ -61,6 +62,20 @@ class SkeenServicio(models.Model):
|
||||
('unique_code', 'unique(code)', 'El código debe ser único!')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_category_selection(self):
|
||||
"""Categorías configuradas. Incluye las inactivas para no invalidar
|
||||
servicios históricos; si aún no hay datos usa las de siempre."""
|
||||
categorias = self.env['skeen.service.category'].sudo().with_context(
|
||||
active_test=False).search([])
|
||||
seleccion = [(c.code, c.name) for c in categorias if c.code]
|
||||
return seleccion or [
|
||||
('consulta', 'Consulta'),
|
||||
('tratamiento', 'Tratamiento'),
|
||||
('procedimiento', 'Procedimiento'),
|
||||
('paquete', 'Paquete'),
|
||||
]
|
||||
|
||||
|
||||
class SkeenServiceTag(models.Model):
|
||||
_name = 'skeen.service.tag'
|
||||
@@ -103,10 +118,12 @@ class SkeenCita(models.Model):
|
||||
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
|
||||
|
||||
# Ubicación y médico
|
||||
branch = fields.Selection([
|
||||
('rosarito', 'Rosarito'),
|
||||
('tijuana', 'Tijuana'),
|
||||
], string='Sucursal', default='rosarito')
|
||||
# Sucursal: valores configurables en skeen.sucursal. Las semilla conservan
|
||||
# los códigos 'rosarito'/'tijuana' de las citas existentes.
|
||||
branch = fields.Selection(
|
||||
selection='_get_branch_selection',
|
||||
string='Sucursal',
|
||||
default=lambda self: self.env['skeen.sucursal'].default_code())
|
||||
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
|
||||
medium = fields.Selection([
|
||||
('videocall', 'Videollamada'),
|
||||
@@ -177,7 +194,16 @@ class SkeenCita(models.Model):
|
||||
# Paquete terminado (para servicios con categoría 'paquete')
|
||||
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
||||
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
|
||||
|
||||
|
||||
@api.model
|
||||
def _get_branch_selection(self):
|
||||
"""Sucursales configuradas. Incluye las inactivas para no invalidar
|
||||
citas históricas; si aún no hay datos usa las de siempre."""
|
||||
sucursales = self.env['skeen.sucursal'].sudo().with_context(
|
||||
active_test=False).search([])
|
||||
seleccion = [(s.code, s.name) for s in sucursales if s.code]
|
||||
return seleccion or [('rosarito', 'Rosarito'), ('tijuana', 'Tijuana')]
|
||||
|
||||
@api.depends('date', 'time', 'servicio_id', 'duration')
|
||||
def _compute_datetime(self):
|
||||
for cita in self:
|
||||
@@ -430,8 +456,8 @@ class SkeenCita(models.Model):
|
||||
|
||||
Con doctor_id, además excluye los horarios bloqueados de ese médico
|
||||
(el usuario no debe poder elegir una hora bloqueada) y respeta su
|
||||
horario semanal cuando lo tiene configurado (sin horario configurado
|
||||
el comportamiento es el de siempre: 8:00 a 19:00).
|
||||
horario semanal cuando lo tiene configurado. Los límites de la jornada
|
||||
salen de los ajustes de la clínica; sin configurar son 8:00 a 19:00.
|
||||
|
||||
`duration` (min) permite simular una duración distinta a la del
|
||||
servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
|
||||
@@ -449,9 +475,8 @@ class SkeenCita(models.Model):
|
||||
('date', '=', date),
|
||||
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
|
||||
|
||||
# Horario de la clínica: 8:00 AM a 7:00 PM
|
||||
start_time = 8.0 # 8:00 AM
|
||||
end_time = 19.0 # 7:00 PM
|
||||
# Horario de la clínica (configurable en los ajustes; por defecto 8:00-19:00)
|
||||
start_time, end_time = agenda_bounds(self.env)
|
||||
|
||||
# Horario del médico (si lo tiene configurado manda sobre el de la clínica)
|
||||
if doctor_id:
|
||||
|
||||
27
odoo-addons/skeen_citas/models/service_category.py
Normal file
27
odoo-addons/skeen_citas/models/service_category.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class SkeenServiceCategory(models.Model):
|
||||
"""Categoría de servicio (consulta, tratamiento, procedimiento, paquete...).
|
||||
|
||||
Antes era un Selection fijo en skeen.servicio. Los `code` semilla
|
||||
('consulta', 'tratamiento', 'procedimiento', 'paquete') son los valores que
|
||||
el backend ya usa en sus búsquedas, por eso deben conservarse.
|
||||
"""
|
||||
_name = 'skeen.service.category'
|
||||
_description = 'Categoría de servicio'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True, translate=True)
|
||||
code = fields.Char(string='Código', required=True, copy=False)
|
||||
sequence = fields.Integer(string='Secuencia', default=10)
|
||||
active = fields.Boolean(string='Activa', default=True)
|
||||
is_system = fields.Boolean(
|
||||
string='De sistema', default=False,
|
||||
help='Las categorías de sistema no se pueden eliminar, solo renombrar.')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_code', 'unique(code)', 'El código de la categoría debe ser único!')
|
||||
]
|
||||
38
odoo-addons/skeen_citas/models/sucursal.py
Normal file
38
odoo-addons/skeen_citas/models/sucursal.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SkeenSucursal(models.Model):
|
||||
"""Sucursal de la clínica.
|
||||
|
||||
Antes las sucursales eran un Selection fijo ('rosarito', 'tijuana')
|
||||
hardcodeado en skeen.cita. Ahora son datos: el `code` es el valor que se
|
||||
guarda en las citas, por eso las sucursales semilla conservan los códigos
|
||||
actuales y las citas históricas no se tocan.
|
||||
"""
|
||||
_name = 'skeen.sucursal'
|
||||
_description = 'Sucursal de la clínica'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True, translate=True)
|
||||
code = fields.Char(string='Código', required=True, copy=False,
|
||||
help='Identificador estable que se guarda en las citas.')
|
||||
sequence = fields.Integer(string='Secuencia', default=10)
|
||||
active = fields.Boolean(string='Activa', default=True)
|
||||
is_default = fields.Boolean(string='Predeterminada', default=False)
|
||||
address = fields.Char(string='Dirección')
|
||||
phone = fields.Char(string='Teléfono')
|
||||
notes = fields.Text(string='Notas')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_code', 'unique(code)', 'El código de la sucursal debe ser único!')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def default_code(self):
|
||||
"""Código de la sucursal predeterminada (o la primera); 'rosarito' si no hay."""
|
||||
sucursal = self.search([('is_default', '=', True)], limit=1)
|
||||
if not sucursal:
|
||||
sucursal = self.search([], limit=1)
|
||||
return sucursal.code or 'rosarito'
|
||||
@@ -5,3 +5,5 @@ access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1
|
||||
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
|
||||
access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1
|
||||
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
|
||||
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
|
||||
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Pagos',
|
||||
'version': '1.0.2',
|
||||
'version': '1.0.3',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
||||
'description': """
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_settings as _clinic_settings
|
||||
from odoo.addons.skeen_citas.clinic_settings import url_publica as _clinic_url_publica
|
||||
import requests
|
||||
import json
|
||||
|
||||
@@ -61,7 +63,24 @@ class SkeenPago(models.Model):
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001'
|
||||
return super(SkeenPago, self).create(vals_list)
|
||||
|
||||
|
||||
def _payment_base(self, fallback):
|
||||
"""URL pública base de la instancia para los enlaces de pago.
|
||||
|
||||
Sale de los ajustes de la clínica (url_publica); si la instancia no está
|
||||
configurada, de `web.base.url` cuando es público y, en último caso, del
|
||||
valor que ya usaba el código (`fallback`).
|
||||
"""
|
||||
return _clinic_url_publica(self.env, fallback=fallback)
|
||||
|
||||
def _product_name(self):
|
||||
"""Nombre del producto en la pasarela: '<clínica> - <servicio>'."""
|
||||
nombre = (_clinic_settings(self.env).get('nombre') or '').strip() or 'SKEEN Derma Experts'
|
||||
servicio = ''
|
||||
if self.cita_id and self.cita_id.servicio_id:
|
||||
servicio = self.cita_id.servicio_id.name or ''
|
||||
return '%s - %s' % (nombre, servicio) if servicio else nombre
|
||||
|
||||
def action_generate_stripe_link(self):
|
||||
"""Generar link de pago Stripe"""
|
||||
self.ensure_one()
|
||||
@@ -70,6 +89,7 @@ class SkeenPago(models.Model):
|
||||
raise ValidationError(_('Stripe no está configurado!'))
|
||||
|
||||
# Crear sesión de checkout
|
||||
base = self._payment_base('https://app.skeen.mx')
|
||||
headers = {
|
||||
'Authorization': f'Bearer {stripe_key}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -77,12 +97,12 @@ class SkeenPago(models.Model):
|
||||
data = {
|
||||
'payment_method_types[]': 'card',
|
||||
'line_items[0][price_data][currency]': 'mxn',
|
||||
'line_items[0][price_data][product_data][name]': f'Servicio SKEEN - {self.cita_id.servicio_id.name}',
|
||||
'line_items[0][price_data][product_data][name]': self._product_name(),
|
||||
'line_items[0][price_data][unit_amount]': int(self.amount * 100),
|
||||
'line_items[0][quantity]': 1,
|
||||
'mode': 'payment',
|
||||
'success_url': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'cancel_url': f'https://app.skeen.mx/pagos/cancelado?ref={self.name}',
|
||||
'success_url': f'{base}/pagos/exitoso?ref={self.name}',
|
||||
'cancel_url': f'{base}/pagos/cancelado?ref={self.name}',
|
||||
'metadata[payment_id]': str(self.id),
|
||||
'metadata[patient_phone]': self.partner_id.phone or '',
|
||||
}
|
||||
@@ -111,22 +131,24 @@ class SkeenPago(models.Model):
|
||||
if not mp_token:
|
||||
raise ValidationError(_('MercadoPago no está configurado!'))
|
||||
|
||||
# Crear preferencia de checkout
|
||||
base = self._payment_base('https://app.skeen.mx')
|
||||
headers = {
|
||||
'Authorization': f'Bearer {mp_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
data = {
|
||||
'items': [{
|
||||
'title': f'Servicio SKEEN - {self.cita_id.servicio_id.name}',
|
||||
'title': self._product_name(),
|
||||
'quantity': 1,
|
||||
'unit_price': int(self.amount),
|
||||
}],
|
||||
'back_urls': {
|
||||
'success': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'failure': f'https://app.skeen.mx/pagos/fallido?ref={self.name}',
|
||||
'pending': f'https://app.skeen.mx/pagos/pendiente?ref={self.name}',
|
||||
'success': f'{base}/pagos/exitoso?ref={self.name}',
|
||||
'failure': f'{base}/pagos/fallido?ref={self.name}',
|
||||
'pending': f'{base}/pagos/pendiente?ref={self.name}',
|
||||
},
|
||||
'notification_url': 'https://skeen.mx/webhooks/mercadopago',
|
||||
'notification_url': self._payment_base('https://skeen.mx') + '/webhooks/mercadopago',
|
||||
'metadata': {
|
||||
'payment_id': str(self.id),
|
||||
'patient_phone': self.partner_id.phone or '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN WhatsApp',
|
||||
'version': '1.0.0',
|
||||
'version': '1.0.1',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración WhatsApp para SKEEN Derma Experts',
|
||||
'description': """
|
||||
|
||||
@@ -12,6 +12,20 @@ from .auth import sign_token, current_user, require_role, forbid_roles, verify_t
|
||||
from .auth import require_agenda, agenda_perm_error
|
||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||
from ..roles import AGENDA_PERM_KEYS
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
agenda_bounds as clinic_agenda_bounds,
|
||||
get_settings as get_clinic_settings,
|
||||
set_settings as set_clinic_settings,
|
||||
)
|
||||
from odoo.addons.skeen_citas.glosario import (
|
||||
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
|
||||
get_config as get_glosario_config,
|
||||
list_packs as list_glosario_packs,
|
||||
resolve as resolver_glosario,
|
||||
set_config as set_glosario_config,
|
||||
)
|
||||
from odoo.addons.skeen_citas.catalogs import slugify as catalog_slugify
|
||||
from odoo.addons.skeen_citas.catalogs import unique_slug as catalog_unique_slug
|
||||
|
||||
|
||||
def json_response(data, status=200):
|
||||
@@ -93,20 +107,35 @@ def _patient_search_domain(search):
|
||||
class SkeenFrontendController(http.Controller):
|
||||
"""Controladores API públicos para el Frontend React de SKEEN"""
|
||||
|
||||
# Horario de agenda de la clínica (hora de inicio permitida de una cita)
|
||||
# Horario de agenda de la clínica. Son solo el respaldo: el horario real
|
||||
# sale de los ajustes de la clínica (clinic-settings). Sin configurar se
|
||||
# usan estos valores, idénticos al comportamiento de siempre.
|
||||
AGENDA_START = 8.0 # 8:00 AM
|
||||
AGENDA_END = 19.0 # 7:00 PM
|
||||
|
||||
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
|
||||
EXPORT_MAX = 100000
|
||||
|
||||
def _agenda_bounds(self):
|
||||
"""(inicio, fin) de la agenda en horas float, según los ajustes."""
|
||||
try:
|
||||
return clinic_agenda_bounds(request.env)
|
||||
except Exception:
|
||||
return self.AGENDA_START, self.AGENDA_END
|
||||
|
||||
def _agenda_horario_txt(self):
|
||||
"""'8:00 a 19:00' para los mensajes de validación."""
|
||||
inicio, fin = self._agenda_bounds()
|
||||
return '%s a %s' % (self._fmt_horas(inicio), self._fmt_horas(fin))
|
||||
|
||||
def _valid_agenda_time(self, value):
|
||||
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
|
||||
"""Valida hora de inicio dentro del horario de agenda configurado."""
|
||||
try:
|
||||
t = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return self.AGENDA_START <= t < self.AGENDA_END
|
||||
inicio, fin = self._agenda_bounds()
|
||||
return inicio <= t < fin
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auditoría de configuración
|
||||
@@ -633,7 +662,7 @@ class SkeenFrontendController(http.Controller):
|
||||
first_time = data.get('time')
|
||||
if first_time is not None and not self._valid_agenda_time(first_time):
|
||||
return json_response(
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
|
||||
created = []
|
||||
# Para paquetes, generamos citas separadas con fechas consecutivas
|
||||
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
|
||||
@@ -688,7 +717,7 @@ class SkeenFrontendController(http.Controller):
|
||||
apt_time = data.get('time')
|
||||
if apt_time is None or not self._valid_agenda_time(apt_time):
|
||||
return json_response(
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
|
||||
|
||||
Partner = request.env['res.partner'].sudo()
|
||||
digits = re.sub(r'\D', '', phone)
|
||||
@@ -793,7 +822,7 @@ class SkeenFrontendController(http.Controller):
|
||||
vals = {k: data[k] for k in allowed if k in data}
|
||||
if 'time' in vals and not self._valid_agenda_time(vals['time']):
|
||||
return json_response(
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
|
||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
|
||||
try:
|
||||
cita.write(vals)
|
||||
except ValidationError as e:
|
||||
@@ -1218,9 +1247,11 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
|
||||
if not all_day and time_to <= time_from:
|
||||
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
|
||||
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
|
||||
return json_response(
|
||||
{'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400)
|
||||
if not all_day:
|
||||
agenda_ini, agenda_fin = self._agenda_bounds()
|
||||
if time_from < agenda_ini - 1e-6 or time_to > agenda_fin + 1e-6:
|
||||
return json_response(
|
||||
{'status': 'error', 'message': 'El rango de horas debe estar entre %s' % self._agenda_horario_txt()}, 400)
|
||||
|
||||
motivo_tipo = data.get('motivo_tipo') or 'otro'
|
||||
if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
|
||||
@@ -6416,20 +6447,33 @@ class SkeenFrontendController(http.Controller):
|
||||
logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
|
||||
logo_oscuro - logo para fondos claros (tema HomeNest)
|
||||
pie - línea del pie (login / documentos impresos)
|
||||
url_publica - URL pública de la instancia (opcional; la usan los
|
||||
enlaces de pago y correos)
|
||||
colores - {'primario': '#hex', 'acento': '#hex'} (opcional)
|
||||
"""
|
||||
defaults = {
|
||||
'nombre': 'SKEEN Derma Experts',
|
||||
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
|
||||
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
|
||||
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
|
||||
'url_publica': '',
|
||||
'colores': {'primario': '', 'acento': ''},
|
||||
}
|
||||
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
|
||||
if raw:
|
||||
try:
|
||||
guardado = json.loads(raw)
|
||||
if isinstance(guardado, dict):
|
||||
defaults.update({k: str(v) for k, v in guardado.items()
|
||||
if k in defaults and v is not None and str(v).strip()})
|
||||
for k in ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica'):
|
||||
v = guardado.get(k)
|
||||
if v is not None and str(v).strip():
|
||||
defaults[k] = str(v)
|
||||
colores = guardado.get('colores')
|
||||
if isinstance(colores, dict):
|
||||
for c in ('primario', 'acento'):
|
||||
v = colores.get(c)
|
||||
if v is not None and str(v).strip():
|
||||
defaults['colores'][c] = str(v)
|
||||
except Exception:
|
||||
pass # JSON roto: se sirven los defaults
|
||||
return defaults
|
||||
@@ -6458,8 +6502,29 @@ class SkeenFrontendController(http.Controller):
|
||||
'message': 'brand debe ser un objeto'}, 400)
|
||||
antes = self._brand_dict()
|
||||
limpio = {k: str(brand[k]).strip() for k in
|
||||
('nombre', 'logo_claro', 'logo_oscuro', 'pie')
|
||||
('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica')
|
||||
if brand.get(k) is not None and str(brand.get(k)).strip()}
|
||||
# Colores de marca (opcional). Formato hex básico; se ignora lo vacío.
|
||||
colores = brand.get('colores')
|
||||
if colores is not None:
|
||||
if not isinstance(colores, dict):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'colores debe ser un objeto'}, 400)
|
||||
limpio_colores = {}
|
||||
for clave in ('primario', 'acento'):
|
||||
valor = colores.get(clave)
|
||||
if valor is None:
|
||||
continue
|
||||
valor = str(valor).strip()
|
||||
if not valor:
|
||||
continue
|
||||
if not re.match(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', valor):
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'El color %s debe ser hex (#RRGGBB)' % clave}, 400)
|
||||
limpio_colores[clave] = valor
|
||||
if limpio_colores:
|
||||
limpio['colores'] = limpio_colores
|
||||
request.env['ir.config_parameter'].sudo().set_param(
|
||||
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
|
||||
nuevo = self._brand_dict()
|
||||
@@ -6474,6 +6539,464 @@ class SkeenFrontendController(http.Controller):
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Terminología de la clínica (glosario) — white-label
|
||||
# ============================================================
|
||||
def _puede_configurar_catalogo(self):
|
||||
"""Sucursales, categorías y terminología: admin o gerencia."""
|
||||
u = self._fu_user()
|
||||
return bool(u and u.role in ('admin', 'gerencia'))
|
||||
|
||||
@http.route('/skeen/frontend/v1/glosario', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_glosario(self, **kw):
|
||||
"""Glosario vigente: siempre las 24 claves resueltas (pack + overrides)."""
|
||||
try:
|
||||
pack, terms = resolver_glosario(request.env)
|
||||
return json_response({'status': 'success', 'pack': pack, 'terms': terms})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/glosario/packs', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_glosario_packs(self, **kw):
|
||||
"""Packs predefinidos con nombre legible, para el selector."""
|
||||
try:
|
||||
return json_response({'status': 'success', 'packs': list_glosario_packs()})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/glosario', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def put_glosario(self, **kw):
|
||||
"""Guarda pack y overrides. Los overrides pisan al pack.
|
||||
|
||||
Body: {"pack": "spa", "overrides": {"patient_singular": "Huésped"}}
|
||||
Si se omite una de las dos claves, se conserva lo que ya había. Los
|
||||
overrides enviados reemplazan a los anteriores (no se mezclan).
|
||||
"""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
pack = data.get('pack')
|
||||
overrides = data.get('overrides')
|
||||
if pack is not None and str(pack).strip():
|
||||
if str(pack).strip() not in [p['code'] for p in list_glosario_packs()]:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Pack no válido: %s' % pack}, 400)
|
||||
if overrides is not None and not isinstance(overrides, dict):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'overrides debe ser un objeto'}, 400)
|
||||
if isinstance(overrides, dict):
|
||||
desconocidas = [k for k in overrides if k not in GLOSARIO_KEY_SET]
|
||||
if desconocidas:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Claves desconocidas: %s' % ', '.join(desconocidas)}, 400)
|
||||
antes = get_glosario_config(request.env)
|
||||
set_glosario_config(request.env, pack=pack, overrides=overrides)
|
||||
pack_actual, terms = resolver_glosario(request.env)
|
||||
self._auditar('clinica', 'glosario', 'terminologia',
|
||||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||
new_value=json.dumps({'pack': pack_actual,
|
||||
'overrides': get_glosario_config(request.env)['overrides']},
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Terminología de la clínica (glosario)',
|
||||
notes=(data.get('motivo') or 'actualizado').strip())
|
||||
return json_response({'status': 'success', 'pack': pack_actual,
|
||||
'terms': terms,
|
||||
'message': 'Terminología actualizada'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Ajustes de la clínica por instancia
|
||||
# ============================================================
|
||||
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_clinic_settings_api(self, **kw):
|
||||
"""Ajustes de la clínica (nombre, contacto, horario, moneda...)."""
|
||||
try:
|
||||
return json_response({'status': 'success',
|
||||
'settings': get_clinic_settings(request.env)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def put_clinic_settings_api(self, **kw):
|
||||
"""Guarda los ajustes mergeando sobre lo actual.
|
||||
|
||||
Acepta los campos al nivel raíz o dentro de {"settings": {...}}.
|
||||
"""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
vals = data.get('settings') if isinstance(data.get('settings'), dict) else data
|
||||
antes = get_clinic_settings(request.env)
|
||||
nuevo = set_clinic_settings(request.env, vals)
|
||||
self._auditar('clinica', 'clinic-settings', 'ajustes',
|
||||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||||
entity_label='Ajustes de la clínica',
|
||||
notes=(data.get('motivo') or 'actualizado').strip())
|
||||
return json_response({'status': 'success', 'settings': nuevo,
|
||||
'message': 'Ajustes actualizados'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Sucursales (configurables)
|
||||
# ============================================================
|
||||
def _sucursal_to_dict(self, s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'code': s.code,
|
||||
'name': s.name,
|
||||
'sequence': s.sequence,
|
||||
'active': s.active,
|
||||
'is_default': s.is_default,
|
||||
'address': s.address or '',
|
||||
'phone': s.phone or '',
|
||||
'notes': s.notes or '',
|
||||
}
|
||||
|
||||
def _marcar_sucursal_default(self, sucursal):
|
||||
"""Como mucho una sucursal predeterminada."""
|
||||
otras = request.env['skeen.sucursal'].sudo().with_context(
|
||||
active_test=False).search(
|
||||
[('is_default', '=', True), ('id', '!=', sucursal.id)])
|
||||
if otras:
|
||||
otras.write({'is_default': False})
|
||||
|
||||
@http.route('/skeen/frontend/v1/sucursales', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_sucursales(self, **kw):
|
||||
"""Sucursales. Por defecto solo activas; ?include_inactive=1 las trae todas."""
|
||||
try:
|
||||
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
|
||||
Sucursal = request.env['skeen.sucursal'].sudo()
|
||||
if incluir:
|
||||
Sucursal = Sucursal.with_context(active_test=False)
|
||||
recs = Sucursal.search([], order='sequence, name')
|
||||
return json_response({'status': 'success',
|
||||
'sucursales': [self._sucursal_to_dict(s) for s in recs],
|
||||
'total': len(recs)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/sucursales', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def create_sucursal(self, **kw):
|
||||
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El nombre es obligatorio'}, 400)
|
||||
Sucursal = request.env['skeen.sucursal'].sudo()
|
||||
codigo = (data.get('code') or '').strip()
|
||||
if codigo:
|
||||
codigo = catalog_slugify(codigo) or codigo
|
||||
if Sucursal.with_context(active_test=False).search_count([('code', '=', codigo)]):
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
|
||||
else:
|
||||
codigo = catalog_unique_slug(Sucursal, name)
|
||||
try:
|
||||
sequence = int(data.get('sequence') or 10)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'La secuencia debe ser numérica'}, 400)
|
||||
sucursal = Sucursal.create({
|
||||
'name': name,
|
||||
'code': codigo,
|
||||
'sequence': sequence,
|
||||
'active': bool(data.get('active', True)),
|
||||
'is_default': bool(data.get('is_default', False)),
|
||||
'address': data.get('address') or '',
|
||||
'phone': data.get('phone') or '',
|
||||
'notes': data.get('notes') or '',
|
||||
})
|
||||
if sucursal.is_default:
|
||||
self._marcar_sucursal_default(sucursal)
|
||||
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'crear',
|
||||
new_value=json.dumps(self._sucursal_to_dict(sucursal),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Sucursal %s' % sucursal.name,
|
||||
notes=(data.get('motivo') or 'creada').strip())
|
||||
return json_response({'status': 'success',
|
||||
'sucursal': self._sucursal_to_dict(sucursal)}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def update_sucursal(self, sucursal_id, **kw):
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
|
||||
if not sucursal.exists():
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Sucursal no encontrada'}, 404)
|
||||
data = _parse_json_body()
|
||||
vals = {}
|
||||
if 'name' in data and str(data.get('name') or '').strip():
|
||||
vals['name'] = str(data['name']).strip()
|
||||
if 'code' in data:
|
||||
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
|
||||
if codigo and codigo != sucursal.code:
|
||||
dup = request.env['skeen.sucursal'].sudo().with_context(
|
||||
active_test=False).search_count(
|
||||
[('code', '=', codigo), ('id', '!=', sucursal.id)])
|
||||
if dup:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
|
||||
vals['code'] = codigo
|
||||
for campo in ('address', 'phone', 'notes'):
|
||||
if campo in data:
|
||||
vals[campo] = data.get(campo) or ''
|
||||
if 'sequence' in data:
|
||||
try:
|
||||
vals['sequence'] = int(data['sequence'])
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'La secuencia debe ser numérica'}, 400)
|
||||
if 'active' in data:
|
||||
vals['active'] = bool(data['active'])
|
||||
if 'is_default' in data:
|
||||
vals['is_default'] = bool(data['is_default'])
|
||||
if not vals:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Nada que actualizar'}, 400)
|
||||
sucursal.write(vals)
|
||||
if sucursal.is_default:
|
||||
self._marcar_sucursal_default(sucursal)
|
||||
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'editar',
|
||||
new_value=json.dumps(self._sucursal_to_dict(sucursal),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Sucursal %s' % sucursal.name,
|
||||
notes=(data.get('motivo') or 'actualizada').strip())
|
||||
return json_response({'status': 'success',
|
||||
'sucursal': self._sucursal_to_dict(sucursal)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
||||
auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def delete_sucursal(self, sucursal_id, **kw):
|
||||
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
|
||||
if not sucursal.exists():
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Sucursal no encontrada'}, 404)
|
||||
en_uso = request.env['skeen.cita'].sudo().search_count(
|
||||
[('branch', '=', sucursal.code)])
|
||||
sucursal.write({'active': False, 'is_default': False})
|
||||
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'desactivar',
|
||||
old_value=json.dumps(self._sucursal_to_dict(sucursal),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Sucursal %s' % sucursal.name,
|
||||
notes='desactivada (%d citas)' % en_uso)
|
||||
if en_uso:
|
||||
mensaje = ('La sucursal tiene %d cita(s) registradas y no se puede '
|
||||
'eliminar; quedó desactivada.' % en_uso)
|
||||
else:
|
||||
mensaje = 'Sucursal desactivada.'
|
||||
return json_response({'status': 'success',
|
||||
'sucursal': self._sucursal_to_dict(sucursal),
|
||||
'en_uso': en_uso, 'message': mensaje})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Categorías de servicio (configurables)
|
||||
# ============================================================
|
||||
def _service_category_to_dict(self, c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'name': c.name,
|
||||
'sequence': c.sequence,
|
||||
'active': c.active,
|
||||
'is_system': c.is_system,
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/service-categories', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_service_categories(self, **kw):
|
||||
"""Categorías de servicio. ?include_inactive=1 trae también las inactivas."""
|
||||
try:
|
||||
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
|
||||
Category = request.env['skeen.service.category'].sudo()
|
||||
if incluir:
|
||||
Category = Category.with_context(active_test=False)
|
||||
recs = Category.search([], order='sequence, name')
|
||||
return json_response({'status': 'success',
|
||||
'categories': [self._service_category_to_dict(c) for c in recs],
|
||||
'total': len(recs)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/service-categories', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def create_service_category(self, **kw):
|
||||
"""Crea una categoría (las creadas por API nunca son de sistema)."""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El nombre es obligatorio'}, 400)
|
||||
Category = request.env['skeen.service.category'].sudo()
|
||||
codigo = (data.get('code') or '').strip()
|
||||
if codigo:
|
||||
codigo = catalog_slugify(codigo) or codigo
|
||||
if Category.with_context(active_test=False).search_count([('code', '=', codigo)]):
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
|
||||
else:
|
||||
codigo = catalog_unique_slug(Category, name)
|
||||
try:
|
||||
sequence = int(data.get('sequence') or 10)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'La secuencia debe ser numérica'}, 400)
|
||||
categoria = Category.create({
|
||||
'name': name,
|
||||
'code': codigo,
|
||||
'sequence': sequence,
|
||||
'active': bool(data.get('active', True)),
|
||||
'is_system': False,
|
||||
})
|
||||
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'crear',
|
||||
new_value=json.dumps(self._service_category_to_dict(categoria),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Categoría %s' % categoria.name,
|
||||
notes=(data.get('motivo') or 'creada').strip())
|
||||
return json_response({'status': 'success',
|
||||
'category': self._service_category_to_dict(categoria)}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def update_service_category(self, category_id, **kw):
|
||||
"""Edita una categoría. `is_system` no se puede cambiar."""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
|
||||
if not categoria.exists():
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Categoría no encontrada'}, 404)
|
||||
data = _parse_json_body()
|
||||
vals = {}
|
||||
if 'name' in data and str(data.get('name') or '').strip():
|
||||
vals['name'] = str(data['name']).strip()
|
||||
if 'code' in data:
|
||||
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
|
||||
if codigo and codigo != categoria.code:
|
||||
dup = request.env['skeen.service.category'].sudo().with_context(
|
||||
active_test=False).search_count(
|
||||
[('code', '=', codigo), ('id', '!=', categoria.id)])
|
||||
if dup:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
|
||||
vals['code'] = codigo
|
||||
if 'sequence' in data:
|
||||
try:
|
||||
vals['sequence'] = int(data['sequence'])
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'La secuencia debe ser numérica'}, 400)
|
||||
if 'active' in data:
|
||||
vals['active'] = bool(data['active'])
|
||||
if not vals:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Nada que actualizar'}, 400)
|
||||
categoria.write(vals)
|
||||
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'editar',
|
||||
new_value=json.dumps(self._service_category_to_dict(categoria),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Categoría %s' % categoria.name,
|
||||
notes=(data.get('motivo') or 'actualizada').strip())
|
||||
return json_response({'status': 'success',
|
||||
'category': self._service_category_to_dict(categoria)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
||||
auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def delete_service_category(self, category_id, **kw):
|
||||
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
|
||||
try:
|
||||
if not self._puede_configurar_catalogo():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
|
||||
if not categoria.exists():
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Categoría no encontrada'}, 404)
|
||||
if categoria.is_system:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': ('"%s" es una categoría del sistema: no se puede '
|
||||
'eliminar, solo renombrar.' % categoria.name)}, 400)
|
||||
en_uso = request.env['skeen.servicio'].sudo().search_count(
|
||||
[('category', '=', categoria.code)])
|
||||
categoria.write({'active': False})
|
||||
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'desactivar',
|
||||
old_value=json.dumps(self._service_category_to_dict(categoria),
|
||||
ensure_ascii=False)[:500],
|
||||
entity_label='Categoría %s' % categoria.name,
|
||||
notes='desactivada (%d servicios)' % en_uso)
|
||||
mensaje = 'Categoría desactivada.'
|
||||
if en_uso:
|
||||
mensaje = ('La categoría tiene %d servicio(s) y no se puede eliminar; '
|
||||
'quedó desactivada.' % en_uso)
|
||||
return json_response({'status': 'success',
|
||||
'category': self._service_category_to_dict(categoria),
|
||||
'en_uso': en_uso, 'message': mensaje})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Diseño de tickets (Configuración › Tickets)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user