Módulo 8: Project Kioskos Analytics Warehouse
Reconstruyendo bronze y silver desde foundations
Descripción
Los siete módulos anteriores de esta guía dieron por sentado que fact_orders ya existía —lo recibiste, listo, del capstone de foundations, y lo reconstruiste una y otra vez con transform_fact_orders() sobre RAW_ORDERS—. Ese atajo fue deliberado: el foco de esta guía nunca fue la ingesta, sino el modelado. Pero un warehouse integrado no puede empezar a mitad de camino — necesita, primero, las dos capas que foundations construyó y que esta guía nunca reconstruyó explícitamente con sus propios nombres: bronze (el dato crudo, tal como llegó) y silver (el dato validado y modelado). Esta lección cierra esa deuda: nombra bronze y silver con sus propias tablas en DuckDB, corre la misma compuerta de calidad que foundations diseñó, y confirma —con el mismo assert de siempre— que el resultado es, número por número, el fact_orders que ya conoces.
Conexión con el módulo. Esta lección no introduce ningún concepto de modelado nuevo — nombra, con tablas reales de DuckDB, las dos primeras capas de la arquitectura Medallion que el módulo 7 (lección 5) ya formalizó con validate_gold_schema(). Es el primer paso del warehouse integrado: sin bronze y silver reconstruidos aquí, ninguna de las capas gold de las lecciones 4, 5 y 6 tendría un fact_orders sobre el cual construirse.
Una analogía: la cadena de custodia, desde el mostrador hasta el archivo
Piensa en cómo un laboratorio de análisis clínico maneja una muestra de sangre: primero, la muestra cruda, tal como el enfermero la extrajo, etiquetada con la fecha y el paciente, sin ningún resultado todavía —eso es bronze—. Después, un técnico la procesa: descarta las muestras contaminadas o mal etiquetadas, y convierte las válidas en un resultado con unidades estándar, listo para el archivo médico —eso es silver—. Ningún laboratorio serio salta directo de "la muestra llegó" a "aquí está el resultado" sin ese paso intermedio de control de calidad — y ningún laboratorio guarda solo el resultado final, descartando la muestra cruda, porque si alguna vez hay que auditar un resultado dudoso, la muestra original es la única forma de verificarlo desde cero.
Esta lección hace, con bronze_orders y fact_orders, exactamente esa cadena de custodia: el dato crudo se preserva, sin tocar; la compuerta de calidad decide qué pasa; y el resultado final —fact_orders— es trazable, paso a paso, hasta la fila cruda de la que salió.
Ejemplo trabajado: bronze, la compuerta de calidad, y silver
Parte 1 — Bronze: el aterrizaje crudo, sin transformar
RAW_ORDERS, la semana fija de Kiosko que ya usaste en los ocho módulos anteriores, es el equivalente de esta guía a los siete archivos orders_*.csv de foundations —los mismos cuarenta valores, declarados como datos fijos en Python en vez de archivos en disco, para que esta guía sea autocontenida—. Bronze aterriza esos valores tal como llegan, sin convertir ningún tipo todavía: quantity y unit_price se guardan como texto (VARCHAR), igual que llegarían de un archivo CSV real, porque la responsabilidad de bronze es preservar el dato de origen, no interpretarlo.
# capstone_bronze_silver.py -- Parte 1: BRONZE
from datetime import datetime
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
from events import RAW_EVENTS
REQUIRED_COLUMNS = ["order_id", "store_id", "product_id", "quantity", "unit_price", "order_ts"]
print("=== Kiosko: reconstruyendo bronze y silver desde foundations ===\n")
con = duckdb.connect()
bronze_rows = [
{"order_id": r[0], "store_id": r[1], "product_id": r[2],
"quantity": str(r[3]), "unit_price": str(r[4]), "order_ts": r[5]}
for r in RAW_ORDERS
]
con.execute("""
CREATE TABLE bronze_orders (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity VARCHAR, unit_price VARCHAR, order_ts VARCHAR
)
""")
con.executemany(
"INSERT INTO bronze_orders VALUES (?, ?, ?, ?, ?, ?)",
[(r["order_id"], r["store_id"], r["product_id"], r["quantity"], r["unit_price"], r["order_ts"]) for r in bronze_rows],
)
con.execute("CREATE TABLE bronze_events (event_id VARCHAR, event_type VARCHAR, session_id VARCHAR, event_ts VARCHAR)")
con.executemany("INSERT INTO bronze_events VALUES (?, ?, ?, ?)", RAW_EVENTS)
print("Parte 1 -- BRONZE: aterrizaje crudo, sin transformar")
print(f" bronze_orders {con.sql('SELECT COUNT(*) FROM bronze_orders').fetchone()[0]:3} filas (quantity y unit_price como VARCHAR)")
print(f" bronze_events {con.sql('SELECT COUNT(*) FROM bronze_events').fetchone()[0]:3} filas (los 32 eventos canonicos del modulo 6)")
Qué esperar.
=== Kiosko: reconstruyendo bronze y silver desde foundations ===
Parte 1 -- BRONZE: aterrizaje crudo, sin transformar
bronze_orders 40 filas (quantity y unit_price como VARCHAR)
bronze_events 32 filas (los 32 eventos canonicos del modulo 6)
Fíjate en el tipo declarado de quantity y unit_price: VARCHAR, no INTEGER ni DOUBLE. Esa elección no es un descuido — es la misma decisión que tomó bronze.py en foundations al escribir cada fila directamente desde el CSV original, sin ningún int() ni float() aplicado todavía. Bronze nunca decide si un valor es válido; solo lo preserva, tal como llegó.
Parte 2 — La compuerta de calidad: validate_orders(), sin cambios de criterio
validate_orders(), tal como la diseñó foundations, separa filas válidas de rechazadas con cuatro reglas: ningún campo requerido vacío, quantity un entero mayor que cero, unit_price un decimal no negativo, y ningún order_id repetido dentro del mismo lote. Esta lección la reconstruye con el mismo criterio exacto, adaptada a las columnas de Kiosko:
def validate_orders(rows: list[dict]) -> tuple[list[dict], list[dict]]:
"""Separa filas bronze crudas en (validas, rechazadas) -- el mismo criterio
de esquema/nulos/tipo/rango que validate_orders() de foundations."""
valid: list[dict] = []
rejected: list[dict] = []
seen_order_ids: set[str] = set()
for row in rows:
reasons: list[str] = []
missing = [c for c in REQUIRED_COLUMNS if not row.get(c)]
if missing:
reasons.append(f"missing or empty fields: {missing}")
rejected.append({"row": row, "reasons": reasons})
continue
try:
quantity = int(row["quantity"])
except ValueError:
reasons.append(f"quantity is not a valid integer: {row['quantity']!r}")
quantity = None
try:
unit_price = float(row["unit_price"])
except ValueError:
reasons.append(f"unit_price is not a valid decimal: {row['unit_price']!r}")
unit_price = None
if quantity is not None and quantity <= 0:
reasons.append(f"quantity must be > 0, got {quantity}")
if unit_price is not None and unit_price < 0:
reasons.append(f"unit_price must be >= 0, got {unit_price}")
if row["order_id"] in seen_order_ids:
reasons.append(f"duplicate order_id: {row['order_id']}")
if reasons:
rejected.append({"row": row, "reasons": reasons})
else:
seen_order_ids.add(row["order_id"])
valid.append(row)
return valid, rejected
valid_rows, rejected_rows = validate_orders(bronze_rows)
print(f"\nParte 2 -- LA COMPUERTA DE CALIDAD: validate_orders()")
print(f" filas validas: {len(valid_rows)}")
print(f" filas rechazadas: {len(rejected_rows)}")
assert len(valid_rows) == 40 and len(rejected_rows) == 0
print(" Verificacion OK: las 40 filas de Kiosko pasan la compuerta, cero rechazadas")
Qué esperar.
Parte 2 -- LA COMPUERTA DE CALIDAD: validate_orders()
filas validas: 40
filas rechazadas: 0
Cero rechazadas — exactamente lo que ya sabías desde el módulo 5 de foundations: los datos fijos de Kiosko están limpios a propósito, para que el foco de esta guía sea el modelado dimensional, no la limpieza de datos. Pero fíjate en que la compuerta corrió igual, con las cuatro reglas completas — no se saltó porque "ya sabías que iba a dar cero". Confirmarlo con evidencia, aunque el resultado sea el esperado, es la misma disciplina que el módulo 1 exigió al declarar el grano.
Parte 3 — Silver: transform_fact_orders(), el gold que ya conoces
Con las filas validadas, transform_fact_orders() —del módulo 1, sin ningún cambio— convierte cada fila válida en una línea de fact_orders, con revenue calculado por primera vez.
orders = [
Order(order_id=r["order_id"], store_id=r["store_id"], product_id=r["product_id"],
quantity=int(r["quantity"]), unit_price=float(r["unit_price"]),
order_ts=datetime.fromisoformat(r["order_ts"]))
for r in valid_rows
]
fact_orders_rows = transform_fact_orders(orders, DIM_STORE, DIM_PRODUCT)
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_rows],
)
con.execute("CREATE TABLE events (event_id VARCHAR, event_type VARCHAR, session_id VARCHAR, event_ts TIMESTAMP)")
con.executemany("INSERT INTO events VALUES (?, ?, ?, ?)",
[(r[0], r[1], r[2], datetime.fromisoformat(r[3])) for r in RAW_EVENTS])
total_orders = con.sql("SELECT COUNT(*) FROM fact_orders").fetchone()[0]
total_revenue = con.sql("SELECT ROUND(SUM(revenue), 2) FROM fact_orders").fetchone()[0]
grain_check = con.sql("SELECT COUNT(*), COUNT(DISTINCT order_id || '-' || product_id) FROM fact_orders").fetchone()
print(f"\nParte 3 -- SILVER: transform_fact_orders(), el fact_orders de siempre")
print(f" fact_orders {total_orders:3} filas, revenue total = {total_revenue}")
print(f" grano verificado: COUNT(*)={grain_check[0]} == COUNT(DISTINCT order_id-product_id)={grain_check[1]}")
assert total_orders == 40 and total_revenue == 106.15
assert grain_check[0] == grain_check[1] == 40
print(" Verificacion OK: bronze -> silver reproduce fact_orders identico al de foundations, sin diferencias")
Qué esperar.
Parte 3 -- SILVER: transform_fact_orders(), el fact_orders de siempre
fact_orders 40 filas, revenue total = 106.15
grano verificado: COUNT(*)=40 == COUNT(DISTINCT order_id-product_id)=40
Verificacion OK: bronze -> silver reproduce fact_orders identico al de foundations, sin diferencias
106.15, otra vez — el número que abrió esta guía en el módulo 1, sigue siendo el mismo al final del camino que empieza en bronze. Eso es, con precisión, lo que esta lección demuestra: nada en los ocho módulos de esta guía cambió el dato de origen — solo le agregó estructura, historia, y contexto alrededor.
Diagrama: la cadena bronze -> silver, con evidencia en cada paso
flowchart LR
A["RAW_ORDERS (40)\nRAW_EVENTS (32)\nfijos, de foundations y M6"] --> B["bronze_orders, bronze_events\nVARCHAR crudo, sin transformar"]
B --> C["validate_orders()\n40 validas, 0 rechazadas"]
C --> D["transform_fact_orders()\nfact_orders, revenue calculado"]
D --> E["fact_orders: 40 filas\nrevenue = 106.15\nVERIFICADO"]
Profundización: por qué esta guía nunca nombró "bronze" hasta ahora
Vale la pena ser honestos sobre una decisión de esta guía: los módulos 1 a 7 nunca crearon una tabla llamada bronze_orders — simplemente asumieron que fact_orders ya existía, reconstruida directamente desde RAW_ORDERS con transform_fact_orders(), sin ningún paso intermedio con nombre propio. Esa fue una decisión pedagógica deliberada: el foco de esta guía siempre fue el modelado dimensional, no la ingesta —esa frontera ya se declaró desde el módulo 1, y data-engineering-foundations-guide es la guía que enseña bronze y silver a fondo, con particionamiento real en disco y el patrón overwrite-partition—.
Esta lección no contradice esa decisión — la hace explícita, ahora que el capstone necesita mostrar el camino completo. Nombrar bronze_orders y correr validate_orders() aquí no es "empezar a enseñar ingesta de datos" — es reconocer, con tablas reales, que el fact_orders que esta guía dio por sentado desde su primera línea siempre tuvo esas dos capas detrás, aunque nunca las hiciste visibles hasta este módulo final.
Errores comunes
Pensar que bronze y silver, en este módulo, son un pipeline particionado en disco como en foundations. Qué pasa: alguien, al ver los nombres "bronze" y "silver", espera encontrar aquí el mismo patrón data/bronze/orders/dt=YYYY-MM-DD/orders.csv con particionamiento por fecha que construyó foundations. Por qué pasa: los nombres son idénticos, y es razonable esperar la misma implementación. Cómo detectarlo: si buscas en esta lección alguna llamada a pathlib.Path o a csv.DictWriter, no la vas a encontrar. Cómo corregirlo: como declaró el diseño de esta guía desde el módulo 1, la orquestación real y el particionamiento en disco son terreno de data-engineering-foundations-guide (ya construido) y de airflow-and-declarative-orchestration-guide (guía hermana). Aquí, bronze y silver son tablas de DuckDB dentro de la misma conexión — la misma responsabilidad conceptual, con una implementación deliberadamente más simple, porque el foco de esta guía siempre fue el modelo, no la infraestructura de ingesta.
Confundir validate_orders() con validate_gold_schema() del módulo 7. Qué pasa: alguien, al ver dos funciones con nombres parecidos ("validate"), intenta usar validate_gold_schema() para revisar las filas de bronze_orders, o validate_orders() para revisar el esquema de una tabla gold. Por qué pasa: ambas empiezan con "validate", y ambas aparecen en el mismo flujo bronze→silver→gold. Cómo detectarlo: si le pasas bronze_orders (una tabla) a validate_orders() (que espera una lista de diccionarios de filas crudas), o si esperas que validate_gold_schema() detecte un quantity negativo, mezclaste las dos funciones. Cómo corregirlo: recuerda la distinción exacta que ya estableció el módulo 7 — validate_orders() valida datos (¿esta fila es correcta?), corre entre bronze y silver; validate_gold_schema() valida esquema (¿esta tabla tiene la forma correcta?), corre sobre gold. Esta lección solo usa la primera.
Saltarse la Parte 2 porque "ya sabes que da cero rechazadas". Qué pasa: alguien, familiarizado con el dataset de Kiosko después de siete módulos, decide omitir la llamada a validate_orders() y construir fact_orders directamente desde bronze_rows, razonando que el resultado va a ser el mismo. Por qué pasa: después de ver "cero rechazadas" en cada ejecución anterior, la compuerta empieza a sentirse redundante. Cómo detectarlo: si tu script salta directo de bronze a transform_fact_orders(), sin ningún paso intermedio de validación, no tienes evidencia de que las filas son válidas — tienes una suposición basada en corridas anteriores. Cómo corregirlo: la compuerta de calidad no es un paso decorativo que se pueda omitir una vez que "ya conoces el resultado" — es la garantía de que, si algún día el dataset de Kiosko cambiara (una fila con quantity negativo, por ejemplo), el pipeline lo detectaría en vez de dejarlo pasar silenciosamente hasta gold.
Ejercicios
Ejercicio 1 — Simula una fila bronze rota y confirma que la compuerta la rechaza. Agrega una fila adicional a bronze_rows, con quantity="-2" (una cantidad inválida), y confirma que validate_orders() la mueve a rejected_rows con la razón correcta, sin afectar las 40 filas originales.
Ver solución
broken_row = {"order_id": "ORD-9999", "store_id": "S01", "product_id": "P001",
"quantity": "-2", "unit_price": "0.55", "order_ts": "2026-08-03T11:00:00"}
valid_with_break, rejected_with_break = validate_orders(bronze_rows + [broken_row])
print(f"validas: {len(valid_with_break)}, rechazadas: {len(rejected_with_break)}")
print(f"razon del rechazo: {rejected_with_break[0]['reasons']}")
Salida esperada:
validas: 40, rechazadas: 1
razon del rechazo: ['quantity must be > 0, got -2']
La fila rota queda exactamente en rejected_rows, sin afectar ninguna de las 40 filas originales que siguen pasando la compuerta — la misma garantía de aislamiento que validate_orders() de foundations demostró desde su propio módulo 5: una fila mala nunca contamina a las buenas.
Ejercicio 2 — Confirma que bronze_orders preserva el texto crudo, sin convertir tipos. Usando bronze_orders, escribe una consulta que confirme que quantity sigue siendo de tipo VARCHAR en DuckDB, no INTEGER.
Ver solución
print(con.sql("DESCRIBE bronze_orders"))
Salida esperada (columna quantity, tipo VARCHAR):
┌─────────────┬─────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │ column_type │ null │ key │ default │ extra │
│ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │
├─────────────┼─────────────┼─────────┼─────────┼─────────┼─────────┤
│ order_id │ VARCHAR │ YES │ NULL │ NULL │ NULL │
│ store_id │ VARCHAR │ YES │ NULL │ NULL │ NULL │
│ product_id │ VARCHAR │ YES │ NULL │ NULL │ NULL │
│ quantity │ VARCHAR │ YES │ NULL │ NULL │ NULL │
│ unit_price │ VARCHAR │ YES │ NULL │ NULL │ NULL │
│ order_ts │ VARCHAR │ YES │ NULL │ NULL │ NULL │
└─────────────┴─────────────┴─────────┴─────────┴─────────┴─────────┘
quantity y unit_price son VARCHAR, confirmando que bronze nunca decidió que esos valores fueran numéricos — esa decisión ocurre recién en validate_orders() (que intenta convertirlos con int()/float()) y se confirma en transform_fact_orders(), que ya recibe los tipos correctos.
Ejercicio 3 — Explica, de memoria, por qué esta lección construye events (tipado) además de bronze_events (crudo). En 2-3 frases, explica qué diferencia hay entre ambas tablas, y por qué la lección 5 de este módulo va a necesitar la segunda, no la primera.
Ver solución
bronze_events guarda los 32 eventos exactamente como llegarían de un sistema de clickstream real —con event_ts como texto, sin ningún tipo TIMESTAMP aplicado—, mientras que events ya tiene event_ts convertido a TIMESTAMP, lista para las operaciones de fecha (CAST(event_ts AS DATE), comparaciones BETWEEN) que fact_sessions necesita en la lección 5. La relación entre ambas es la misma que entre bronze_orders y fact_orders: una preserva el dato crudo para auditoría, la otra ya pasó por la conversión de tipos que el modelado necesita. La lección 5 va a construir fact_sessions a partir de events (tipada), no de bronze_events, exactamente por la misma razón que fact_orders se construye desde las filas ya validadas, no desde bronze_orders directamente.
Resumen y siguiente paso
En esta lección nombraste, con tablas reales de DuckDB, las dos primeras capas de la arquitectura Medallion que esta guía nunca hizo explícitas hasta ahora: bronze_orders/bronze_events (crudo, sin transformar) y fact_orders/events (validado y tipado, silver). Corriste validate_orders() con el mismo criterio exacto de foundations —cero rechazadas de cuarenta—, y confirmaste, con el mismo assert de siempre, que el resultado final es idéntico al fact_orders que conoces desde el módulo 1: 40 filas, revenue 106.15.
Antes de avanzar deberías poder: explicar la diferencia entre bronze_orders (crudo, VARCHAR) y fact_orders (silver, tipado y modelado); nombrar las cuatro reglas de validate_orders(); y confirmar, de memoria, los números que este flujo reproduce (40 filas, 0 rechazadas, revenue 106.15).
La lección 4 toma este fact_orders recién reconstruido y construye, sobre él, el star completo: dim_store, dim_date, y la pieza central de este módulo — dim_product_scd, historizada con dos corridas de MERGE INTO, unida con el join punto-en-el-tiempo que corrige el error más caro de un modelo dimensional.
Recursos
- Databricks — "What is the medallion lakehouse architecture?" — la definición oficial de bronze/silver/gold que esta lección nombra con tablas reales por primera vez en esta guía. docs.databricks.com/aws/en/lakehouse/medallion. En inglés.
- DuckDB — documentación oficial del statement
DESCRIBE— usada en el ejercicio 2 para confirmar que bronze preserva el tipo crudo de cada columna. duckdb.org/docs/current/guides/meta/describe. En inglés. - Python — documentación oficial de
dataclasses, reutilizada sin cambios desde foundations para representarOrder. docs.python.org/3/library/dataclasses.html. En inglés. - DuckDB — documentación oficial del cliente Python, la interfaz que ejecuta cada consulta de esta lección. duckdb.org/docs/current/clients/python/overview. En inglés.