两项预留各结算 200 元,日报中的租金合计也是 400 元。把日报与结算表按预留编号连接,结算金额却变成 600 元。SQL 没有语法错误,外键也全部有效,错误来自连接两端记录代表的事情不同。

本篇把设备租赁数据整理为一个可执行的 SQLite 分析样本,再施加门店归属变更。实验区分按天确认的收入、按预留结算的金额及设备归属版本,检查粒度混算、历史改写与晚到事实的影响。金额全部使用整数分,教学样本不涉及税费、折扣或汇率。

先说清一行代表什么

rental_day 的一行表示某项预留在某个服务日确认的收入。R1 跨两天,每天 100 元,因此有两行;R2 只占一天,收入 200 元,因此有一行。主键是预留编号与服务日,但业务解释先于这组键:它记录一天的收入事实。

settlement 的一行则表示一项预留的结算金额。R1、R2 各有一行、各 200 元。这个表不能直接承载任意多次付款或退款;若结算支持分期,应重新声明粒度并调整唯一性。字段名称叫金额,不足以说明它可以与另一张表里的金额逐行相加。

Kimball 将粒度定义为单行事实代表的测量事件,并要求候选事实和维度与之保持一致。本例选择的日收入规则是教学假设,不能由租赁开始、结束时间自动推导出会计确认政策。Grain,Kimball Group

flowchart LR
    E["设备维度版本<br/>代理键、设备编号、门店、有效区间"] -->|equipment_sk| D["日收入事实<br/>一项预留 × 一个服务日"]
    D --> A["按预留汇总日收入"]
    S["结算事实<br/>一项预留一行"] --> J["共同粒度上比较"]
    A --> J
    D -. "直接按预留连接会重复结算额" .-> S

第一张图中的设备维度提供当时的门店背景。预留编号留在事实中作为业务标识;这个小样本没有为了套用星型图而单独创建只有一个编号字段的预留维度。完整模型是否需要日期维度、客户维度,要由查询中的描述与分组需求决定。

连接放大的是哪一个度量

R1 在日收入表有两行。连接结算表后,R1 的 200 元被复制到两行,R2 的 200 元出现一次,合计就成了 600 元。复制发生在关系连接阶段,后面的 sum 只是忠实累加了已经重复的数据。

把它改成 sum(distinct invoice_cents) 也不正确。样本两项结算恰好都为 200 元,按金额去重只剩一个数,结果变成 200 元。金额相等不表示同一张结算单;去重依据与业务身份不一致,会把两条真实事实合并。

实验先将日收入按预留汇总,再与每项预留一行的结算表连接,两边总额都恢复为 400 元。这次比较有一个明确前提:样本中每项预留都有结算记录。真实系统若存在未结算预留,还要选择外连接或另列缺失项,不能让它们静默消失。

Kimball 的多遍查询建议分别聚合不同事实表,再按共同维度结果对齐,以免事实直接连接造成不可控基数。本例在预留层做了一个受限实现,不包含完整的多事实数据集市。Multipass SQL,Kimball Group

金额能加,不代表所有数字都能加

日收入在本样本中可以跨日相加,因为各行表示不同服务日的新增确认额。如果换成每天收盘时的应收余额,连续两日相加通常不再表示总应收;它们是同一存量在不同时间的观察。两列同为整数,也有不同的汇总语义。

本实验没有建立余额事实表,因此不把这一点报告成已测试能力。它提示粒度声明应同时说明度量含义、单位与允许汇总的轴。比率还需要保留分子、分母的定义,按行平均利用率不一定等于整体设备利用率。

交易领域模型需要决定是否允许确认、取消或延期;分析模型需要回答哪些金额属于哪个服务日、哪个门店。后者可以展平描述并保存冗余背景,但不应因此替代交易端的约束。报表算对了 400 元,并不能证明预留没有重复承诺。

一台设备对应多个历史版本

设备 E1 在 10 月 3 日从 North 调到 South。维度表保留两行:代理键 1 的有效区间到 10 月 3 日之前,代理键 2 从该日开始。两行共享业务编号 E1,但代表不同时间段的描述版本。

有效区间采用左闭右开。10 月 2 日的事实关联版本 1;恰好在 10 月 3 日的事实关联版本 2。这个边界让相邻版本不用同时包含交接日,也避免把结束日期解释成“当天最后一秒”这样的精度假设。

