Causa raíz, evidencia de logs y fix propuesto — TsArticulos / Dino, sucursal 1
2500176000005, sucursal 1 (QUESO CREMOSO SAINT PAULIN TROZ X KG, código interno 3500176).dm_artic en orden distinto.expireAfterWrite) es lo que alterna el tráfico entre el camino correcto y el incorrecto.dm_artic y eliminó la fila superada; los reinicios manuales del 19/09 solo daban alivio temporal (~4 horas cada uno).El incidente combina cuatro eslabones independientes; solo uno de ellos es un defecto de software. La tabla resume qué pasó, con qué evidencia, quién es responsable, y si constituye un bug de TsArticulos.
| Hecho | Evidencia | Responsable | ¿Es bug? |
|---|---|---|---|
| 1. 18/09 11:03–11:19 (16 min): el cliente publicó 1.05 (novedad 28771) y lo corrigió en la 29281. | Archivos Btrieve de las novedades; 3 tickets en cajas comunes (POS 52, 19, 11) a 1.05/kg. | Pricing del cliente | No |
2. La corrección fue completa y persistente: todas las novedades desde la 29281 (11:19) hasta la 30611 (17:13), y los completos dM_Artic 28480 (17/09 22:04) y 30620 (20/09 21:44), traen 8490.00. El Btrieve de POS nunca volvió a 1.05. |
Archivos Btrieve de novedades y completos (sección 7, "Origen del precio 1.05"). | — | No — el 19–20/09 no hubo un precio mal actualizado por el cliente. |
3. dm_artic conserva todas las versiones intermedias hasta el próximo completo — por diseño de SrvPOS; sin completo el 18 y 19/09 por política del cliente. La fila superada v28771 (1.05, iid 826360887) convivió en la tabla junto a la vigente v29365 (8490, iid 826361083) desde el 18/09 11:19 hasta el completo del 20/09 21:44 (58 horas). |
SrvPOS_Artic_Novedades.cpp: cada ciclo de novedades hace INSERT de todas las filas PRODUCTO WHERE COMPLETO=0 pendientes desde el último completo, sin dedup por EAN y en orden AUTOINC (línea 1290); un completo es lo único que borra versiones superadas (DELETE FROM dM_Artic, líneas 962–979) y no se generó ninguno el 18/09 ni el 19/09 por política del cliente. |
Diseño de SrvPOS / política del cliente | No — condición conocida que el consumidor (TsArticulos) debe manejar aplicando el contrato de versionado. |
| 4. 19/09 11:09 → 20/09 20:55: TsArticulos sirvió la fila superada por el camino on-miss. | Sección 7, Evidencia (línea de tiempo completa). | TsArticulos | Sí — resolución no determinista de duplicados e inconsistencia entre precarga y on-miss, activada por el vencimiento de la cache a las 4 horas. |
fix/duplicate-tiebreak-sql-it (ver sección 8, F1).dm_artic.Causas descartadas (evaluadas y refutadas por la evidencia):
SrvPOS_Artic_Novedades.cpp: no existe lógica de replay (la escritura y el marcado "procesado" comparten una sola transacción; si aborta, la corrida siguiente repite limpio).
Ticket a cargo de sprados / jsalvini. Última modificación: 21/09/2026 10:19:11 por lbevolo.
Solo para que puedan ver el lunes este problema que surgió este finde. Hubo un cambio de precio de un producto en el cual no impacto en las cajas de autocobros. Luego de consultar con tipre, nos sugirió que reiniciemos el servicio TsArticulos, en el cual luego de unos minutos se probro en las pantallas y ya pasaba sin problemas. [...] Antes de reiniciar el servicio salía a 1.05 , al reiniciar empezó a salir 8490 pesos.
Adjunto del ticket: Autocompra_ReclamoDino20260919.png.
Se informó que la actualización de cache para Autocompra a partir de novedades y completos ya estaba habilitada; el reinicio se sugirió por falta de acceso remoto en el momento del reclamo.
Log del proceso de novedades (sender) del 18/09 11:03:54:
[REFRESH][ARTIC] POST 172.17.11.210:48082/articulos/v1/cache/refresh -> HTTP 202 {"success":true,...,"status":"STARTED","refreshId":"e8de4533-a7bc-4e6a-ac0f-3888e71682f8"}
[REFRESH][PROMO] POST 172.17.11.210:48085/promos/v1/promociones/update -> HTTP 202
(Un primer log pegado en el ticket, fechado 28/08, fue corregido por el autor del ticket por este del 18/09.)
El ticket planteaba que "le bajan el precio" y que ese cambio "no impactó en cache". La primera parte queda confirmada: el 18/09 a las 11:03 la política de precios del cliente publicó, en efecto, un precio de 1.05 para este EAN (novedad iVersion 28771), corregido 16 minutos después (11:19, iVersion 29281 en adelante). La segunda parte es parcialmente incorrecta: la base de datos y la precarga tuvieron el precio correcto apenas se corrigió; el defecto es que TsArticulos, el 19–20/09, resucitó mediante el camino on-miss la fila superada (iVersion 28771) un día entero después de la corrección — no que la actualización de cache nunca haya ocurrido (ver sección 7, "Origen del precio 1.05").
| Ticket de reclamo + captura | 19–21/09/2026 — síntoma exacto (1.05 vs 8490.00, EAN, efecto del reinicio). |
| Log del proceso de novedades (sender) | 18/09 11:03 — solo prueba que el POST fue aceptado (202), no que el refresh terminó. |
| logs/tsarticulos.log.2026-09-17.0 (3.5 MB) | 17/09 — eventos de refresh, reinicios, y cada respuesta para el EAN. |
| logs/tsarticulos.log.2026-09-18.0 (4.7 MB) | 18/09 — ídem. |
| logs/tsarticulos.log.2026-09-19.0 (5.9 MB) | 19/09 — ídem. |
| logs/tsarticulos.log.2026-09-20.0 (6.8 MB) | 20/09 — ídem. |
| tsarticulos.log (1.1 MB) | 21/09 — ídem. |
| Código fuente TsArticulos | branch main, commit e64bf7f "v20260813- Add iVersion" — ArticuloCacheService, ArticuloCacheAdminController, ArticuloCacheRefreshScheduler, ArticulosCacheLoadRepositoryImpl, ArticulosRepository, ArticuloCacheManager, CacheConfig, application.yml. |
| Docs/zip/dX_Artic.<versión>.zip | 28 novedades del 17/09 22:04 al 18/09 17:14 + 2 completos (dM_Artic.28480 y dM_Artic.30620) — localizar en qué versión cambió el precio. |
| Docs/zip/Srcs/SrvPOS_Artic_Novedades.cpp (3453 líneas) | Código fuente C++ del proceso de novedades (SrvPOS) — verificar el contrato de versionado de dm_artic y descartar la hipótesis de reprocesamiento tras la caída de SQL Server. |
| Docs/zip/B/dM_Artic.19515 (18/08) y .19673 (19/08) | Completos de agosto, solo contexto: el mismo EAN figura a 11140.00 en ambos — no forman parte de la línea de tiempo del incidente. |
POST /cache/refresh → startRefresh → executor asíncrono
→ buildAndSwapCache). Hallazgo: el HTTP 202 se devuelve también para SKIPPED_ALREADY_RUNNING;
el log del sender no prueba finalización.
refreshRunning=true y todos los siguientes se omiten.cean|inrosuc en dm_artic.>>> Inicio refresh,
Refresh de cache finalizado|fallido, Cache activa actualizada, ERROR.
Resultado: todos los refresh del 19–20/09 finalizaron OK (2–3 s, 84058 artículos), ningún fallido,
ningún skip → H1, H2 y H3 descartadas. Único ERROR relevante: 18/09 16:53:51, arranque fallido
por SQL Server no disponible (localhost:1433 connection refused), recuperado a las 16:54:39. El análisis
posterior del código fuente de SrvPOS (sección 3, Ranking de causas) descartó que este evento explicara
las filas duplicadas — no existe lógica de reprocesamiento en el proceso de novedades.
precioTotal=1.05 → 19 respuestas el 19/09 y 6 el 20/09, todas para el
EAN 2500176000005 sucursal 1.
id distintos para el mismo EAN
(826360887 → 1.05; 826361083 → 8490.00) y correlación exacta de cada respuesta 1.05
con un Producto agregado a cache previo (camino on-miss) → H4 confirmada.
expireAfterWrite(4h) en CacheConfig; el primer 1.05 aparece
a las 11:09, 4h09 después del preload de 07:00; tras el reinicio de 15:54 vuelve el 1.05 a las 20:59.
POST /cache/refresh entre el 18/09 17:13:49 y el
20/09 21:40:22.
No. La consulta on-miss filtra WHERE dm.inrosuc = :nrosuc AND dm.cean = :ean;
la precarga está restringida a cache-sucursales: [1] y la clave de cache incluye el
inrosuc propio de cada fila.
Además, los tres ids vistos el 18/09 (826353988, 826360887, 826361083)
están a menos de ~7.100 de distancia entre sí, mientras que filas de sucursales distintas están entre 56.000
y 84.000 ids de diferencia (un catálogo completo por sucursal). Ninguna sucursal tiene un precio cercano a
1.05 para este EAN.
No se consultó la base de datos directamente durante el incidente: los dos iid de 18–20/09 se
infieren de las respuestas logueadas por el servicio, no de una lectura directa de dm_artic en
ese momento; ejecutar el SQL de duplicados (sección 8, F4) ante un caso similar para confirmarlo en vivo.
Tampoco se dispone del log del sender (novedades) del 19/09.
Una consulta ejecutada el 21/09 contra dm_artic muestra exactamente una fila por sucursal para
este EAN (15 sucursales), lo cual es esperable después del completo del 20/09 a la noche: ni confirma ni
refuta los duplicados de 18–20/09, que quedan evidenciados solo por los ids de respuesta logueados en ese
período.
La tabla dm_artic tiene dos filas para el mismo cean + inrosuc:
el id 826360887 con precio 1.05 (iVersion 28771) y el id 826361083 con precio 8490.00
(iVersion 29365). Dos caminos distintos del código resuelven esta ambigüedad de forma diferente, y solo uno de
ellos es determinista.
Esto no es un accidente aislado: por diseño del proceso de novedades (SrvPOS_Artic_Novedades.cpp),
cada ciclo borra e inserta en dm_artic todas las filas PRODUCTO WHERE
COMPLETO=0 pendientes desde el último completo, sin deduplicar por EAN y en orden AUTOINC
(línea 1290) — por eso dm_artic es una tabla multi-versión mientras no llega un completo, y por
eso el orden de iid coincide con el de iVersion (ambos derivan del mismo
AUTOINC). Un completo (DELETE FROM dM_Artic + reinserción del snapshot, líneas
962–979) es lo único que limpia las versiones superadas; no se generó ninguno el 18/09 ni el 19/09 por
política del cliente, así que la fila v28771 (1.05) convivió con la v29365 (8490.00) hasta el completo del
20/09 21:44 (ver sección 7, Evidencia).
El precio 1.05 no fue un error de TsArticulos: fue un precio real publicado por el cliente el 18/09 a las
11:03 y corregido 16 minutos más tarde (ver "Origen del precio 1.05" en la sección 7). El defecto de
TsArticulos es que el camino on-miss no respeta el contrato implícito de dm_artic ("la fila
vigente es la de mayor iVersion") y resucitó la fila superada un día después, en vez de
resolver el duplicado con el mismo criterio que la precarga.
ArticulosCacheLoadRepositoryImpl) ordena ORDER BY dm.inrosuc, dm.iid.ArticuloCacheService.loadSucursalInCache hace targetCache.put(key, row) fila por fila; la última escritura pisa a las anteriores.iid, el id mayor (826361083, 8490.00) siempre queda al final y gana.ArticuloCacheService.getArticuloByEanAndSucursal (línea ~74) llama a articulosRepository.findByEanSuc(ean, sucursal).stream().findFirst().ArticulosRepository.findByEanSuc no tiene ORDER BY; SQL Server es libre de devolver cualquiera de las dos filas primero.826360887 (1.05).CacheConfig (línea 20): Caffeine.newBuilder().expireAfterWrite(4, TimeUnit.HOURS).put() —precarga u on-miss— la entrada expira.ORDER BY, se propone la regla elegida: opción B (mayor
iVersion, luego mayor iid) en ambos caminos. Propuesto — pendiente de aprobación.
Ningún cambio aplicado en main (ver sección 8, F1, y Anexo A).
La línea de tiempo reconstruida a partir de logs/tsarticulos.log.2026-09-19.0 y
logs/tsarticulos.log.2026-09-20.0 muestra cómo el reinicio manual del 19/09 solo
desplazó el problema 4 horas, en lugar de resolverlo.
POST /cache/refresh, trigger MANUAL) llegaban cada ~5 minutos.POST /cache/refresh entre el 18/09 17:13:49 y el 20/09 21:40:22 porque
no hubo novedades en ese lapso: la última dX_Artic fue la versión 30611 a las
18/09 17:13; el siguiente artefacto es recién el completo dM_Artic.30620 del 20/09 21:44.
MANUAL), no por un
scheduler propio del emisor; sin novedades no hay refresh — esto no es una falla ni del emisor ni del
receptor.
iid; la fila de sucursal 1 de este EAN pasó a ser id 826533626,
fPrecio neto 7016.5289 (8490.00 final), iVersion 30620.
cean|inrosuc se acumulan entre completos (~47 al momento
del incidente) y desaparecen con cada completo. No se generó ningún completo el 18/09 ni el 19/09 por
noche por política del cliente (no hay lógica de día de la semana en SrvPOS: un completo depende
únicamente de que el origen publique filas con COMPLETO=1).
Los dos reinicios manuales (19/09 15:54 y 21:06) no resolvieron nada: cada uno solo repobló la cache por las siguientes ~4 horas, y el precio erróneo se siguió sirviendo después — incluso el 20/09, hasta las 20:55.
El fin real del incidente fue el completo del 20/09 21:44 (dM_Artic.30620), que reconstruyó
dm_artic (84058 → 84011 filas en sucursal 1) y eliminó la fila superada. Esta condición puede
repetirse con cualquier artículo cuyo precio se corrija durante el día y luego pase más de
4 horas sin un refresh de novedades.
25 consultas de Autocompra recibieron el precio erróneo (1.05): 19 el 19/09 entre las 11:09 y las 21:04, y
6 el 20/09 entre las 12:37 y las 20:55. Corresponden a 16 etiquetas de peso distintas, ≈ 21.1 kg pesados
(33.7 kg contando re-escaneos de la misma etiqueta). El peso se decodificó de la etiqueta de pesada
25 00176 WWWWW C (gramos).
| Fecha | Horario(s) | Escaneos | Peso |
|---|---|---|---|
| 19/09 | 11:09–11:11 | ×4 | 1.985 kg |
| 19/09 | 11:22–11:24 | ×2 | 0.132 kg |
| 19/09 | 11:34 | ×1 | 1.330 kg |
| 19/09 | 11:40 | ×1 | 0.096 kg |
| 19/09 | 11:57 | ×1 | 1.110 kg |
| 19/09 | 11:59 | ×1 | sin etiqueta previa en el log |
| 19/09 | 12:29 | ×1 | 2.510 kg |
| 19/09 | 12:37 | ×1 | 0.096 kg |
| 19/09 | 12:57 | ×1 | 1.215 kg |
| 19/09 | 14:28 | ×1 | 1.090 kg |
| 19/09 | 15:26 | ×1 | 1.100 kg |
| 19/09 | 20:59–21:04 | ×4 | 2.140 kg |
| 20/09 | 12:37 | ×1 | 2.400 kg |
| 20/09 | 13:13 | ×1 | 1.365 kg |
| 20/09 | 19:40 | ×1 | 1.200 kg |
| 20/09 | 19:57 | ×1 | 1.080 kg |
| 20/09 | 20:00 | ×1 | 1.270 kg |
| 20/09 | 20:55 | ×1 | 1.075 kg |
Exposición máxima estimada ≈ $179.000 (21.1 kg × 8488.95, la diferencia de precio por kg entre 8490.00 y 1.05). Es una cota superior: una consulta de Autocompra no implica una venta concretada; para una cifra real hay que cruzar estos escaneos con los tickets efectivamente emitidos por Autocompra.
Se decodificaron directamente los archivos Btrieve de las novedades (Docs/zip/dX_Artic.<versión>.zip)
para el EAN 2500176000005: registro con cEan en el offset 0, iArticulo
en +14, descripción en +21 y precio como double IEEE-754 little-endian en +49
(bytes CD CC CC CC CC CC F0 3F = 1.05).
28480 (completo, dM_Artic) |
17/09 22:04 — 8490.00. |
| 28494 … 28763 (novedades) | 17/09 22:04 – 18/09 10:53 — EAN ausente de la novedad. |
| 28771 | 18/09 11:03 — 1.05 ← ingresa el precio erróneo. |
| 28948 | 18/09 11:09 — 1.05. |
| 29086 | 18/09 11:14 — 1.05. |
| 29281 | 18/09 11:19 — 8490.00 ← corrección. |
| 29317 … 30611 (todas, incluye 29365 a las 11:44) | 18/09 11:24 – 17:13 — 8490.00 en todas, sin excepción. |
30620 (completo, dM_Artic) |
20/09 21:44 — 8490.00. |
iVersion en dm_artic: la
fila 1.05 servida por TsArticulos tenía iVersion 28771 — coincidencia exacta con la novedad
que introdujo el precio erróneo. La fila 8490.00 tenía iVersion 29365.
POST /cache/refresh) es exactamente la generación de la novedad 28771.
dm_artic — dm_artic es multi-versión por diseño hasta el próximo
completo, y no hubo completo el 18 ni el 19/09 (ver la sección 8, "El contrato de versionado de dm_artic").
Esto confirma que la opción B (desempate por iVersion primero) es exactamente el contrato que
ya respeta el generador de archivos para POS, y es la regla elegida para la propuesta de fix — pendiente de
aprobación, sin cambios aplicados en main.
HTTP 202 STARTED, pero eso no prueba que el refresh haya terminado:
el controller también devuelve 202 cuando el estado es SKIPPED_ALREADY_RUNNING (con success:false en el body).
Un 202 en el log del emisor no es evidencia de que la cache se haya actualizado.
La regla elegida para la propuesta es la opción B: mayor iVersion, luego mayor iid,
aplicada en ambos caminos (on-miss y precarga) — ver el callout "El contrato de versionado de dm_artic"
debajo. Ningún cambio de código fue aprobado ni aplicado en main. El detalle
técnico de cada cambio propuesto (archivo, línea, snippet, pruebas requeridas, riesgo) está en el
Anexo A.
fix/duplicate-tiebreak-sql-it, sin commit, no integrada; sirve como prototipo para revisión y
puede descartarse.
Hasta que se apruebe y despliegue un fix de código, configurar
app.articulo.cache-refresh-cron con el default del repositorio (cada 30 minutos) en lugar de
depender solo del scheduler diario. Con refresh periódico, el camino de precarga gana con la misma
frecuencia que en el resto de las sucursales y la ventana de exposición al camino on-miss se reduce de
horas a minutos. Es un cambio de configuración, no de código (ver Anexo A, A7).
El análisis del código fuente del proceso de novedades (SrvPOS_Artic_Novedades.cpp, C++)
verifica el diseño real de dm_artic y descarta la hipótesis, barajada en una revisión
anterior de este documento, de un reprocesamiento tras la caída de SQL Server de las 16:53:
dm_artic toda fila cuyo cEan aparezca en cualquier fila origen
PRODUCTO WHERE COMPLETO=0 para esa sucursal (líneas 1964–1972; el comentario de la línea
1949 dice "TODAS LAS NOVEDADES!!!" — el filtro IMPORTADO_TIPRE se omite a propósito);
(b) inserta en dm_artic todas esas filas ORDER BY AUTOINC
(línea 1290), sin deduplicar por EAN, con iVersion = AUTOINC de origen; (c)
recién ahí marca las filas origen como IMPORTADO_TIPRE='S' (línea 2057). Por diseño,
dm_artic conserva todas las versiones intermedias de un artículo cambiado
más de una vez desde el último completo, y todas se reinsertan (con iid nuevo) en cada
ciclo, en orden AUTOINC — por eso, dentro de dm_artic, el orden de
iid coincide con el de iVersion.
Generate_dX_Artic
corre SELECT * FROM dM_Artic WHERE iNroSuc=%ld AND iVersion>'%ld' ORDER BY iVersion DESC
(línea 2818), con el comentario de la línea 2814 "tomo primero los de ultima version asi si esta 2 veces
el mismo producto en diferentes novedades no se duplica..."; las filas se agregan a un archivo Btrieve y
un error de clave duplicada (status 5) se descarta silenciosamente (líneas 2686–2696).
Generate_dM_Artic hace lo mismo con ORDER BY cEan, iVersion DESC (línea 2742).
Por eso, en el POS regular, siempre gana el último cambio (mayor iVersion) — y por eso el
precio ahí fue correcto desde las 11:19 del 18/09.
DELETE FROM dM_Artic WHERE iNroSuc=%ld más la reinserción del snapshot
(líneas 962–979), que es lo único que elimina versiones superadas. Depende solo de que el origen publique
filas con COMPLETO=1; no hay lógica de día de la semana en SrvPOS. No se generó completo el
18/09 ni el 19/09 por la noche por política del cliente → la fila superada 1.05 vivió en
dm_artic desde el 18/09 11:19 hasta el completo del 20/09 21:44 (58 horas).
POST /cache/refresh se envía solo cuando el ciclo generó efectivamente un archivo
Btrieve (líneas 2895–2917) → sin novedades en el fin de semana, no hay refresh. Es el comportamiento
esperado, no una falla.
Conclusión: dm_artic es una tabla multi-versión cuyo contrato implícito es "la fila vigente
es la de mayor iVersion". SrvPOS lo respeta (ver el generador de POS arriba); la precarga de
TsArticulos lo respeta solo por coincidencia (el iid mayor coincide con el
iVersion mayor porque los inserts van en orden AUTOINC); el on-miss de
TsArticulos lo viola directamente (sin ORDER BY). La opción B
(iVersion DESC, iid DESC) es exactamente la regla del generador de POS, propuesta para
hacerla explícita en ambos caminos de TsArticulos.
Regla elegida: opción B (iVersion DESC, iid DESC en ambos caminos).
Propuesto — pendiente de aprobación. Ningún cambio aplicado en main. Detalle de
implementación y pruebas requeridas: Anexo A, ítems A1–A3.
Agregar ORDER BY dm.iVersion DESC, dm.iid DESC a la consulta nativa de
ArticulosRepository.findByEanSuc, para que on-miss resuelva duplicados con el mismo criterio
que la precarga. Snippet antes/después, pruebas requeridas y riesgo: Anexo A, ítem A1.
Test que verifique que el camino on-miss y el camino de precarga resuelven al mismo artículo bajo la regla
(iVersion, iid) DESC (opción B), para ambos caminos — a escribir primero, antes de aplicar el
cambio de código (TDD estricto). Pruebas unitarias y de integración SQL detalladas en Anexo A, ítems A1 y
A2.
Registrar un WARN cuando la precarga descarta una fila por tener menor prioridad
(iVersion, iid) que la ya cacheada, para que los duplicados dejen de ser
invisibles. Incluido en la propuesta A2 (Anexo A).
dm_artic acumula por diseño todas las versiones intermedias hasta el próximo completo (ver
"El contrato de versionado de dm_artic" arriba) — no es una falla del proceso de novedades, sino un
contrato que todo consumidor debe respetar. Con F1 ya aplicando ese contrato en ambos caminos, este ítem
es opcional: SrvPOS podría insertar en dm_artic solo la última versión por EAN (en vez de
todas las pendientes), o exponerse a TsArticulos a través de una vista dm_artic_vigente que
ya filtre por iVersion máximo. Ninguna de las dos es necesaria una vez que los consumidores
aplican el contrato correctamente. SQL para dimensionar cuántas filas duplicadas hay en un momento dado:
SELECT cean, inrosuc, COUNT(*) c FROM dm_artic WITH(NOLOCK) GROUP BY cean, inrosuc HAVING COUNT(*) > 1
POST /cache/refresh entre el 18/09 17:13 y el 20/09 21:40 quedó explicada: no hubo novedades en ese lapso de fin de semana (ver sección 7, "Factor agravante"). No requiere investigación adicional.status del body de la respuesta, o consultar GET /cache/refresh/status, en lugar de confiar únicamente en el HTTP 202.expireAfterWrite ocultaría el síntoma (el precio incorrecto tardaría más en aparecer) pero no corrige la divergencia entre los dos caminos.dm_artic.2500176000005 más de 4 horas después de la última precarga y confirmar que responde con id=826361083 / 8490.00.| ArticulosRepository.java | src/main/java/com/tipre/tsarticulos/repository/ArticulosRepository.java — contiene findByEanSuc, la consulta que necesita ORDER BY (F1). |
| ArticuloCacheService.java | src/main/java/com/tipre/tsarticulos/service/cache/ArticuloCacheService.java — resolución on-miss, precarga y armado de la cache. |
| ArticulosCacheLoadRepositoryImpl.java | src/main/java/com/tipre/tsarticulos/repository/ArticulosCacheLoadRepositoryImpl.java — consulta de precarga con ORDER BY dm.inrosuc, dm.iid. |
| CacheConfig.java | src/main/java/com/tipre/tsarticulos/config/CacheConfig.java — configuración de Caffeine, expireAfterWrite(4, TimeUnit.HOURS). |
| ArticuloCacheAdminController.java | src/main/java/com/tipre/tsarticulos/controller/ArticuloCacheAdminController.java — endpoints POST /cache/refresh y GET /cache/refresh/status. |
Listado ordenado por prioridad (P0 = más urgente). Cada ítem indica archivo:línea, problema, cambio
propuesto (con snippet antes/después cuando corresponde), pruebas requeridas y riesgo del cambio.
Ningún ítem de este anexo fue aprobado ni aplicado en main; A1–A3 tienen un
prototipo de referencia sin commitear en la rama local fix/duplicate-tiebreak-sql-it (ver
sección 8).
Archivo:línea: repository/ArticulosRepository.java:58 (findByEanSuc)
Problema: sin ORDER BY + .findFirst() en ArticuloCacheService.java:74 → fila arbitraria entre versiones.
" WHERE dm.inrosuc = :nrosuc AND dm.cean = :ean "
" WHERE dm.inrosuc = :nrosuc AND dm.cean = :ean " + " ORDER BY dm.iVersion DESC, dm.iid DESC, env.iVersion DESC, env.iid DESC "
Pruebas requeridas: IT con SQL real replicando el incidente (v29365/8490 vs v28771/1.05, todas las permutaciones de inserción, iVersion NULL).
Riesgo: bajo (1–3 filas por consulta; costo de orden despreciable).
Archivo:línea: service/cache/ArticuloCacheService.java:303 (loadSucursalInCache)
Problema: targetCache.put incondicional → gana el mayor iid solo por coincidencia con el orden de inserción de SrvPOS.
Cambio propuesto: reemplazar por putIfHigherPriority(targetCache, key, row), que solo sobrescribe si el (iVersion, iid) entrante es mayor (NULL = menor), comparando contra targetCache para cubrir duplicados entre lotes; el cursor lastInrosuc/lastIid avanza siempre.
ArticuloProjection current = targetCache.getIfPresent(cacheKey);
if (current == null || isHigherPriority(candidate, current)) {
targetCache.put(cacheKey, candidate);
}
private static boolean isHigherPriority(ArticuloProjection candidate, ArticuloProjection current) {
int c = compareNullableVersion(candidate.getIversion(), current.getIversion()); // null = lowest
return c != 0 ? c > 0 : candidate.getIid() > current.getIid();
}
Incluir un WARN por duplicado (ean, sucursal, iid/iVersion ganador y descartado — ver F3).
Pruebas requeridas: unitarias (mismo lote, entre lotes, NULL, empate de versión) + IT de consistencia cruzada preload vs on-miss.
Riesgo: bajo.
Archivo:línea: repository/ArticulosCacheLoadRepositoryImpl.java:59-74,126
Problema: consulta batch sin ORDER BY + putIfAbsent → envase no determinista.
Cambio propuesto: agregar ORDER BY env.iVersion DESC, env.iid DESC (con putIfAbsent queda el de mayor prioridad); el on-miss ya lo cubre A1.
Riesgo: bajo.
Archivo:línea: model/ArticuloProjection.java (getters primitivos getItax, getBpeso, getBrandomprice, getIunidadesminimas, getIrubro, getIfamilia, getIsector, getInivelref, getIpromocion, getUxb, getEnv*) y model/ArticuloDto.java (constructor).
Problema: la proyección de interfaz JPA lanza AopInvocationException cuando la columna es NULL; el preload (RowMapper JDBC) convierte NULL→0/false — mismo artículo, dos comportamientos distintos; el POS no recibe respuesta. Verificado con un IT de caracterización sobre el DDL real.
Cambio propuesto (recomendado): que el on-miss use el mismo RowMapper JDBC que el preload (nuevo método en ArticulosCacheLoadRepository, p. ej. findByEanSucForCache(ean, suc), reutilizando mapArticuloProjection + la resolución de envase), eliminando el doble mapeo.
Alternativa: getters boxed + manejo de null en ArticuloDto (más superficie de cambio).
Pendiente previo: consultar en producción si existen NULL en esas columnas.
Riesgo: medio.
Archivo:línea: service/cache/ArticuloCacheService.java:110,210-242; ArticulosCacheLoadRepositoryImpl.
Problema: sin timeout de consulta; si la carga se cuelga, refreshRunning queda en true y todo refresh posterior devuelve SKIPPED hasta reiniciar.
Cambio propuesto: jdbcTemplate.setQueryTimeout(...) (configurable, p. ej. 60 s) y/o un watchdog que libere el flag si startedAt supera un máximo.
Riesgo: bajo.
Archivo:línea: controller/ArticuloCacheAdminController.java:26
Cambio propuesto: 202 solo para STARTED; 409 para SKIPPED_ALREADY_RUNNING; 500/503 para FAILED_TO_START. Coordinar con SrvPOS (NotificarRefresh solo registra el resultado).
Riesgo: bajo.
Archivo:línea: config/CacheConfig.java:20, application.yml:47, configuración de Dino.
Problema: expireAfterWrite(4h) con scheduler diario → en días sin novedades todo el tráfico pasa a on-miss.
Cambio propuesto: (config, inmediato) cron cada 30 min en Dino — ver F0; (código, a evaluar) eliminar la expiración por tiempo ya que la cache se reemplaza completa en cada refresh, o hacerla configurable y mayor que el intervalo del scheduler.
Trade-off: sin expiración, un artículo modificado fuera del flujo de novedades no se vería hasta el próximo refresh.
Riesgo: bajo.
Archivo:línea: ArticuloCacheService.java:182-208
Cambio propuesto: log.error con la excepción; evaluar compartir refreshRunning.
Riesgo: bajo.
Archivo:línea: ArticulosRepository.java:63-107
Problema: el JOIN de envase puede duplicar filas y el COUNT no lo contempla → páginas con repetidos y hasNext incorrecto; además searchBy tampoco aplica la regla de versión.
Cambio propuesto: resolver versión vigente y envase único en la consulta (p. ej. ROW_NUMBER() OVER (PARTITION BY dm.cean, dm.inrosuc ORDER BY dm.iVersion DESC, dm.iid DESC) = 1) y contar sobre el mismo conjunto.
Riesgo: medio (requiere IT).
Archivo:línea: config/WebSecurityConfig.java:45-47, WebSocketConfig.java:30
Problema: /cache/refresh y /ws-articulos/** en permitAll aun con seguridad habilitada; security.enabled=false en todos los perfiles; orígenes *.
Cambio propuesto: proteger POST /cache/refresh (rol o allow-list de IP de SrvPOS), orígenes explícitos.
Riesgo: medio (coordinar con clientes).
Archivo:línea: src/main/resources/application-dev.yml:6-7
Cambio propuesto: mover a variables de entorno y rotar la credencial expuesta.
Riesgo: bajo como cambio de configuración; alto mientras la rotación no se haga.
Archivo:línea: pom.xml:88,93 (spring-websocket, spring-messaging 6.2.0 con Boot 3.5.14)
Cambio propuesto: quitar el <version> explícito y dejar que lo resuelva el BOM de Spring Boot.
Riesgo: bajo.
Archivo:línea: model/ArticuloDto.java:146-177: el neto se redondea a decimales antes de calcular el IVA → neto + IVA + impuesto interno puede diferir del total en 1 centavo con decimales=2.
Cambio propuesto: escala interna alta y redondeo final; derivar un componente por diferencia. Requiere validación fiscal/POS.
Adicional: ArticuloProjection.java:98-116: iTax desconocido cae a 21 % sin log → agregar WARN.
Riesgo: medio (impacto fiscal; requiere validación con POS antes de aplicar).
iVersion es BIGINT en la tabla y Integer en el servicio (riesgo latente; migrar a Long).@ControllerAdvice/@MessageExceptionHandler: los errores de base de datos llegan al POS con un formato distinto al de ResponseMessage.EnvaseDto.java:30-31 no aplica trim a la descripción.
Perfil Maven integration-sql (failsafe, *IT.java, SQL Server local, base
descartable TsArticulosIT, credenciales solo por variables de entorno, se omiten si faltan) y
perfil pit (mutation testing sobre ArticuloDto, EnvaseDto,
ArticuloService, ArticuloCacheService).
PIT se corrió y verificó el 21/09 contra la rama prototipo (solo tests unitarios, mutadores STRONGER, 43 s): 248 mutantes, 166 detectados = 67 % (cobertura de líneas 87 %, test strength 71 %).
| Clase | Detectados / total | % |
|---|---|---|
| ArticuloDto | 42 / 52 | 81 % (línea base antes de las pruebas nuevas: 36/52 = 69 %) |
| ArticuloProjection | 5 / 6 | 83 % |
| ArticuloProjectionRow | 0 / 2 | 0 % |
| EnvaseDto | 4 / 9 | 44 % |
| ArticuloService | 19 / 32 | 59 % |
| ArticuloCacheService | 96 / 147 | 65 % |
Mutantes sobrevivientes relevantes en el camino monetario (ArticuloDto):
calcularValoresMonetarios.applyMonetaryValues se puede eliminar EnvaseDto.setImpInterno y EnvaseDto.setAlicImpInterno sin que falle ninguna prueba — el impuesto interno del envase queda sin cobertura efectiva.calcularAlicImpInternoDesdeMonto y defaultValue.mvn -o test-compile org.pitest:pitest-maven:mutationCoverage -Ppit
Reportes HTML en target/pit-reports/.