in
Inicio › Blog › Stock en Power BI: guía completa de medidas semiaditivas para inventario
Power BI

Stock en Power BI: guía completa de medidas semiaditivas para inventario

Jose Luis Rico 7 septiembre 2026 ⏱ 17 min lectura
Almacén de retail con paneles de datos flotantes que muestran la evolución del stock, ilustrando el análisis de inventario en Power BI

Un responsable de tienda abre el informe de inventario, selecciona el año en el filtro y ve 4.380.000 unidades de stock. Su almacén tiene capacidad para 12.000. Cierra el informe y vuelve al Excel de siempre.

Esta escena la he visto repetida en más proyectos de retail de los que me gustaría admitir. Y el origen es siempre el mismo: alguien arrastró la columna de unidades de la tabla de stock a un visual y Power BI hizo lo que hace por defecto con cualquier número: sumarlo. Sumó el stock de cada uno de los 365 días del año como si fueran ventas.

El stock no se suma en el tiempo. Se suma entre tiendas, entre productos, entre almacenes. Pero en el tiempo se toma una foto: el stock de enero no es la suma de los 31 días, es lo que había el día 31 al cierre. Esta propiedad tiene nombre, medida semiaditiva, y saber modelarla en DAX es la diferencia entre un informe de inventario que se usa y uno que se ignora.

En esta guía te explico por qué ocurre el error, cómo modelar el inventario para que no ocurra, las tres funciones DAX que lo resuelven y cuándo usar cada una, cómo evitar que los totales por tienda se rompan, qué hacer cuando el modelo crece a cientos de millones de filas, y cómo derivar de ahí los KPIs que de verdad importan en retail: cobertura, rotación y roturas.

Aditivo, semiaditivo y no aditivo: la clasificación que lo cambia todo

Antes de escribir una sola línea de DAX conviene tener clara esta clasificación, porque define cómo debe comportarse cada medida cuando el usuario cambia el nivel de agregación.

Tipo ¿Se suma entre fechas? ¿Se suma entre tiendas o productos? Ejemplos
Aditiva Sí Sí Ventas, unidades vendidas, coste de ventas, tickets
Semiaditiva No. Se toma un valor: el último, el primero o la media Sí Stock, saldo bancario, plantilla, clientes activos
No aditiva No No Ratios, precios medios, porcentaje de margen, recuentos distintos

Power BI no sabe nada de esto. Para el motor VertiPaq, una columna numérica es una columna numérica y la agregación por defecto es la suma. La responsabilidad de indicarle cómo comportarse en el eje temporal es tuya, y se hace con medidas explícitas.

💡

Primera regla de esta guía: nunca arrastres la columna de unidades de stock a un visual. Siempre una medida. Aunque la medida sea una simple suma al principio, tener el nombre reservado te permitirá cambiar la lógica después sin tocar ningún informe.

Dos formas de modelar el inventario: fotos o movimientos

Antes de las medidas está el modelo. Hay dos maneras de representar el stock en una tabla de hechos, y la elección condiciona todo lo que viene después.

Snapshots: una foto por fecha

Una fila por fecha, tienda y producto con las unidades que había en ese momento. Es la forma más natural de consultar el stock: para saber cuánto había el 31 de marzo, filtras el 31 de marzo y sumas. El problema es el volumen. Con 300 tiendas, 15.000 referencias y una foto diaria, el año completo son 1.642 millones de filas si guardas todas las combinaciones. En la práctica se guardan solo las combinaciones con stock, lo que reduce mucho la cifra, pero sigue siendo la tabla más grande del modelo con diferencia.

Movimientos: entradas y salidas con signo

Una fila por transacción: recepciones en positivo, ventas y mermas en negativo, ajustes de inventario con el signo que toque. El stock en una fecha es la suma acumulada de todos los movimientos hasta ese día. Ocupa mucho menos y da trazabilidad completa, pero cada consulta obliga al motor a recorrer todo el histórico desde el origen.

Híbrido: cierre mensual más movimientos del mes

Una foto al cierre de cada mes y los movimientos diarios solo del período abierto. El stock de una fecha es la foto del último cierre anterior más los movimientos desde entonces. Es el patrón que recomiendo en modelos grandes: combina el volumen contenido de los movimientos con consultas que solo recorren unas semanas de datos.

