363 lines
14 KiB
Python
363 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Segmentos de pacientes: constructor de criterios reutilizable.
|
|
|
|
Un segmento guarda un NOMBRE, unos CRITERIOS (JSON) y una previsualización
|
|
(cuántos pacientes y una muestra de 5) que se calcula ANTES de usarlo.
|
|
|
|
La evaluación corre en el servidor: cada criterio aporta un conjunto de
|
|
pacientes que se INTERSECTA con el resultado (y se RESTA cuando el criterio es
|
|
negativo, p. ej. "con membresía = no"). Así el conteo del segmento coincide
|
|
exactamente con lo que responde la base de datos, sin contar de más.
|
|
|
|
Criterios soportados (todos opcionales; vacío = no aplica):
|
|
|
|
ultima_visita_dias última visita registrada hace MÁS de N días
|
|
sin_visita_meses sin visitas en los últimos N meses (incluye a quien
|
|
nunca ha venido)
|
|
cumple_mes cumpleaños en el mes en curso
|
|
cumple_proximos_dias cumpleaños en los próximos N días
|
|
nivel_codigo nivel de lealtad vigente
|
|
gasto_minimo gasto acumulado (ventas no canceladas) >= X
|
|
sucursal con al menos una cita en esa sucursal
|
|
servicio_id que SÍ ha tomado el servicio (cita completada o venta)
|
|
servicio_nunca_id que NUNCA ha tomado ese servicio
|
|
con_membresia con (true) / sin (false) membresía activa
|
|
con_paquetes con (true) / sin (false) paquetes con sesiones disponibles
|
|
con_saldo con (true) / sin (false) saldo pendiente
|
|
consentimiento 'con' / 'sin' consentimiento de marketing
|
|
"""
|
|
|
|
import json
|
|
from datetime import date, timedelta
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import api, fields, models
|
|
|
|
CRITERIOS_DEFAULT = {
|
|
'ultima_visita_dias': 0,
|
|
'sin_visita_meses': 0,
|
|
'cumple_mes': False,
|
|
'cumple_proximos_dias': 0,
|
|
'nivel_codigo': '',
|
|
'gasto_minimo': 0.0,
|
|
'sucursal': '',
|
|
'servicio_id': 0,
|
|
'servicio_nunca_id': 0,
|
|
'con_membresia': None,
|
|
'con_paquetes': None,
|
|
'con_saldo': None,
|
|
'consentimiento': '',
|
|
}
|
|
|
|
DIAS_MAX = 3650
|
|
MESES_MAX = 120
|
|
GASTO_MAX = 100000000.0
|
|
MOSTRAR_MUESTRA = 5
|
|
|
|
|
|
def _int(valor, por_defecto=0):
|
|
try:
|
|
return int(valor)
|
|
except (TypeError, ValueError):
|
|
return por_defecto
|
|
|
|
|
|
def _num(valor, por_defecto=0.0):
|
|
try:
|
|
n = float(valor)
|
|
except (TypeError, ValueError):
|
|
return por_defecto
|
|
if n != n or n in (float('inf'), float('-inf')):
|
|
return por_defecto
|
|
return n
|
|
|
|
|
|
def normalizar_criterios(raw):
|
|
"""Criterios completos, con defaults. Tolerante: nunca lanza."""
|
|
out = dict(CRITERIOS_DEFAULT)
|
|
if not isinstance(raw, dict):
|
|
return out
|
|
out['ultima_visita_dias'] = min(max(_int(raw.get('ultima_visita_dias'), 0), 0), DIAS_MAX)
|
|
out['sin_visita_meses'] = min(max(_int(raw.get('sin_visita_meses'), 0), 0), MESES_MAX)
|
|
out['cumple_mes'] = bool(raw.get('cumple_mes'))
|
|
out['cumple_proximos_dias'] = min(max(_int(raw.get('cumple_proximos_dias'), 0), 0), DIAS_MAX)
|
|
out['nivel_codigo'] = str(raw.get('nivel_codigo') or '').strip()[:40]
|
|
out['gasto_minimo'] = min(max(_num(raw.get('gasto_minimo'), 0.0), 0.0), GASTO_MAX)
|
|
out['sucursal'] = str(raw.get('sucursal') or '').strip()[:40]
|
|
out['servicio_id'] = max(_int(raw.get('servicio_id'), 0), 0)
|
|
out['servicio_nunca_id'] = max(_int(raw.get('servicio_nunca_id'), 0), 0)
|
|
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
|
|
valor = raw.get(k)
|
|
out[k] = bool(valor) if isinstance(valor, bool) else None
|
|
cons = str(raw.get('consentimiento') or '').strip().lower()
|
|
out['consentimiento'] = cons if cons in ('con', 'sin') else ''
|
|
return out
|
|
|
|
|
|
def validar_criterios(raw):
|
|
"""Valida los criterios de un payload. Lanza ValueError si algo no cuadra.
|
|
|
|
Devuelve SIEMPRE el objeto normalizado (los criterios se reemplazan
|
|
completos: no hay merge parcial de criterios)."""
|
|
if raw is None:
|
|
raw = {}
|
|
if not isinstance(raw, dict):
|
|
raise ValueError('Los criterios deben ser un objeto')
|
|
for k in ('ultima_visita_dias', 'sin_visita_meses', 'cumple_proximos_dias'):
|
|
if k in raw and raw[k] not in (None, ''):
|
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
|
raise ValueError('El campo %s debe ser un número de días entero' % k)
|
|
if raw[k] < 0:
|
|
raise ValueError('El campo %s no puede ser negativo' % k)
|
|
for k in ('servicio_id', 'servicio_nunca_id'):
|
|
if k in raw and raw[k] not in (None, ''):
|
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int) or raw[k] < 0:
|
|
raise ValueError('El campo %s debe ser un identificador válido' % k)
|
|
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
|
|
if k in raw and raw[k] is not None and not isinstance(raw[k], bool):
|
|
raise ValueError('El campo %s debe ser verdadero, falso o nulo' % k)
|
|
if 'consentimiento' in raw and raw['consentimiento'] not in (None, '', 'con', 'sin'):
|
|
raise ValueError("El consentimiento debe ser 'con' o 'sin'")
|
|
if 'cumple_mes' in raw and not isinstance(raw['cumple_mes'], bool):
|
|
raise ValueError('cumple_mes debe ser verdadero o falso')
|
|
return normalizar_criterios(raw)
|
|
|
|
|
|
class SkeenSegmento(models.Model):
|
|
_name = 'skeen.segmento'
|
|
_description = 'Segmento de pacientes'
|
|
_order = 'name, id'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
descripcion = fields.Text(string='Descripción')
|
|
activo = fields.Boolean(string='Activo', default=True, index=True)
|
|
criterios = fields.Text(string='Criterios (JSON)', default='{}')
|
|
creado_por = fields.Char(string='Creado por', copy=False)
|
|
|
|
ultimo_conteo = fields.Integer(string='Pacientes (último cálculo)', default=0)
|
|
muestra = fields.Text(string='Muestra (JSON)', default='[]')
|
|
preview_at = fields.Datetime(string='Calculado el')
|
|
|
|
_sql_constraints = [
|
|
('name_uniq', 'unique(name)', 'Ya existe un segmento con ese nombre.'),
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Serialización
|
|
# ------------------------------------------------------------------
|
|
def criterios_dict(self):
|
|
self.ensure_one()
|
|
try:
|
|
data = json.loads(self.criterios or '{}')
|
|
except (ValueError, TypeError):
|
|
return dict(CRITERIOS_DEFAULT)
|
|
return normalizar_criterios(data)
|
|
|
|
def muestra_lista(self):
|
|
self.ensure_one()
|
|
try:
|
|
data = json.loads(self.muestra or '[]')
|
|
except (ValueError, TypeError):
|
|
return []
|
|
return data if isinstance(data, list) else []
|
|
|
|
# ------------------------------------------------------------------
|
|
# Evaluación
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _universo(self):
|
|
return set(self.env['res.partner'].sudo().search(
|
|
[('is_patient', '=', True)]).ids)
|
|
|
|
@api.model
|
|
def _ids_ultima_visita(self, dias):
|
|
corte = fields.Date.today() - timedelta(days=dias)
|
|
return set(self.env['res.partner'].sudo().search([
|
|
('is_patient', '=', True),
|
|
('last_visit', '!=', False),
|
|
('last_visit', '<=', corte),
|
|
]).ids)
|
|
|
|
@api.model
|
|
def _ids_sin_visita(self, meses):
|
|
corte = fields.Date.today() - relativedelta(months=meses)
|
|
return set(self.env['res.partner'].sudo().search([
|
|
('is_patient', '=', True),
|
|
'|', ('last_visit', '=', False), ('last_visit', '<=', corte),
|
|
]).ids)
|
|
|
|
@api.model
|
|
def _ids_cumple(self, mes_actual=True, proximos_dias=0):
|
|
hoy = fields.Date.today()
|
|
if proximos_dias:
|
|
limite = hoy + timedelta(days=proximos_dias)
|
|
else:
|
|
limite = hoy
|
|
ids = set()
|
|
pacientes = self.env['res.partner'].sudo().search([
|
|
('is_patient', '=', True), ('birth_date', '!=', False)])
|
|
for p in pacientes:
|
|
b = p.birth_date
|
|
if mes_actual and not proximos_dias:
|
|
if b.month == hoy.month:
|
|
ids.add(p.id)
|
|
continue
|
|
# cumpleaños dentro de los próximos N días (mes-día, con 29-feb)
|
|
for yr in (hoy.year, hoy.year + 1):
|
|
try:
|
|
ocurre = b.replace(year=yr)
|
|
except ValueError:
|
|
ocurre = b.replace(year=yr, day=28)
|
|
if hoy <= ocurre <= limite:
|
|
ids.add(p.id)
|
|
break
|
|
return ids
|
|
|
|
@api.model
|
|
def _ids_nivel(self, codigo):
|
|
if not codigo:
|
|
return set()
|
|
return set(self.env['res.partner'].sudo().search([
|
|
('is_patient', '=', True),
|
|
('lealtad_nivel_codigo', '=', codigo),
|
|
]).ids)
|
|
|
|
@api.model
|
|
def _ids_gasto(self, minimo):
|
|
if minimo <= 0:
|
|
return set()
|
|
grupos = self.env['skeen.venta'].sudo().read_group(
|
|
[('state', '!=', 'cancelled'), ('partner_id', '!=', False)],
|
|
['partner_id', 'total:sum'], ['partner_id'])
|
|
return {g['partner_id'][0] for g in grupos
|
|
if (g.get('total') or 0.0) >= minimo}
|
|
|
|
@api.model
|
|
def _ids_sucursal(self, code):
|
|
if not code:
|
|
return set()
|
|
citas = self.env['skeen.cita'].sudo().search([
|
|
('branch', '=', code), ('state', '!=', 'cancelled')])
|
|
return set(citas.mapped('partner_id').ids)
|
|
|
|
@api.model
|
|
def _ids_servicio(self, servicio_id):
|
|
"""Pacientes que SÍ tomaron el servicio (cita completada o venta)."""
|
|
if not servicio_id:
|
|
return set()
|
|
ids = set(self.env['skeen.cita'].sudo().search([
|
|
('servicio_id', '=', servicio_id), ('state', '=', 'done'),
|
|
]).mapped('partner_id').ids)
|
|
ventas = self.env['skeen.venta'].sudo().search([
|
|
('state', '!=', 'cancelled'), ('line_ids.service_id', '=', servicio_id)])
|
|
ids |= set(ventas.mapped('partner_id').ids)
|
|
return ids
|
|
|
|
@api.model
|
|
def _ids_membresia_activa(self):
|
|
Model = self.env.get('skeen.membresia')
|
|
if Model is None:
|
|
return set()
|
|
return set(Model.sudo().search([('estado', '=', 'activa')]).mapped('partner_id').ids)
|
|
|
|
@api.model
|
|
def _ids_paquetes_disponibles(self):
|
|
compras = self.env['skeen.paquete.compra'].sudo().search([
|
|
('state', '=', 'active')])
|
|
return set(compras.filtered(
|
|
lambda c: (c.sessions_remaining or 0) > 0).mapped('partner_id').ids)
|
|
|
|
@api.model
|
|
def _ids_saldo_pendiente(self):
|
|
ventas = self.env['skeen.venta'].sudo().search([
|
|
('state', '!=', 'cancelled'), ('amount_due', '>', 0)])
|
|
return set(ventas.mapped('partner_id').ids)
|
|
|
|
@api.model
|
|
def _ids_consentimiento(self):
|
|
return set(self.env['res.partner'].sudo().search([
|
|
('is_patient', '=', True),
|
|
('consentimiento_marketing', '=', True),
|
|
]).ids)
|
|
|
|
@api.model
|
|
def _evaluar(self, criterios):
|
|
"""Conjunto de ids de pacientes que cumplen los criterios."""
|
|
c = normalizar_criterios(criterios)
|
|
resultado = self._universo()
|
|
if not resultado:
|
|
return resultado
|
|
|
|
if c['ultima_visita_dias'] > 0:
|
|
resultado &= self._ids_ultima_visita(c['ultima_visita_dias'])
|
|
if c['sin_visita_meses'] > 0:
|
|
resultado &= self._ids_sin_visita(c['sin_visita_meses'])
|
|
if c['cumple_mes']:
|
|
resultado &= self._ids_cumple(mes_actual=True)
|
|
if c['cumple_proximos_dias'] > 0:
|
|
resultado &= self._ids_cumple(proximos_dias=c['cumple_proximos_dias'])
|
|
if c['nivel_codigo']:
|
|
resultado &= self._ids_nivel(c['nivel_codigo'])
|
|
if c['gasto_minimo'] > 0:
|
|
resultado &= self._ids_gasto(c['gasto_minimo'])
|
|
if c['sucursal']:
|
|
resultado &= self._ids_sucursal(c['sucursal'])
|
|
if c['servicio_id']:
|
|
resultado &= self._ids_servicio(c['servicio_id'])
|
|
if c['servicio_nunca_id']:
|
|
resultado -= self._ids_servicio(c['servicio_nunca_id'])
|
|
if c['con_membresia'] is not None:
|
|
con = self._ids_membresia_activa()
|
|
resultado = (resultado & con) if c['con_membresia'] else (resultado - con)
|
|
if c['con_paquetes'] is not None:
|
|
con = self._ids_paquetes_disponibles()
|
|
resultado = (resultado & con) if c['con_paquetes'] else (resultado - con)
|
|
if c['con_saldo'] is not None:
|
|
con = self._ids_saldo_pendiente()
|
|
resultado = (resultado & con) if c['con_saldo'] else (resultado - con)
|
|
if c['consentimiento']:
|
|
con = self._ids_consentimiento()
|
|
resultado = (resultado & con) if c['consentimiento'] == 'con' \
|
|
else (resultado - con)
|
|
return resultado
|
|
|
|
@api.model
|
|
def _muestra_de(self, ids):
|
|
"""Muestra de hasta 5 pacientes con datos mínimos para la vista previa."""
|
|
if not ids:
|
|
return []
|
|
partners = self.env['res.partner'].sudo().browse(sorted(ids)[:MOSTRAR_MUESTRA])
|
|
return [{
|
|
'id': p.id,
|
|
'name': p.name,
|
|
'phone': p.phone or p.whatsapp or '',
|
|
'email': p.email or '',
|
|
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
|
|
'nivel': p.lealtad_nivel_codigo or '',
|
|
'consentimiento': bool(p.consentimiento_marketing),
|
|
} for p in partners]
|
|
|
|
@api.model
|
|
def previsualizar(self, criterios):
|
|
"""Conteo + muestra SIN guardar (vista previa del constructor)."""
|
|
ids = self._evaluar(criterios)
|
|
return {'total': len(ids), 'muestra': self._muestra_de(ids)}
|
|
|
|
def guardar_preview(self, ids=None):
|
|
"""Recalcula y persiste el conteo y la muestra del segmento."""
|
|
for seg in self:
|
|
ids = self._evaluar(seg.criterios_dict()) if ids is None else ids
|
|
seg.write({
|
|
'ultimo_conteo': len(ids),
|
|
'muestra': json.dumps(self._muestra_de(ids), ensure_ascii=False),
|
|
'preview_at': fields.Datetime.now(),
|
|
})
|
|
return True
|
|
|
|
def pacientes(self):
|
|
"""Recordset de pacientes del segmento (recalcula al vuelo)."""
|
|
self.ensure_one()
|
|
ids = self._evaluar(self.criterios_dict())
|
|
return self.env['res.partner'].sudo().browse(sorted(ids))
|