566 lines
25 KiB
Python
566 lines
25 KiB
Python
# -*- coding: utf-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.
|
|
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
|
|
|
|
# Orden del checklist de atención (permite avanzar y retroceder paso a paso).
|
|
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
|
|
|
|
# Duración de respaldo cuando ni la cita ni el servicio la definen
|
|
DEFAULT_DURATION = 30
|
|
|
|
|
|
class SkeenServicio(models.Model):
|
|
_name = 'skeen.servicio'
|
|
_description = 'Servicio Dermatológico'
|
|
_order = 'sequence, name'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
code = fields.Char(string='Código', required=True, copy=False)
|
|
description = fields.Text(string='Descripción')
|
|
|
|
# Precio y duración
|
|
price = fields.Float(string='Precio', required=True, default=0.0)
|
|
duration_min = fields.Integer(string='Duración (min)', required=True, default=30)
|
|
|
|
# Precio por paquete (Opción B)
|
|
package_price = fields.Float(string='Precio Paquete', default=0.0)
|
|
package_sessions = fields.Integer(string='Sesiones en Paquete', default=0)
|
|
package_notes = fields.Char(string='Notas del Paquete')
|
|
|
|
# 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')
|
|
service_group = fields.Char(string='Grupo')
|
|
is_favorite = fields.Boolean(string='Favorito', default=False)
|
|
genera_monedero = fields.Boolean(string='Genera monedero', default=True,
|
|
help='Si está activo, la venta de este servicio acumula puntos monedero.')
|
|
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
|
|
|
|
# Impuesto propio del servicio (%). Solo se usa cuando la configuración de
|
|
# la clínica tiene los impuestos activos. Con 'Usar tasa por defecto' el
|
|
# servicio NO congela una tasa: al vender se toma la de la configuración
|
|
# vigente en ese momento.
|
|
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
|
|
help='Tasa de impuesto del servicio. Se ignora si usa la tasa por defecto.')
|
|
tax_use_default = fields.Boolean(string='Usar tasa de impuesto por defecto', default=True,
|
|
help='Si está activo, se aplica la tasa por defecto de la configuración de la clínica.')
|
|
|
|
# Configuración
|
|
active = fields.Boolean(string='Activo', default=True)
|
|
sequence = fields.Integer(string='Secuencia', default=10)
|
|
|
|
# Relaciones
|
|
cita_ids = fields.One2many('skeen.cita', 'servicio_id', string='Citas')
|
|
|
|
_sql_constraints = [
|
|
('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'
|
|
_description = 'Etiqueta de Servicio'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
color = fields.Integer(string='Color')
|
|
service_ids = fields.Many2many('skeen.servicio', string='Servicios')
|
|
|
|
|
|
class SkeenCita(models.Model):
|
|
_name = 'skeen.cita'
|
|
_description = 'Cita Médica'
|
|
_order = 'date, time'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
|
|
# Identificación
|
|
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
|
|
|
# ID en plataforma legacy (migración)
|
|
legacy_id = fields.Integer(string='ID Legacy', index=True, copy=False,
|
|
help='ID en plataforma legacy (migración)')
|
|
|
|
# Paciente
|
|
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
|
domain=[('is_patient', '=', True)])
|
|
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
|
partner_email = fields.Char(related='partner_id.email', string='Email', readonly=True)
|
|
|
|
# Servicio
|
|
servicio_id = fields.Many2one('skeen.servicio', string='Servicio', required=True)
|
|
servicio_price = fields.Float(related='servicio_id.price', string='Precio Servicio', readonly=True)
|
|
servicio_duration = fields.Integer(related='servicio_id.duration_min', string='Duración', readonly=True)
|
|
|
|
# Fecha y hora
|
|
date = fields.Date(string='Fecha', required=True, index=True)
|
|
time = fields.Float(string='Hora', required=True)
|
|
time_str = fields.Char(string='Hora Texto', compute='_compute_time_str', store=True)
|
|
datetime_start = fields.Datetime(string='Inicio', compute='_compute_datetime', store=True)
|
|
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
|
|
|
|
# Ubicación y médico
|
|
# 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'),
|
|
('onsite', 'Presencial'),
|
|
], string='Medio', default='onsite')
|
|
|
|
# Estado de flujo del sistema original
|
|
flow_state = fields.Selection([
|
|
('preconsulta', 'Pre-consulta'),
|
|
('link_sent', 'Link enviado'),
|
|
('approval', 'Aprobación'),
|
|
('assign_record', 'Asignar expediente'),
|
|
('scheduled', 'Agendar'),
|
|
], string='Estado de Flujo', default='scheduled')
|
|
|
|
# Estado (rubros alineados al vocabulario de la clínica; 'waitlist' viene
|
|
# de la leyenda del sistema legacy: 4/5 = Lista de espera)
|
|
state = fields.Selection([
|
|
('pending', 'Agendada'),
|
|
('confirmed', 'Confirmada'),
|
|
('arrived', 'En cabina'),
|
|
('in_progress', 'En consulta'),
|
|
('done', 'Completada'),
|
|
('cancelled', 'Canceló'),
|
|
('no_show', 'No se presentó'),
|
|
('waitlist', 'Lista de espera'),
|
|
], string='Estado', default='pending', tracking=True)
|
|
|
|
# Duración propia de la cita en minutos. 0 = usar la del servicio.
|
|
# Permite que una cita de 60 min ocupe 4 bloques de 15 min en la agenda.
|
|
duration = fields.Integer(
|
|
string='Duración (min)', default=0,
|
|
help='Duración de esta cita. 0 = usar la duración del servicio.')
|
|
|
|
# Bitácora de cambios de estado (avance y retroceso del checklist)
|
|
state_log_ids = fields.One2many(
|
|
'skeen.cita.state.log', 'cita_id', string='Bitácora de estados')
|
|
|
|
# Notas
|
|
notes = fields.Text(string='Notas')
|
|
cancellation_reason = fields.Text(string='Motivo Cancelación')
|
|
|
|
# Recordatorio
|
|
reminder_sent = fields.Boolean(string='Recordatorio Enviado', default=False)
|
|
reminder_date = fields.Datetime(string='Fecha Recordatorio')
|
|
|
|
# Doble confirmación:
|
|
# 1ra = confirmación telefónica (recepción marca al paciente)
|
|
# 2da = llegada a la clínica (check-in existente, state 'arrived')
|
|
phone_confirmed = fields.Boolean(string='Confirmada por Teléfono', default=False, tracking=True)
|
|
phone_confirmed_at = fields.Datetime(string='Fecha Confirmación Tel.', readonly=True, copy=False)
|
|
phone_confirmed_by = fields.Char(string='Confirmada por', readonly=True, copy=False,
|
|
help='Login del usuario del frontend que confirmó por teléfono')
|
|
# Timestamp del check-in de llegada (2da confirmación); base del tiempo de espera
|
|
arrived_at = fields.Datetime(string='Hora de Llegada', readonly=True, copy=False)
|
|
|
|
# Pago
|
|
payment_state = fields.Selection([
|
|
('not_paid', 'No Pagado'),
|
|
('partial', 'Parcial'),
|
|
('paid', 'Pagado'),
|
|
], string='Estado de Pago', default='not_paid')
|
|
amount_paid = fields.Float(string='Monto Pagado', default=0.0)
|
|
|
|
# WACRM
|
|
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
|
|
|
# Origen de la reserva: permite distinguir en la agenda las citas que
|
|
# entraron por el portal público de reservas en línea. 'internal' es el
|
|
# comportamiento de siempre (no cambia nada); 'public_portal' lo pone el
|
|
# controlador de reservas públicas.
|
|
booking_source = fields.Selection([
|
|
('internal', 'Interno'),
|
|
('public_portal', 'Portal en línea'),
|
|
], string='Origen de la reserva', default='internal', index=True)
|
|
|
|
# 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:
|
|
if cita.date and cita.time is not False:
|
|
hours = int(cita.time)
|
|
minutes = int((cita.time - hours) * 60)
|
|
cita.datetime_start = datetime.combine(cita.date, datetime.min.time().replace(hour=hours, minute=minutes))
|
|
cita.datetime_end = cita.datetime_start + timedelta(minutes=cita._duracion())
|
|
|
|
@api.depends('time')
|
|
def _compute_time_str(self):
|
|
for cita in self:
|
|
if cita.time is not False:
|
|
hours = int(cita.time)
|
|
minutes = int((cita.time - hours) * 60)
|
|
cita.time_str = f"{hours:02d}:{minutes:02d}"
|
|
else:
|
|
cita.time_str = ""
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if not vals.get('name'):
|
|
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.cita') or 'CITA-001'
|
|
return super(SkeenCita, self).create(vals_list)
|
|
|
|
def _duracion(self):
|
|
"""Duración efectiva de la cita en minutos.
|
|
|
|
Prioridad: `duration` propio de la cita -> `duration_min` del servicio
|
|
-> 30 min. Es la base de los solapes, los bloqueos, el horario del
|
|
médico y el pintado del grid de agenda.
|
|
"""
|
|
self.ensure_one()
|
|
return int(self.duration or (self.servicio_id.duration_min if self.servicio_id else 0) or DEFAULT_DURATION)
|
|
|
|
def _log_state(self, from_state, to_state, user=None, note=''):
|
|
"""Registra el cambio de estado en la bitácora. Nunca tumba la operación."""
|
|
try:
|
|
self.env['skeen.cita.state.log'].sudo().create({
|
|
'cita_id': self.id,
|
|
'from_state': from_state or '',
|
|
'to_state': to_state or '',
|
|
'user_name': user or '',
|
|
'note': note or '',
|
|
})
|
|
except Exception:
|
|
import logging
|
|
logging.getLogger(__name__).exception('No se pudo registrar la bitácora de estado')
|
|
|
|
def set_state(self, new_state, user=None, note=''):
|
|
"""Cambia el estado de la cita y registra quién/cuándo en la bitácora.
|
|
|
|
A diferencia de las acciones de flujo (que solo avanzan), este método
|
|
permite retroceder de un paso a otro. Mantiene sincronizados los campos
|
|
de doble confirmación:
|
|
- al volver a 'pending' se retira la confirmación telefónica;
|
|
- al confirmar se sella phone_confirmed/at/by si aún no estaba;
|
|
- al llegar (o pasar a en consulta/completada) se sella arrived_at.
|
|
"""
|
|
if not new_state:
|
|
raise ValidationError(_('Falta el estado destino'))
|
|
if new_state not in dict(self._fields['state'].selection):
|
|
raise ValidationError(_('Estado no válido: %s') % new_state)
|
|
for cita in self:
|
|
old_state = cita.state
|
|
if old_state == new_state:
|
|
continue
|
|
vals = {'state': new_state}
|
|
if new_state == 'pending':
|
|
vals.update({
|
|
'phone_confirmed': False,
|
|
'phone_confirmed_at': False,
|
|
'phone_confirmed_by': False,
|
|
})
|
|
elif new_state == 'confirmed' and not cita.phone_confirmed:
|
|
vals.update({
|
|
'phone_confirmed': True,
|
|
'phone_confirmed_at': fields.Datetime.now(),
|
|
'phone_confirmed_by': user or '',
|
|
})
|
|
if new_state in ('arrived', 'in_progress', 'done') and not cita.arrived_at:
|
|
vals['arrived_at'] = fields.Datetime.now()
|
|
cita.write(vals)
|
|
if new_state == 'done' and cita.partner_id:
|
|
cita.partner_id.write({'last_visit': fields.Date.today()})
|
|
cita._log_state(old_state, new_state, user=user, note=note)
|
|
return True
|
|
|
|
def action_next(self):
|
|
"""Avanza un paso en el checklist (Agendada -> ... -> Completada)."""
|
|
for cita in self:
|
|
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
|
|
if idx < 0 or idx >= len(STATE_FLOW) - 1:
|
|
raise ValidationError(_('No hay un paso siguiente desde el estado actual'))
|
|
cita.set_state(STATE_FLOW[idx + 1])
|
|
|
|
def action_back(self):
|
|
"""Retrocede un paso en el checklist (p. ej. Completada -> En consulta)."""
|
|
for cita in self:
|
|
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
|
|
if idx <= 0:
|
|
raise ValidationError(_('No hay un paso anterior desde el estado actual'))
|
|
cita.set_state(STATE_FLOW[idx - 1])
|
|
|
|
def action_confirm(self):
|
|
self.set_state('confirmed')
|
|
|
|
def action_arrive(self):
|
|
self.set_state('arrived')
|
|
|
|
def action_start(self):
|
|
self.set_state('in_progress')
|
|
|
|
def action_done(self):
|
|
self.set_state('done')
|
|
|
|
def action_cancel(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Cancelar Cita',
|
|
'res_model': 'skeen.cita.cancel.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'default_cita_id': self.id}
|
|
}
|
|
|
|
def action_no_show(self):
|
|
self.set_state('no_show')
|
|
|
|
def action_package_finished(self):
|
|
"""Marca la cita como paquete terminado (servicios de categoría 'paquete').
|
|
|
|
Reversible: se puede desmarcar escribiendo package_finished=False.
|
|
También deja la cita en 'done' y actualiza la última visita del paciente.
|
|
"""
|
|
for cita in self:
|
|
vals = {
|
|
'package_finished': True,
|
|
'package_finished_date': fields.Datetime.now(),
|
|
}
|
|
if cita.state not in ('done', 'cancelled', 'no_show'):
|
|
vals['state'] = 'done'
|
|
cita.write(vals)
|
|
if vals.get('state'):
|
|
cita._log_state('', 'done', note='Paquete terminado')
|
|
if cita.partner_id:
|
|
cita.partner_id.write({'last_visit': fields.Date.today()})
|
|
return True
|
|
|
|
def action_send_reminder(self):
|
|
self.write({'reminder_sent': True, 'reminder_date': fields.Datetime.now()})
|
|
|
|
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
|
|
def _check_availability(self):
|
|
for cita in self:
|
|
if cita.state in INACTIVE_STATES:
|
|
continue
|
|
# Verificar solapamiento SOLO dentro del mismo médico:
|
|
# la clínica permite citas simultáneas con doctores distintos.
|
|
# Criterio para citas sin médico: solo traslapan con otras sin médico.
|
|
# datetime_end ya incluye la duración real de la cita.
|
|
domain = [
|
|
('id', '!=', cita.id),
|
|
('date', '=', cita.date),
|
|
('state', 'not in', INACTIVE_STATES),
|
|
('doctor_id', '=', cita.doctor_id.id if cita.doctor_id else False),
|
|
('datetime_start', '<', cita.datetime_end),
|
|
('datetime_end', '>', cita.datetime_start),
|
|
]
|
|
overlapping = self.search(domain)
|
|
if overlapping:
|
|
raise ValidationError(_('Ya existe una cita en ese horario para ese médico!'))
|
|
|
|
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
|
|
def _check_bloqueos(self):
|
|
"""Rechaza citas que se solapan con un bloqueo de agenda del médico.
|
|
|
|
Sin médico asignado (citas que entran por WACRM/Hermes) solo se rechaza
|
|
si el horario está bloqueado para TODOS los médicos: es decir, la
|
|
clínica está cerrada a esa hora.
|
|
"""
|
|
Bloqueo = self.env['skeen.cita.bloqueo']
|
|
for cita in self:
|
|
if cita.state in INACTIVE_STATES or not cita.date:
|
|
continue
|
|
dur = cita._duracion() / 60.0
|
|
ini = cita.time
|
|
fin = ini + dur
|
|
if not cita.doctor_id:
|
|
doctores = self.env['hr.employee'].search([
|
|
('staff_type', 'in', ('doctor', 'cosmetologa')),
|
|
])
|
|
if not doctores:
|
|
continue
|
|
bloqueos_dia = Bloqueo.search([('date', '=', cita.date)])
|
|
bloqueados = bloqueos_dia.filtered(
|
|
lambda b: b.all_day or (ini < b.time_to and fin > b.time_from)).mapped('doctor_id')
|
|
if set(doctores.ids).issubset(set(bloqueados.ids)):
|
|
raise ValidationError(_('La clínica tiene la agenda bloqueada (%s)') % (
|
|
bloqueos_dia[0].nombre_motivo() if bloqueos_dia else 'agenda bloqueada'))
|
|
continue
|
|
bloqueos = Bloqueo.search([
|
|
('doctor_id', '=', cita.doctor_id.id),
|
|
('date', '=', cita.date),
|
|
])
|
|
for b in bloqueos:
|
|
if b.all_day or (ini < b.time_to and fin > b.time_from):
|
|
raise ValidationError(_('El médico tiene un bloqueo (%s)') % b.nombre_motivo())
|
|
|
|
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
|
|
def _check_horario_medico(self):
|
|
"""Rechaza citas fuera del horario semanal del médico.
|
|
|
|
Sólo aplica a médicos CON horario configurado y sólo al crear/editar la
|
|
fecha, la hora, el médico o la duración: las citas históricas fuera de
|
|
horario se conservan intactas (cambios de estado u otros campos no las
|
|
revalidan).
|
|
"""
|
|
for cita in self:
|
|
if cita.state in INACTIVE_STATES or not cita.doctor_id or not cita.date:
|
|
continue
|
|
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, cita._duracion())
|
|
if not ok:
|
|
raise ValidationError(_(mensaje))
|
|
|
|
@api.constrains('duration')
|
|
def _check_duration(self):
|
|
for cita in self:
|
|
if cita.duration and (cita.duration < 5 or cita.duration > 480):
|
|
raise ValidationError(_('La duración debe estar entre 5 y 480 minutos'))
|
|
|
|
@api.constrains('doctor_id')
|
|
def _check_doctor_activo(self):
|
|
"""Un médico dado de baja no puede recibir citas nuevas.
|
|
|
|
Solo corre cuando cambia `doctor_id`, así que el historial de un médico
|
|
desactivado sigue intacto y editable (estado, pagos, notas).
|
|
"""
|
|
for cita in self:
|
|
if cita.doctor_id and not cita.doctor_id.active:
|
|
raise ValidationError(_(
|
|
'%(nombre)s está dado de baja y no se le pueden asignar '
|
|
'citas. Reactívalo en el módulo de Médicos o elige otro '
|
|
'especialista.'
|
|
) % {'nombre': cita.doctor_id.name})
|
|
|
|
def get_available_slots(self, date, servicio_id, doctor_id=None, duration=None):
|
|
"""Retorna slots disponibles para una fecha y servicio.
|
|
|
|
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. 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.
|
|
El cálculo de ocupación usa inicio + duración real de cada cita.
|
|
"""
|
|
servicio = self.env['skeen.servicio'].browse(int(servicio_id)) if servicio_id else self.env['skeen.servicio']
|
|
try:
|
|
duration = int(duration or 0) or (servicio.duration_min if servicio else DEFAULT_DURATION)
|
|
except (TypeError, ValueError):
|
|
duration = servicio.duration_min if servicio else DEFAULT_DURATION
|
|
duration = duration or DEFAULT_DURATION
|
|
|
|
# Bloqueos del médico para la fecha (día completo y por rango)
|
|
bloqueos = self.env['skeen.cita.bloqueo'].search([
|
|
('date', '=', date),
|
|
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
|
|
|
|
# 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:
|
|
doctor = self.env['hr.employee'].sudo().browse(int(doctor_id))
|
|
if doctor.exists() and doctor.horario_ids:
|
|
linea = doctor._horario_del_dia(date)
|
|
if not linea:
|
|
return [] # día no laborable para el médico
|
|
start_time = max(start_time, linea.hour_from)
|
|
end_time = min(end_time, linea.hour_to)
|
|
|
|
# Generar slots
|
|
slots = []
|
|
current = start_time
|
|
while current + (duration / 60.0) <= end_time + 1e-9:
|
|
# Verificar si está ocupado
|
|
minutes = int(round((current % 1) * 60))
|
|
slot_start = datetime.combine(date, datetime.min.time().replace(hour=int(current), minute=minutes))
|
|
slot_end = slot_start + timedelta(minutes=duration)
|
|
|
|
domain = [
|
|
('date', '=', date),
|
|
('state', 'not in', INACTIVE_STATES),
|
|
# Con médico explícito el solape es por médico (la clínica
|
|
# permite citas simultáneas de médicos distintos) y una cita
|
|
# de 60 min descarta los 4 bloques de 15 min que cubre. Sin
|
|
# médico (flujo externo WACRM/Hermes) se conserva el criterio
|
|
# conservador: cualquier cita del día ocupa el hueco.
|
|
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []) + [
|
|
('datetime_start', '<', slot_end),
|
|
('datetime_end', '>', slot_start),
|
|
]
|
|
en_bloqueo = any(
|
|
b.all_day or (current < b.time_to and current + (duration / 60.0) > b.time_from)
|
|
for b in bloqueos
|
|
)
|
|
if not en_bloqueo and not self.search(domain):
|
|
slots.append({
|
|
'time': current,
|
|
'time_str': f"{int(current):02d}:{minutes:02d}",
|
|
})
|
|
current += 0.25 # Slots cada 15 min
|
|
|
|
return slots
|
|
|
|
|
|
class SkeenCitaStateLog(models.Model):
|
|
"""Bitácora de cambios de estado de una cita (avance y retroceso).
|
|
|
|
Registra el estado anterior, el nuevo, quién lo cambió (login del usuario
|
|
del frontend o del usuario de Odoo) y cuándo. Es la evidencia de auditoría
|
|
del checklist de atención.
|
|
"""
|
|
_name = 'skeen.cita.state.log'
|
|
_description = 'Bitácora de estados de la cita'
|
|
_order = 'create_date desc, id desc'
|
|
|
|
cita_id = fields.Many2one('skeen.cita', string='Cita', required=True,
|
|
ondelete='cascade', index=True)
|
|
from_state = fields.Char(string='Estado anterior')
|
|
to_state = fields.Char(string='Estado nuevo')
|
|
user_name = fields.Char(string='Usuario')
|
|
note = fields.Char(string='Nota')
|
|
date_change = fields.Datetime(string='Fecha', default=fields.Datetime.now)
|
|
|
|
def _state_label(self, state):
|
|
selection = dict(self.env['skeen.cita']._fields['state'].selection)
|
|
return selection.get(state, state or '')
|