Módulo 5: Point In Time Joins And Deduplication
Mini-proyecto: revenue histórico correcto de Kiosko
Descripción
Este proyecto cierra el módulo integrando las seis piezas anteriores: el fan-out de un JOIN sin filtro (lección 2), el contraste entre el JOIN roto por is_current y el correcto punto-en-el-tiempo (lección 3), el riesgo de una dimensión de llegada tardía (lección 4), el diagnóstico de un lote con filas duplicadas (lección 5), su deduplicación con ROW_NUMBER()/QUALIFY (lección 6), y ANTI JOIN/SEMI JOIN para detectar huérfanos y cambios (lección 7). Lo que falta es reunir todo en una sola entrega formal, verificada de punta a punta con assert, sobre las mismas fact_orders (40 filas) y dim_product_scd (5 filas) que este módulo usó desde la lección 2.
El proyecto tiene siete partes. Primero, reconstruyes fact_orders y dim_product_scd, heredadas sin cambios. Segundo, rechazas formalmente el JOIN ingenuo de la lección 2 (fan-out, revenue inflado). Tercero, ejecutas el JOIN roto (is_current) y mides su mala atribución. Cuarto, ejecutas el JOIN correcto (punto-en-el-tiempo) y confirmas que el revenue total es idéntico, pero la categoría y el margen no. Quinto, deduplicas un lote con tres filas reenviadas. Sexto, corres los ANTI JOIN de la lección 7 sobre dos escenarios. Séptimo, documentas todo en POINT_IN_TIME_SUMMARY, la estructura formal que cierra el módulo.
Conexión con el módulo. Este proyecto no introduce ningún concepto nuevo — es la integración final de las siete lecciones anteriores, empaquetada como POINT_IN_TIME_SUMMARY, la estructura que el módulo 6 de esta guía puede citar sin volver a reconstruir la evidencia desde cero.
Una analogía: la auditoría completa, antes de publicar el reporte
Cada lección de este módulo resolvió una pieza del problema por separado: la forma de unir que rompe el conteo, la forma que arregla el conteo pero atribuye mal, la que atribuye bien pero depende de que la dimensión esté al día, y la limpieza de filas repetidas antes de que cualquiera de esas formas de unir importe. Este proyecto es el momento de correr la auditoría completa: todas esas piezas, ensambladas en un solo flujo, con cada paso verificado por un assert antes de pasar al siguiente — exactamente el rigor que alguien esperaría antes de publicar un reporte de revenue histórico que el equipo de finanzas de Kiosko va a usar para tomar decisiones reales.
El material: todo lo que este módulo construyó, en un solo flujo
Necesitas, en la misma carpeta: kiosko.py, raw_orders.py (idénticos a los módulos anteriores) y dim_product_scd.py (el archivo nuevo de este módulo, introducido en la lección 2, con DIM_PRODUCT_SCD_ROWS). No necesitas ningún archivo adicional — el lote reenviado y las órdenes de demostración se definen directamente en el script de este proyecto, igual que en los proyectos anteriores.
La solución de referencia, verificada
Parte 1 — Reconstruir fact_orders y dim_product_scd, heredadas sin cambios
# kiosko_pit_project.py -- revenue historico correcto, mini-proyecto de cierre del modulo 5
from datetime import datetime, timedelta
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
from dim_product_scd import DIM_PRODUCT_SCD_ROWS
print("=== Kiosko: revenue historico correcto, entrega final del modulo 5 ===\n")
orders = [
Order(order_id=r[0], store_id=r[1], product_id=r[2], quantity=r[3],
unit_price=r[4], order_ts=datetime.fromisoformat(r[5]))
for r in RAW_ORDERS
]
fact_orders = transform_fact_orders(orders, DIM_STORE, DIM_PRODUCT)
con = duckdb.connect()
con.execute("""
CREATE TABLE fact_orders (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
)
""")
con.executemany(
"INSERT INTO fact_orders VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["order_id"], r["store_id"], r["product_id"], r["quantity"],
r["unit_price"], r["revenue"], r["order_ts"]) for r in fact_orders],
)
con.execute("""
CREATE TABLE dim_product_scd (
product_key INTEGER PRIMARY KEY,
product_id VARCHAR NOT NULL,
product_name VARCHAR,
category VARCHAR,
unit_cost DOUBLE,
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOLEAN NOT NULL DEFAULT true
)
""")
con.executemany("INSERT INTO dim_product_scd VALUES (?, ?, ?, ?, ?, ?, ?, ?)", DIM_PRODUCT_SCD_ROWS)
total_orders = con.sql("SELECT COUNT(*) FROM fact_orders").fetchone()[0]
total_dim_rows = con.sql("SELECT COUNT(*) FROM dim_product_scd").fetchone()[0]
total_dim_products = con.sql("SELECT COUNT(DISTINCT product_id) FROM dim_product_scd").fetchone()[0]
print("Parte 1 -- fact_orders y dim_product_scd, heredadas sin cambios")
print(f" fact_orders {total_orders:3} filas")
print(f" dim_product_scd {total_dim_rows:3} filas ({total_dim_products} productos, 1 historizado)")
Esta primera parte no construye nada nuevo — reconstruye, exactamente como en cada lección de este módulo, las dos tablas de entrada que el módulo 1 y el módulo 4 dejaron listas.
Parte 2 — El JOIN ingenuo (lección 2): rechazado, con el número que lo prueba
naive = con.sql("""
SELECT COUNT(*) AS rows_, ROUND(SUM(f.revenue), 2) AS revenue_inflado
FROM fact_orders f JOIN dim_product_scd d ON f.product_id = d.product_id
""").fetchone()
print("\nParte 2 -- JOIN ingenuo (solo product_id): el fan-out, RECHAZADO")
print(f" filas resultantes: {naive[0]} (deberian ser {total_orders}) -- revenue inflado: {naive[1]}")
assert naive[0] == 50 and naive[1] == 127.75, "el fan-out no se comporto como se esperaba"
Exactamente el resultado de la lección 2: 50 filas, revenue inflado a 127.75. Este proyecto no usa esta forma de unir para nada más — queda documentada como la evidencia de por qué se rechaza, no como una opción viable.
Parte 3 — El JOIN roto (lección 3): sin fan-out, mal atribuido
broken = con.sql("""
SELECT d.category, ROUND(SUM(f.revenue), 2) AS revenue, ROUND(SUM(f.revenue - f.quantity * d.unit_cost), 2) AS margin
FROM fact_orders f JOIN dim_product_scd d ON f.product_id = d.product_id AND d.is_current = true
GROUP BY d.category ORDER BY d.category
""").fetchall()
print("\nParte 3 -- JOIN roto (is_current = true): sin fan-out, pero mal atribuido")
for category, revenue, margin in broken:
print(f" {category:14} revenue={revenue:7} margin={margin:6}")
Parte 3 -- JOIN roto (is_current = true): sin fan-out, pero mal atribuido
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
health-snacks revenue= 21.6 margin= 9.36
Parte 4 — El JOIN correcto (lección 3): punto-en-el-tiempo
correct = con.sql("""
SELECT d.category, ROUND(SUM(f.revenue), 2) AS revenue, ROUND(SUM(f.revenue - f.quantity * d.unit_cost), 2) AS margin
FROM fact_orders f
JOIN dim_product_scd d
ON f.product_id = d.product_id
AND f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
GROUP BY d.category ORDER BY d.category
""").fetchall()
print("\nParte 4 -- JOIN correcto (punto-en-el-tiempo)")
for category, revenue, margin in correct:
print(f" {category:14} revenue={revenue:7} margin={margin:6}")
broken_categories = {c for c, _, _ in broken}
correct_categories = {c for c, _, _ in correct}
assert "health-snacks" in broken_categories and "health-snacks" not in correct_categories
assert "snacks" in correct_categories and "snacks" not in broken_categories
print(" Verificacion: 'health-snacks' (roto) vs 'snacks' (correcto) -- misma plata, categoria distinta")
total_broken = con.sql("""
SELECT ROUND(SUM(f.revenue), 2)
FROM fact_orders f JOIN dim_product_scd d ON f.product_id = d.product_id AND d.is_current = true
""").fetchone()[0]
total_correct = con.sql("""
SELECT ROUND(SUM(f.revenue), 2)
FROM fact_orders f JOIN dim_product_scd d
ON f.product_id = d.product_id
AND f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
""").fetchone()[0]
assert total_broken == total_correct == 106.15
print(f" Revenue total: identico en ambos casos -- {total_correct}")
Parte 4 -- JOIN correcto (punto-en-el-tiempo)
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
snacks revenue= 21.6 margin= 10.8
Verificacion: 'health-snacks' (roto) vs 'snacks' (correcto) -- misma plata, categoria distinta
Revenue total: identico en ambos casos -- 106.15
Los dos assert de esta parte son el corazón del proyecto: confirman, con evidencia y no con la palabra de nadie, que health-snacks solo aparece del lado roto, snacks solo del lado correcto, y que el revenue total —106.15— nunca se movió, en ninguno de los dos casos.
Parte 5 — Deduplicando un lote con 3 filas reenviadas (lecciones 5 y 6)
con.execute("""
CREATE TABLE raw_orders_batch (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE,
order_ts TIMESTAMP, ingested_at TIMESTAMP
)
""")
rows = []
for r in RAW_ORDERS:
order_id, store_id, product_id, quantity, unit_price, order_ts_str = r
order_ts = datetime.fromisoformat(order_ts_str)
ingested_at = order_ts + timedelta(minutes=1)
rows.append((order_id, store_id, product_id, quantity, unit_price, quantity * unit_price, order_ts, ingested_at))
RESEND_DELAY = {"ORD-1001": timedelta(minutes=35), "ORD-3001": timedelta(minutes=54), "ORD-6005": timedelta(minutes=49)}
for r in RAW_ORDERS:
order_id, store_id, product_id, quantity, unit_price, order_ts_str = r
if order_id in RESEND_DELAY:
order_ts = datetime.fromisoformat(order_ts_str)
resend_ingest = order_ts + timedelta(minutes=1) + RESEND_DELAY[order_id]
rows.append((order_id, store_id, product_id, quantity, unit_price, quantity * unit_price, order_ts, resend_ingest))
con.executemany("INSERT INTO raw_orders_batch VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
before = con.sql("SELECT COUNT(*) FROM raw_orders_batch").fetchone()[0]
after = con.sql("""
WITH deduped AS (
SELECT * FROM raw_orders_batch
QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id, product_id ORDER BY ingested_at DESC) = 1
)
SELECT COUNT(*) FROM deduped
""").fetchone()[0]
print("\nParte 5 -- Deduplicando un lote con 3 filas reenviadas")
print(f" filas antes de deduplicar: {before}")
print(f" filas despues de QUALIFY ROW_NUMBER() = 1: {after}")
assert before == 43 and after == 40
print(" Verificacion OK: el grano vuelve a coincidir con fact_orders (40)")
Parte 5 -- Deduplicando un lote con 3 filas reenviadas
filas antes de deduplicar: 43
filas despues de QUALIFY ROW_NUMBER() = 1: 40
Verificacion OK: el grano vuelve a coincidir con fact_orders (40)
Parte 6 — Anti-joins: huérfanos y qué cambió (lección 7)
con.execute("""
CREATE TABLE orphan_order (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
)
""")
con.execute("INSERT INTO orphan_order VALUES ('ORD-9001', 'S02', 'P002', 1, 1.20, 1.20, '2026-07-30T09:00:00')")
orphans_in_week = con.sql("""
SELECT COUNT(*) FROM fact_orders f
ANTI JOIN dim_product_scd d
ON f.product_id = d.product_id
AND f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
""").fetchone()[0]
orphans_demo = con.sql("""
SELECT COUNT(*) FROM orphan_order o
ANTI JOIN dim_product_scd d
ON o.product_id = d.product_id
AND o.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
""").fetchone()[0]
print("\nParte 6 -- Anti-joins: huerfanos y que cambio")
print(f" huerfanas dentro de la semana real de Kiosko (40 ordenes): {orphans_in_week}")
print(f" huerfanas en el lote de demostracion (ORD-9001, antes del 2026-08-01): {orphans_demo}")
assert orphans_in_week == 0 and orphans_demo == 1
con.execute("""
CREATE TABLE staging_product_v3 (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)
""")
con.executemany(
"INSERT INTO staging_product_v3 VALUES (?, ?, ?, ?)",
[
("P001", "Bottled Water 600ml", "beverages", 0.40),
("P002", "Energy Bar", "health-snacks", 0.72),
("P003", "Instant Coffee Sachet", "beverages", 0.35),
("P004", "Phone Charger Cable", "electronics", 2.10),
],
)
changed = con.sql("""
SELECT s.product_id FROM staging_product_v3 s
ANTI JOIN dim_product_scd d
ON s.product_id = d.product_id AND d.is_current = true
AND s.category = d.category AND s.unit_cost = d.unit_cost
""").fetchall()
print(f" productos que cambiaron en staging_product_v3 (antes de correr el MERGE): {[c[0] for c in changed]}")
assert [c[0] for c in changed] == ["P002"]
Parte 6 -- Anti-joins: huerfanos y que cambio
huerfanas dentro de la semana real de Kiosko (40 ordenes): 0
huerfanas en el lote de demostracion (ORD-9001, antes del 2026-08-01): 1
productos que cambiaron en staging_product_v3 (antes de correr el MERGE): ['P002']
Parte 7 — Documentar como una estructura formal
POINT_IN_TIME_SUMMARY = {
"fact_orders_rows": total_orders,
"dim_product_scd_rows": total_dim_rows,
"naive_join_product_id_only_rows": naive[0],
"naive_join_revenue_inflated": naive[1],
"broken_join_is_current_only_category_for_p002": "health-snacks",
"correct_join_point_in_time_category_for_p002": "snacks",
"total_revenue_broken_vs_correct": [total_broken, total_correct],
"margin_p002_broken_vs_correct": [9.36, 10.8],
"dedup_batch_rows_before_after": [before, after],
"orphan_orders_in_kiosko_week": orphans_in_week,
"orphan_orders_in_demo_batch": orphans_demo,
"antijoin_detected_changes": [c[0] for c in changed],
}
print("\nParte 7 -- la declaracion formal: POINT_IN_TIME_SUMMARY")
for key, value in POINT_IN_TIME_SUMMARY.items():
print(f" {key}: {value}")
Qué esperar. Al correr python3 kiosko_pit_project.py completo (las siete partes juntas), la salida es exactamente esta:
=== Kiosko: revenue historico correcto, entrega final del modulo 5 ===
Parte 1 -- fact_orders y dim_product_scd, heredadas sin cambios
fact_orders 40 filas
dim_product_scd 5 filas (4 productos, 1 historizado)
Parte 2 -- JOIN ingenuo (solo product_id): el fan-out, RECHAZADO
filas resultantes: 50 (deberian ser 40) -- revenue inflado: 127.75
Parte 3 -- JOIN roto (is_current = true): sin fan-out, pero mal atribuido
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
health-snacks revenue= 21.6 margin= 9.36
Parte 4 -- JOIN correcto (punto-en-el-tiempo)
beverages revenue= 44.05 margin= 14.75
electronics revenue= 40.5 margin= 21.6
snacks revenue= 21.6 margin= 10.8
Verificacion: 'health-snacks' (roto) vs 'snacks' (correcto) -- misma plata, categoria distinta
Revenue total: identico en ambos casos -- 106.15
Parte 5 -- Deduplicando un lote con 3 filas reenviadas
filas antes de deduplicar: 43
filas despues de QUALIFY ROW_NUMBER() = 1: 40
Verificacion OK: el grano vuelve a coincidir con fact_orders (40)
Parte 6 -- Anti-joins: huerfanos y que cambio
huerfanas dentro de la semana real de Kiosko (40 ordenes): 0
huerfanas en el lote de demostracion (ORD-9001, antes del 2026-08-01): 1
productos que cambiaron en staging_product_v3 (antes de correr el MERGE): ['P002']
Parte 7 -- la declaracion formal: POINT_IN_TIME_SUMMARY
fact_orders_rows: 40
dim_product_scd_rows: 5
naive_join_product_id_only_rows: 50
naive_join_revenue_inflated: 127.75
broken_join_is_current_only_category_for_p002: health-snacks
correct_join_point_in_time_category_for_p002: snacks
total_revenue_broken_vs_correct: [106.15, 106.15]
margin_p002_broken_vs_correct: [9.36, 10.8]
dedup_batch_rows_before_after: [43, 40]
orphan_orders_in_kiosko_week: 0
orphan_orders_in_demo_batch: 1
antijoin_detected_changes: ['P002']
Detente en la Parte 4 y en la Parte 7 juntas, porque son las que resumen todo el módulo en una sola imagen. Los dos assert de la Parte 4 confirman, con evidencia ejecutada, la afirmación central de este módulo: el revenue total nunca miente (106.15 en ambos casos), pero la categoría y el margen sí, si el JOIN está mal escrito. Y POINT_IN_TIME_SUMMARY reúne, en una sola estructura, cada número que las seis lecciones anteriores midieron por separado — desde el fan-out de la lección 2 hasta los ANTI JOIN de la lección 7.
Diagrama: las seis piezas del módulo, cerradas con evidencia
flowchart TD
A["L2: JOIN sin filtro\nVERIFICADO -- fan-out, 50 filas"] --> B
B["L3: is_current vs punto-en-el-tiempo\nVERIFICADO -- health-snacks vs snacks"] --> C
C["L4: llegada tardia\nVERIFICADO -- mismo JOIN, distinta dimension"] --> D
D["L5: de donde vienen los duplicados\nVERIFICADO -- 40 -> 43 filas"] --> E
E["L6: QUALIFY + ROW_NUMBER\nVERIFICADO -- 43 -> 40 filas"] --> F
F["L7: ANTI JOIN / SEMI JOIN\nVERIFICADO -- huerfanos y cambios detectados"] --> G
G["POINT_IN_TIME_SUMMARY\nel contrato formal que este proyecto entrega"]
G --> H["Modulo 6: accumulating snapshot\nsobre fact_sessions"]
Cerrando el checklist del módulo 1, pieza por pieza
| Pieza del checklist (lección 2, módulo 1) | Estado al cerrar este módulo |
|---|---|
Grano de fact_orders declarado y verificado | Resuelto — módulo 1 |
Llaves sustitutas, dim_date, dimensiones conformadas | Resuelto — módulo 2 |
| Snowflake vs tabla ancha | Resuelto — módulo 3 |
| Historización de una dimensión que cambia (SCD) | Resuelto — módulo 4 |
| Join punto-en-el-tiempo contra una dimensión historizada | Resuelto — ESTE MÓDULO, POINT_IN_TIME_SUMMARY verificado: snacks/10.8 de margen, no health-snacks/9.36 |
| Deduplicación explícita de filas repetidas | Resuelto — ESTE MÓDULO, 43 -> 40 filas verificado con assert |
| Accumulating snapshot, cumulative design | Pendiente — módulo 6 |
| Dimensión junk, más de un hecho | Pendiente — módulo 7 |
Seis filas de las ocho ya quedaron resueltas. El módulo 6, el siguiente en la lista, necesita fact_orders y dim_product_scd exactamente como quedaron —sin cambios—, más los events que foundations ya generó, para construir fact_sessions, el accumulating snapshot del funnel de sesiones de Kiosko. Ninguna de las técnicas de este módulo (join punto-en-el-tiempo, deduplicación, ANTI JOIN) se descarta al avanzar — cualquier hecho que este warehouse agregue de aquí en adelante puede necesitar unirse contra dim_product_scd con el mismo patrón, o llegar con sus propios duplicados que deduplicar de la misma forma.
Errores comunes
Entregar POINT_IN_TIME_SUMMARY sin los assert de las Partes 4, 5 y 6. Qué pasa: alguien, apurado por mostrar la estructura de resumen como resultado final, construye POINT_IN_TIME_SUMMARY directamente después de correr las consultas, sin haber pasado por los assert que confirman cada número. Por qué pasa: la estructura de resumen se ve más presentable como "el entregable", y los assert se sienten como pasos preliminares descartables. Cómo detectarlo: si tu entrega final no incluye ninguna evidencia ejecutada de que health-snacks solo aparece del lado roto, que el lote deduplicado volvió a 40 filas, y que el ANTI JOIN encontró exactamente los huérfanos esperados, estás documentando un proceso sin haber confirmado que funcionó. Cómo corregirlo: los assert de este proyecto no son opcionales — son la garantía que hace confiable todo lo que POINT_IN_TIME_SUMMARY documenta.
Asumir que este proyecto reemplaza el JOIN que el resto de la guía usa para reportes que no necesitan historia. Qué pasa: alguien, al terminar este proyecto, asume que de aquí en adelante toda la guía debería usar el join punto-en-el-tiempo contra dim_product_scd, incluso para reportes que solo describen el estado actual del catálogo, donde dim_product (sin historia) o is_current = true siguen siendo perfectamente correctos. Por qué pasa: después de un módulo entero dedicado a demostrar que is_current puede estar mal, es fácil sobregeneralizar a "nunca usar is_current". Cómo detectarlo: si esperas que un reporte de "catálogo vigente de Kiosko hoy" —sin relación a ninguna venta fechada— use el patrón BETWEEN valid_from AND valid_to, perdiste de vista la distinción de la lección 3: ese patrón es para hechos con fecha propia, is_current sigue siendo correcto para consultas sobre el presente sin relación a un evento histórico. Cómo corregirlo: la pregunta correcta sigue siendo "¿qué describe cada fila del reporte?" — un evento pasado necesita el join punto-en-el-tiempo; el estado presente del catálogo no.
Pensar que este proyecto agotó todos los escenarios posibles de duplicación o de llegada tardía. Qué pasa: alguien termina este proyecto pensando que ya vio "todos los casos" de deduplicación o de dimensiones desactualizadas, sin considerar escenarios que Kiosko no tuvo en este módulo —duplicados con valores distintos entre copias (correcciones, no reenvíos exactos), una dimensión con más de un producto cambiando el mismo día, o un MERGE que corre parcialmente y falla a la mitad—. Por qué pasa: un ejemplo completo y bien verificado puede sentirse como "el caso general" cuando en realidad es un caso específico y deliberadamente simple. Cómo detectarlo: si no puedes explicar cómo cambiaría tu criterio de deduplicación si dos copias de la misma orden tuvieran valores distintos (no idénticos), o cómo el ANTI JOIN de la lección 7 detectaría dos productos cambiando el mismo día en vez de uno, te falta considerar variaciones que este proyecto no cubrió a propósito. Cómo corregirlo: este proyecto resuelve, con evidencia completa, el caso que Kiosko necesitaba — una dimensión con un producto historizado, un lote con tres reenvíos exactos; escenarios más complejos son extensiones del mismo patrón, no técnicas distintas.
Ejercicios
Ejercicio 1 — Verifica que el margen total de Kiosko (las tres categorías sumadas) también difiere entre el JOIN roto y el correcto. Usando fact_orders y dim_product_scd ya construidas, escribe una consulta que sume el margen de las tres categorías en cada escenario y confirme la diferencia exacta.
Ver solución
print(con.sql("""
SELECT
(SELECT ROUND(SUM(f.revenue - f.quantity * d.unit_cost), 2)
FROM fact_orders f JOIN dim_product_scd d ON f.product_id = d.product_id AND d.is_current = true) AS margen_total_roto,
(SELECT ROUND(SUM(f.revenue - f.quantity * d.unit_cost), 2)
FROM fact_orders f JOIN dim_product_scd d
ON f.product_id = d.product_id
AND f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')) AS margen_total_correcto
"""))
Salida esperada:
┌───────────────────┬────────────────────────┐
│ margen_total_roto │ margen_total_correcto │
│ double │ double │
├───────────────────┼─────────────────────────┤
│ 45.71 │ 47.15 │
└───────────────────┴─────────────────────────┘
45.71 contra 47.15 — una diferencia de 1.44, la misma que la lección 3 ya midió para P002 en solitario. Esto confirma, a nivel de todo el negocio de Kiosko para esa semana, que el JOIN roto no solo desatribuye una categoría — subestima la rentabilidad real en 1.44, un número que un equipo de finanzas real notaría si comparara este reporte contra el de un período sin ningún cambio de dimensión de por medio.
Ejercicio 2 — Extiende POINT_IN_TIME_SUMMARY con un campo que confirme, explícitamente, que el revenue total nunca cambió en ningún escenario de este proyecto. Agrega un campo revenue_invariant_across_all_scenarios que compare el revenue total de los cuatro escenarios que este proyecto tocó: el JOIN roto, el correcto, el lote deduplicado, y fact_orders sin ningún JOIN.
Ver solución
revenue_no_join = con.sql("SELECT ROUND(SUM(revenue), 2) FROM fact_orders").fetchone()[0]
revenue_deduped = con.sql("""
WITH deduped AS (
SELECT * FROM raw_orders_batch
QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id, product_id ORDER BY ingested_at DESC) = 1
)
SELECT ROUND(SUM(revenue), 2) FROM deduped
""").fetchone()[0]
POINT_IN_TIME_SUMMARY["revenue_invariant_across_all_scenarios"] = {
"fact_orders_sin_join": revenue_no_join,
"join_roto_is_current": total_broken,
"join_correcto_punto_en_el_tiempo": total_correct,
"lote_deduplicado": revenue_deduped,
}
print(f"revenue_invariant_across_all_scenarios: {POINT_IN_TIME_SUMMARY['revenue_invariant_across_all_scenarios']}")
Salida esperada:
revenue_invariant_across_all_scenarios: {'fact_orders_sin_join': 106.15, 'join_roto_is_current': 106.15, 'join_correcto_punto_en_el_tiempo': 106.15, 'lote_deduplicado': 106.15}
Los cuatro escenarios dan 106.15 — confirmando, una vez más, que revenue es una medida que vive en fact_orders, calculada una sola vez (quantity * unit_price, en el momento de la venta) y que ningún JOIN contra una dimensión, correcto o roto, puede alterar. Esta extensión deja explícito, en una sola estructura, el hallazgo más importante de este módulo: el revenue total es el número menos útil para detectar un JOIN mal escrito contra una dimensión historizada.
Ejercicio 3 — Explica, de memoria, qué necesita el módulo 6 de este proyecto para poder empezar. Sin mirar el diseño de la guía, describe en un párrafo de 4-6 frases qué piezas de fact_orders, dim_product_scd, o POINT_IN_TIME_SUMMARY va a necesitar el módulo 6 para construir fact_sessions, el accumulating snapshot del funnel de sesiones de Kiosko.
Ver solución
El módulo 6 necesita, como base, fact_orders y dim_product_scd exactamente como quedaron en este módulo —sin ningún cambio—, porque el accumulating snapshot que va a construir (fact_sessions) describe un proceso de negocio distinto (el funnel de sesiones: vista, agregado al carrito, compra), construido sobre los events que foundations ya generó, no sobre fact_orders directamente. No necesita reconstruir ningún JOIN punto-en-el-tiempo contra dim_product_scd para ese propósito específico —el funnel de sesiones no depende de qué categoría o costo tenía un producto en el momento de la venta—, aunque el mismo patrón de esta lección seguiría aplicando si algún reporte futuro de Kiosko necesitara cruzar sesiones contra el catálogo histórico. Lo que sí hereda, en espíritu más que en código directo, es la disciplina de verificación de este módulo: cada tabla nueva que el módulo 6 construya —fact_sessions, fact_store_activity— va a necesitar su propia consulta de verificación ejecutada, con assert, antes de darla por correcta, exactamente como este proyecto verificó cada una de sus siete partes antes de documentarlas en POINT_IN_TIME_SUMMARY.
Resumen y siguiente paso: el final del módulo 5
Con este mini-proyecto cierras el módulo 5 completo. Rechazaste, con evidencia, el JOIN sin filtro (fan-out, 50 filas); contrastaste el JOIN roto por is_current contra el correcto punto-en-el-tiempo, confirmando que el revenue total nunca cambia pero la categoría y el margen sí (health-snacks/9.36 contra snacks/10.8); deduplicaste un lote real con tres reenvíos, restaurando el grano de cuarenta filas; y corriste ANTI JOIN/SEMI JOIN sobre dos escenarios —huérfanos temporales y detección de cambios—. Documentaste todo en POINT_IN_TIME_SUMMARY, verificado con assert en cada paso.
Diste el quinto paso de un camino de ocho módulos: fact_orders y dim_product_scd ahora se unen con el patrón correcto, punto-en-el-tiempo, con la deduplicación y la detección de cambios ya resueltas — la base sobre la que cualquier hecho futuro de este warehouse puede construirse con confianza.
Hacia dónde sigues. El módulo 6 —accumulating-and-cumulative-patterns— cambia de tema por completo: en vez de unir hechos contra dimensiones, construye dos patrones de tabla de hechos distintos —el accumulating snapshot de Kimball, aplicado al funnel de sesiones de Kiosko sobre los events de foundations, y el cumulative table design de Zach Wilson/DataExpert, aplicado a la actividad diaria por tienda con columnas tipo arreglo y ventanas móviles de 7 y 30 días.
Recursos
- Kimball Group — "Slowly Changing Dimension Type 2" — la definición formal que sostiene
dim_product_scd, verificada en cada una de las siete partes de este proyecto. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/type-2. En inglés. - Kimball Group — "Late Arriving Dimension" — la técnica que sostiene la Parte 6 de este proyecto, el escenario de órdenes huérfanas y detección de cambios. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/late-arriving-dimension. En inglés.
- DuckDB — documentación de las cláusulas
FROMyJOIN(incluyendoSEMI JOIN/ANTI JOIN) — la referencia de sintaxis para la Parte 6 de este proyecto. duckdb.org/docs/current/sql/query_syntax/from. En inglés. - DuckDB — documentación de la cláusula
QUALIFYy de funciones de ventana — la referencia de sintaxis para la Parte 5 de este proyecto. duckdb.org/docs/current/sql/query_syntax/qualify y duckdb.org/docs/current/sql/functions/window_functions. En inglés. - DuckDB — documentación oficial del cliente Python, la interfaz que ejecutó cada verificación de este proyecto. duckdb.org/docs/current/clients/python/overview. En inglés.