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

@@ -3,3 +3,5 @@
from . import cita
from . import bloqueo
from . import lista_espera
from . import sucursal
from . import service_category

View File

@@ -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:

View 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!')
]

View 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'