Declarar el grano por escrito, separar hechos de dimensiones con una regla que se pueda aplicar sin pensar, y resolver el conflicto descuento-por-factura vs producto-por-línea con modelamiento y no con parches — dejando las tablas cargadas y verificadas en la máquina de cada estudiante.
El notebook de esta clase: https://gaa1104.vercel.app/notebooks/clase-10.ipynb
Ing. en Administración · mención Finanzas · Prof. Tomás Fernández
Se dibuja el modelo —hechos y dimensiones—, que es un diagrama de estructura, no una visualización de datos.
La forma la decide el trabajo que hace el dato —magnitud, identidad, polaridad, cambio en el tiempo, distribución—, no el gusto. Regla del curso: un solo eje.
Lo que quedó planteado en la clase insignia (se proyecta la pregunta, NO la respuesta):
«El descuento se cuenta 2,60 veces y el flete 2,04. Si los dos se repiten en las mismas líneas de las mismas facturas, ¿por qué los factores son distintos?»
Tres preguntas, en silencio, 90 segundos (espaciado 1–3–7–21):
Par (2 min) → plenaria (2 min). Exigir el número, no la idea: 2,60 · 2,04 · 14,90 · 24,59.
Y ahora la pregunta colgada se responde con ellos, con la consulta en pantalla. Nadie dicta nada.
Punto de partida: el promedio de líneas por factura es 2,03.
SELECT ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT numero_factura), 2) AS lineas_por_factura FROM ventas; -- 2.03
Entonces, si cada cargo se repite una vez por línea, los dos factores deberían ser 2,03. Uno lo es (2,04). El otro no (2,60).
WITH f AS (SELECT numero_factura, COUNT(*) AS n, MAX(descuento_factura) AS d, MAX(flete_despacho) AS fl FROM ventas WHERE moneda = 'CLP' GROUP BY 1) SELECT ROUND(SUM(n * d) / SUM(d), 2) AS factor_descuento, ROUND(SUM(n * fl) / SUM(fl), 2) AS factor_flete, ROUND(AVG(n), 2) AS lineas_promedio FROM f; -- 2.60 · 2.04 · 2.03
Se lee en voz alta y se deja que lo digan ellos:
La fórmula, escrita en la pizarra:
factor = Σ (líneas × cargo) ÷ Σ (cargo)
Es el promedio de líneas por factura ponderado por el tamaño del cargo. Si el cargo no depende del tamaño de la factura (flete), el factor cae en 2,03. Si el cargo crece con la factura (descuento), las facturas grandes pesan más y el factor se va a 2,60.
Y el remate, que es la lección de oficio del día:
«El error no es aleatorio: está sesgado hacia las facturas grandes. Si fuera ruido, se compensaría solo y a nadie le importaría. Pero castiga justamente a tus clientes más importantes y a tus meses más fuertes. Un error con sesgo no se promedia: se acumula en la dirección equivocada.»
Una lámina por regla, con su ejemplo de Austral al lado. Se copian textuales al cuaderno.
«Una fila de fact_venta_linea es un producto vendido en una factura.»
Se escribe arriba del notebook, en una celda de texto, antes de cualquier CREATE TABLE. Todo lo que después entre a esa tabla tiene que respetar esa frase. Lo que no la respeta, no entra: se va a otra tabla.
dim_producto de Austral: 16 filas. dim_cliente: 18.«Si tu dimensión tiene un millón de filas, casi siempre es un hecho disfrazado. Si tu tabla de hechos tiene 40 columnas de texto, casi siempre le sobran dimensiones adentro.»
Un cliente, muchas líneas. Un producto, muchas líneas. Una fecha, muchas líneas.
«Si te encuentras una relación muchos-a-muchos, no la fuerces: te falta una tabla en el medio, y casi siempre es una tabla de hechos que todavía no descubriste. Eso es lo que nos va a pasar hoy, en cuarenta minutos, con el descuento.»
Un minuto de traducción de vocabulario, dentro del bloque, porque el enunciado del Parcial 2 puede venir en el otro idioma:
| Como lo decimos en clase | Como lo dice el enunciado |
|---|---|
llave natural (numero_factura + sku) | clave candidata — identifica la fila y sale de los propios datos |
llave surrogate (id_producto, la que construimos en la clase 11) | clave primaria artificial — la candidata que se eligió, inventada y estable |
| la relación 1:N que Power BI dibuja de la dimensión al hecho | clave foránea — la columna del hecho que apunta a la primaria de la dimensión |
Y la escalera completa, que se lee de arriba abajo: superclave → clave candidata → clave primaria → clave foránea.
«Eso dibujado es un modelo entidad-relación. Y el esquema estrella que estamos armando es un modelo entidad-relación con una forma particular: si el enunciado pide "modelo entidad-relación o esquema estrella", ustedes ya lo tienen. Lo que faltaba era reconocer el nombre.»
El bloque donde trabajan ellos. El docente reparte las tarjetas y plantea el contexto:
«Llega un correo de Control de Gestión: "desde el próximo cierre, la exportación del ERP va a traer seis columnas más". Ninguna viene con explicación. ¿Dónde va cada una en tu modelo?»
Mecánica, con reloj (probada, no improvisar):
| Columna nueva | Dónde va | La trampa |
|---|---|---|
costo_financiero_credito_clp | Medida → fact_venta_linea (o al hecho de cabecera, si viene por factura) | Directa. Sirve de modelo. Ojo: preguntar a qué grano llega. |
nombre_ejecutivo_cobranza | Atributo → dim_comercial (o dimensión propia si tiene atributos) | «¿Creamos dim_ejecutivo?» Depende de si tiene atributos propios (sucursal, meta, jefatura). Si es sólo un nombre, vive como atributo. |
tipo_cambio_dia | Atributo de dim_tiempo | ES NUMÉRICA Y NO SE SUMA. La trampa mayor. |
limite_credito_cliente_clp | Atributo de dim_cliente | La segunda trampa: también es numérica, y además pertenece al cliente, no a la venta. |
folio_nota_credito | Dimensión degenerada → se queda en el hecho | Identificador sin atributos propios. No merece tabla. |
dias_mora | Medida derivada, pero al grano FACTURA | La más fina: se calcula, sí — pero de la fecha de pago del documento, no de la línea. Es el problema del descuento otra vez. |
SELECT ROUND(SUM(tipo_cambio), 1) AS suma_del_tipo_de_cambio, ROUND(AVG(tipo_cambio), 1) AS promedio, COUNT(tipo_cambio) AS lineas_con_dato FROM ventas WHERE moneda = 'USD'; -- 269.881,8 · 882,0 · 306
«269.882 pesos por dólar. Ese número no significa absolutamente nada y ninguna base de datos te va a avisar. El promedio, 882, sí significa algo. Si lo puedes sumar pero la suma no significa nada, no es una medida: es un atributo que se disfraza de número.»
Cuando alguien cae —y siempre cae alguien—: «es EL error que comete todo el mundo la primera vez, yo incluido».
Pararse, estirar, subir la luz.
Éste es el bloque que convierte dos sesiones en una clase. Se dicta de pie, sin proyectar SQL.
«Descuento y flete viven al grano factura; el resto vive al grano línea. ¿Se reparten entre las líneas, o se guardan aparte en su propia tabla?»
Y el enlace con lo que acaban de escribir, que es lo que hace que la clase sea una y no dos:
«Fíjense en lo que acaba de pasar. La regla 1 dice que el grano se declara por escrito. La regla 3 dice que si te aparece un muchos-a-muchos te falta una tabla. Ydias_mora, que clasificamos hace tres minutos, ya nos lo dijo: hay columnas que llegan al grano factura y no al grano línea.
La respuesta a la pregunta colgada no es "reparto" ni "tabla aparte": son las dos, y hay que saber qué gana y qué pierde cada una. En los próximos cuarenta minutos las comparamos con números y después las cargamos las dos.»
Cada cargo se guarda una sola vez, donde de verdad ocurrió:
CREATE OR REPLACE TABLE fact_venta_factura AS SELECT numero_factura, COALESCE(TRY_STRPTIME(fecha_emision, '%Y-%m-%d'), TRY_STRPTIME(fecha_emision, '%d/%m/%Y'))::DATE AS fecha_key, MAX(descuento_factura) AS descuento_clp, MAX(flete_despacho) AS flete_clp, COUNT(*) AS lineas FROM ventas WHERE moneda = 'CLP' GROUP BY numero_factura, fecha_key;
SELECT COUNT(*) AS facturas, ROUND(SUM(descuento_clp)) AS descuento_total, ROUND(SUM(flete_clp)) AS flete_total FROM fact_venta_factura; -- 2.758 facturas · 416.964.602 · 209.502.530
-- 1) El cargo y el bruto de cada factura, una sola vez. CREATE OR REPLACE VIEW cabecera AS SELECT numero_factura, MAX(descuento_factura) + MAX(flete_despacho) AS cargo, COUNT(*) AS lineas, SUM(cantidad * precio_unitario) AS bruto FROM ventas WHERE moneda = 'CLP' GROUP BY 1;
-- 2) Lo que le toca a cada línea de la factura de las tres líneas. SELECT v.numero_factura, v.sku, v.linea_negocio, ROUND(v.cantidad * v.precio_unitario) AS venta_linea, ROUND(CASE WHEN c.bruto <> 0 THEN (v.cantidad * v.precio_unitario) / c.bruto ELSE 1.0 / c.lineas END * c.cargo) AS cargo_asignado FROM ventas v JOIN cabecera c USING (numero_factura) WHERE v.moneda = 'CLP' AND v.numero_factura = 'F-2022-000023'; -- ELE-1004 975.520 → 62.516 -- FIJ-4003 217.664 → 13.949 -- HER-2003 1.455.588 → 93.281
La respuesta profesional, escrita en la pizarra:
Se cargan las dos tablas.fact_venta_facturaguarda la verdad auditable; el reparto se calcula sobrefact_venta_lineacomo una medida declarada, con su regla escrita al lado. Nunca se guarda el reparto como si fuera un dato de origen.
Pregunta a la sala: «¿numero_factura va a una dimensión?»
Se deja que discutan 60 segundos, y después las dos preguntas que lo resuelven:
dim_cliente. La fecha es de dim_tiempo. El canal es de dim_comercial. No le queda nada.«Un identificador sin atributos propios no merece una tabla: se queda dentro de los hechos y se llama dimensión degenerada. numero_factura es el ejemplo perfecto, y es la llave que conecta las dos tablas de hechos.»
Y el matiz que hay que decir hoy para que no muerda en el Parcial 2:
«Si mañana el ERP empieza a mandar por factura el tipo de documento, la sucursal emisora y el estado de aprobación, entonces sí: dim_factura nace con derecho propio. Una dimensión degenerada lo es hasta que deja de serlo. Por eso el modelo se revisa cuando cambia la fuente, no cuando cambia el gusto.»
-- 1) Cada línea, con el total de SU factura al lado. CREATE OR REPLACE VIEW linea_clp AS SELECT numero_factura, linea_negocio, cantidad * precio_unitario AS venta_bruta, cantidad * costo_unitario AS costo_venta, descuento_factura, flete_despacho, SUM(cantidad * precio_unitario) OVER (PARTITION BY numero_factura) AS bruto_factura, COUNT(*) OVER (PARTITION BY numero_factura) AS lineas_factura FROM ventas WHERE moneda = 'CLP';
-- 2) Qué parte de los cargos le toca a cada línea. CREATE OR REPLACE VIEW reparto AS SELECT *, CASE WHEN bruto_factura <> 0 THEN venta_bruta / bruto_factura ELSE 1.0 / lineas_factura END AS cuota FROM linea_clp;
-- 3) El margen por línea de negocio, de las dos maneras. SELECT linea_negocio, ROUND(100.0 * (SUM(venta_bruta) - SUM(costo_venta) - SUM(descuento_factura) - SUM(flete_despacho)) / SUM(venta_bruta), 2) AS margen_naive_pct, ROUND(100.0 * (SUM(venta_bruta) - SUM(costo_venta) - SUM((descuento_factura + flete_despacho) * cuota)) / SUM(venta_bruta), 2) AS margen_repartido_pct FROM reparto GROUP BY linea_negocio ORDER BY margen_repartido_pct DESC;
| Línea de negocio | Margen naive | Margen con reparto | Brecha |
|---|---|---|---|
| Seguridad | 20,79 % | 32,76 % | 11,97 pp |
| Herramientas | 21,76 % | 27,05 % | 5,29 pp |
| Eléctrico | 8,66 % | 19,74 % | 11,08 pp |
| Fijaciones | −13,22 % | 12,45 % | 25,67 pp |
Cinco segundos de silencio en la última fila. Y la pregunta a la sala:
«Si eres el gerente y ves que Fijaciones pierde 13,22 % en cada peso vendido, ¿qué haces?»
Van a decir: la cierro, la reviso, cambio de proveedor. Todas razonables.
«La línea gana 12,45 %. La decisión de cerrar un negocio rentable no se toma por mala fe ni por mal criterio comercial: se toma porque alguien sumó una columna que no se podía sumar. Fijaciones es la más castigada porque es la línea de tickets chicos: sus líneas conviven con otras en facturas grandes y absorbe el descuento y el flete completos de facturas donde apenas participa.»
Los cuatro números del margen bien calculado, que se anotan y vuelven en la clase 13: Seguridad 32,8 % · Herramientas 27,1 % · Eléctrico 19,7 % · Fijaciones 12,5 %.
Cada estudiante construye su propio modelo y lo verifica. Las verificaciones se ejecutan, no se asumen.
ElCOALESCErescata las 21 fechas escritasdd/mm/yyyyque unCASTdirecto habría convertido enNULLen silencio. Es la lección de la clase 04, cobrada aquí.
-- 1) La dimensión del tiempo. CREATE OR REPLACE TABLE dim_tiempo AS WITH f AS ( SELECT COALESCE(TRY_STRPTIME(fecha_emision, '%Y-%m-%d'), TRY_STRPTIME(fecha_emision, '%d/%m/%Y'))::DATE AS fecha_key FROM ventas ) SELECT DISTINCT fecha_key, EXTRACT(year FROM fecha_key) AS anio, EXTRACT(month FROM fecha_key) AS mes, EXTRACT(quarter FROM fecha_key) AS trimestre FROM f;
SELECT COUNT(*) AS dias, MIN(fecha_key) AS desde, MAX(fecha_key) AS hasta FROM dim_tiempo; -- 1.032 días · 2022-01-03 → 2024-12-27
-- 2) El hecho al grano LÍNEA (el grano declarado: un producto vendido en una factura) CREATE OR REPLACE TABLE fact_venta_linea AS SELECT numero_factura, -- dimensión degenerada COALESCE(TRY_STRPTIME(fecha_emision, '%Y-%m-%d'), TRY_STRPTIME(fecha_emision, '%d/%m/%Y'))::DATE AS fecha_key, sku, cantidad, precio_unitario, costo_unitario, cantidad * precio_unitario AS venta_bruta, cantidad * costo_unitario AS costo_venta FROM ventas WHERE moneda = 'CLP';
(El hecho al grano FACTURA, fact_venta_factura, ya quedó creado en el bloque de las 21:23.)
-- V1 · conteo cruzado: no se perdió ni se duplicó una línea SELECT (SELECT COUNT(*) FROM ventas WHERE moneda = 'CLP') AS origen, (SELECT COUNT(*) FROM fact_venta_linea) AS hechos; -- 5.605 = 5.605
-- V3 · el cargo, contado una sola vez (la insignia del modelo) SELECT (SELECT ROUND(SUM(descuento_clp) + SUM(flete_clp)) FROM fact_venta_factura) AS cargo_una_vez, (SELECT ROUND(SUM(descuento_factura) + SUM(flete_despacho)) FROM ventas WHERE moneda = 'CLP') AS cargo_plano; -- 626.467.132 contra 1.511.313.216
CREATE OR REPLACE VIEW total_factura AS SELECT numero_factura, SUM(venta_bruta) AS bruto FROM fact_venta_linea GROUP BY 1;
CREATE OR REPLACE VIEW rep AS SELECT l.venta_bruta, l.costo_venta, CASE WHEN t.bruto <> 0 THEN l.venta_bruta / t.bruto ELSE 1.0 / f.lineas END * (f.descuento_clp + f.flete_clp) AS cargo_linea FROM fact_venta_linea l JOIN total_factura t USING (numero_factura) JOIN fact_venta_factura f USING (numero_factura);
SELECT ROUND(100.0 * (SUM(venta_bruta) - SUM(costo_venta) - SUM(cargo_linea)) / SUM(venta_bruta), 2) AS margen_pct, ROUND(SUM(cargo_linea)) AS cargo_repartido_total FROM rep; -- 24,59 % · 626.467.132
Ahí está la verificación más elegante del semestre: el total repartido entre las líneas (626.467.132) es exactamente el total que la empresa cobró una sola vez. Si al repartir te sobra o te falta un peso, el reparto está mal.
Y la trampa fina del CASE, dicha en 40 segundos: hay 9 facturas en pesos cuyo bruto es cero (todas sus líneas salieron a precio 0: las muestras y reposiciones de garantía, las 43 líneas de la clase 05). Sin la rama ELSE 1.0 / lineas no se puede dividir por cero, y sus fletes se pierden en silencio: el margen sube a 24,60 % y nadie se entera.
«Una centésima. Y esa centésima es la diferencia entre un modelo que sabe qué hizo con cada peso y uno que redondeó el problema. Los pesos no se evaporan: se pierden en el ELSE que no escribiste.»
La elección entre A y B se declara por escrito en el notebook, ahora, en una celda de texto. Es entregable.
Los dos roles en la misma lámina, con reloj: 3 minutos cada uno.
Control de Gestión: «tipo_cambio_dia. La pregunta no es dónde lo pongo: es ¿qué es? ¿Es un dato del negocio o un parámetro del cierre?»
— Si es el tipo de cambio observado del día que publica el Banco Central, es un atributo del día: va adim_tiempo, y sirve para todas las facturas de esa fecha.
— Si es el tipo de cambio del cierre que la empresa fija una vez al mes para valorizar, entonces no es un dato del negocio: es un parámetro contable, cambia cuando Contabilidad lo decide, y ponerlo endim_tiempote va a dar diferencias con el balance.
El mismo número, dos modelos distintos. Y la respuesta no está en el archivo: está en Control de Gestión.
Contabilidad: se proyectaplan_cuentas.json, dos líneas:
````
5-02-001 Fletes de despacho tipo: Costo
6-01-001 Remuneraciones fuerza de venta tipo: Gasto
«¿El flete de despacho es costo de venta o gasto de distribución?
En este plan de cuentas está como costo, y por eso todo lo que hemos calculado lo resta del margen bruto: 24,59 %. Si mañana Contabilidad decide que es gasto de distribución —porque es el costo de llevar el producto, no de comprarlo—, sale del margen bruto y baja a resultado operacional. El margen bruto de Austral pasa a 26,89 %.
2,3 puntos, 209.502.530 pesos, y ni una sola fila cambió. La cuenta contable decide dónde va en el modelo, y el modelo decide qué número ve el directorio.»
Y la regla del curso, otra vez, en la pizarra: una consulta SQL sin una conversación previa es una opinión con formato de tabla.
Síntesis, construida con ellos (se pregunta, ellos responden, el docente escribe):
numero_factura es la degenerada que las conecta.Ticket TC10 (3 min, se proyecta): a) ¿Cuál de las tres reglas del modelador obliga a decidir qué hacer con el descuento? Una frase. b) Reparto proporcional o segunda tabla de hechos al grano factura: elige una para el descuento de Austral y di qué pierdes con la que descartaste. c) 1–5 y qué te falta.
Anki del día: las tres reglas · los dos factores (2,60 / 2,04) · dimensión degenerada · las cuatro verificaciones · 24,59 contra 26,89.
Pregunta que queda planteada (se escribe en la pizarra y no se responde hoy):
→ El hecho está cargado y cada fila repite el segmento, la región y la descripción del producto. ¿Cuánto cuesta esa repetición, y cuándo empieza a doler?
Tres minutos. No suma nota. Sirve para armar la apertura de la próxima clase.
El notebook de esta clase:
https://gaa1104.vercel.app/notebooks/clase-10.ipynb