Incidente real Causa raíz identificada SQL Server + Btrieve/POS Race condition de importación

Auditoría técnica: combos no procesados por llegada tardía de dE_Promo

Reconstrucción completa del análisis realizado sobre tickets con promociones combinadas, desde el síntoma inicial hasta la identificación de la carrera entre la carga de db_tdeta/dE_TResu y la llegada posterior de dE_Promo.

Caso principal: sucursal 43 · POS 32 · Z 20260750 · ticket 475987 · promoción 279730000 · combo CMB-FERNET+COC (37054). Documento preparado como evidencia técnica, handoff y material de auditoría interna. Fecha de documentación: 11/08/2026.

1. Resumen ejecutivo

Ticket afectado
475987

POS 32 · Z 20260750

Promo / combo
279730000

Artículo virtual 37054

Demora de dE_Promo
~48 min

llegó después del detalle

Firma del incidente

iUpStatus=8 + iProcesadoPromo=0 + línea combo iPromoPrior=7, bAcumulado=1.

Causa

El ticket fue marcado completo y consumido por STO_ComputoVentaNuevo antes de que existiera su dE_Promo. El SP no reintenta promos después de convertir el ticket a bit 8.

Conclusión: el POS y el Btrieve generaban correctamente la línea de combo. El problema no era bAcumulado, ni el cálculo monetario de la promoción. La falla fue temporal: dE_Promo llegó demasiado tarde respecto del ciclo de stock.
db_tdeta + dE_TResu completos │ ▼ STO_ComputoVentaNuevo detecta ticket completo │ ├─ iUPStatus |= 16 │ ▼ spu_PreComputoPromociones │ ├─ busca dE_Promo │ └─ NO EXISTE TODAVÍA │ ▼ no genera transformación agrupada │ ▼ STO_ComputoVentaNuevo finaliza igualmente │ └─ iUPStatus 16 → 8 │ ▼ dE_Promo llega ~48 min después │ └─ ticket ya no vuelve a ser candidato

2. Evidencia original y comparación de tickets

Se compararon tres tickets con la misma promoción 279730000. Dos históricos funcionaban correctamente y el nuevo no generaba correctamente las transformaciones agrupadas.

TicketFechaEstructuraCombo 37054bAcumuladoiProcesadoPromoResultado
47156627/06/2026Fernet + Coca + Coca + combo-5.12911Procesado
47157727/06/2026Fernet + Coca + combo-5.12911Procesado
47598708/08/2026Fernet + Coca + Coca + Hielo + combo-3.49910No procesado como combo

2.1. El importe de la promo era correcto

La Coca pasó de $5.629 a $3.999, pero la promoción mantenía conceptualmente el mismo precio final:

Junio:
Fernet 18.399 + Coca 5.629 = 24.028
Descuento combo             = -5.129
Total promo                 = 18.899

Agosto:
Fernet 18.399 + Coca 3.999 = 22.398
Descuento combo             = -3.499
Total promo                 = 18.899

Por lo tanto, el cambio -5129 → -3499 no era anomalía: era coherente con el nuevo precio de Coca.

2.2. Ticket 475987 completo

iOrdencBarCodeDescripciónPrecioiPromocioniPromoPriorbAcumuladoiProcesadoPromoiUpStatus
864304117790290101602FERNET BRANCA183992797300001008
864304127790895005794GASEOSA COCA39992797300002008
864304137790895005794GASEOSA COCA39992797300002008
86430414763571660973HIELO GELOMAX KG2698.9200008
8643041537054CMB-FERNET+COC-34992797300007108
Corrección de auditoría: durante el análisis inicial se interpretó erróneamente que el ticket nuevo tenía bAcumulado=0 en la línea combo. Al releer el dataset original se confirmó que siempre estuvo en 1. La anomalía real era iProcesadoPromo=0. Esta rectificación se conserva porque fue parte del proceso de diagnóstico.

3. Hipótesis investigadas y descartadas