📌

¿Cuál elegir? Si tu ERP ya te entrega el stock diario y el modelo se queda por debajo de unos 100 millones de filas, snapshots. Si necesitas trazabilidad y el volumen es grande, híbrido. Solo movimientos, casi nunca: te obliga a recalcular todo el histórico en cada consulta y el rendimiento se degrada con cada año que pasa.

El modelo de ejemplo

Todas las medidas de este artículo se apoyan en este esquema en estrella. Es deliberadamente simple para que puedas replicarlo con tus propios datos.

  • Calendario: una fila por día, marcada como tabla de fechas, con columnas Fecha, Año, AñoMes.
  • Tienda: TiendaID, Tienda, Región.
  • Producto: ProductoID, SKU, Categoría.
  • StockSnapshot: Fecha, TiendaID, ProductoID, Unidades, CosteUnitario. Una fila por combinación y día con foto de inventario.
  • Ventas: Fecha, TiendaID, ProductoID, Unidades, Importe, Coste.

Las relaciones son de uno a varios desde cada dimensión hacia las dos tablas de hechos, con filtro en una sola dirección. Un detalle que parece menor y que será clave más adelante: la tabla de snapshots no tiene necesariamente una fila para cada día. Hay domingos sin foto, festivos, tiendas cerradas por reforma, cargas que fallaron. Cualquier solución que asuma un snapshot diario perfecto se romperá en producción.

Partimos de una medida base que deja claro el problema:

Stock Unidades (sum) =
SUM ( StockSnapshot[Unidades] )

Esta es la medida que produce los 4.380.000. La dejamos con ese nombre a propósito: es una medida intermedia que nunca debería aparecer en un visual, pero que reutilizaremos dentro de las demás.

Solución 1: LASTDATE, la más simple y la más frágil

La primera idea que se le ocurre a cualquiera es filtrar la última fecha del período:

Stock Unidades (LASTDATE) =
CALCULATE (
    [Stock Unidades (sum)],
    LASTDATE ( 'Calendario'[Fecha] )
)

LASTDATE devuelve una tabla con una sola fila: la última fecha visible en el contexto de filtro del calendario. Si el usuario selecciona enero de 2025, devuelve el 31 de enero. Si selecciona el año completo, el 31 de diciembre. CALCULATE aplica esa fecha como filtro y la suma se limita a un solo día. Funciona.

Funciona hasta que no hay foto ese día. Y eso pasa más de lo que parece:

  • Mes en curso. Estamos a día 21 y el usuario selecciona el mes. La última fecha del contexto es el día 30, que aún no existe en los datos. Resultado: en blanco.
  • Días sin snapshot. El último día del mes cae en domingo y el proceso de carga no corre los domingos. En blanco.
  • Cierre de año. El 31 de diciembre se hace inventario físico y el ERP no genera la foto habitual. El stock anual de toda la compañía aparece en blanco justo en la fecha que más miran.

Úsala solo si puedes garantizar un snapshot diario sin huecos y nunca muestras períodos abiertos. En mi experiencia, esa garantía no existe.

Solución 2: LASTNONBLANK, la que busca el último día con datos

En lugar de asumir que la última fecha del calendario tiene datos, le pedimos al motor que busque la última fecha que sí los tiene:

Stock Unidades (LASTNONBLANK) =
CALCULATE (
    [Stock Unidades (sum)],
    LASTNONBLANK (
        'Calendario'[Fecha],
        CALCULATE ( SUM ( StockSnapshot[Unidades] ) )
    )
)

LASTNONBLANK recibe dos argumentos: una columna y una expresión. Itera cada valor de la columna dentro del contexto actual, evalúa la expresión para cada uno, y devuelve el último valor para el que la expresión no está en blanco. Con un mes seleccionado, recorre sus días de atrás hacia delante y se detiene en el último con stock registrado. Ese día se convierte en el filtro para CALCULATE.

