Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy

- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -15,6 +15,7 @@
'security/ir.model.access.csv',
'data/sequences.xml',
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Secuencia para citas -->
<record id="seq_skeen_cita" model="ir.sequence">
<field name="name">Cita SKEEN</field>
@@ -19,4 +20,5 @@
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import cita
from . import bloqueo

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class SkeenCitaBloqueo(models.Model):
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_order = 'date desc'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0)
time_to = fields.Float(string='Hasta', default=10.0)
motivo = fields.Char(string='Motivo')
@api.constrains('time_from', 'time_to', 'all_day')
def _check_horas(self):
for b in self:
if not b.all_day and b.time_to <= b.time_from:
raise ValidationError(_('La hora final debe ser mayor a la inicial'))

View File

@@ -232,6 +232,23 @@ class SkeenCita(models.Model):
overlapping = self.search(domain)
if overlapping:
raise ValidationError(_('Ya existe una cita en ese horario!'))
@api.constrains('date', 'time', 'doctor_id', 'servicio_id')
def _check_bloqueos(self):
"""Rechaza citas que se solapan con un bloqueo de agenda del médico"""
for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
continue
dur = ((cita.servicio_id.duration_min if cita.servicio_id else 30) or 30) / 60.0
ini = cita.time
fin = ini + dur
bloqueos = self.env['skeen.cita.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.motivo or 'agenda bloqueada'))
def get_available_slots(self, date, servicio_id):
"""Retorna slots disponibles para una fecha y servicio"""

View File

@@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_servicio_user,Acceso a Servicios SKEEN,model_skeen_servicio,base.group_user,1,1,1,1
access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1
access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_servicio_user Acceso a Servicios SKEEN model_skeen_servicio base.group_user 1 1 1 1
3 access_skeen_service_tag_user Acceso a Etiquetas de Servicio SKEEN model_skeen_service_tag base.group_user 1 1 1 1
4 access_skeen_cita_user Acceso a Citas SKEEN model_skeen_cita base.group_user 1 1 1 1
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de bloqueos -->
<record id="view_skeen_cita_bloqueo_tree" model="ir.ui.view">
<field name="name">skeen.cita.bloqueo.tree</field>
<field name="model">skeen.cita.bloqueo</field>
<field name="arch" type="xml">
<tree>
<field name="date"/>
<field name="doctor_id"/>
<field name="all_day"/>
<field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/>
<field name="motivo"/>
</tree>
</field>
</record>
<!-- Vista de formulario de bloqueos -->
<record id="view_skeen_cita_bloqueo_form" model="ir.ui.view">
<field name="name">skeen.cita.bloqueo.form</field>
<field name="model">skeen.cita.bloqueo</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="doctor_id"/>
<field name="date"/>
<field name="motivo"/>
</group>
<group>
<field name="all_day"/>
<field name="time_from" widget="float_time" invisible="all_day"/>
<field name="time_to" widget="float_time" invisible="all_day"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción para bloqueos -->
<record id="action_skeen_cita_bloqueo_tree" model="ir.actions.act_window">
<field name="name">Bloqueos de Agenda</field>
<field name="res_model">skeen.cita.bloqueo</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Bloqueos (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_bloqueos" name="Bloqueos de Agenda" parent="menu_skeen_citas"
action="action_skeen_cita_bloqueo_tree" sequence="20"/>
</odoo>