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