Promociones¶
El módulo promos es un motor completo de cálculo de promociones: evalúa condiciones, resuelve conflictos entre promos, calcula el beneficio y lo distribuye proporcionalmente entre las líneas del ticket. Es el paso 4 del cómputo del ticket y una de las piezas con más reglas de negocio del sistema.
El código es la fuente de verdad
PromoService tiene ~2650 líneas y decenas de casos de borde. Esta página documenta el modelo, los tipos, el algoritmo y los fixes conocidos; el detalle fino vive en src/main/java/com/tipre/autocompras/promos/.
Los tres tipos de promoción¶
El enum PromocionTipo (promos/model/enums/PromocionTipo.java) distingue:
| Tipo | Qué es | Resuelto por |
|---|---|---|
PROMOCION |
Promociones genéricas: se aplican con un método (COMBO o CANTIDAD). |
PromoService (COMBO/CANTIDAD) |
MAYORISTA |
Escalas de precio por volumen de un EAN. | PrecioMayoristaService |
BULTO |
Venta especial por bulto (identificado por DUN). | PrecioBultoService |
Además existen las CUPONES: promos cuyo beneficio.destino = "CUPONES", que no dan descuento monetario directo sino que acumulan cupones (acción tipo IMPRESION).
Métodos de PROMOCION¶
- COMBO: requiere comprar productos de varios grupos (ej. "2 del grupo A + 1 del grupo B"). Agrupa por
nro, cada grupo con sucantidadminima. Soporta múltiples ocurrencias por ticket. - CANTIDAD: se activa por cantidad mínima de unidades elegibles (ej. "llevando 3, descuento"). Puede topar repeticiones con
maxOcurrenciasPorTicket.
Tipos de beneficio¶
BeneficioDto:
private BigDecimal valor; // monto o porcentaje
private String tipo; // PORCENTAJE | MONTO | PRECIO_VENTA_FIJO
private String accion; // DESCUENTO (default) | RECARGO
private String destino; // ITEMS (default) | CUPONES | MDEP
tipo |
Cálculo |
|---|---|
PORCENTAJE |
base × (valor / 100). |
MONTO |
Descuento fijo en dinero. |
PRECIO_VENTA_FIJO |
Precio final fijo; beneficio = base − valor. |
Modelo de datos¶
Entidad Promociones (base TsPromos):
@Entity @Table(name = "Promociones")
public class Promociones {
@Id @GeneratedValue Long id;
int version;
@Column(columnDefinition = "NVARCHAR(MAX)") String jsondata; // payload JSON
String dataType; // PROMOCION | MAYORISTA | BULTO
LocalDateTime fechaActualizacion;
}
El detalle de cada promo se deserializa del jsondata a PromocionesDto, que incluye vigencia (fechadesde/fechahasta, horaEjecucionDesde/Hasta, diassemana), metodo, beneficio, condiciones, sucursales, mediodepago, convenios y listas de inclusión/exclusión de artículos.
Las condiciones (CondicionesDto) son ricas: acumulativa (SI/NO), usaPrecioLista, maxOcurrenciasPorTicket, montominimo, cantidadminima, y exclusiones (excluyeVentaMayorista, excluyeVentaXBulto, excluyeArticulosOferta).
Las listas (ListaDto) definen qué artículos alcanza la promo por tipoElemento: EAN, PLU, FAMILIA, AREA, RUBRO, MARCA, NEGOCIO, SECTOR, PROVEEDOR, CUPONES_TEXTO, con tipo INCLUSION o EXCLUSION.
El motor: calculatePromoTicket¶
Punto de entrada (consumido por tickets):
Flujo:
graph TD
A["resetComputedPromotions"] --> B["MAYORISTA por ítem<br/>(calcPrecioMayoristaItems)"]
B --> C["BULTO por ítem<br/>(calcPrecioBultoItems)"]
C --> D["PROMOCIONES<br/>(COMBO + CANTIDAD + CUPONES)"]
D --> E["saveTransaction<br/>(auditoría TrxPromo)"]
Modos de cómputo: ITEMS vs MDP¶
| Modo | Cuándo | Qué aplica |
|---|---|---|
ITEMS |
Mientras se arma el carrito. | Todas las promos excepto las que dependen del medio de pago. |
MDP |
Al elegir/abrir un medio de pago. | Solo las promos con mediodepago que coincida. |
tickets resuelve el modo: si hay una orden de pago en estado OPEN, usa MDP; si no, ITEMS. Por eso una promo "pagando con tarjeta X, 10% off" recién aparece en el paso de pago.
MAYORISTA (escalas por volumen)¶
Para cada EAN, suma las unidades del ticket y busca el nivel mayorista con la mayor cantidadminima que esa cantidad alcanza. Si el precio mayorista mejora al vigente, el beneficio = precioMayorista − precioVigente (negativo), distribuido entre los movimientos. Motivos de no-aplicación auditados: sin mayorista para el EAN, no aplica a la sucursal, cantidad no alcanza, el precio no mejora.
BULTO¶
Para artículos de bulto (DUN > 13 caracteres, unidadesPorBulto > 0), busca el bulto por DUN y elige el de menor precioXBulto que mejore el vigente.
COMBO y CANTIDAD¶
- Filtra promos elegibles: beneficio no nulo, dentro de vigencia (fechas, horas, días de semana en zona Buenos Aires), sucursal/convenio coincidentes, y según el modo (ITEMS excluye MDP, y viceversa).
- Arma ocurrencias: agrupa las líneas elegibles según las listas de inclusión (para COMBO, por grupo
nro; para CANTIDAD, en bloques decantidadminima), validandomontominimo. - Resuelve conflictos (ver abajo).
- Distribuye el beneficio proporcionalmente.
Acumulatividad: cómo se resuelven los conflictos¶
graph TD
Q{acumulativa?}
Q -->|SI default| ACC["Varias promos pueden stackear<br/>sobre la misma línea"]
Q -->|NO| EXC["Solo una; gana la de MAYOR beneficio absoluto"]
ACC --> FLOOR["Floor a cero tras cada aplicación<br/>(TSPM-005)"]
EXC --> TIE["Desempate: orden de evaluación, luego id menor"]
acumulativa = SI(default): los descuentos se apilan; tras cada aplicación, el precio vigente no puede bajar de cero (fix TSPM-005).acumulativa = NO: compite con otras no-acumulativas; gana la de mayor beneficio absoluto; desempata por orden de evaluación y luego por id. Excluye las líneas que ya tienen una promo acumulativa.- En COMBO acumulativo, se aplica iterativamente: en cada ronda gana el combo de mayor beneficio y se excluyen los que se solapan.
Distribución proporcional del beneficio¶
El beneficio total se reparte entre las líneas según su base:
ratio_línea = base_línea / Σ bases
porción_línea = beneficio_total × ratio_línea
última línea: absorbe (beneficio_total − Σ porciones) // evita el centavo perdido
Cada porción se aplica con NucleoImpositivoDto.recomponer() para preservar la composición fiscal (ver Núcleo impositivo).
Auditoría: TrxPromo¶
Cada cálculo persiste una fila en TrxPromo con el ticket antes (jsonRequest), después (jsonResponse), las observaciones de evaluación (por qué cada promo aplicó o se descartó) y el modoComputo. Es la caja negra para entender por qué un ticket recibió (o no) una promo.
Observaciones típicas: "descartada por mayor beneficio", "cantidadminima no alcanzada", "lista.montominimo no alcanzado", "exclusión por lista", "precio mayorista no mejora el precio vigente".
Cache¶
PromosCacheService mantiene en memoria: promociones, mayoristas (y un índice por EAN), bultos (y un índice por DUN). Se carga al startup (@PostConstruct) y se refresca async por triggers: STARTUP, MANUAL (POST /cache/refresh), SCHEDULED, UPDATE_COMPLETED. Hay un endpoint de diagnóstico (GET /promociones/diagnostico) que compara DB vs cache y reporta promos inválidas.
Casos de borde y fixes conocidos¶
| Fix | Problema → solución |
|---|---|
| TSPM-001 | Descuento mayor que la base → se acota a la base (evita precio negativo). |
| TSPM-004 | Acción nula se interpretaba como recargo → acción desconocida = DESCUENTO (seguro). |
| TSPM-005 | Promos acumulativas llevaban el precio bajo cero → floor a cero tras cada aplicación. |
| TSPM-006 | Beneficio MONTO negativo inflaba el descuento → clamp a cero. |
| TSPM-016 | recomponer() dividía por cero con base cero → ítem con base cero no recibe descuento. |
| TSPM-018 | Stack traces en la respuesta HTTP → mensaje genérico, detalle solo en logs. |
| TSPM-019 | Colisión de ids de movimiento (size()+1) → max(id)+1. |
Si una promo 'no aplica' y no entendés por qué
Mirá la fila de TrxPromo de ese ticket: las observaciones dicen exactamente qué condición falló. No adivines — el motor ya te dejó la traza.