flowchart TB
    ID["设备业务编号 E1"] --> V1["代理键1:North<br/>10月1日 ≤ 日期 < 10月3日"]
    ID --> V2["代理键2:South<br/>10月3日 ≤ 日期"]
    F1["R1 / 10月2日 / 100元"] --> V1
    F2["R1 / 10月3日 / 100元"] --> V2
    F3["R2 / 10月3日 / 200元"] --> V2
    V1 --> H["按发生时归属:North100、South300"]
    V2 --> H

第二张图表达的是服务发生时的归属。Type 2 通过增加版本行与新的代理键保存属性变化,事实指向适用版本。本例只对门店这个属性采用该策略,没有把所有设备字段都无条件复制成历史。Type 2: Add New Row,Kimball Group

三种连接回答三个不同的问题

按事实里的代理键连接,结果为 North 100 元、South 300 元。改为通过 E1 查当前版本,两天收入都会被分到 South,得到 South 400 元。后者可能适合“按当前组织看历史业务”的报表,但它不再回答发生时归属的问题。

如果直接按业务编号连接所有历史版本,每条事实都会遇到 E1 的两行维度,总收入被翻倍为 800 元。它既不是发生时口径,也不是当前口径,只是遗漏版本选择条件造成的重复匹配。

因此,Type 1 覆盖更新、Type 2 版本保存和查询时重分类要分开决定。历史报表是否允许随组织变化重算,是业务问题;同一数据平台可以提供两个口径,但列名、说明与查询入口需要让读者知道选中了哪一个。

晚到事实按发生日找版本

一条 10 月 2 日的收入在设备调店后才到达,并不应因此关联 South。实验调用按日查询,仍得到代理键 1。样本没有消息队列,只验证查找函数根据服务日选择版本,而不是根据导入时刻选择当前版本。

查找必须恰好命中一行。对于版本历史之前的日期,函数拒绝;人为插入与已有区间重叠的第三个版本后,10 月 3 日会命中两行,函数同样拒绝。这避免用“随便取第一条”掩盖缺失或矛盾的历史。

SQLite 表上的区间起止检查只保证单行起点早于终点,没有自动阻止跨行重叠。本例把多版本冲突留给查找函数发现,并明确记录这个限制。完整装载流程通常还需要在写入维度时检测冲突,而不能等所有报表查询才失败。

日事实主键拒绝同一预留、同一天的重复装载,外键拒绝不存在的代理键。这些约束不会自动检查服务日与被引用版本的有效区间一致;本例采用受控输入和按日查找。若对外开放任意写入,还需要补上时间一致性检查。

发生时间、装载时间与修正时间

本例只保存服务日和维度的业务有效日期,没有保存仓库何时获知调店事实。假如调店记录本身晚到,先前装入的日事实可能已经关联了错误版本;单次按日查找不能自动修正这些旧外键。是否重挂事实、重算报表以及保留重算前版本,需要另外制定政策。

更复杂的追溯问题还包括“按当时已知信息看报表”和“按后来纠正的信息看历史”。两者要求区分事实何时有效与系统何时记录。本例只有第一条时间轴,不声称通过两行 Type 2 维度就解决了所有双时态查询。

日事实的复合主键可以发现重复装载,但不能决定重发数据是重复消息还是更正。相同预留与日期收到不同金额时,直接忽略、覆盖或增加调整事实会产生不同审计含义。当前实验只验证数据库拒绝重复键,未实现自动冲正或幂等更正协议。

跨粒度对齐也可能丢失分析能力。日收入汇总到预留后可以与结算比较,但结算金额仍不能无依据地分摊到每天。如果需求改成门店日结算额,需要给出分摊或确认规则;将金额平均除以天数只是一个候选政策,不能由连接写法替代业务决定。

复跑与证据

1
bash examples/software-modeling/labs/E02/run.sh

运行使用 Python 标准库内置的 SQLite,实际完成 15 个场景,全部通过,退出码为 0。原始 SQL、查询输出和 SQLite 版本随实验保存;historical-report.json 记录历史归属,tested-state.sql 包含实验最后故意加入的重叠版本,不能当作合格生产数据。

E02维度建模实验包 保留仓库目录即可运行。这个模型服务于分析查询,没有修改已有租赁应用,也没有实现完整 ETL、迟到数据补偿或财务对账。继续增加事实表之前,需要为新的每一行重新确定测量事件与历史口径。