Hay un detalle en esta fórmula que provoca más errores que ningún otro en este tema: el CALCULATE dentro del segundo argumento. LASTNONBLANK es un iterador y crea un contexto de fila para cada fecha. Si escribes solo SUM ( StockSnapshot[Unidades] ), esa suma se evalúa en el contexto de filtro original, sin la fecha de la iteración, y devuelve el total del período en cada vuelta. Nunca está en blanco. Por tanto, LASTNONBLANK devuelve la última fecha del calendario, tenga datos o no, y vuelves al problema de LASTDATE. El CALCULATE fuerza la transición de contexto y convierte la fecha de la iteración en filtro. Si usas una medida en lugar de una expresión, la transición ocurre sola porque toda medida lleva un CALCULATE implícito.

El coste de este patrón es la iteración: un año son 365 evaluaciones de una suma filtrada. En modelos medianos es perfectamente aceptable. En modelos grandes, una optimización sencilla es iterar sobre las fechas que realmente existen en la tabla de snapshots en lugar de sobre todo el calendario:

Stock Unidades (LNB snapshot) =
CALCULATE (
    [Stock Unidades (sum)],
    LASTNONBLANK (
        StockSnapshot[Fecha],
        CALCULATE ( SUM ( StockSnapshot[Unidades] ) )
    )
)

Como la relación con el calendario ya filtra las filas de la tabla de hechos, los valores visibles de StockSnapshot[Fecha] son solo los días con foto dentro del período. Si el proceso genera una foto semanal, iteras 4 valores en lugar de 30.

Solución 3: LASTNONBLANKVALUE, la sintaxis limpia

Desde 2019 existe una función que hace exactamente lo que acabamos de construir a mano, sin la trampa del CALCULATE:

Stock Unidades =
LASTNONBLANKVALUE (
    'Calendario'[Fecha],
    [Stock Unidades (sum)]
)

LASTNONBLANKVALUE recorre la columna, evalúa la medida en cada fecha y devuelve directamente el valor correspondiente a la última fecha no vacía. Como el segundo argumento es una medida, la transición de contexto es automática. El coste es el mismo que el de LASTNONBLANK, pero la fórmula se lee de un vistazo y no deja margen a olvidar el CALCULATE. Es mi recomendación por defecto cuando el modelo es de tamaño moderado.

Su pareja, FIRSTNONBLANKVALUE, te da el stock inicial del período con la misma sintaxis, y con ambas puedes calcular la variación:

Stock Inicial =
FIRSTNONBLANKVALUE (
    'Calendario'[Fecha],
    [Stock Unidades (sum)]
)

Variación de Stock =
[Stock Unidades] - [Stock Inicial]

El total que se rompe: tiendas con fechas distintas

Con cualquiera de las soluciones anteriores, en una matriz por tienda cada fila muestra el valor correcto. Y aun así el total no cuadra con la suma de las filas. Este es el problema que más consultas genera, y tiene una explicación precisa.

Imagina que la tienda A tiene su última foto el día 30 y la tienda B cerró por reforma y su última foto es del día 28. En la fila de la tienda A, la última fecha con datos es el 30. En la fila de B, el 28. Pero en la fila del total, el contexto incluye ambas tiendas, y la última fecha con datos del conjunto es el 30. El motor suma el stock del día 30, en el que solo existe la tienda A. La tienda B desaparece del total.

La solución es forzar que la búsqueda de la última fecha se haga tienda a tienda y sumar después:

Stock Unidades =
SUMX (
    VALUES ( Tienda[TiendaID] ),
    LASTNONBLANKVALUE (
        'Calendario'[Fecha],
        [Stock Unidades (sum)]
    )
)

SUMX itera las tiendas visibles y, para cada una, la transición de contexto hace que LASTNONBLANKVALUE encuentre su propia última fecha. El total pasa a ser la suma de las fotos más recientes de cada tienda, que es exactamente lo que el negocio entiende por «stock actual». El coste se multiplica por el número de tiendas, pero sigue siendo una iteración sobre una lista corta.

El stock fantasma: cuando el ERP no exporta los ceros

Hay una segunda versión de este problema mucho más difícil de detectar. Muchos sistemas solo exportan las combinaciones con stock positivo: si un producto se agota, simplemente deja de aparecer en la foto. A nivel de tienda no importa, porque la tienda sigue teniendo filas de otros productos. Pero cuando el usuario baja al detalle de una referencia, LASTNONBLANKVALUE busca la última fecha en la que esa referencia tuvo datos, encuentra el día anterior a agotarse, y muestra las unidades que había entonces. El informe dice que hay 40 unidades de un producto que lleva tres semanas agotado. Stock fantasma.

