· Domain Model Reference
v202604200002
com.tipre.ruleengine
Domain Model Reference
POS / Ticket / Ledger / Motor de Promociones
Documento unificado de referencia del dominio. Define el modelo de agregado Ticket, las reglas de cálculo e itemización, el ledger distribuido y el motor completo de promociones. Reemplaza a core.md y promociones.md.
Versión202604200002
RuntimeJava 21
Packagecom.tipre.ruleengine
JSON refjsonmodel_optionB_v7
Parte I — Core
§ 01Objetivo y principios

Objetivo y principios de diseño

Este documento es la referencia central única del modelo de dominio. En caso de contradicción entre este documento y archivos JSON o documentos históricos, model.md tiene prioridad absoluta.

Principios de diseño

  • Detallado — cada monto relevante debe poder explicarse.
  • Trazable — toda transformación económica debe rastrearse en movimientos[].
  • Reconciliable — la suma algebraica del ledger debe cerrar en 0 al finalizar el ticket.
  • Auditable — el modelo soporta auditoría fiscal y contable completa.
  • Extensible — nuevos tipos de promoción, pago o impuesto se incorporan sin romper el núcleo.
  • Conservador — ante dudas de modelado, se elige la solución más conservadora y explicable.
No se deben introducir reglas no confirmadas. Toda ambigüedad debe resolverse explícitamente antes de codificar.
§ 02Agregado raíz: Ticket

El agregado raíz Ticket

El agregado raíz es Ticket. Representa el estado completo de una operación POS y es la unidad principal para cálculo, validación, testing, auditoría y reconciliación.

{
  "ticket": {
    "datosreferenciales": {},
    "cliente":            {},
    "articulos":          [],
    "items":              [],
    "promociones":        [],
    "pagos":              [],
    "movimientos":        []
  }
}
ArrayTipoDescripción
datosreferencialesObjetoMetadatos: nroTicket, comercio, sucursal, fechaHora, totales
clienteObjetoComprador: identificación, tipo, convenio, impuestos
articulos[]Catálogo internoArtículos reutilizables. Evita duplicar datos de producto
items[]RegistroCaptura comercial original. Un registro por ingreso operativo
promociones[]RegistroRegistros maestros de promociones aplicadas
pagos[]RegistroRegistros maestros de pagos ingresados
movimientos[]LedgerDetalle económico distribuido. Fuente de verdad fiscal/contable

Panel interactivo — estructura del ticket

datosreferencialesobjeto
clienteobjeto
articulos[]catálogo
items[]registro
promociones[]registro
pagos[]registro
movimientos[]ledger
← Seleccione una estructura para ver sus campos
§ 03Tres estructuras del modelo

Las tres estructuras del modelo

El modelo trabaja con tres estructuras claramente separadas. Esta separación es obligatoria y no debe perderse.

Estructura 1
articulos[]
Catálogo de artículos reutilizables dentro del ticket. Si al ingresar un EAN el artículo ya existe, se reutiliza su id — no se duplica.
Estructura 2
items[]
Captura comercial original. Cada ingreso tiene id incremental, referencia un articuloid e informa unidades. Conserva el dato tal como fue ingresado operativamente.
Estructura 3
movimientos[]
Ledger distribuido y detallado. Es el core de explicación económica. Refleja ventas itemizadas, promociones distribuidas, pagos, excedentes y vueltos.
items[] = captura comercial. movimientos[] = impacto económico distribuido. Esta distinción no debe perderse. No son equivalentes ni intercambiables.
§ 04Objetos del dominio

Objetos del dominio

DatosReferenciales

Contiene: nroTicket, comercio, nroSucursal, nroPv, fechaHora, tickettipo, tipocomprobante, total, saldo, vuelto, nucleoimpositivo. El campo nucleoimpositivo aquí representa el total del ticket sin considerar pagos.

Cliente

CampoTipoDescripción
idintegerIdentificador
nombrestringNombre o razón social
cuitstringCUIT / documento
tipodeclienteobjetoTipo de cliente y tipo de comprobante asociado
convenioobjeto / null{ id, nombre } — un solo convenio por cliente. null si no tiene. El convenio.id filtra conveniosclientes[]
impuestos[]arrayPercepciones y retenciones aplicables

Articulo

CampoTipoDescripción
eanstringCódigo de barras EAN
plustringCódigo PLU
descripcionstringNombre del artículo
pesablebooleanSi se vende por peso
preciolistanumberPrecio de lista base
rubrostringRubro
deptostringDepartamento
marcastringMarca
codigoclasificacionintegerCódigo de clasificación genérico
proveedorstringProveedor
excluyelistaofertasbooleanSi true, excluido de promociones con condiciones.excluyelistaofertas=true
nucleoimpositivo[]arrayComposición impositiva base del artículo

Item

CampoTipoDescripción
idintegerIdentificador incremental del ingreso
articuloidintegerReferencia al artículo en articulos[]
unidadesnumberCantidad ingresada. Admite decimales para pesables

NucleoImpositivo

Estructura central de importes e impuestos. Expresada como colección de componentes { impuesto: { id }, monto }. Se usa en artículos, movimientos y datosreferenciales. No asumir que todo impuesto es porcentual.

TipoDePago

