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.
movimientos[].TicketEl 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": []
}
}| Array | Tipo | Descripción |
|---|---|---|
| datosreferenciales | Objeto | Metadatos: nroTicket, comercio, sucursal, fechaHora, totales |
| cliente | Objeto | Comprador: identificación, tipo, convenio, impuestos |
| articulos[] | Catálogo interno | Artículos reutilizables. Evita duplicar datos de producto |
| items[] | Registro | Captura comercial original. Un registro por ingreso operativo |
| promociones[] | Registro | Registros maestros de promociones aplicadas |
| pagos[] | Registro | Registros maestros de pagos ingresados |
| movimientos[] | Ledger | Detalle económico distribuido. Fuente de verdad fiscal/contable |
El modelo trabaja con tres estructuras claramente separadas. Esta separación es obligatoria y no debe perderse.
id — no se duplica.id incremental, referencia un articuloid e informa unidades. Conserva el dato tal como fue ingresado operativamente.items[] = captura comercial. movimientos[] = impacto económico distribuido. Esta distinción no debe perderse. No son equivalentes ni intercambiables.Contiene: nroTicket, comercio, nroSucursal, nroPv, fechaHora, tickettipo, tipocomprobante, total, saldo, vuelto, nucleoimpositivo. El campo nucleoimpositivo aquí representa el total del ticket sin considerar pagos.
| Campo | Tipo | Descripción |
|---|---|---|
| id | integer | Identificador |
| nombre | string | Nombre o razón social |
| cuit | string | CUIT / documento |
| tipodecliente | objeto | Tipo de cliente y tipo de comprobante asociado |
| convenio | objeto / null | { id, nombre } — un solo convenio por cliente. null si no tiene. El convenio.id filtra conveniosclientes[] |
| impuestos[] | array | Percepciones y retenciones aplicables |
| Campo | Tipo | Descripción |
|---|---|---|
| ean | string | Código de barras EAN |
| plu | string | Código PLU |
| descripcion | string | Nombre del artículo |
| pesable | boolean | Si se vende por peso |
| preciolista | number | Precio de lista base |
| rubro | string | Rubro |
| depto | string | Departamento |
| marca | string | Marca |
| codigoclasificacion | integer | Código de clasificación genérico |
| proveedor | string | Proveedor |
| excluyelistaofertas | boolean | Si true, excluido de promociones con condiciones.excluyelistaofertas=true |
| nucleoimpositivo[] | array | Composición impositiva base del artículo |
| Campo | Tipo | Descripción |
|---|---|---|
| id | integer | Identificador incremental del ingreso |
| articuloid | integer | Referencia al artículo en articulos[] |
| unidades | number | Cantidad ingresada. Admite decimales para pesables |
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.
| Campo | Tipo | Descripción |
|---|---|---|
| id | integer | ID del medio de pago |
| idid | integer | Sub-ID del medio de pago |
| cuota | integer | Número de cuotas. 0 = contado |
| descripcion | string | Nombre descriptivo |
| davuelto | boolean | true: vuelto en el mismo medio |
| vueltomediodepago | integer / null | ID del medio alternativo para vuelto. null = operación denegada |
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.| Campo | Tipo | Descripción |
|---|---|---|
| id | integer | Identificador |
| mediodepagoid | integer | Referencia al TipoDePago |
| descripcion | string | Nombre descriptivo |
| monto | number | Positivo = ingreso. Negativo = vuelto entregado al cliente |
Pago negativo es válido por diseño — representa un medio de pago utilizado como vuelto. No agregar campos adicionales.Un item puede representar varias unidades ingresadas en una sola acción operativa, pero el ledger en movimientos[] debe representar el detalle distribuido.
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.
tickettipo · tipocomprobante · tipodecliente · tipodeimpuesto · tipoconcepto · tiposdepago · listapromociones · metodocomputo · tipobeneficio · promociondecision · promocionestado · promocionlistatype · promocionlistanumber · promociontipoelemento
datosreferenciales · cliente · articulos[] · items[] · promociones[] · pagos[] · movimientos[]
Movimiento| Campo | Tipo | Regla |
|---|---|---|
| id | integer | Identificador incremental |
| concepto | enum | VENTA_ITEM · PROMOCION · PAGO |
| origenid | integer | Referencia al registro maestro origen según el concepto |
| movimientoid | integer / null | Referencia al VENTA_ITEM sobre el que aplica. null para excedentes y vuelto |
| nivelprecio | objeto / null | { id } solo en VENTA_ITEM. null en PROMOCION y PAGO |
| nucleoimpositivo[] | array | Estructura de importes e impuestos del movimiento |
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.
| Concepto | movimientoid |
|---|---|
| VENTA_ITEM | null (es el origen) |
| PROMOCION | id del VENTA_ITEM impactado |
| PAGO distribuido | id del VENTA_ITEM que cancela |
| PAGO excedente | null |
| PAGO vuelto | null |
pagos[] contiene registros maestros. movimientos[] contiene el impacto distribuido.datosreferenciales.vuelto conserva el resultado global y movimientos[] explica la distribución.Pago con campos adicionales. La versión base usa solo monto.La convención de signos es estricta. El signo correcto en cada movimiento es condición necesaria para que el ledger cierre en cero.
| Concepto | Signo | Condición |
|---|---|---|
| VENTA_ITEM | + | Siempre — representa ingreso comercial |
| PROMOCION (descuento) | − | Cuando representa descuento |
| PROMOCION (recargo) | + | Cuando representa recargo |
| PAGO aplicado a ítem | − | movimientoid ≠ null |
| PAGO excedente | − | movimientoid = null |
| PAGO vuelto | + | movimientoid = null — entregado al cliente |
promociones[].monto no sustituye al detalle distribuido del ledger. CUPON es excepción: solo en promociones[] con monto=0, no genera movimiento.Cuando un medio de pago ingresa un monto mayor al necesario para cancelar el ticket:
PAGO negativos contra ítems.PAGO negativo con movimientoid=null, origenid = id del pago ingresado.PAGO positivo con movimientoid=null, origenid = id del pago de vuelto.Pago negativo en el ledger representa el vuelto.vueltomediodepago.La suma algebraica de todos los nucleoimpositivo.monto en movimientos[] debe ser exactamente 0 al cierre final del ticket. Sin excepciones.
3 ítems × $1310, promoción 2×1 (50% sobre 2 unidades), pago $3000 en cheque, vuelto $380 en efectivo.
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.
articulos[], crear items[].articuloid → datos de producto y nucleoimpositivo.preciolista como base de cálculo. nivelprecio = MINORISTA para todos los ítems.MAYORISTA → VENTAXBULTO → GRUPOMAYORISTA → COMBO → CANTIDADnivelprecio en cada paso.datosreferenciales.totalpagos[] y movimientos[].tiposdepago.VENTA_ITEMs con saldo positivo.datosreferenciales.saldomovimientos[] = 0 — test definitivo de coherencia del ticket.Las tres capas son inseparables y no deben mezclarse. Cada una tiene responsabilidad exclusiva y tipo distinto.
nucleoimpositivo completo.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.| Modo | Descripción | Modifica ticket |
|---|---|---|
| ITEM | Computa promociones sobre ítems. Evalúa pipeline completo. | Sí |
| PAGO CONSULTA | Devuelve promociones disponibles para un medio. Solo lectura. | No |
| PAGO APLICAR | Aplica promo de pago sobre el monto ingresado. | Sí |
El modo se infiere de la configuración de la promoción. No existe campo promocionalcance.
mediosdepago[] vacío → promoción de MODO ITEMmediosdepago[] con entradas → promoción de MODO PAGOlistasitems[] vacío → promoción inválida, no procesar| Orden | metodocomputo | Modos habilitados |
|---|---|---|
| 1 | MAYORISTA | ITEM solamente |
| 2 | VENTAXBULTO | ITEM solamente |
| 3 | GRUPOMAYORISTA | ITEM solamente |
| 4 | COMBO | ITEM y PAGO |
| 5 | CANTIDAD | ITEM y PAGO |
MAYORISTA1/2/3 → no se evalúa en VENTAXBULTO ni GRUPOMAYORISTA.VTABULTO → no se evalúa en GRUPOMAYORISTA.COMBO y CANTIDAD con su nivel vigente.Aplica exclusivamente en métodos COMBO y CANTIDAD.
condiciones.acumulativa = true.Un ítem puede acumular múltiples promociones acumulativas. Las acumulativas y no acumulativas son mutuamente excluyentes en su aplicación efectiva.
nivelprecio vigente del ítem en ese momento del cómputo.nivelprecio vigente.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.
| id | Descripción |
|---|---|
| MINORISTA | Precio de lista estándar. Valor por defecto. |
| MAYORISTA1 | Nivel mayorista 1. |
| MAYORISTA2 | Nivel mayorista 2. |
| MAYORISTA3 | Nivel mayorista 3. |
| GRUPOMAY1 | Nivel grupo mayorista 1. |
| GRUPOMAY2 | Nivel grupo mayorista 2. |
| GRUPOMAY3 | Nivel grupo mayorista 3. |
| VTABULTO | Nivel venta por bulto. |
| id | Modos | Descripción |
|---|---|---|
| MAYORISTA | ITEM | Precio mayorista por volumen o condición de cliente. |
| VENTAXBULTO | ITEM | Precio especial por venta de bulto completo. |
| GRUPOMAYORISTA | ITEM | Precio mayorista por grupo de artículos. |
| COMBO | ITEM + PAGO | Beneficio por combinación de ítems de distintas listas. |
| CANTIDAD | ITEM + PAGO | Beneficio por cantidad de unidades de uno o más ítems. |
valorbeneficio positivo (ej: 10 = 10%). Calcula sobre el precio base vigente.valorbeneficio positivo. Aumenta el precio del ítem.valorbeneficio = precio final resultante.valorbeneficio = null.valorbeneficio = null.promociones[] con monto=0.| id | Descripción |
|---|---|
| POSIBLE | Evaluada, podría aplicar, aún no aplicada. |
| APLICADA | Aplicada efectivamente al ticket. |
| NOAPLICA | Evaluada, no corresponde aplicar. |
| ANULADA | Aplicada pero luego anulada. |
| id | Descripción |
|---|---|
| LISTA1 | Primera lista de ítems — primer grupo del combo. |
| LISTA2 | Segunda lista de ítems — segundo grupo del combo. |
| LISTA3 | Precio de venta unitario del combo. tipoelemento=MONTO (directo) o EAN (indirección). |
| id | Descripción |
|---|---|
| EAN | Código de barras EAN. |
| PLU | Código PLU. |
| DEPARTAMENTO | Departamento del artículo. |
| RUBRO | Rubro del artículo. |
| SECTOR | Sector. |
| FAMILIA | Familia de productos. |
| CODIGOCLASIFICACION | Código de clasificación genérico. |
| PROVEEDOR | Proveedor. |
| MARCA | Marca. |
| MONTO | Monto de venta. Usado en LISTA3 para precio unitario del combo. |
| TICKET | La condición aplica al conjunto completo de ítems del ticket. |
SUCURSAL (→ sucursales[]), CANTIDAD_MAX_PROMOS (→ condiciones.maximacantidadpromosxticket), MEDIODEPAGO (→ mediosdepago[]).| id | Descripción |
|---|---|
| INCLUSION | El elemento está alcanzado por la promoción. |
| EXCLUSION | El elemento está excluido explícitamente. |
listapromocionesCatá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
}
]
}| Campo | Tipo | Req. | Descripción |
|---|---|---|---|
| id | integer | Sí | Identificador único. |
| descripcion | string | Sí | Nombre descriptivo. |
| metodocomputo | objeto {id} | Sí | Método de cómputo. |
| tipobeneficio | objeto {id} | Sí | Tipo de beneficio. |
| valorbeneficio | number/null | Sí* | Valor del beneficio. Null para NO_PERMITIR_VENTA_ITEM, EAN_COMBO, CUPON. |
| codigodescarga | string/null | No | PLU para grabar la promo como producto en el detalle de ventas. |
| conveniosclientes[] | array | Sí | Lista de {id} de convenios. Vacío = aplica a todos. |
| condiciones | objeto | Sí | Ver subcampos abajo. |
| vigencia | objeto | Sí | Fechas, días y horas de activación. |
| sucursales[] | array | Sí | Sucursales habilitadas. Vacío = todas. Sin exclusión. |
| mediosdepago[] | array | Sí | Vacío → MODO ITEM. Con entradas → MODO PAGO. |
| listasitems[] | array | Sí | Al menos una entrada. Vacío = promoción inválida. |
| Campo | Tipo | Descripción |
|---|---|---|
| acumulativa | boolean | true = acumulativa. Solo aplica a COMBO y CANTIDAD. |
| maximacantidadpromosxticket | integer | 0 = sin límite. Otro valor = máximo de aplicaciones por ticket. |
| montominimo | number | Monto mínimo de ítems incluidos. 0 = sin mínimo. |
| excluyelistaofertas | boolean | Si true, excluye ítems con articulo.excluyelistaofertas=true. |
| excluyeventamayorista | boolean | Si true, excluye ítems con nivelprecio en MAYORISTA1/2/3. |
| Campo | Formato | Descripción |
|---|---|---|
| fechadesde | AAAAMMDD | Fecha de inicio (inclusive). |
| fechahasta | AAAAMMDD | Fecha de fin (inclusive). |
| diassemana[] | array string | LUNES MARTES MIERCOLES JUEVES VIERNES SABADO DOMINGO |
| horadesde | HH | Hora de inicio en cada día habilitado. |
| horahasta | HH | Hora de fin. |
// sucursales[] { "nrosucursal": 1, "descripcion": "CASA CENTRAL" } // mediosdepago[] { "idmdep": 1, "subidmdep": 10, "cuotanumero": 0, "descripcion": "EFECTIVO" } // Match: idmdep + subidmdep + cuotanumero ↔ tiposdepago.id + idid + cuota
| Campo | Tipo | Descripción |
|---|---|---|
| listaindex | integer | Orden de la entrada. |
| tipodeLista | objeto {id} | INCLUSION o EXCLUSION. |
| nrolista | objeto {id} | LISTA1, LISTA2 o LISTA3. |
| tipoelemento | objeto {id} | Tipo del elemento evaluado. |
| valor | string | Valor concreto a comparar (EAN, PLU, rubro, etc.). |
| cantidad | number | Cantidad necesaria. Exactos múltiplos por aplicación. |
| monto | number/null | En LISTA3 con tipoelemento=MONTO: precio directo del combo. |
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).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 }
]
}| Campo | Tipo | Req. | Descripción |
|---|---|---|---|
| id | integer | Sí | Identificador del registro aplicado. |
| promocionid | integer | Sí | Referencia a listapromociones. |
| descripcion | string | Sí | Nombre heredado de la definición. |
| tipoPromo | string | Sí | "ITEM" o "PAGO". Naming conservado por consistencia con JSON. |
| monto | number | Sí | Monto total del beneficio. 0 para CUPON. |
| promocionestado | objeto {id} | No | Estado de la promoción aplicada. |
| elementos[] | array | No | Trazabilidad: movimientoid, articuloid, unidadesimpactadas, monto. |
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.nivelprecio = MINORISTA a todos los ítems. Filtrar listapromociones activas (vigencia, sucursal, convenio, condiciones).nivelprecio a MAYORISTA1/2/3, registrar PROMOCION en movimientos[] y en promociones[]. Ese ítem no se evalúa en VENTAXBULTO ni GRUPOMAYORISTA.nivelprecio a VTABULTO, registrar PROMOCION. Ese ítem no se evalúa en GRUPOMAYORISTA.nivelprecio a GRUPOMAY1/2/3, registrar PROMOCION.nivelprecio vigente: evaluar y computar todas las promos acumulativas. Calcular beneficio sobre precio base del nivelprecio vigente del ítem. Registrar PROMOCION.nivelprecio vigente.nivelprecio en cada VENTA_ITEM de movimientos[] con el nivel resultante. Retornar ticket actualizado.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 restantestipoelemento=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.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.
// 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
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
El saldo de referencia es el saldo pendiente neto (ya considerando la promo del medio de pago):
| Escenario | Condición | Comportamiento |
|---|---|---|
| Pago parcial | monto < saldo neto | Promo aplicada proporcionalmente. Queda saldo pendiente. |
| Pago exacto | monto = saldo neto | Promo aplicada completa. Ticket saldado. |
| Pago con excedente | monto > saldo neto | Consume hasta el saldo neto. Excedente resuelto por Casos A/B/C. |
NucleoImpositivo, Vigencia, etc.).instanceof para type-safe dispatch.Optional idiomáticos.BigDecimal para todos los montos — 8 decimales internos, round2() solo en display.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
// 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 ) {}
| Nombre obsoleto | Reemplazado por |
|---|---|
| promocionalcance | Inferido por mediosdepago[] |
| promocionmetodo | metodocomputo |
| promocionbeneficio | tipobeneficio |
| valor (campo beneficio) | valorbeneficio |
| lista[] en promociones | listasitems[] |
| valordeelemento | valor dentro de listasitems[] |
| CANTIDAD_MAX_PROMOS (tipoelemento) | condiciones.maximacantidadpromosxticket |
| SUCURSAL (tipoelemento) | sucursales[] en la promoción |
| MEDIODEPAGO (tipoelemento) | mediosdepago[] en la promoción |
| Área | Verificación requerida |
|---|---|
| Consistencia de dominio | Objetos separados, responsabilidades claras, sin pérdida de detalle entre estructuras. |
| Consistencia de cálculo | Orden lógico correcto del pipeline, nivelprecio actualizado al final del cómputo ITEM. |
| Consistencia de ledger | Separación maestro/distribuido, nivelprecio solo en VENTA_ITEM, movimientos trazables. |
| Auditabilidad | Todo monto explicable, toda transformación económica rastreable en movimientos[]. |
| Reconciliación | Σ nucleoimpositivo.monto en movimientos[] = 0 al cierre. Test automático. |
| Naming | Sin nombres obsoletos, todos los catálogos como {id}, convenciones uniformes. |
| Capas de promoción | Definición / Aplicada / Ledger no mezcladas. CUPON solo en capa 2. |
| Inferencia de modo | No existe campo promocionalcance. Modo inferido de mediosdepago[]. |
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.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.mediosdepago[] — no existe campo explícito.MAYORISTA → VENTAXBULTO → GRUPOMAYORISTA → COMBO → CANTIDAD.COMBO y CANTIDAD.CUPON solo en promociones[] con monto=0. No genera movimiento. No afecta reconciliación.tiposdepago.movimientos[] = 0. Sin excepciones.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;
}
}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
}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;
}
}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 }@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[] == 0public 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
}
}