HipótesisEstadoEvidencia
El POS nuevo dejó de setear bAcumulado=1DescartadaEl dataset SQL original y un segundo importador mostraron bAcumulado=1 en 37054.
La versión POS 20260728F cambió la lógica del comboDescartadaLa rutina de generación conserva explícitamente la asignación de bAcumulado=1.
El importador Btrieve→SQL perdió el campo bAcumuladoDescartadaUn segundo sistema leyendo Btrieve directo a SQL obtuvo acumulado=1, y el primer SQL ya lo tenía en 1.
dE_Promo contenía datos monetarios incorrectosDescartadaEl precio de promo final seguía siendo $18.899 y los registros negativos/positivos eran estructuralmente coherentes.
El ticket estaba incompleto según dE_TResuDescartadadb_tdeta COUNT=5 y iNroItemsEnDetalle=5.
Algún stored procedure ponía bAcumulado=0DescartadaEn el script de DB no aparece ningún UPDATE de ese campo en el flujo analizado.
dE_Promo llegó después de que el ticket fue consumidoConfirmadaTimestamps + logs de Nuevo Computo de Ventas prueban la carrera.

4. Stored procedures involucrados

Fuente auditada: dump st.sql. Procedimientos principales:

4.1. STO_ComputoVentaNuevo

Marca como “en proceso” sólo tickets que considera completos, usando la igualdad entre cantidad de detalle y cantidad declarada por dE_TResu:

HAVING COUNT(*) = MAX(ISNULL(dE_TResu.iNroItemsEnDetalle, 0))

Luego ejecuta:

EXEC spu_PreComputoPromociones
EXEC STO_Procesar_ComputoPromociones_TransformacionAgrupada @Z=@Z, @u=@u, @Origen=@Origen

Y al final convierte indiscriminadamente los registros con bit 16 en bit 8:

UPDATE dB_TDeta
SET iUPStatus = ((iUPStatus ^ 16) | 8)
WHERE (iUPStatus & 16) = 16;
Agujero lógico: el SP no valida que una promoción haya sido realmente procesada antes de finalizar el ticket como venta.

4.2. spu_PreComputoPromociones

Construye #PromoArticulos cruzando dE_Promo negativa contra las líneas físicas de db_tdeta.

FROM dE_Promo
WHERE iId < 0
  AND iAction = 7
  AND (bTStatus & 1) != 1
  AND iMetodo != 99

Los artículos participantes salen de db_tdeta con:

bAcumulado != 1
AND iPromocion != 0
AND iProcesadoPromo = 0
AND (iUPStatus & 16) = 16

La línea virtual del combo se reconoce con:

iPromoPrior = 7
AND bAcumulado = 1
AND iPromocion != 0
AND iProcesadoPromo = 0
AND (iUPStatus & 16) = 16

Si el cruce funciona, genera ComputoPromociones con operaciones Alta X TA, Baja X TA y Baja X Vta, y termina marcando:

db_tdeta.iProcesadoPromo = 1
dE_Promo.bTStatus |= 1

4.3. STO_Procesar_ComputoPromociones_TransformacionAgrupada

Consume ComputoPromociones, crea la transformación agrupada, actualiza stock y puede reinsertar ventas en db_tdeta con iUpStatus=16. Esto es correcto en el pipeline normal, pero vuelve peligroso reutilizar el procedimiento completo para reparar tickets históricos ya vendidos.

4.4. Comentario histórico de “8 minutos”

El encabezado del SP conserva la nota:

Se demora la lectura de la db_tdeta a 8 min.
porque el precomputos esta demorado 6 minutos
(para no leer info sin procesar)

Sin embargo, en la versión auditada no aparece una condición temporal efectiva de 8 minutos dentro del criterio actual de completitud. El caso real demuestra que, aun existiendo un scheduler alrededor de ese orden de magnitud, una demora de 48 minutos en dE_Promo supera ampliamente esa protección.

5. Cronología probatoria del ticket 475987

21:14:09
dE_TResu.dStaDateTime: inicio del ticket.
21:16:11
dE_TResu.dEndDateTime: fin de ticket.
21:18:00.750
dE_TResu.dUPFechaHora. Declara iNroItemsEnDetalle=5.
21:18:00.940 → 21:18:01.020
Se insertan las cinco filas de db_tdeta: Fernet, Coca, Coca, Hielo, combo 37054.
21:26:16.117
Primer log Nuevo Computo de Ventas iId:1627482. El ticket ya era completo 5/5.
21:31:37 / 21:36:48 / 21:51:26 / 21:52:54
Nuevos ciclos de cómputo. Para entonces el ticket ya quedó con iUpStatus=8.
22:06:14.577 → 22:06:14.673
Recién se insertan los registros de dE_Promo para promo 279730000.
22:08:58.277
Siguiente ciclo de cómputo. La promo ya existe, pero el ticket original no vuelve a ser candidato porque ya tiene bit 8.
La ventana crítica fue aproximadamente 21:18:01 → 22:06:14. El primer cómputo ocurrió a las 21:26:16, por lo que el orden de eventos requerido para reproducir el bug está demostrado.