CampoTipoDescripción
idintegerID del medio de pago
ididintegerSub-ID del medio de pago
cuotaintegerNúmero de cuotas. 0 = contado
descripcionstringNombre descriptivo
davueltobooleantrue: vuelto en el mismo medio
vueltomediodepagointeger / nullID del medio alternativo para vuelto. null = operación denegada
La clave compuesta de matching es id + idid + cuota. Esta clave empareja el pago ingresado con su TipoDePago y con las entradas de mediosdepago[] en la definición de promoción.

Pago

CampoTipoDescripción
idintegerIdentificador
mediodepagoidintegerReferencia al TipoDePago
descripcionstringNombre descriptivo
montonumberPositivo = ingreso. Negativo = vuelto entregado al cliente
Un Pago negativo es válido por diseño — representa un medio de pago utilizado como vuelto. No agregar campos adicionales.
§ 05Regla de itemización

Regla de itemización

Un item puede representar varias unidades ingresadas en una sola acción operativa, pero el ledger en movimientos[] debe representar el detalle distribuido.

Regla obligatoria: si un item tiene unidades enteras, deben generarse tantos movimientos VENTA_ITEM como unidades registradas, salvo que exista una regla futura explícita en contrario.

El modelo permite unidades decimales para artículos pesables. La distribución para unidades no enteras debe mantener trazabilidad, explicabilidad y reconciliación.

§ 06Catálogos y registros

Catálogos y registros del ticket

Catálogos / configuración

tickettipo · tipocomprobante · tipodecliente · tipodeimpuesto · tipoconcepto · tiposdepago · listapromociones · metodocomputo · tipobeneficio · promociondecision · promocionestado · promocionlistatype · promocionlistanumber · promociontipoelemento

Registros operativos del ticket

datosreferenciales · cliente · articulos[] · items[] · promociones[] · pagos[] · movimientos[]

§ 07Estructura de Movimiento

Estructura de Movimiento

CampoTipoRegla
idintegerIdentificador incremental
conceptoenumVENTA_ITEM · PROMOCION · PAGO
origenidintegerReferencia al registro maestro origen según el concepto
movimientoidinteger / nullReferencia al VENTA_ITEM sobre el que aplica. null para excedentes y vuelto
nivelprecioobjeto / null{ id } solo en VENTA_ITEM. null en PROMOCION y PAGO
nucleoimpositivo[]arrayEstructura de importes e impuestos del movimiento

Regla de nivelprecio

En VENTA_ITEM: refleja el nivel de precio resultante al finalizar el cómputo de todas las promociones ITEM. Valor por defecto: MINORISTA. Catálogo: MINORISTA · MAYORISTA1/2/3 · GRUPOMAY1/2/3 · VTABULTO.

En PROMOCION y PAGO: siempre null.

Regla general de movimientoid

Conceptomovimientoid
VENTA_ITEMnull (es el origen)
PROMOCIONid del VENTA_ITEM impactado
PAGO distribuidoid del VENTA_ITEM que cancela
PAGO excedentenull
PAGO vueltonull
§ 08Reglas de pagos

Reglas de pagos

  • pagos[] contiene registros maestros. movimientos[] contiene el impacto distribuido.
  • Los pagos se evalúan contra el saldo real del ticket luego de promociones e impuestos.
  • El pago negativo está permitido cuando el medio de pago participa como vuelto.
  • datosreferenciales.vuelto conserva el resultado global y movimientos[] explica la distribución.
  • No sobrecargar Pago con campos adicionales. La versión base usa solo monto.
§ 09Signos del ledger

Convenciones de signos del ledger

La convención de signos es estricta. El signo correcto en cada movimiento es condición necesaria para que el ledger cierre en cero.

ConceptoSignoCondición
VENTA_ITEM+Siempre — representa ingreso comercial
PROMOCION (descuento)Cuando representa descuento
PROMOCION (recargo)+Cuando representa recargo
PAGO aplicado a ítemmovimientoid ≠ null
PAGO excedentemovimientoid = null
PAGO vuelto+movimientoid = null — entregado al cliente
Separación maestro / distribuido: promociones[].monto no sustituye al detalle distribuido del ledger. CUPON es excepción: solo en promociones[] con monto=0, no genera movimiento.
§ 10Excedente y vuelto

Regla de excedente y vuelto

Cuando un medio de pago ingresa un monto mayor al necesario para cancelar el ticket:

  1. La parte aplicada → movimientos PAGO negativos contra ítems.
  2. El excedente → movimiento PAGO negativo con movimientoid=null, origenid = id del pago ingresado.
  3. El vuelto → movimiento PAGO positivo con movimientoid=null, origenid = id del pago de vuelto.
CASO A
Mismo medio
davuelto = true
Aceptado. El vuelto se entrega en el mismo medio de pago. Un Pago negativo en el ledger representa el vuelto.
CASO B
Operación denegada
davuelto=false · vueltomediodepago=null
Operación denegada. El medio no da vuelto y no hay alternativa. Rechazar antes de procesar.
CASO C
Medio alternativo
davuelto=false · vueltomediodepago=id
Aceptado. El vuelto se entrega en el medio alternativo indicado por vueltomediodepago.
§ 11Reconciliación Σ=0

Invariante de reconciliación: Σ = 0

La suma algebraica de todos los nucleoimpositivo.monto en movimientos[] debe ser exactamente 0 al cierre final del ticket. Sin excepciones.

Demostración — ledger animado

3 ítems × $1310, promoción 2×1 (50% sobre 2 unidades), pago $3000 en cheque, vuelto $380 en efectivo.

movimientos[] — ledger distribuido
#
Concepto
Ref
Descripción
Monto
Σ movimientos[] = ··· Reconciliado ✓

