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

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import visita
from . import insumo
from . import adjunto
from . import cita

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenVisitaAdjunto(models.Model):
_name = 'skeen.visita.adjunto'
_description = 'Adjunto de Visita (foto o documento)'
_order = 'create_date asc, id asc'
visita_id = fields.Many2one('skeen.visita', string='Visita', required=True, ondelete='cascade')
kind = fields.Selection([
('antes', 'Foto Antes'),
('despues', 'Foto Después'),
('documento', 'Documento'),
], string='Tipo', required=True)
name = fields.Char(string='Archivo')
file = fields.Binary(string='Contenido', attachment=True, required=True)
mimetype = fields.Char(string='Tipo MIME')
notes = fields.Char(string='Notas')

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from odoo import models
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
def _get_visita_en_curso(self):
self.ensure_one()
return self.env['skeen.visita'].search([
('cita_id', '=', self.id),
('state', '=', 'en_curso'),
], limit=1)
def action_arrive(self):
res = super(SkeenCita, self).action_arrive()
for cita in self:
# Crear la visita clínica al llegar el paciente (una por cita)
if not cita._get_visita_en_curso():
self.env['skeen.visita'].create({
'cita_id': cita.id,
'partner_id': cita.partner_id.id,
'doctor_id': cita.doctor_id.id if cita.doctor_id else False,
'servicio_id': cita.servicio_id.id if cita.servicio_id else False,
'motivo': cita.servicio_id.name if cita.servicio_id else '',
})
return res
def action_done(self):
res = super(SkeenCita, self).action_done()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_complete()
return res
def action_cancel(self):
res = super(SkeenCita, self).action_cancel()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_cancel()
return res
def action_no_show(self):
res = super(SkeenCita, self).action_no_show()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_cancel()
return res

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenVisitaInsumo(models.Model):
_name = 'skeen.visita.insumo'
_description = 'Insumo Usado en Visita'
visita_id = fields.Many2one('skeen.visita', string='Visita', required=True, ondelete='cascade')
item_id = fields.Many2one('skeen.inventario.item', string='Artículo', required=True,
domain=[('active', '=', True)])
qty = fields.Float(string='Cantidad', required=True, default=1.0)
notes = fields.Char(string='Notas')
_sql_constraints = [
('qty_positiva', 'CHECK(qty > 0)', 'La cantidad debe ser mayor a 0!')
]

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenVisita(models.Model):
_name = 'skeen.visita'
_description = 'Visita Clínica'
_order = 'date_start desc'
# Identificación
name = fields.Char(string='Referencia', readonly=True, copy=False)
legacy_id = fields.Char(string='ID Legacy', index=True, copy=False,
help='ID de la visita en el sistema legacy SKEEN')
# Relaciones
cita_id = fields.Many2one('skeen.cita', string='Cita Origen', ondelete='set null')
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)])
doctor_id = fields.Many2one('hr.employee', string='Médico')
cosmetologa_id = fields.Many2one('hr.employee', string='Cosmetóloga')
servicio_id = fields.Many2one('skeen.servicio', string='Servicio')
# Fechas
date_start = fields.Datetime(string='Inicio', default=fields.Datetime.now, required=True)
date_end = fields.Datetime(string='Fin')
# Datos clínicos
motivo = fields.Char(string='Motivo de Consulta')
diagnostico = fields.Text(string='Diagnóstico')
tratamiento = fields.Text(string='Tratamiento')
notas = fields.Text(string='Notas')
receta_text = fields.Text(string='Receta')
# Estado
state = fields.Selection([
('en_curso', 'En Curso'),
('completada', 'Completada'),
('cancelada', 'Cancelada'),
], string='Estado', default='en_curso', required=True)
# Origen: creada desde una cita o como consulta médica directa
origen = fields.Selection([
('cita', 'Cita'),
('consulta', 'Consulta Directa'),
], string='Origen', default='cita', required=True)
# Insumos (se descargan del inventario al completar la visita)
insumo_ids = fields.One2many('skeen.visita.insumo', 'visita_id', string='Insumos')
insumos_descargados = fields.Boolean(string='Insumos Descargados', default=False, readonly=True, copy=False)
# Adjuntos (fotos antes/después y documentos)
adjunto_ids = fields.One2many('skeen.visita.adjunto', 'visita_id', string='Adjuntos')
@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.visita') or 'VIS-00001'
return super(SkeenVisita, self).create(vals_list)
def action_complete(self):
self.write({'state': 'completada', 'date_end': fields.Datetime.now()})
for visita in self:
visita._descargar_insumos()
def _descargar_insumos(self):
"""Crea el movimiento de baja en inventario por cada insumo (una sola vez)"""
self.ensure_one()
if self.insumos_descargados:
return
Move = self.env['skeen.inventario.move']
for linea in self.insumo_ids:
Move.create({
'item_id': linea.item_id.id,
'type': 'baja',
'qty': linea.qty,
'reference': self.name,
'notes': 'Consumo en visita',
})
self.insumos_descargados = True
def action_cancel(self):
self.write({'state': 'cancelada'})