Files
SKEEN-Proyecto/odoo-addons/skeen_campanas/models/segmento.py

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))