Coherencia esperada

El modelo debe poder verificar: total de ítems, total de promociones, total de impuestos, total pagado, saldo y vuelto, y la suma de movimientos distribuidos.

§ 12Orden base de cálculo

Orden base de cálculo

01
Identificar cliente
Incluye convenio — determina qué promociones son elegibles.
02
Ingresar ítems
Deduplicar articulos[], crear items[].
03
Resolver referencias
articuloid → datos de producto y nucleoimpositivo.
04
Bases iniciales
preciolista como base de cálculo. nivelprecio = MINORISTA para todos los ítems.
05
Modo Item
Aplicar promociones en orden: MAYORISTA → VENTAXBULTO → GRUPOMAYORISTA → COMBO → CANTIDAD
06
Recalcular bases
Último precio vigente según nivelprecio en cada paso.
07
Calcular impuestos
Núcleo impositivo del ticket completo.
08
Total del ticket
datosreferenciales.total
09
Modo Pago — Consulta
Promociones disponibles por medio de pago. No modifica el ticket.
10
Modo Pago — Aplicar
Registrar en pagos[] y movimientos[].
11
Excedente y vuelto
Resolver casos A / B / C según tiposdepago.
12
Distribuir pagos
Proporcional entre VENTA_ITEMs con saldo positivo.
13
Saldo final
datosreferenciales.saldo
14
Validar reconciliación
Σ movimientos[] = 0 — test definitivo de coherencia del ticket.
Parte II — Motor de Promociones
§ 13Tres capas

Modelo de promociones: tres capas obligatorias

Las tres capas son inseparables y no deben mezclarse. Cada una tiene responsabilidad exclusiva y tipo distinto.

Capa 1
Definición
listapromociones
Catálogo / configuración
Regla configurable de negocio. No es un registro operativo del ticket. Define condiciones, vigencia, beneficio y alcance.
Capa 2
Aplicada
promociones[]
Registro operativo
Resultado maestro de la aplicación en una operación concreta. Contiene monto total y trazabilidad por elemento.
Capa 3
Ledger
movimientos[]
Ledger distribuido
Impacto económico distribuido. Fuente de verdad fiscal. Una línea por unidad impactada con nucleoimpositivo completo.
Invariante de capas: Σ elementos[].monto == promociones[].monto. El detalle fiscal definitivo siempre vive en movimientos[]. CUPON es la única excepción: solo en capa 2 con monto=0, no genera movimiento.
§ 14Modos e inferencia de alcance

Modos de cómputo e inferencia de alcance

ModoDescripciónModifica ticket
ITEMComputa promociones sobre ítems. Evalúa pipeline completo.
PAGO CONSULTADevuelve promociones disponibles para un medio. Solo lectura.No
PAGO APLICARAplica promo de pago sobre el monto ingresado.

Inferencia de modo — sin campo explícito

El modo se infiere de la configuración de la promoción. No existe campo promocionalcance.

  • mediosdepago[] vacío → promoción de MODO ITEM
  • mediosdepago[] con entradas → promoción de MODO PAGO
  • listasitems[] vacío → promoción inválida, no procesar

Orden de procesamiento por método

OrdenmetodocomputoModos habilitados
1MAYORISTAITEM solamente
2VENTAXBULTOITEM solamente
3GRUPOMAYORISTAITEM solamente
4COMBOITEM y PAGO
5CANTIDADITEM y PAGO

Exclusión por nivel de precio

  • Ítem que alcanza MAYORISTA1/2/3no se evalúa en VENTAXBULTO ni GRUPOMAYORISTA.
  • Ítem que alcanza VTABULTOno se evalúa en GRUPOMAYORISTA.
  • Tras esos tres métodos, el ítem pasa a COMBO y CANTIDAD con su nivel vigente.

Lógica acumulativa / no acumulativa

Aplica exclusivamente en métodos COMBO y CANTIDAD.

  1. Primero se evalúan y computan todas las promociones con condiciones.acumulativa = true.
  2. Las no acumulativas solo se evalúan si ninguna acumulativa se cumplió para ese ítem.

Un ítem puede acumular múltiples promociones acumulativas. Las acumulativas y no acumulativas son mutuamente excluyentes en su aplicación efectiva.

Base de cálculo del precio

  • Promociones ITEM: base = último precio del nivelprecio vigente del ítem en ese momento del cómputo.
  • Promociones de PAGO: base = precio resultante del ítem tras todas las promociones ITEM ya computadas.
  • Acumulativa: calcula sobre el último precio vigente post-promos previas.
  • No acumulativa: calcula sobre el precio base del nivelprecio vigente.
§ 15Catálogos auxiliares

Catálogos auxiliares de promociones

Todos los catálogos se referencian como objetos {"id": "VALOR"}. Este patrón es uniforme en todo el modelo y permite extensión futura sin breaking changes.

nivelprecio

idDescripción
MINORISTAPrecio de lista estándar. Valor por defecto.
MAYORISTA1Nivel mayorista 1.
MAYORISTA2Nivel mayorista 2.
MAYORISTA3Nivel mayorista 3.
GRUPOMAY1Nivel grupo mayorista 1.
GRUPOMAY2Nivel grupo mayorista 2.
GRUPOMAY3Nivel grupo mayorista 3.
VTABULTONivel venta por bulto.

metodocomputo