6. Causa raíz

6.1. Race condition de importación

El motor SQL tomaba como condición de completitud únicamente:

db_tdeta completo según dE_TResu

pero no verificaba:

si el ticket declara combo
    entonces dE_Promo correspondiente debe existir y estar completa

Cuando dE_Promo tardó aproximadamente 48 minutos, ocurrió:

  1. db_tdeta quedó completo 5/5.
  2. STO_ComputoVentaNuevo habilitó el ticket con bit 16.
  3. spu_PreComputoPromociones no encontró dE_Promo, por lo tanto no creó #PromoArticulos para ese ticket.
  4. No hubo Alta X TA/Baja X TA/Baja X Vta de combo.
  5. No se setearon iProcesadoPromo=1 ni dE_Promo.bTStatus|=1.
  6. El SP de ventas igual convirtió el ticket a bit 8.
  7. Cuando llegó dE_Promo, ya no existía reintento automático sobre el ticket.

6.2. Firma persistente del incidente

db_tdeta:
  iPromoPrior      = 7
  bAcumulado       = 1
  iPromocion       != 0
  iProcesadoPromo  = 0
  (iUpStatus & 8) = 8

dE_Promo:
  existen registros para la misma promo
  bTStatus sigue sin bit 1

6.3. Causa operativa corregida en origen

La decisión final fue corregir el origen/configuración de transformación (dm_tranf) para restablecer la llegada correcta de dE_Promo, evitando inicialmente modificar stored procedures productivos por el riesgo de bloquear tickets cuando una promo venga incompleta o corrupta.

7. Detección de tickets actuales afectados

Consulta recomendada para encontrar tickets con la misma firma:

;WITH ComboPendiente AS
(
    SELECT
        d.iNroPOS,
        d.iNroZ,
        d.iNroTicket,
        d.iPromocion,
        d.cBarCode AS cBarCodeCombo,
        MAX(d.cDescripcion) AS DescripcionCombo,
        MAX(d.fechainsercion) AS FechaComboTDeta,
        MAX(d.iUpStatus) AS iUpStatus,
        MAX(d.iProcesadoPromo) AS iProcesadoPromo
    FROM dB_TDeta d WITH (NOLOCK)
    WHERE d.iPromoPrior = 7
      AND d.bAcumulado = 1
      AND d.iPromocion <> 0
      AND d.iProcesadoPromo = 0
      AND (d.iUpStatus & 8) = 8
    GROUP BY
        d.iNroPOS, d.iNroZ, d.iNroTicket,
        d.iPromocion, d.cBarCode
),
PromoEstado AS
(
    SELECT
        dp.iNroPOS,
        dp.iNroZ,
        dp.iNroTicket,
        ABS(dp.iId) AS iPromocion,
        MIN(dp.fechainsercion) AS PrimeraDEPromo,
        MAX(dp.fechainsercion) AS UltimaDEPromo,
        SUM(CASE
            WHEN dp.iId < 0
             AND dp.iAction = 7
             AND dp.iMetodo <> 99
            THEN 1 ELSE 0 END) AS RegNegativos,
        SUM(CASE
            WHEN dp.iId > 0
             AND dp.iAction = 7
            THEN 1 ELSE 0 END) AS RegPositivos,
        MAX(CASE
            WHEN (dp.bTStatus & 1) = 1
            THEN 1 ELSE 0 END) AS AlgunaFinalizada
    FROM dE_Promo dp WITH (NOLOCK)
    WHERE dp.iAction = 7
    GROUP BY dp.iNroPOS, dp.iNroZ, dp.iNroTicket, ABS(dp.iId)
)
SELECT
    c.iNroPOS, c.iNroZ, c.iNroTicket, c.iPromocion,
    c.cBarCodeCombo, c.DescripcionCombo,
    c.FechaComboTDeta,
    p.PrimeraDEPromo, p.UltimaDEPromo,
    DATEDIFF(SECOND,c.FechaComboTDeta,p.PrimeraDEPromo)
        AS SegundosDEPromoDespuesTDeta,
    p.RegNegativos, p.RegPositivos, p.AlgunaFinalizada,
    CASE
      WHEN p.PrimeraDEPromo > c.FechaComboTDeta
      THEN 'RACE: dE_Promo llego despues de db_tdeta'
      ELSE 'REVISAR: otro motivo'
    END AS DiagnosticoProbable
