- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
267 lines
10 KiB
Python
267 lines
10 KiB
Python
# -*- 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
|