idModosDescripción
MAYORISTAITEMPrecio mayorista por volumen o condición de cliente.
VENTAXBULTOITEMPrecio especial por venta de bulto completo.
GRUPOMAYORISTAITEMPrecio mayorista por grupo de artículos.
COMBOITEM + PAGOBeneficio por combinación de ítems de distintas listas.
CANTIDADITEM + PAGOBeneficio por cantidad de unidades de uno o más ítems.

tipobeneficio

PORCENTAJE_DESCUENTO
valorbeneficio positivo (ej: 10 = 10%). Calcula sobre el precio base vigente.
PORCENTAJE_RECARGO
Recargo porcentual. valorbeneficio positivo. Aumenta el precio del ítem.
MONTO_DESCUENTO
Descuento de monto fijo. Distribuido proporcionalmente entre ítems impactados.
MONTO_RECARGO
Recargo de monto fijo. Mismo criterio de distribución.
NUEVOPRECIOITEM
Reemplaza el precio del ítem. valorbeneficio = precio final resultante.
NO_PERMITIR_VENTA_ITEM
Bloquea la venta del ítem. Sin monto. valorbeneficio = null.
EAN_COMBO
El beneficio es un ítem adicional identificado por EAN. valorbeneficio = null.
CUPON
Genera cupones de beneficio. Sin impacto en ledger. Solo en promociones[] con monto=0.

promocionestado

idDescripción
POSIBLEEvaluada, podría aplicar, aún no aplicada.
APLICADAAplicada efectivamente al ticket.
NOAPLICAEvaluada, no corresponde aplicar.
ANULADAAplicada pero luego anulada.

promocionlistanumber

idDescripción
LISTA1Primera lista de ítems — primer grupo del combo.
LISTA2Segunda lista de ítems — segundo grupo del combo.
LISTA3Precio de venta unitario del combo. tipoelemento=MONTO (directo) o EAN (indirección).

promociontipoelemento

idDescripción
EANCódigo de barras EAN.
PLUCódigo PLU.
DEPARTAMENTODepartamento del artículo.
RUBRORubro del artículo.
SECTORSector.
FAMILIAFamilia de productos.
CODIGOCLASIFICACIONCódigo de clasificación genérico.
PROVEEDORProveedor.
MARCAMarca.
MONTOMonto de venta. Usado en LISTA3 para precio unitario del combo.
TICKETLa condición aplica al conjunto completo de ítems del ticket.
Eliminados: SUCURSAL (→ sucursales[]), CANTIDAD_MAX_PROMOS (→ condiciones.maximacantidadpromosxticket), MEDIODEPAGO (→ mediosdepago[]).

promocionlistatype

idDescripción
INCLUSIONEl elemento está alcanzado por la promoción.
EXCLUSIONEl elemento está excluido explícitamente.
§ 16listapromociones

Definición de promoción: listapromociones

Catálogo de reglas configurables de negocio. No es un registro operativo del ticket.

{
  "id": 1,
  "descripcion": "PROMO_2X1_ARROZ",
  "metodocomputo": { "id": "CANTIDAD" },
  "tipobeneficio": { "id": "PORCENTAJE_DESCUENTO" },
  "valorbeneficio": 50.0,
  "codigodescarga": null,
  "conveniosclientes": [],
  "condiciones": {
    "acumulativa": false,
    "maximacantidadpromosxticket": 1,
    "montominimo": 0.0,
    "excluyelistaofertas": false,
    "excluyeventamayorista": false
  },
  "vigencia": {
    "fechadesde": "20260301",
    "fechahasta": "20260331",
    "diassemana": ["MIERCOLES"],
    "horadesde": "10",
    "horahasta": "11"
  },
  "sucursales": [],
  "mediosdepago": [],
  "listasitems": [
    {
      "listaindex": 1,
      "tipodeLista": { "id": "INCLUSION" },
      "nrolista":    { "id": "LISTA1" },
      "tipoelemento":{ "id": "EAN" },
      "valor": "7791234567890",
      "cantidad": 2.0,
      "monto": null
    }
  ]
}

Campos del objeto Promoción

CampoTipoReq.Descripción
idintegerIdentificador único.
descripcionstringNombre descriptivo.
metodocomputoobjeto {id}Método de cómputo.
tipobeneficioobjeto {id}Tipo de beneficio.
valorbeneficionumber/nullSí*Valor del beneficio. Null para NO_PERMITIR_VENTA_ITEM, EAN_COMBO, CUPON.
codigodescargastring/nullNoPLU para grabar la promo como producto en el detalle de ventas.
conveniosclientes[]arrayLista de {id} de convenios. Vacío = aplica a todos.
condicionesobjetoVer subcampos abajo.
vigenciaobjetoFechas, días y horas de activación.
sucursales[]arraySucursales habilitadas. Vacío = todas. Sin exclusión.
mediosdepago[]arrayVacío → MODO ITEM. Con entradas → MODO PAGO.
listasitems[]arrayAl menos una entrada. Vacío = promoción inválida.

condiciones

CampoTipoDescripción
acumulativabooleantrue = acumulativa. Solo aplica a COMBO y CANTIDAD.
maximacantidadpromosxticketinteger0 = sin límite. Otro valor = máximo de aplicaciones por ticket.
montominimonumberMonto mínimo de ítems incluidos. 0 = sin mínimo.
excluyelistaofertasbooleanSi true, excluye ítems con articulo.excluyelistaofertas=true.
excluyeventamayoristabooleanSi true, excluye ítems con nivelprecio en MAYORISTA1/2/3.

vigencia

