# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import ValidationError from datetime import datetime, timedelta 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 category = fields.Selection([ ('consulta', 'Consulta'), ('tratamiento', 'Tratamiento'), ('procedimiento', 'Procedimiento'), ('paquete', 'Paquete'), ], 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) tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas') # 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!') ] 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) # 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 branch = fields.Selection([ ('rosarito', 'Rosarito'), ('tijuana', 'Tijuana'), ], string='Sucursal', default='rosarito') 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 state = fields.Selection([ ('pending', 'Pendiente'), ('confirmed', 'Confirmada'), ('arrived', 'Llegó'), ('in_progress', 'En Progreso'), ('done', 'Completada'), ('cancelled', 'Cancelada'), ('no_show', 'No Show'), ], string='Estado', default='pending', tracking=True) # 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') # 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.depends('date', 'time', 'servicio_id') 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)) if cita.servicio_id: cita.datetime_end = cita.datetime_start + timedelta(minutes=cita.servicio_id.duration_min) else: cita.datetime_end = cita.datetime_start + timedelta(minutes=30) @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 action_confirm(self): self.write({'state': 'confirmed'}) def action_arrive(self): self.write({'state': 'arrived'}) def action_start(self): self.write({'state': 'in_progress'}) def action_done(self): self.write({'state': 'done'}) # Actualizar última visita del paciente self.partner_id.write({'last_visit': fields.Date.today()}) 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.write({'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 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', 'servicio_id') def _check_availability(self): for cita in self: if cita.state in ('cancelled', 'no_show'): continue # Verificar solapamiento domain = [ ('id', '!=', cita.id), ('date', '=', cita.date), ('state', 'not in', ('cancelled', 'no_show')), ('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!')) def get_available_slots(self, date, servicio_id): """Retorna slots disponibles para una fecha y servicio""" servicio = self.env['skeen.servicio'].browse(servicio_id) duration = servicio.duration_min if servicio else 30 # Horario de la clínica start_time = 9.0 # 9:00 AM end_time = 18.0 # 6:00 PM # Generar slots slots = [] current = start_time while current + (duration / 60.0) <= end_time: # Verificar si está ocupado slot_start = datetime.combine(date, datetime.min.time().replace(hour=int(current), minute=int((current % 1) * 60))) slot_end = slot_start + timedelta(minutes=duration) domain = [ ('date', '=', date), ('state', 'not in', ('cancelled', 'no_show')), ('datetime_start', '<', slot_end), ('datetime_end', '>', slot_start), ] if not self.search(domain): slots.append({ 'time': current, 'time_str': f"{int(current):02d}:{int((current % 1) * 60):02d}", }) current += 0.5 # Slots cada 30 min return slots