Saltar a contenido

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 su cantidadminima. 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):

public TicketPromoDto calculatePromoTicket(TicketPromoDto ticket, String modoComputo)

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

  1. 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).
  2. Arma ocurrencias: agrupa las líneas elegibles según las listas de inclusión (para COMBO, por grupo nro; para CANTIDAD, en bloques de cantidadminima), validando montominimo.
  3. Resuelve conflictos (ver abajo).
  4. 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.