CampoFormatoDescripción
fechadesdeAAAAMMDDFecha de inicio (inclusive).
fechahastaAAAAMMDDFecha de fin (inclusive).
diassemana[]array stringLUNES MARTES MIERCOLES JUEVES VIERNES SABADO DOMINGO
horadesdeHHHora de inicio en cada día habilitado.
horahastaHHHora de fin.

sucursales[] y mediosdepago[]

// sucursales[]
{ "nrosucursal": 1, "descripcion": "CASA CENTRAL" }

// mediosdepago[]
{ "idmdep": 1, "subidmdep": 10, "cuotanumero": 0, "descripcion": "EFECTIVO" }
// Match: idmdep + subidmdep + cuotanumero  ↔  tiposdepago.id + idid + cuota

listasitems[]

CampoTipoDescripción
listaindexintegerOrden de la entrada.
tipodeListaobjeto {id}INCLUSION o EXCLUSION.
nrolistaobjeto {id}LISTA1, LISTA2 o LISTA3.
tipoelementoobjeto {id}Tipo del elemento evaluado.
valorstringValor concreto a comparar (EAN, PLU, rubro, etc.).
cantidadnumberCantidad necesaria. Exactos múltiplos por aplicación.
montonumber/nullEn LISTA3 con tipoelemento=MONTO: precio directo del combo.
Semántica LISTA3: tipoelemento=MONTO → precio directo en monto. tipoelemento=EAN → precio obtenido de articulos[].preciolista para ese EAN (indirección que permite actualizar precios sin tocar la config).
§ 17promociones[] aplicada

Promoción aplicada en el ticket: promociones[]

{
  "id": 1,
  "promocionid": 1,
  "descripcion": "PROMO_2X1_ARROZ",
  "tipoPromo": "ITEM",
  "promocionestado": { "id": "APLICADA" },
  "monto": -1310.0,
  "elementos": [
    { "movimientoid": 1, "articuloid": 1,
      "unidadesimpactadas": 1.0, "monto": -655.0 },
    { "movimientoid": 2, "articuloid": 1,
      "unidadesimpactadas": 1.0, "monto": -655.0 }
  ]
}
CampoTipoReq.Descripción
idintegerIdentificador del registro aplicado.
promocionidintegerReferencia a listapromociones.
descripcionstringNombre heredado de la definición.
tipoPromostring"ITEM" o "PAGO". Naming conservado por consistencia con JSON.
montonumberMonto total del beneficio. 0 para CUPON.
promocionestadoobjeto {id}NoEstado de la promoción aplicada.
elementos[]arrayNoTrazabilidad: movimientoid, articuloid, unidadesimpactadas, monto.
Invariante: Σ elementos[].monto = promociones[].monto. El detalle fiscal definitivo sigue en movimientos[]. CUPON solo en promociones[] con monto=0, no genera movimiento, no afecta reconciliación.
§ 18Flujo MODO ITEM

Flujo del motor MODO ITEM

1
Asignar nivel inicial
Asignar nivelprecio = MINORISTA a todos los ítems. Filtrar listapromociones activas (vigencia, sucursal, convenio, condiciones).
2
MAYORISTA
Para cada ítem que cumpla condiciones: cambiar nivelprecio a MAYORISTA1/2/3, registrar PROMOCION en movimientos[] y en promociones[]. Ese ítem no se evalúa en VENTAXBULTO ni GRUPOMAYORISTA.
3
VENTAXBULTO
Para cada ítem sin nivel MAYORISTA que cumpla condiciones: cambiar nivelprecio a VTABULTO, registrar PROMOCION. Ese ítem no se evalúa en GRUPOMAYORISTA.
4
GRUPOMAYORISTA
Para cada ítem sin nivel MAYORISTA ni VTABULTO que cumpla condiciones: cambiar nivelprecio a GRUPOMAY1/2/3, registrar PROMOCION.
5A
COMBO y CANTIDAD — acumulativas
Para todos los ítems con su nivelprecio vigente: evaluar y computar todas las promos acumulativas. Calcular beneficio sobre precio base del nivelprecio vigente del ítem. Registrar PROMOCION.
5B
COMBO y CANTIDAD — no acumulativas
Solo si ninguna acumulativa se cumplió para ese ítem: evaluar promos no acumulativas. Calcular beneficio sobre precio base del nivelprecio vigente.
6
Actualizar VENTA_ITEMs
Actualizar nivelprecio en cada VENTA_ITEM de movimientos[] con el nivel resultante. Retornar ticket actualizado.
§ 19Estrategia COMBO

Estrategia COMBO — algoritmo greedy-recursivo

El método COMBO aplica un algoritmo greedy-recursivo global sobre todas las promociones COMBO simultáneamente. No se evalúan de a una: se evalúan todas en cada iteración y se selecciona la de mayor descuento.

// Algoritmo principal
Repetir hasta que no haya más combos posibles:

  A) Para cada promo COMBO activa:
       — Identificar ítems disponibles (no consumidos) en sus listas
       — Generar TODAS las combinaciones posibles de (LISTA1 × LISTA2)
       — Calcular descuento = Σ(precios ítems) − precio_combo (LISTA3)
       — Registrar la combinación de máximo descuento para esta promo

  B) Comparar el máximo de cada promo entre sí
     → Seleccionar la promo con mayor descuento global
     → En caso de empate: selección indiferente

  C) Marcar como CONSUMIDAS las unidades de esa combinación ganadora
     (solo las unidades usadas, no el ítem completo si quedan más)

  D) Registrar PROMOCION en movimientos[] y en promociones[]
     Distribuir descuento proporcionalmente al precio de cada ítem

  E) Volver a A) con el pool de ítems restantes