Hay dos formas de resolverlo. La primera es en el proceso de carga: exigir que cada foto incluya todas las referencias del surtido de cada tienda, con ceros donde corresponda. Es lo más limpio pero no siempre está en tu mano. La segunda es en DAX: determinar la última fecha de foto a nivel de tienda, ignorando el filtro de producto, y aplicar esa fecha a la suma:

Stock Unidades =
SUMX (
    VALUES ( Tienda[TiendaID] ),
    VAR UltimaFoto =
        CALCULATE (
            MAX ( StockSnapshot[Fecha] ),
            REMOVEFILTERS ( Producto )
        )
    RETURN
        CALCULATE (
            [Stock Unidades (sum)],
            StockSnapshot[Fecha] = UltimaFoto
        )
)

Para cada tienda, UltimaFoto es la fecha de la foto más reciente considerando todos sus productos. Después se suma el stock de esa fecha con el filtro de producto intacto. Si la referencia no tiene fila ese día, el resultado es blanco, que es lo correcto: no hay stock. Esta es la versión que uso en producción. Tiene además la ventaja de que no itera fechas: MAX y la suma filtrada son dos operaciones que el motor de almacenamiento resuelve sin pasar por el motor de fórmulas.

Stock desde movimientos: el patrón acumulado

Si tu tabla de hechos son movimientos, el stock en una fecha es la suma de todo lo que ocurrió hasta ese día. El patrón es el clásico total acumulado, con una sutileza importante en el ALL:

Stock desde Movimientos =
CALCULATE (
    SUM ( Movimientos[Cantidad] ),
    FILTER (
        ALL ( 'Calendario' ),
        'Calendario'[Fecha] <= MAX ( 'Calendario'[Fecha] )
    )
)

El ALL va sobre la tabla completa, no sobre la columna Fecha. Si escribes ALL ( 'Calendario'[Fecha] ) y el usuario ha filtrado el año 2025 con la columna Año, ese filtro sobrevive: el acumulado solo recorre los movimientos de 2025 y el stock de enero ignora todo lo anterior. Con ALL sobre la tabla, se eliminan todos los filtros del calendario y la condición de fecha vuelve a construir el rango correcto desde el origen.

Y el patrón híbrido, que combina la foto del último cierre con los movimientos posteriores:

Stock Híbrido =
VAR FechaFin = MAX ( 'Calendario'[Fecha] )
VAR UltimoCierre =
    CALCULATE (
        MAX ( StockCierre[Fecha] ),
        REMOVEFILTERS ( 'Calendario' ),
        'Calendario'[Fecha] <= FechaFin
    )
VAR FotoCierre =
    CALCULATE (
        SUM ( StockCierre[Unidades] ),
        REMOVEFILTERS ( 'Calendario' ),
        'Calendario'[Fecha] = UltimoCierre
    )
VAR MovimientosDesdeCierre =
    CALCULATE (
        SUM ( Movimientos[Cantidad] ),
        REMOVEFILTERS ( 'Calendario' ),
        'Calendario'[Fecha] > UltimoCierre,
        'Calendario'[Fecha] <= FechaFin
    )
RETURN
    FotoCierre + MovimientosDesdeCierre

La tabla StockCierre contiene una fila por tienda y producto al cierre de cada mes. La medida localiza el último cierre anterior o igual a la fecha de fin, toma su foto, y añade solo los movimientos entre ese cierre y la fecha consultada. En lugar de recorrer tres años de movimientos, recorre como mucho 31 días. Si no existe ningún cierre anterior, UltimoCierre queda en blanco, la foto es cero y la suma de movimientos parte del origen: el resultado sigue siendo correcto.

Rendimiento: qué patrón usar según el tamaño del modelo

Todos los patrones anteriores dan el mismo resultado en un modelo pequeño. La diferencia aparece cuando la tabla de snapshots pasa de decenas a cientos de millones de filas y los usuarios empiezan a cruzar tienda, categoría y semana en una misma matriz.