FROM ComboPendiente c
JOIN PromoEstado p
  ON p.iNroPOS = c.iNroPOS
 AND p.iNroZ = c.iNroZ
 AND p.iNroTicket = c.iNroTicket
 AND p.iPromocion = c.iPromocion
WHERE p.RegNegativos > 0
  AND p.RegPositivos > 0
  AND p.AlgunaFinalizada = 0
ORDER BY c.iNroZ,c.iNroPOS,c.iNroTicket,c.iPromocion;

7.1. Control obligatorio antes de reparar

SELECT *
FROM ComputoPromociones
WHERE iNroPOS = @POS
  AND iNroZ = @Z
  AND iNroTicket = @Ticket;
Si existen filas de ComputoPromociones, el caso puede ser un procesamiento parcial. No debe entrar en una reparación automática “limpia”.

8. Repair histórico: qué sí y qué no hacer

8.1. No reabrir la venta completa

No ejecutar indiscriminadamente:

UPDATE db_tdeta SET iUpStatus = 0 ...

ni:

UPDATE db_tdeta SET iUpStatus = 16 ...
EXEC spu_PreComputoPromociones
EXEC STO_Procesar_ComputoPromociones_TransformacionAgrupada

El motivo es que la venta original ya impactó stock y oAccionesDetalle. Reintroducir el ticket al pipeline normal puede duplicar movimientos.

8.2. Reparación lógica mínima

Para un caso limpio —venta ya procesada, combo no procesado, dE_Promo ahora completa, cero filas previas en ComputoPromociones— la reconciliación lógica puede limitarse a:

BEGIN TRAN;

-- Mantener iUpStatus = 8.
-- NO reabrir venta.

UPDATE d
SET iProcesadoPromo = 1
FROM db_tdeta d
WHERE d.iNroPOS = @POS
  AND d.iNroZ = @Z
  AND d.iNroTicket = @Ticket
  AND d.iPromocion = @Promo
  AND d.iProcesadoPromo = 0;

UPDATE dp
SET bTStatus = bTStatus | 1
FROM dE_Promo dp
WHERE dp.iNroPOS = @POS
  AND dp.iNroZ = @Z
  AND dp.iNroTicket = @Ticket
  AND ABS(dp.iId) = @Promo
  AND dp.iAction = 7;

-- Primera ejecución:
ROLLBACK;
-- Luego de validar:
-- COMMIT;

8.3. Efecto neto de stock del caso 475987

En la venta fallida, los productos físicos ya se descargaron correctamente en cantidad neta:

Fernet -1
Coca   -2
Hielo  -1

El código virtual 37054 sí quedó con una venta directa -1, mientras que el flujo correcto de TA habría hecho:

TA:
  37054 +1
  Fernet -1
  Coca   -1

Venta:
  37054 -1
  Coca   -1

NETO:
  37054  0
  Fernet -1
  Coca   -2

Por eso, para repair histórico, el desvío principal a estudiar en stock es el artículo virtual de combo. Cualquier ajuste debe validarse contra oStock, oAccionesDetalle, mAcciones e inventarios posteriores.

9. Hardening propuesto para los stored procedures

Aunque se decidió corregir primero el origen y no desplegar cambios de SP en producción, se diseñó un safeguard para revisión.

9.1. Principio

Ticket normal → completo según dE_TResu → procesa inmediatamente Ticket con combo → completo según dE_TResu → ¿dE_Promo correspondiente completa? ├─ sí → procesa └─ no → queda pendiente/reintenta │ └─ timeout de seguridad → fail-open + warning

9.2. Señal de promo lista

En los casos observados, para una promo P aparecen:

El positivo puede utilizarse como completion marker, pero debe confirmarse como contrato estable del importador antes de depender de él productivamente.

9.3. Segundo safeguard

Después de spu_PreComputoPromociones, si todavía existe:

iPromoPrior = 7
AND bAcumulado = 1
AND iPromocion != 0
AND iProcesadoPromo = 0
AND (iUpStatus & 16) = 16

el ticket debería salir del lote de venta en esa corrida, en vez de pasar silenciosamente a bit 8.

9.4. Fail-open