Precio del combo — LISTA3

  • tipoelemento=MONTO → precio directo en campo monto de la entrada LISTA3.
  • tipoelemento=EAN → precio obtenido de articulos[].preciolista para ese EAN. Permite actualizar precios sin tocar la configuración de la promo.

Distribución del descuento

El descuento total del combo se distribuye entre los ítems participantes proporcionalmente a su precio. La última unidad absorbe el residuo exacto para garantizar que Σ descuentos distribuidos == descuento total.

Ejemplo — tres iteraciones

// Ticket: ATUN-A×3, ATUN-B×1, MAYONESA×1, PASCUALINA×2, TAPAS×1
// Promos: 260000 (2×ATUN + 1×PASCUALINA = $3.79)
//         261000 (2×ATUN + 1×MAYONESA   = $3.79)
//         263000 (1×PASCUALINA + 1×TAPAS = $3.00)

// ITERACION 1: Evalúa los 3 combos simultáneamente
260000: mejor combinación → ATUN-A + ATUN-B + PASCUALINA = $12 − $3.79 = −$8.21
261000: mejor combinación → ATUN-A + ATUN-B + MAYONESA  = $13 − $3.79 = −$9.21  ← ganador
263000: mejor combinación → PASCUALINA + TAPAS          = $7  − $3.00 = −$4.00

// Consume: ATUN-A×1, ATUN-B×1, MAYONESA×1

// ITERACION 2: Remanente: ATUN-A×2, PASCUALINA×2, TAPAS×1
260000: ATUN-A×2 + PASCUALINA × 1 = $11 − $3.79 = −$7.21  ← ganador
261000: sin MAYONESA → no aplica
263000: PASCUALINA + TAPAS = −$4.00

// Consume: ATUN-A×2, PASCUALINA×1

// ITERACION 3: Remanente: PASCUALINA×1, TAPAS×1
263000: PASCUALINA + TAPAS = −$4.00  ← único disponible

// RESULTADO: −$9.21 + −$7.21 + −$4.00 = −$20.42
// TOTAL TICKET: $31 − $20.42 = $10.58
§ 20Flujo MODO PAGO

Flujo del motor MODO PAGO

Sub-etapa CONSULTA

1. Recibir mediodepagoid (idmdep + subidmdep + cuotanumero)
2. Filtrar listapromociones donde mediosdepago[] contiene ese medio
3. Para cada promo encontrada verificar:
   — Vigencia activa (fecha, hora, día de semana)
   — Sucursal del ticket en sucursales[] (si no vacío)
   — cliente.convenio.id en conveniosclientes[] (si no vacío)
   — Condiciones de ítems (excluyelistaofertas, excluyeventamayorista)
   — Ítems del ticket presentes en listasitems[]
4. Calcular beneficio sobre precio resultante post-promos ITEM de cada ítem
5. Retornar: promocionesdisponibles[], saldoneto
6. NO modificar el ticket

Sub-etapa APLICAR — escenarios de monto

El saldo de referencia es el saldo pendiente neto (ya considerando la promo del medio de pago):

EscenarioCondiciónComportamiento
Pago parcialmonto < saldo netoPromo aplicada proporcionalmente. Queda saldo pendiente.
Pago exactomonto = saldo netoPromo aplicada completa. Ticket saldado.
Pago con excedentemonto > saldo netoConsume hasta el saldo neto. Excedente resuelto por Casos A/B/C.
Parte III — Implementación
§ 21Convenciones Java 21

Convenciones Java 21

  • Records Java 21 para value objects inmutables (NucleoImpositivo, Vigencia, etc.).
  • Sealed classes para jerarquías cerradas donde aplique.
  • Pattern matching con instanceof para type-safe dispatch.
  • Streams y Optional idiomáticos.
  • BigDecimal para todos los montos — 8 decimales internos, round2() solo en display.
  • La última unidad absorbe el residuo exacto en distribuciones para garantizar cierre del ledger.

Estructura de paquetes

com.tipre.ruleengine
├── model/
│   ├── Ticket.java
│   ├── Item.java
│   ├── Articulo.java
│   ├── Movimiento.java
│   ├── Pago.java
│   ├── Promocion.java
│   └── NucleoImpositivo.java
├── model/enums/
│   ├── TipoConcepto.java         // VENTA_ITEM, PROMOCION, PAGO
│   ├── NivelPrecio.java          // MINORISTA, MAYORISTA1..3, GRUPOMAY1..3, VTABULTO
│   ├── MetodoComputo.java        // MAYORISTA, VENTAXBULTO, GRUPOMAYORISTA, COMBO, CANTIDAD
│   ├── TipoBeneficio.java        // PORCENTAJE_DESCUENTO, MONTO_DESCUENTO, etc.
│   └── PromoEstado.java          // POSIBLE, APLICADA, NOAPLICA, ANULADA
├── model/catalog/
│   ├── ListaPromociones.java
│   ├── DefinicionPromocion.java
│   ├── TipoDePago.java
│   ├── Vigencia.java
│   ├── Condiciones.java
│   └── ListaItem.java
├── logic/
│   └── MotorPromociones.java
├── logic/item/
│   ├── ModoItemProcessor.java
│   ├── MayoristaEvaluator.java
│   ├── CantidadEvaluator.java
│   └── ComboEvaluator.java       // Greedy recursivo
├── logic/pago/
│   ├── ModoPagoConsulta.java
│   ├── ModoPagoAplicar.java
│   └── VueltoResolver.java       // Casos A/B/C
├── validation/
│   ├── ReconciliacionValidator.java
│   └── LedgerConsistencyChecker.java
└── example/
    └── TicketConPromocion2x1.java

