# -*- 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') # 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 '')