Patrón Coste Cuándo usarlo
LASTDATE Mínimo Solo con snapshot diario garantizado y sin períodos abiertos. En la práctica, nunca
LASTNONBLANKVALUE sobre el calendario Itera las fechas del contexto Modelos moderados, fotos diarias con pocos huecos
LASTNONBLANK sobre la fecha del snapshot Itera solo las fechas con foto Fotos semanales o mensuales
MAX de fecha más filtro Dos operaciones del motor de almacenamiento, sin iteración Mi elección por defecto en modelos grandes
SUMX por tienda Multiplica el coste por el número de tiendas Siempre que las fotos no sean uniformes entre tiendas
Acumulado de movimientos Recorre el histórico completo Solo combinado con cierres mensuales (híbrido)

Algunas decisiones de modelo pesan más que cualquier fórmula:

  • Reduce el volumen antes de optimizar DAX. Una foto al cierre de cada mes más fotos diarias solo del mes en curso ocupa una fracción de la foto diaria completa y responde el 95% de las preguntas del negocio. Se conoce como snapshot rodante.
  • Claves enteras y sin columnas innecesarias. La tabla de snapshots no necesita descripciones, ni nombres, ni nada que no sea clave o cantidad. Cada columna de texto en una tabla de mil millones de filas es memoria y tiempo de consulta.
  • Una sola dirección de filtro. Las relaciones bidireccionales entre dimensiones y una tabla de snapshots grande multiplican el trabajo del motor en cada consulta.
  • Mide antes de decidir. Performance Analyzer en Power BI Desktop y los tiempos de servidor de DAX Studio te dicen si el tiempo se va en el motor de almacenamiento o en el de fórmulas. Si se va en el de fórmulas, estás iterando de más.

Los KPIs que salen de un stock bien calculado

Una vez que la medida [Stock Unidades] es fiable en cualquier nivel de agregación, el resto de indicadores de inventario se construyen encima. Si quieres ver cómo encajan con el resto del cuadro de mando, en el artículo sobre los 7 KPIs de retail que debes tener en tu dashboard explico la rotación y el sell-through en el contexto de ventas y margen. Aquí me centro en la parte de inventario.

Días de cobertura

Cuántos días aguanta el stock actual al ritmo de venta reciente. Es el KPI que un responsable de tienda entiende sin explicación.

Venta Media Diaria 28d =
VAR FechaFin = MAX ( 'Calendario'[Fecha] )
RETURN
    CALCULATE (
        SUM ( Ventas[Unidades] ),
        DATESINPERIOD ( 'Calendario'[Fecha], FechaFin, -28, DAY )
    ) / 28

Días de Cobertura =
DIVIDE ( [Stock Unidades], [Venta Media Diaria 28d] )

La ventana de 28 días evita el sesgo del día de la semana, porque siempre contiene cuatro semanas completas. Ajusta el período a la estacionalidad de tu negocio: en moda puede tener sentido una ventana más corta al inicio de temporada.

Stock medio y rotación

La rotación relaciona lo que has vendido con lo que has tenido almacenado. Necesita el stock valorado a coste y un stock medio calculado sobre varios cierres, no sobre un solo día:

Stock Valor (sum) =
SUMX (
    StockSnapshot,
    StockSnapshot[Unidades] * StockSnapshot[CosteUnitario]
)

Stock Valor =
SUMX (
    VALUES ( Tienda[TiendaID] ),
    LASTNONBLANKVALUE ( 'Calendario'[Fecha], [Stock Valor (sum)] )
)

Stock Medio 12M =
VAR FechaFin = MAX ( 'Calendario'[Fecha] )
VAR Meses =
    CALCULATETABLE (
        VALUES ( 'Calendario'[AñoMes] ),
        DATESINPERIOD ( 'Calendario'[Fecha], FechaFin, -12, MONTH )
    )
RETURN
    AVERAGEX (
        Meses,
        VAR MesActual = 'Calendario'[AñoMes]
        RETURN
            CALCULATE (
                [Stock Valor],
                REMOVEFILTERS ( 'Calendario' ),
                'Calendario'[AñoMes] = MesActual
            )
    )

Coste Ventas 12M =
CALCULATE (
    SUM ( Ventas[Coste] ),
    DATESINPERIOD ( 'Calendario'[Fecha], MAX ( 'Calendario'[Fecha] ), -12, MONTH )
)