ItemPricingView — precio vigente por ítem

// Clase de trabajo en memoria durante el cómputo ITEM
// NO persiste en el modelo — solo vive durante el pipeline
record ItemPricingView(
    int           itemId,
    int           articuloId,
    int           movimientoId,
    BigDecimal    precioBase,         // preciolista original
    BigDecimal    precioVigente,      // se actualiza en cada paso
    NivelPrecio   nivelVigente,       // comienza MINORISTA
    boolean       mayorista,
    boolean       vtaBulto,
    boolean       acumulativaAplicada
) {}
§ 22Naming canónico

Convenciones de naming

Nombres canónicos vigentes

articuloidmediodepagoidpromocionidorigenidmovimientoidnucleoimpositivotipoPromotipodeclientetipocomprobantetickettiponivelprecioconvenioexcluyelistaofertasmetodocomputotipobeneficiovalorbeneficiolistasitemscondicionessucursalesmediosdepagoconveniosclientescodigodescargalistaindextipodeListanrolistatipoelementoacumulativamontominimomaximacantidadpromosxticket

Nombres obsoletos — no reutilizar

Nombre obsoletoReemplazado por
promocionalcanceInferido por mediosdepago[]
promocionmetodometodocomputo
promocionbeneficiotipobeneficio
valor (campo beneficio)valorbeneficio
lista[] en promocioneslistasitems[]
valordeelementovalor dentro de listasitems[]
CANTIDAD_MAX_PROMOS (tipoelemento)condiciones.maximacantidadpromosxticket
SUCURSAL (tipoelemento)sucursales[] en la promoción
MEDIODEPAGO (tipoelemento)mediosdepago[] en la promoción
§ 23Criterios de revisión

Criterios de revisión

ÁreaVerificación requerida
Consistencia de dominioObjetos separados, responsabilidades claras, sin pérdida de detalle entre estructuras.
Consistencia de cálculoOrden lógico correcto del pipeline, nivelprecio actualizado al final del cómputo ITEM.
Consistencia de ledgerSeparación maestro/distribuido, nivelprecio solo en VENTA_ITEM, movimientos trazables.
AuditabilidadTodo monto explicable, toda transformación económica rastreable en movimientos[].
ReconciliaciónΣ nucleoimpositivo.monto en movimientos[] = 0 al cierre. Test automático.
NamingSin nombres obsoletos, todos los catálogos como {id}, convenciones uniformes.
Capas de promociónDefinición / Aplicada / Ledger no mezcladas. CUPON solo en capa 2.
Inferencia de modoNo existe campo promocionalcance. Modo inferido de mediosdepago[].
§ 24Resumen ejecutivo

Resumen ejecutivo

  • Ticket es el agregado raíz. Representa el estado completo de una operación POS.
  • articulos[] evita duplicación de datos de producto dentro del ticket.
  • items[] es la captura comercial original — una entrada por ingreso operativo.
  • movimientos[] es el ledger distribuido — fuente de verdad fiscal y contable.
  • La itemización es obligatoria a nivel ledger: una unidad = un VENTA_ITEM.
  • promociones[] y pagos[] son registros maestros. Su efecto económico vive en movimientos[].
  • Pago.monto puede ser negativo cuando el medio actúa como vuelto.
  • nucleoimpositivo es la base común para importes e impuestos en todo el modelo.
  • nivelprecio en VENTA_ITEM: nivel resultante tras cómputo ITEM. En PROMOCION y PAGO: siempre null.
  • cliente.convenio es un único { id, nombre }. Su id filtra conveniosclientes[].
  • articulo.excluyelistaofertas habilita exclusión del artículo por condición de promo.
  • tiposdepago tiene clave compuesta id + idid + cuota.
  • El modo de la promoción se infiere de mediosdepago[] — no existe campo explícito.
  • Pipeline: MAYORISTA → VENTAXBULTO → GRUPOMAYORISTA → COMBO → CANTIDAD.
  • Acumulativa/no acumulativa aplica solo en COMBO y CANTIDAD.
  • COMBO usa algoritmo greedy-recursivo: en cada iteración se selecciona la combinación de mayor descuento global.
  • El descuento de COMBO se distribuye proporcionalmente al precio de cada ítem participante.
  • CUPON solo en promociones[] con monto=0. No genera movimiento. No afecta reconciliación.
  • Excedente/vuelto resuelto por Casos A/B/C de tiposdepago.
  • El ticket debe cerrar: Σ movimientos[] = 0. Sin excepciones.
Anexos
A.1Algoritmo COMBO — detalle

Algoritmo COMBO — implementación de referencia

public class ComboEvaluator {

