Durante un mes hubo en producción un modelo que se llamaba kpi_02_20_revenue_contracts y que contenía contratos de alquiler en los que la empresa paga. Lo contrario de revenue. El nombre venía del ticket, yo lo copié, y nadie lo cuestionó porque el nombre sonaba bien. Cuando lo renombré a kpi_02_20_rent_contracts no cambió ni una fila, pero cambió cómo lo leía todo el que pasaba por ahí. Un nombre así no es cosmética: alguien lo lee, asume el signo, y construye encima.
Ese modelo era parte de una herramienta de BI para una empresa grande. La primera versión tenía unas ochenta líneas de SQL, un contrato con su fecha de inicio, su fecha de fin y unas condiciones colgando. Un año después lo había reescrito más de diez veces. Mirando el historial ahora, lo que me llama la atención no es el número, sino que casi todas las reescrituras que fueron marcha atrás tienen la misma forma: había una pregunta de negocio escondida en el SQL, yo la había contestado sin darme cuenta, y la respuesta estaba mal.
Aviso de que esto es distinto de diseñar el esquema de tu propia app. Ahí, si una tabla está mal, la cambias. Aquí los datos venían de sistemas y Excel ajenos, y eran los que eran. El modelo es lo único que puedes tocar, así que todo el desorden del mundo real acaba metido en sus case.
El case que parecía exhaustivo
La tabla de origen tenía dos columnas de fecha de fin, DT_FIRST_CONTRACT_END y DT_CONTRACT_END_NEXT, y había que sacar una sola. Mi primera versión:
case
when c.DT_FIRST_CONTRACT_END is null then c.DT_CONTRACT_END_NEXT
when c.DT_CONTRACT_END_NEXT is null then c.DT_FIRST_CONTRACT_END
when c.DT_FIRST_CONTRACT_END < CURRENT_DATE() then c.DT_CONTRACT_END_NEXT
else c.DT_FIRST_CONTRACT_END
end as dt_end_merged
Lo miré varias veces y me parecía que cubría todo. No cubría dos casos: que las dos fechas fueran nulas, y que la primera estuviera en el pasado sin que existiera la segunda. En los datos, los dos casos querían decir lo mismo, contrato indefinido. Mi case los mandaba a un null que nadie había elegido, y más abajo ese null se contaba como expirado. O sea, que la consulta estaba respondiendo a "¿un contrato sin fecha de fin está vivo o muerto?" y respondía "muerto", y yo no sabía que me lo había preguntado.
La versión final añade esas dos ramas de forma explícita y, en el flag is_unlimited, un when dt_end_merged is null then True. Ahí el nulo lleva información, y hay que tratarlo como un valor más.
Ese mismo flag arrastraba ya otra perla:
when year_end_merged in (8909, 8899, 9999) then True
Tres años centinela diferentes para decir "sin fin". Ninguno documentado en ningún sitio. Me imagino que cada uno lo introdujo alguien distinto en una época distinta del sistema de origen, pero eso es especular; lo único seguro es que estaban los tres en producción.
El año 99, o cómo tener razón en la regla y no en el código
En los Excel de origen había fechas de fin cuyo año venía como 99, y alguien del equipo sostenía que eso significaba "indefinido". Me lo creí y añadí esto:
case
when {{ normalize_date('ew.WARUNEK_WA_NY_DO') }} like '%99%' then '2099-12-31'::date
else {{ normalize_date('ew.WARUNEK_WA_NY_DO') }}::date
end
Más un or ... like '%99%' en el filtro para que esas filas no se cayeran por la ventana de fechas. Tres días después lo quité. Un like '%99%' sobre una fecha convertida a texto casa con 1999, con 2099 y con cualquier fecha que tenga un 99 en algún sitio del string. Estaba metiendo en el revenue activo contratos que se habían cerrado veinte años antes. Puede que la regla de negocio fuera cierta; la implementación con like sobre un string no lo era.
Dos meses más tarde volvió el mismo problema por otra puerta, en el modelo de renegociaciones: años de dos dígitos. Esta vez lo hice sobre el año ya extraído y no sobre el texto (abrevio la expresión con ...):
case
when year(normalized_next_possible_termination_old_contract) < 100
then year(...) + 2000
else year(...)
end
Tampoco duró. Dos semanas después estaba comentado en el fichero con un -- ! delante. Tercera vuelta sobre la misma columna, y a día de hoy no te sabría decir con total seguridad cuál es la interpretación correcta de esos años. Sé cuál es la que está en producción.
El inner join que nadie pidió
El rollback que más me enseñó no tiene nada de ingenioso. Contratos y condiciones iban unidos con un inner join, y un contrato sin condición simplemente no aparecía. Cuando lo cambié a left join, cambiaron los números del dashboard.
Esa es exactamente la conversación que hay que tener con quien pide el KPI: si un contrato sin condición cuenta o no cuenta. Es una pregunta de negocio perfectamente legítima, con dos respuestas razonables. La primera vez la contesté yo, solo, escribiendo inner porque es lo que se escribe por defecto. No decidí nada conscientemente; el tipo de join decidió por mí.
Ya sé la objeción: "eso es un problema de requisitos, habla con negocio antes de escribir SQL". En parte sí, y lo concedo. Pero hablé con negocio. Lo que me contaron fue "un contrato tiene fecha de fin" y "cada contrato tiene sus condiciones", y las dos frases eran verdad para la mayoría de filas. Las preguntas buenas, la de los nulos, la de los centinelas, la del contrato huérfano, no aparecen en una reunión. Aparecen cuando miras los datos, y entonces tienes que volver a la reunión con la pregunta concreta. Mi error fue contestarlas en el SQL en vez de volver con ellas.
Cuándo dejó de moverse
Lo noto en el tipo de commit. Durante casi todo el año eran estructurales: joins que cambian, filtros que entran y salen, columnas que se renombran. Al final eran de otra clase: quitar un multiplicador por 100, fusionar dos case en uno, escribir la documentación de columnas. El modelo había dejado de moverse por debajo y solo se tocaba por encima.
La otra señal fue el .yml de documentación. Empezó con cuatro bullets vagos y acabó listando los criterios de selección exactos: qué entidades, qué tipos de contrato, qué moneda, qué se excluye y por qué. Cuando fui capaz de escribir esa lista sin tener el SQL abierto al lado, el modelo por fin representaba algo. Antes era una consulta que daba números.
Lo que hago ahora antes de escribir la primera línea
Primero miro los datos, y luego escribo el modelo. Parece evidente dicho así, pero yo lo hacía al revés. En concreto:
- Cuento los nulos de cada columna que vaya a acabar en un
caseo unwhere. Si una fecha tiene un 40% de nulos, probablemente ahí hay dos conceptos mezclados, y quiero saber cuáles antes de fusionarla con otra. - Busco centinelas con algo tan tonto como
select distinct year(...) order by 1 desc. Si salen 9999, 8899 o 2099, hay reglas sin documentar esperándome. - Hago una lista de las decisiones que el SQL va a tomar (qué pasa con los nulos, qué join, qué se excluye) y se la mando a quien pide el KPI como preguntas, no como afirmaciones. Suele ser un mensaje de cinco líneas, y me habría ahorrado al menos tres de las reescrituras.
- Escribo el filtro en una frase antes que en SQL. Si no sé decir qué contratos entran, el
whereva a acabar con seis condiciones contradictorias y dos comentadas. - Normalizo sobre tipos, nunca sobre strings. Un
likesobre una fecha es una bomba de relojería, lo aprendí con el 99.
El tercero es el que más me cuesta, porque preguntar se siente como no saber hacer tu trabajo. Pero cada rollback de ese modelo fue una pregunta que me ahorré por orgullo o por prisa, y que acabé pagando con un dashboard que había enseñado números equivocados.
