我给一家大公司的 BI 工具写的第一个合同模型有大约八十行 SQL,我当时觉得挺好。一个合同、一个开始日期、一个结束日期,下面挂一些条件。读起来很干净。一年之后,同一个模型我已经重写了十几次,其中一次还改了名字,因为那个名字在撒谎。
这不是规划不当。而是领域只有在真实数据进来时才看得见。
为什么第一版模型总是看起来是对的
因为你是对着别人讲给你的领域写的,不是对着真实存在的那个。他们说"合同有结束日期"。你就这么建模。它对你看的那二十行有效。
第一个警告来自名字。这个模型原本叫 kpi_02_20_revenue_contracts,我把它改成了 kpi_02_20_rent_contracts。它不是 revenue,是 rent:公司付钱的租赁合同,不是收钱的。符号相反。名字来自工单,不是来自表。我用一个名字和内容相反的模型工作了一个月。
名字取错不是装饰问题。有人会读它,然后照着假设。
只有真实数据进来才会出现的东西
结束日期是个无底洞。表里有两列:DT_FIRST_CONTRACT_END 和 DT_CONTRACT_END_NEXT。统一日期的第一版是这样:
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
看起来很周全。其实不是。缺了两个都为空的情况,也缺了第一个在过去而第二个不存在的情况。在数据里,这两种情况含义相同:无限期合同。这个 case 把它们送去了一个意外的 null,之后被统计成"已过期"。最终版本加上了这两个显式分支,并在 is_unlimited 标志里加了 when dt_end_merged is null then True,因为那里的空值是信息,而不是信息的缺失。
而这个标志本身已经背着另一件现实世界的宝贝:
when year_end_merged in (8909, 8899, 9999) then True
三个不同的哨兵年份,都表示"没有结束"。没有一个有文档。三个都在生产里。
我们推翻决定的三次
最有代表性的是 99 这个年份。源头 Excel 里有些结束日期的年份写作 99,团队里有人说那意味着"无限期"。我加了处理:
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
以及对应的过滤条件,一个 or ... like '%99%',好让那些行不会被日期窗口筛掉。三天后我把它删了。like '%99%' 会匹配 1999、匹配 2099,还匹配任何日或月里带 99 的日期。我把二十年前就已终止的合同塞进了活跃 revenue 的计算里。业务规则也许是真的;用 like 对日期字符串的实现方式不是。
两个月后同一个问题从另一扇门回来了,在重新谈判的模型里:两位数年份。这次我是对已提取出来的年份做的,不是对文本:
case
when year(normalized_next_possible_termination_old_contract) < 100
then year(...) + 2000
else year(...)
end
这个也没能原样存活:两周后它在文件里被注释掉了,前面带一个 -- !。同一列的第三轮。
另一次大回滚是合同和条件之间的 inner join。没有条件的合同不会出现。改成 left join 改变了仪表盘上的数字,而这恰恰是必须和提出这个 KPI 的人谈的那个对话:一个没有条件的合同算不算。第一次是我一个人用 inner 决定的,而且没意识到我在做决定。
我怎么知道一个模型已经稳定了
看提交的类型。头一年的提交是结构性的:join 在变、过滤条件进进出出、列在改名。项目末期的提交是另一类:去掉一个乘 100、把两个 case 合成一个、写列的文档。这说明这个模型底下已经不动了,只在上面动。
另一个信号是文档的 .yml 从四条含糊的要点,变成列出精确的筛选标准:实体、合同类型、货币、排除什么以及为什么。当你不看 SQL 就能写出这份清单时,这个模型才真正存在。在那之前你有的只是一个查询。
我现在在第一个 CREATE TABLE 之前做什么
以前我先写模型再看数据。现在反过来,而且按这个顺序:
- 数一数每个将进入条件判断的列有多少空值。 一个 40% 是空值的列不是一个日期,而是两个概念混在一起。
- 找哨兵值。
select distinct year(...) order by 1 desc。如果出现 9999、8899 或 2099,那里藏着没有文档的业务规则。 - 先用散文写过滤条件,再写 SQL。 如果我没法用一句话说清哪些合同算进来,那个
where最后会有六个互相矛盾的条件和两个被注释掉的。 - 在类型上做规范化,绝不在字符串上做。 任何对日期使用
like的地方都是一颗定时炸弹。
领域模型不是设计出来的:它是和数据谈判出来的,而数据不会让步。我每次推翻什么,都是因为我自己决定了本该去问的事。