  public List<ComboResult> evaluar(
      List<ItemPricingView> items,
      List<DefinicionPromocion> combos,
      LocalDateTime ahora) {

    List<ComboResult> resultados = new ArrayList<>();
    // Multiset mutable de unidades disponibles
    Map<Integer, BigDecimal> pool = buildPool(items);

    boolean hayMas = true;
    while (hayMas) {
      Map<DefinicionPromocion, ComboCandidate> mejores = new LinkedHashMap<>();
      for (DefinicionPromocion promo : combos) {
        if (!promo.getVigencia().estaActiva(ahora)) continue;
        generarMejorCombinacion(promo, items, pool)
            .ifPresent(c -> mejores.put(promo, c));
      }

      Optional<ComboCandidate> ganador = mejores.values().stream()
          .max(Comparator.comparing(ComboCandidate::descuento));

      if (ganador.isEmpty()) {
        hayMas = false;
      } else {
        consumirPool(pool, ganador.get());
        resultados.add(new ComboResult(
            ganador.get(),
            distribuirDescuento(ganador.get())));
      }
    }
    return resultados;
  }
}
A.2Distribución proporcional

Distribución proporcional del descuento

private List<BigDecimal> distribuirDescuento(ComboCandidate c) {
    BigDecimal total     = c.subtotalItems();
    BigDecimal descuento = total.subtract(c.precioCombo()); // positivo
    List<BigDecimal> partes = new ArrayList<>();
    BigDecimal acum = BigDecimal.ZERO;

    for (int i = 0; i < c.items().size(); i++) {
        BigDecimal parte;
        if (i == c.items().size() - 1) {
            parte = descuento.subtract(acum); // absorbe residuo
        } else {
            parte = descuento
                .multiply(c.items().get(i).precioVigente())
                .divide(total, 8, RoundingMode.HALF_UP);
            acum = acum.add(parte);
        }
        partes.add(parte.negate()); // negativo en ledger
    }
    return partes;
    // Garantía: Σ partes == descuentoTotal
}
B.1Records Java 21

Value objects — Records

record NucleoComponente(String impuestoId, BigDecimal monto) {
    NucleoComponente negate() {
        return new NucleoComponente(impuestoId, monto.negate());
    }
    NucleoComponente add(NucleoComponente other) {
        return new NucleoComponente(impuestoId, monto.add(other.monto));
    }
}

record Vigencia(
    LocalDate     fechaDesde,
    LocalDate     fechaHasta,
    Set<DayOfWeek> diasSemana,
    int           horaDesde,
    int           horaHasta
) {
    boolean estaActiva(LocalDateTime ahora) {
        return !ahora.toLocalDate().isBefore(fechaDesde)
            && !ahora.toLocalDate().isAfter(fechaHasta)
            && diasSemana.contains(ahora.getDayOfWeek())
            && ahora.getHour() >= horaDesde
            && ahora.getHour() <= horaHasta;
    }
}
B.2Enums

Enumeraciones del dominio

public enum NivelPrecio {
    MINORISTA, MAYORISTA1, MAYORISTA2, MAYORISTA3,
    GRUPOMAY1, GRUPOMAY2, GRUPOMAY3, VTABULTO;

    public boolean esMayorista() {
        return this == MAYORISTA1 || this == MAYORISTA2 || this == MAYORISTA3;
    }
    public boolean esVtaBulto()        { return this == VTABULTO; }
    public boolean esGrupoMayorista()  {
        return this == GRUPOMAY1 || this == GRUPOMAY2 || this == GRUPOMAY3;
    }
}

public enum TipoConcepto     { VENTA_ITEM, PROMOCION, PAGO }
public enum MetodoComputo    { MAYORISTA, VENTAXBULTO, GRUPOMAYORISTA, COMBO, CANTIDAD }
public enum TipoBeneficio    {
    PORCENTAJE_DESCUENTO, PORCENTAJE_RECARGO,
    MONTO_DESCUENTO, MONTO_RECARGO,
    NUEVOPRECIOITEM, NO_PERMITIR_VENTA_ITEM, EAN_COMBO, CUPON
}
public enum PromoEstado      { POSIBLE, APLICADA, NOAPLICA, ANULADA }
B.3ReconciliacionValidator

Validador de reconciliación

@Component
public class ReconciliacionValidator {

    public ValidationResult validate(Ticket ticket) {
        Map<String, BigDecimal> sumas = new HashMap<>();

        ticket.getMovimientos().forEach(mov ->
            mov.getNucleoImpositivo().forEach(comp ->
                sumas.merge(comp.impuestoId(), comp.monto(), BigDecimal::add)
            )
        );

        List<String> errores = sumas.entrySet().stream()
            .filter(e -> e.getValue().compareTo(BigDecimal.ZERO) != 0)
            .map(e -> "Impuesto " + e.getKey()
                    + " no cierra: " + e.getValue())
            .toList();

        return errores.isEmpty()
            ? ValidationResult.ok()
            : ValidationResult.fail(errores);
    }
}

// Uso en test:
ReconciliacionValidator v = new ReconciliacionValidator();
ValidationResult r = v.validate(ticket);
assertThat(r.isOk()).isTrue(); // Σ movimientos[] == 0
B.4VueltoResolver

VueltoResolver — Casos A/B/C

public class VueltoResolver {

  public VueltoResult resolver(
      TipoDePago tipoPago,
      BigDecimal excedente,
      List<TipoDePago> catalogo) {

    if (tipoPago.isDaVuelto()) {
      return VueltoResult.mismoMedio(excedente);         // Caso A
    }

    if (tipoPago.getVueltoMedioDePago() == null) {
      return VueltoResult.denegado();                    // Caso B
    }

    TipoDePago alt = catalogo.stream()
        .filter(m -> m.getId() == tipoPago.getVueltoMedioDePago())
        .findFirst()
        .orElseThrow();

    return VueltoResult.medioAlternativo(alt, excedente); // Caso C
  }
}