Rotación 12M =
DIVIDE ( [Coste Ventas 12M], [Stock Medio 12M] )

Stock Medio 12M evalúa el stock al cierre de cada uno de los últimos doce meses y promedia. El REMOVEFILTERS sobre el calendario es necesario porque, si el usuario tiene seleccionado un mes concreto, ese filtro se intersectaría con cada mes de la iteración y solo uno devolvería valor. Se limpia el contexto y se vuelve a aplicar el mes que toca en cada vuelta.

Referencias en rotura

Cuántas combinaciones de tienda y producto están a cero en la última foto. Solo funciona si el proceso de carga escribe los ceros; si no, hay que compararlo contra el surtido teórico de cada tienda.

Referencias en Rotura =
VAR UltimaFoto = MAX ( StockSnapshot[Fecha] )
RETURN
    CALCULATE (
        COUNTROWS ( StockSnapshot ),
        StockSnapshot[Fecha] = UltimaFoto,
        StockSnapshot[Unidades] = 0
    )

La versión que de verdad interesa al negocio es la rotura con demanda: referencias a cero que además han vendido en los últimos 28 días. Multiplicar su venta media diaria por los días en rotura te da una estimación de venta perdida, que es el argumento más convincente que existe para que compras y logística presten atención al informe.

Errores habituales que conviene revisar

  • Columna en el visual en lugar de medida. El origen del 4.380.000. Se corrige creando la medida y, si quieres blindarlo, ocultando la columna Unidades en el modelo.
  • LASTDATE con huecos en los datos. Aparece como celdas en blanco en el mes en curso o en cierres de año.
  • LASTNONBLANK sin CALCULATE en la expresión. Devuelve siempre la última fecha del calendario y se comporta igual que LASTDATE. Es el error más silencioso de la lista.
  • ALL sobre la columna en lugar de la tabla en acumulados. El stock inicial de cada año ignora el histórico anterior.
  • Fotos sin filas a cero. Provoca stock fantasma a nivel de referencia. Se resuelve en la carga o con la fecha de foto calculada a nivel de tienda.
  • Totales sin SUMX por tienda. El total no cuadra con las filas cuando las tiendas tienen fechas de foto distintas.
  • Tabla de fechas sin marcar como tal. DATESINPERIOD y compañía pueden comportarse de forma inconsistente, y las relaciones con la fecha del snapshot deben ser sobre una columna de tipo fecha sin hora.
  • Mezclar granularidades sin regla explícita. Si las fotos son semanales y el calendario diario, define y documenta qué muestra el informe para un día cualquiera: la última foto anterior, no un blanco.

En resumen

El stock es la medida semiaditiva por excelencia, y Power BI no la va a tratar como tal por su cuenta. La secuencia que funciona es: elegir el modelo de inventario adecuado al volumen, crear siempre medidas explícitas, usar LASTNONBLANKVALUE como punto de partida legible, iterar por tienda para que los totales cuadren, calcular la fecha de foto a nivel de tienda para evitar el stock fantasma, y pasar al patrón de MAX más filtro cuando el modelo crezca. A partir de ahí, cobertura, rotación y roturas son medidas de una línea.

Si el responsable de tienda del principio vuelve a abrir el informe y ve 11.400 unidades, con 23 días de cobertura y 14 referencias en rotura con demanda, esta vez no cierra el informe. Toma una decisión.

¿Qué patrón usas tú para el stock? ¿Te has encontrado con el stock fantasma o con totales que no cuadran? Cuéntamelo en los comentarios.

Comentarios

Deja tu comentario

Los comentarios son revisados antes de publicarse.

Jose Luis Rico Rocamora

Jose Luis Rico Rocamora

Power BI & Analytics Specialist · PL-300 Certified

Especialista en Business Intelligence con más de 10 años de experiencia en retail, finanzas y operaciones. En Datency comparto lo que aprendo y aplico en el día a día.

¿Necesitas aplicar esto
en tu empresa?

Diseño e implemento soluciones de Power BI adaptadas a tu negocio. Hablamos sin compromiso y vemos qué tiene sentido en tu caso.

Scroll al inicio