Módulo 4: Modeling Your First Tables
Mini-proyecto: la primera tabla de hechos de Kiosko
Descripción
Es momento de cerrar el módulo con las tres tablas trabajando juntas. Tienes fact_orders diseñada (lección 3), dim_store y dim_product construidas (lección 4), el join por diccionario dominado (lección 5), revenue calculado y materializado (lección 6), y todo eso ensamblado en transform_fact_orders() (lección 7). En este mini-proyecto vas a correr esa función sobre las catorce órdenes reales de Kiosko —los mismos dos días que exploraste crudos en el módulo 1— y vas a producir el primer reporte real de negocio de toda la guía: revenue por tienda, revenue por categoría de producto, y el producto que más vendió.
Conexión con el módulo. Este proyecto no introduce ningún concepto nuevo — es la integración completa de las siete lecciones anteriores, aplicada de punta a punta. Y deja el terreno preparado para el módulo 5: vas a ver, de primera mano, qué tan frágil es este pipeline ante un dato roto —porque transform_fact_orders() todavía no sabe tolerar un error, solo detenerse ante él—.
Una analogía: la reunión de cierre de mes
Un gerente de tienda que llega a la reunión mensual con la dirección de Kiosko no lleva una caja con todos los tickets de venta del mes — llevaría horas revisarlos uno por uno en la reunión. Lleva un reporte: cuánto vendió cada tienda, qué categorías de producto se movieron más. Ese reporte no es un dato nuevo —cada número sale, directamente, de los tickets individuales—; es la misma información, agregada de una forma que alguien puede leer y decidir algo con ella en cinco minutos, no en cinco horas.
Este mini-proyecto es esa reunión. Ya tienes los "tickets" (fact_orders, catorce filas, cada una una venta real) y las "fichas" (dim_store, dim_product). Lo que falta es exactamente lo que un gerente prepara antes de la reunión: agrupar, sumar, y presentar el número que le importa a quien toma la decisión — no la fila individual.
El material: fact_orders, dim_store y dim_product juntas
Primero, todo lo que este módulo construyó, en un solo archivo reutilizable:
# kiosko_model.py
import csv
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Order:
order_id: str
store_id: str
product_id: str
quantity: int
unit_price: float
order_ts: datetime
def parse_order(row: dict) -> Order:
return Order(
order_id=row["order_id"],
store_id=row["store_id"],
product_id=row["product_id"],
quantity=int(row["quantity"]),
unit_price=float(row["unit_price"]),
order_ts=datetime.fromisoformat(row["order_ts"]),
)
def read_orders(path: str) -> list[Order]:
with open(path, newline="") as f:
rows = list(csv.DictReader(f))
return [parse_order(row) for row in rows]
DIM_STORE = [
{"store_id": "S01", "store_name": "Kiosko Centro", "city": "Bogota"},
{"store_id": "S02", "store_name": "Kiosko Norte", "city": "Lima"},
{"store_id": "S03", "store_name": "Kiosko Sur", "city": "Santiago"},
]
DIM_PRODUCT = [
{"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
{"product_id": "P002", "product_name": "Energy Bar", "category": "snacks", "unit_cost": 0.60},
{"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
{"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]
def transform_fact_orders(rows: list[Order], dim_store: list[dict], dim_product: list[dict]) -> list[dict]:
store_index = {s["store_id"]: s for s in dim_store}
product_index = {p["product_id"]: p for p in dim_product}
fact_rows = []
for order in rows:
if order.store_id not in store_index:
raise ValueError(f"unknown store_id: {order.store_id}")
if order.product_id not in product_index:
raise ValueError(f"unknown product_id: {order.product_id}")
fact_rows.append({
"order_id": order.order_id,
"store_id": order.store_id,
"product_id": order.product_id,
"quantity": order.quantity,
"unit_price": order.unit_price,
"revenue": order.quantity * order.unit_price,
"order_ts": order.order_ts,
})
return fact_rows
Necesitas también orders_2026-08-03.csv y orders_2026-08-04.csv en la misma carpeta — son, exactamente, los mismos dos archivos del mini-proyecto del módulo 1, sin ningún cambio.
La solución de referencia, verificada
Parte 1 — Construir fact_orders y verificar el conteo
# report.py
from kiosko_model import read_orders, DIM_STORE, DIM_PRODUCT, transform_fact_orders
all_orders = read_orders("orders_2026-08-03.csv") + read_orders("orders_2026-08-04.csv")
print(f"Total de ordenes combinadas: {len(all_orders)}")
fact_orders = transform_fact_orders(all_orders, DIM_STORE, DIM_PRODUCT)
print(f"Total de filas en fact_orders: {len(fact_orders)}")
assert len(fact_orders) == len(all_orders), "fact_orders debe tener una fila por cada orden"
print("Verificacion: len(fact_orders) == len(all_orders) -> OK")
Igual que en el mini-proyecto del módulo 1, la primera pregunta es la más simple de todas: ¿el conteo cuadra? transform_fact_orders() no debería agregar ni perder ninguna fila — una orden entra, una fila de hecho sale.
Parte 2 — Revenue por tienda
store_index = {s["store_id"]: s for s in DIM_STORE}
product_index = {p["product_id"]: p for p in DIM_PRODUCT}
total_revenue = sum(r["revenue"] for r in fact_orders)
print(f"\nRevenue total del periodo: {total_revenue}")
print("\n=== Revenue por tienda ===")
store_revenue_total = 0
for store_id in ["S01", "S02", "S03"]:
store = store_index[store_id]
matching = [r for r in fact_orders if r["store_id"] == store_id]
store_revenue = sum(r["revenue"] for r in matching)
store_revenue_total += store_revenue
print(f"{store_id} {store['store_name']:14} ({store['city']:9}): {len(matching)} ordenes, revenue={store_revenue}")
Este bloque es, literal, el join de la lección 5 aplicado a un reporte real: fact_orders solo tiene store_id, así que store_index[store_id] es lo que te deja imprimir el nombre completo de la tienda y su ciudad en el reporte.
Parte 3 — Revenue por categoría y producto más vendido
print("\n=== Revenue por categoria de producto ===")
category_revenue_total = 0
for category in ["beverages", "snacks", "electronics"]:
cat_revenue = sum(
r["revenue"] for r in fact_orders if product_index[r["product_id"]]["category"] == category
)
category_revenue_total += cat_revenue
print(f"{category:12}: revenue={cat_revenue}")
print("\n=== Top producto por revenue ===")
for product_id in ["P001", "P002", "P003", "P004"]:
name = product_index[product_id]["product_name"]
product_revenue = sum(r["revenue"] for r in fact_orders if r["product_id"] == product_id)
print(f"{product_id} {name:22}: revenue={product_revenue}")
print("\n=== Verificaciones finales ===")
assert store_revenue_total == total_revenue, "revenue por tienda no cuadra con el total"
print("Verificacion: suma de revenue por tienda == revenue total -> OK")
assert category_revenue_total == total_revenue, "revenue por categoria no cuadra con el total"
print("Verificacion: suma de revenue por categoria == revenue total -> OK")
Igual que la Parte 1, cada agrupación termina con una verificación: la suma de revenue por tienda, y la suma de revenue por categoría, deben coincidir exactamente con el revenue total del período. Si no coinciden, algo se contó de más o de menos en alguna de las agrupaciones.
Qué esperar. Al correr python3 report.py completo (las tres partes juntas, en la misma carpeta donde guardaste kiosko_model.py y los dos archivos CSV), la salida es exactamente esta:
Total de ordenes combinadas: 14
Total de filas en fact_orders: 14
Verificacion: len(fact_orders) == len(all_orders) -> OK
Revenue total del periodo: 31.7
=== Revenue por tienda ===
S01 Kiosko Centro (Bogota ): 6 ordenes, revenue=11.9
S02 Kiosko Norte (Lima ): 4 ordenes, revenue=8.5
S03 Kiosko Sur (Santiago ): 4 ordenes, revenue=11.3
=== Revenue por categoria de producto ===
beverages : revenue=15.5
snacks : revenue=7.199999999999999
electronics : revenue=9.0
=== Top producto por revenue ===
P001 Bottled Water 600ml : revenue=11.0
P002 Energy Bar : revenue=7.199999999999999
P003 Instant Coffee Sachet : revenue=4.5
P004 Phone Charger Cable : revenue=9.0
=== Verificaciones finales ===
Verificacion: suma de revenue por tienda == revenue total -> OK
Verificacion: suma de revenue por categoria == revenue total -> OK
Lee el resultado completo, porque —igual que en el módulo 1— cuenta una historia con piezas que encajan. S01 (Bogotá) tuvo más órdenes (6) y también el revenue más alto (11.9), pero S03 (Santiago) no se queda muy atrás (11.3) con solo 4 órdenes — coherente con la observación del módulo 1 de que las órdenes de Santiago tienden a ser de mayor cantidad por venta. Entre categorías, beverages domina (15.5, más de la mitad del revenue total) — coherente con que P001 (agua embotellada) es el producto individual con más revenue (11.0) de los cuatro. Y las dos verificaciones finales confirman algo que ya deberías esperar de memoria, después del módulo 1: agrupar de dos formas distintas (por tienda, por categoría) y sumar cada agrupación siempre debe devolver el mismo total que sumar todo de una vez — si no coincide, hay un bug antes de confiar en cualquiera de los dos reportes.
Diagrama: el flujo completo del módulo 4
flowchart TD
A["orders_2026-08-03.csv + orders_2026-08-04.csv"] --> B["read_orders() (modulo 1)"]
B --> C["all_orders: list of Order, 14 filas"]
D["DIM_STORE (leccion 4)"] --> F[transform_fact_orders]
E["DIM_PRODUCT (leccion 4)"] --> F
C --> F
F --> G["fact_orders: list[dict], 14 filas"]
G --> H["Revenue por tienda"]
G --> I["Revenue por categoria"]
G --> J["Top producto"]
H --> K["Reporte de cierre de Kiosko"]
I --> K
J --> K
Errores comunes
Presentar un reporte sin la verificación de que cuadra con el total. Qué pasa: alguien construye el reporte de revenue por tienda y por categoría, y lo comparte sin nunca comprobar que ambas agrupaciones suman lo mismo que el revenue total. Por qué pasa: cuando cada número individual "se ve razonable", se asume que el conjunto también está bien — pero un filtro mal escrito (por ejemplo, comparar store_id con el valor equivocado) puede dejar fuera o duplicar filas sin que ningún número individual se vea obviamente mal. Cómo detectarlo: si tu reporte no tiene un assert o una comparación explícita entre la suma de las partes y el total, no tienes ninguna garantía real, solo la esperanza de que esté bien. Cómo corregirlo: las verificaciones finales de este proyecto —dos líneas de assert— son exactamente el hábito que evita presentar un reporte silenciosamente incorrecto; es la misma disciplina que ya practicaste en el mini-proyecto del módulo 1, aplicada ahora a un modelo con dimensiones.
Confiar en que transform_fact_orders() va a tolerar un dato roto, sin haberlo probado. Qué pasa: alguien asume que, si algún día Kiosko envía una orden con un store_id mal escrito, el reporte simplemente la va a ignorar y seguir con las demás. Por qué pasa: es el comportamiento que uno esperaría de un sistema "robusto", y se siente razonable asumirlo sin verificarlo. Cómo detectarlo: repasa la lección 7 — transform_fact_orders(), tal como está escrita en este módulo, se detiene por completo ante la primera fila inválida, no la descarta y sigue. Si tu plan mental de qué hace el pipeline no coincide con eso, tienes una idea equivocada de tu propio código. Cómo corregirlo: para este módulo, "todo o nada" es una decisión de diseño válida y honesta —mejor que fallar en silencio—, pero no es la versión final. El módulo 5 completo construye la versión que sí separa filas válidas de inválidas sin detener el pipeline entero.
Dar por cerrado el modelo de datos de Kiosko para siempre. Qué pasa: alguien termina este mini-proyecto y asume que fact_orders, dim_store y dim_product, tal como quedaron aquí, son la versión definitiva y completa de cómo Kiosko va a modelar sus datos para siempre. Por qué pasa: el reporte final se ve completo y funciona — es fácil confundir "funciona para este alcance" con "está terminado para cualquier alcance". Cómo detectarlo: si no puedes nombrar, de memoria, al menos dos cosas que este modelo no resuelve todavía (una tienda que cambia de nombre, un producto nuevo que aparece a mitad de año), es señal de que estás tratando este modelo como definitivo. Cómo corregirlo: vuelve a la sección de frontera de la lección 1 de este módulo — este es, a propósito, el modelo mínimo, y data-modeling-for-analytics-guide es la guía que lo retoma con SCD, cumulative patterns y Medallion completo.
Ejercicios
Ejercicio 1 — Encuentra la orden individual con mayor revenue. Usando fact_orders de este proyecto, encuentra la fila con el revenue más alto entre las catorce, y di a qué tienda, producto y fecha pertenece.
Ver solución
biggest = max(fact_orders, key=lambda r: r["revenue"])
store_name = store_index[biggest["store_id"]]["store_name"]
product_name = product_index[biggest["product_id"]]["product_name"]
print(f"{biggest['order_id']}: revenue={biggest['revenue']} ({product_name} en {store_name}, {biggest['order_ts']})")
Salida esperada:
ORD-1004: revenue=4.5 (Phone Charger Cable en Kiosko Centro, 2026-08-03 09:02:00)
Hay un empate técnico en revenue: ORD-1004 y ORD-2003 tienen ambas revenue=4.5 (la misma combinación cantidad-precio del Phone Charger Cable, una unidad a 4.50). max() en Python conserva el primer elemento que alcanza el máximo al recorrer la secuencia en orden, y nunca lo reemplaza salvo que encuentre uno estrictamente mayor — como ORD-1004 aparece antes que ORD-2003 en fact_orders (el 3 de agosto es anterior al 4 de agosto) y ninguna fila posterior supera 4.5, el resultado es ORD-1004, no ORD-2003. Es un detalle útil para recordar: ante un empate, max() no es arbitrario — siempre gana el primero en el orden de la secuencia de entrada.
Ejercicio 2 — Agrega una cuarta tienda hipotética y observa el ValueError. Sin modificar DIM_STORE, construye una orden nueva con store_id="S04" (una tienda que Kiosko todavía no tiene registrada) y pásala, junto con all_orders, a transform_fact_orders(). Antes de correr el código, predice qué va a pasar; después, verifica.
Ver solución
from datetime import datetime
from kiosko_model import Order
new_order = Order(
order_id="ORD-9002",
store_id="S04",
product_id="P001",
quantity=2,
unit_price=0.55,
order_ts=datetime.fromisoformat("2026-08-05T09:00:00"),
)
try:
transform_fact_orders(all_orders + [new_order], DIM_STORE, DIM_PRODUCT)
except ValueError as e:
print("ValueError:", e)
Predicción: como "S04" no existe en DIM_STORE, transform_fact_orders() debería lanzar un ValueError y detenerse, sin procesar ninguna fila —ni siquiera las catorce originales que sí son válidas—.
Salida esperada:
ValueError: unknown store_id: S04
La predicción se confirma: es exactamente el comportamiento "todo o nada" que advirtió el segundo error común de esta lección — Kiosko tendría que registrar la tienda S04 en DIM_STORE antes de que cualquier orden de esa tienda pueda procesarse.
Ejercicio 3 — Argumenta qué le falta a este modelo antes de confiar en él en producción. Este mini-proyecto cierra el módulo 4, pero el pipeline todavía tiene huecos reales. Sin escribir código, nombra al menos dos cosas que no están resueltas todavía (pista: revisa los módulos 5 y 6 de esta guía) y en qué módulo se resuelven.
Ver solución
Dos huecos reales, entre varios posibles: primero, no hay compuertas de calidad —si una orden trajera quantity=-1 o un unit_price vacío, transform_fact_orders() no lo detectaría de forma controlada, simplemente fallaría al convertir el tipo o produciría un revenue sin sentido de negocio (como un revenue negativo); eso se resuelve en el módulo 5 (compuertas de calidad), con reglas explícitas de rango y tipo. Segundo, no hay idempotencia: si corrieras este mismo script dos veces seguidas contra un almacenamiento real (no solo variables en memoria, como aquí), no hay ninguna garantía todavía de que la segunda corrida no duplique las catorce filas; eso se resuelve en el módulo 6 (idempotencia y backfill), con el patrón overwrite-partition.
Resumen y siguiente paso
En este mini-proyecto integraste las siete lecciones del módulo 4 en un flujo completo: construiste fact_orders con transform_fact_orders(), verificaste que el conteo cuadrara con las órdenes originales, y produjiste el primer reporte de negocio real de la guía —revenue por tienda (S01 con más, 11.9), por categoría (beverages domina con 15.5) y por producto (P001, el agua embotellada, el más vendido con 11.0)—, con verificaciones explícitas de que cada agrupación cuadra con el total.
Con esto cierras el módulo 4. Tienes ahora un modelo de datos mínimo pero real —un hecho, dos dimensiones, un join que las conecta y una columna calculada materializada— y confirmaste, con los propios ejercicios de esta lección, dos huecos honestos que todavía quedan: sin compuertas de calidad, y sin idempotencia.
Hacia dónde sigues. El módulo 5 toma exactamente ese primer hueco: vas a escribir validate_orders(rows) -> (valid, rejected), una función que separa las órdenes válidas de las rotas —sin detener todo el pipeline, sin dejar pasar un dato malo en silencio— sobre un día de Kiosko con tres filas deliberadamente dañadas. Es, literal, la respuesta a la pregunta que quedó abierta en el segundo error común de esta lección: ¿qué hace tu pipeline cuando el dato que llega no es el que esperabas?
Recursos
- Joe Reis & Matt Housley, Fundamentals of Data Engineering (O'Reilly, 2022) — el marco que vas a seguir usando en el módulo 5, ahora aplicado a la etapa de calidad del ciclo de vida del dato. oreilly.com/library/view/fundamentals-of-data/9781098108298. En inglés.
- AWS Certified Data Engineer – Associate (DEA-C01), guía de examen — el modelado de datos (hechos, dimensiones, esquemas) es parte del dominio de transformación de datos que evalúa esta certificación. docs.aws.amazon.com/aws-certification/latest/examguides/data-engineer-associate-01.html. En inglés.
- Python — documentación oficial de
dataclassesydict, las dos piezas de la librería estándar que sostienen todo el modelo de este módulo. docs.python.org/3/library/dataclasses.html · docs.python.org/3/library/stdtypes.html#mapping-types-dict. En inglés.