Para evitar que un dE_Promo corrupto bloquee un ticket indefinidamente, el diseño propuesto contempla timeout configurable y logging. Esto es un safeguard de resiliencia, no un sustituto de arreglar la llegada de datos en origen.

10. Comparación de versiones POS

Se revisaron dos paquetes fuente:

La investigación buscó específicamente dónde se genera la línea de descuento/combos y cómo se asigna bAcumulado.

La rutina identificada para la línea de combo conserva conceptualmente:

ETdet->assign(str_bAcumulado, 1);
ETdet->assign(str_iPromocion, Id);
ETdet->assign(str_iPromoPrior, iAction);
ETdet->Append();

Esto reforzó la conclusión de que el origen POS no era responsable de transformar el 1 en 0; posteriormente, al releer el dataset, quedó demostrado que el 1 nunca se había perdido.

11. Qué hizo difícil el diagnóstico

12. Conclusiones y decisiones

  1. La promoción estaba bien calculada.
  2. La línea de combo tenía bAcumulado=1 correctamente.
  3. El ticket estaba completo según dE_TResu.
  4. El POS/Btrieve no fue el origen del fallo observado.
  5. dE_Promo llegó aproximadamente 48 minutos tarde.
  6. STO_ComputoVentaNuevo consumió el ticket antes de que la promo estuviera disponible.
  7. El estado final iUpStatus=8 / iProcesadoPromo=0 / bTStatus=0 es consistente con esa carrera.
  8. La corrección primaria se decidió en origen/configuración dm_tranf.
  9. El hardening de SP queda documentado para revisión futura, con estrategia condicional y fail-open.
  10. Los históricos no deben reabrirse como ventas completas; requieren reconciliación de promoción y eventual ajuste específico del artículo virtual de combo.
Estado final del incidente: causa raíz identificada y reproducible por evidencia temporal. Se dispone de criterio de detección de históricos afectados, estrategia de reconciliación y diseño de safeguard para prevenir recurrencia.

13. Checklist operativo para casos futuros

  1. Tomar POS, Z, ticket y iPromocion.
  2. Verificar línea combo: iPromoPrior=7, bAcumulado=1.
  3. Verificar iProcesadoPromo.
  4. Verificar bitmap de iUpStatus.
  5. Comparar COUNT(db_tdeta) con dE_TResu.iNroItemsEnDetalle.
  6. Consultar timestamps de inserción de db_tdeta, dE_TResu y dE_Promo.
  7. Consultar logs Nuevo Computo de Ventas entre esos timestamps.
  8. Verificar existencia de ComputoPromociones.
  9. Si hay bit 8 + promo pendiente + dE_Promo posterior: clasificar como race probable.
  10. No resetear iUpStatus sin analizar movimientos de stock ya aplicados.

14. Archivos y artefactos relacionados

15. Apéndice: incidencias y correcciones durante el diagnóstico

15.1. SQL Server no acepta MAX(bit)

Al comparar db_tdeta contra dE_TResu se intentó inicialmente:

MAX(r.bStatus)

SQL Server devolvió:

Msg 8117, Level 16, State 1
Operand data type bit is invalid for max operator.

La corrección fue convertir explícitamente el bit:

MAX(CAST(r.bStatus AS int)) AS TResuStatus

15.2. El conteo de tickets históricos podía estar contaminado

Los resultados:

471566 → CantidadTDeta=10 / CantidadTResu=8
471577 → CantidadTDeta=10 / CantidadTResu=9
475987 → CantidadTDeta=5  / CantidadTResu=5

no significaban necesariamente que los tickets viejos hubieran llegado incompletos. El pipeline de promociones puede insertar registros derivados nuevamente en db_tdeta. Por eso, para establecer completitud histórica, la comparación debe considerar si el ticket ya fue procesado y qué filas son originales vs. derivadas.

15.3. La quinta fila del ticket 475987

Inicialmente el extracto reducido mostraba cuatro filas relevantes para la promo. La consulta completa reveló la quinta:

86430414 | 763571660973 | HIELO GELOMAX KG 2698.92

Esto cerró la igualdad exacta:

db_tdeta = 5 filas
dE_TResu.iNroItemsEnDetalle = 5

15.4. La prueba definitiva no fue un valor sino un orden temporal

El diagnóstico se cerró al combinar:

Ese cruce permitió demostrar que hubo ejecuciones del cómputo de ventas entre la llegada del detalle y la llegada de dE_Promo, transformando una hipótesis de carrera en una causa soportada por evidencia.