Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- 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/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
# -*- 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