七 · 一个完整案例:五周与三个事故
18 个类、5 条约束、二十余人日、每周对账
前六章讲的是「应该怎么做」。这一章讲「实际怎么发生」——一家中型制造企业用五周时间,把三个反复出错的口径问题,做成一套能拒绝写入、能每周对账的操作本体。所有数字与产物都按真实项目的量级构造,命名为通用化处理,不涉及任何一家具体公司的数据。
选择供应链履约作为案例,是因为它同时具备三个特征:跨系统(ERP、WMS、QMS、BI 四套)、有监管(到货与质检要能追溯)、口径争议多(准时率、合格率、周转天数各自都有三种算法)。这正是本体能产生价值、也最容易被做成无用文档的场景。
起点:三个真实发生的事故
不是设想,是立项的直接原因。
事故一:同一个指标,三个部门三个值。 「准时到货率」在运营看板是 98.2%,供应链周报是 95.1%,财务的供应商考核是 91.7%。差异不是数据错误,是三个定义:运营按「承诺到货日」算、供应链按「订单确认日 + 标准交期」算、财务按「发票结算周期倒推」算,分母还分别是「全部订单」「非取消订单」「已收货订单」。
三个数都对。这就是本体要解决的第一类问题——不是算错,是没有唯一的口径定义。
事故二:被冻结的供应商仍在供货。 供应商冻结状态存在 ERP 的一个备注字段(自由文本),WMS 收货时看不到它。结果是一家因质量问题被冻结的供应商,在冻结后 11 天又送了两批货,且正常入库。
这是第二类问题——约束存在,但不在写入路径上。
事故三:新增物料分类后,三张报表静默少数据。 质管部新增了一个「返修件」分类,下游三张报表的过滤器里没有它,于是这部分数据被静默排除。没有人报错,两周后才发现某指标掉了 4 个点。
第三类问题——变更的影响面不可见。
三个事故对应本体的三种能力:唯一口径、门控写入、变更影响面。这三件事正好是这本书反复讲的三个价值点,所以这个案例的结构就是围绕它们展开。
第 0 周:20 条问题,不是 200 个类
建模的第一件事不是画类图,是写问句。团队花了三天,从三个部门收集了四十多条问句,去重合并成 20 条 competency question(CQ)。其中 10 条最关键:
| # | 问句 | 由谁提出 |
|---|---|---|
| Q1 | 上个月每个供应商的准时到货率是多少?口径是什么? | 供应链 |
| Q2 | 哪些订单的到货时间晚于承诺日超过 3 天? | 运营 |
| Q3 | 某个物料当前可用的供应商有几家,其中被冻结的是哪几家? | 采购 |
| Q4 | 每一批到货对应的质检结论是什么,谁签的字? | 质管 |
| Q5 | 某个供应商在过去 12 个月里被冻结过几次,原因是什么? | 合规 |
| Q6 | 某个指标的口径最近一次变更是什么时候,谁批的? | 财务 |
| Q7 | 如果我把「返修件」并入统计,哪些报表会变? | 质管 |
| Q8 | 订单的承诺到货日、实际到货日分别由哪个系统提供? | 数据团队 |
| Q9 | 哪些订单既没有质检结论也没有豁免记录? | 审计 |
| Q10 | 上周新增的约束拦下了多少条数据,都是什么类型? | 管理层 |
这 20 条问句做了三件事:定义了范围(只涉及订单、供应商、物料、批次、质检、指标六类对象)、暴露了争议(Q1 的三个口径必须选一个)、以及给出了验收标准(每一条后面都要能被一条查询回答)。
第 7 条(Q7)后来成了整个项目最有价值的一条——它把「影响面」从抽象概念变成了一个具体的、有人等着要的答案。
一个反直觉的发现:四十多条问句里,有十余条是「谁改的/什么时候改的/谁批的」。也就是说,近四分之一的需求是溯源,不是查询。这直接决定了后面的设计:所有变更必须留痕,且溯源信息要能被人读到,而不只是存在日志里。
第 1 周:18 个类、6 条关系、2 个枚举
范围定在六类对象,展开成 18 个类:
Supplier ──供应──> Material ──包含──> OrderLine
│ │
└─冻结记录─> FreezeEvent PurchaseOrder ──到货──> Batch
│ │
└─承诺/实际到货日 └─质检─> Inspection
│
Finding(缺陷项)
Metric(指标) ──定义于──> MetricDefinition ──变更──> DefinitionChange
└── owner ──> OrgUnit
6 条关系:supplies / contains / arrivesAs / inspectedBy / definedBy / ownedBy
2 个枚举:FreezeReason(quality / compliance / financial / other)
InspectionResult(pass / fail / conditional)
三个设计决定,每个都有代价
决定一:不用 OWL,用 SHACL + 少量 RDFS。 理由来自第 11 章的实测:OWL 在开放世界假设下不会因为「缺一个责任人」而报错,而 Q9 要的正是「哪些订单缺东西」。选 OWL 等于选了一个不能回答核心问题的工具。
代价:放弃推理。Q1、Q3 这类问题本来可以靠推理从「物料属于某类别」推出「该类别的供应商集合」,现在必须显式建模或在查询里 JOIN。团队接受了这个代价——因为这些问题用 SPARQL 一样能回答,而「拒绝写入」没有替代品。
决定二:指标的口径作为一等对象,而不是注释。 MetricDefinition 单独成类,带 numerator、denominator、timeBasis、version、approvedBy。这是本案例最关键的一步:把「准时到货率怎么算」从文档里搬进模型,让它有版本、有审批人、有生效日期。
代价:指标定义变更要走变更流程,比改一段 SQL 慢。这个代价正是价值所在。
决定三:冻结状态不在备注字段里,而是独立事件。 把 ERP 备注里的「冻结」抽成 FreezeEvent(供应商、原因、时间、操作人、解除时间)。这是一次数据迁移,花了 1.5 天。
代价:ERP 侧的备注字段仍然存在,两边会不同步。解决办法是单向同步 + 每周对账(第 4 周做)。
这三个决定合起来,就是第 10 章那个 A 形态:不引入图数据库、不引入推理机、不建新端点。模型是文件,校验是命令,对外只有一张视图和一份报表。
第 2 周:5 条约束,首跑约 4% 违规
第一批只写了 5 条约束。选择标准很功利:每条约束必须对应一个已经发生的事故或一条 CQ。
# C1 对应事故二:订单必须指向供应商与物料(引用完整性)
ex:OrderLineShape a sh:NodeShape ;
sh:targetClass ex:OrderLine ;
sh:property [ sh:path ex:supplier ; sh:minCount 1 ; sh:class ex:Supplier ;
sh:message "订单行必须有供应商" ] ;
sh:property [ sh:path ex:material ; sh:minCount 1 ; sh:class ex:Material ;
sh:message "订单行必须有物料" ] .
# C2 对应事故二:已冻结供应商不得产生新批次(跨对象一致性)
ex:BatchShape a sh:NodeShape ;
sh:targetClass ex:Batch ;
sh:sparql [
sh:message "批次来自已冻结供应商" ;
sh:select """
SELECT $this WHERE {
$this ex:fromSupplier ?s .
?s ex:hasFreeze ?f .
?f ex:freezeEnd ?end .
FILTER (?end > $this ex:arrivalDate || !BOUND(?end))
}
""" ] .
# C3 对应 Q9:质检结论必须引用批次与签字人
ex:InspectionShape a sh:NodeShape ;
sh:targetClass ex:Inspection ;
sh:property [ sh:path ex:inspects ; sh:minCount 1 ; sh:class ex:Batch ] ;
sh:property [ sh:path ex:signedBy ; sh:minCount 1 ] .
# C4 对应事故一:指标定义必须有口径四要素(分子/分母/时间基准/审批人)
ex:MetricDefinitionShape a sh:NodeShape ;
sh:targetClass ex:MetricDefinition ;
sh:property [ sh:path ex:numerator ; sh:minCount 1 ] ;
sh:property [ sh:path ex:denominator ; sh:minCount 1 ] ;
sh:property [ sh:path ex:timeBasis ; sh:minCount 1 ] ;
sh:property [ sh:path ex:approvedBy ; sh:minCount 1 ] .
# C5 枚举值越界(对应事故三的同源问题:分类值不规范)
ex:BatchShape sh:property [
sh:path ex:freezeReason ;
sh:in ( "quality" "compliance" "financial" "other" ) ;
sh:message "冻结原因必须是四类之一" ] .
首跑结果
数据规模:订单行约 8,000、批次约 2.2 万、供应商约 300、物料约 5,000、质检约 1.9 万,合计约 50–60 万三元组。
pyshacl -s shapes.ttl data.ttl -f human --abort-on-first-error false
| 约束 | 违规数 | 占比 | 判断 |
|---|---|---|---|
| C1 引用完整性 | 0 | 0% | 通过,但说明这层已被 ERP 外键保证 |
| C2 冻结供应商仍有批次 | 约 40 | 约 0.2% | 真实问题,两个供应商 + 一次迁移遗漏 |
| C3 质检缺签字人 | 约 1,200 | 约 6% | 规则错:历史数据(系统上线前)本来就没有签字人字段 |
| C4 指标缺口径 | 约 120 | 100% | 规则错:MetricDefinition 是新类,全部新建,字段还没填 |
| C5 枚举越界 | 约 30 | 约 0.1% | 真实问题:中英混用(质量/quality/Quality) |
总违规约 1,400 条,占 4% 上下。但真正的问题只有六十余条(C2 + C5);其余绝大部分是规则写得不对。
两条教训
教训一:100% 违规几乎总是规则错。 C4 的一百多条全是新对象,字段天然为空。把「新对象的必填字段」写成「全量必填」,等于给自己造了一个永远红的流水线。正确做法:约束先作用在「生效日期之后创建的对象」上,历史对象走豁免。
教训二:枚举值必须先归一,再约束。 C5 报的三十来条里,有近一半是同一个值的三种写法。如果先改枚举定义再加约束,能省掉一轮返工。顺序是:归一 → 约束,反过来会得到一堆假阳性,然后团队会开始不信校验。
这两条经验后来被写进第 12 章「反模式」——它们听起来像常识,但几乎每个项目第一次都会踩。
修正后的第二轮:C3 加 sh:severity sh:Warning 并对生效日期前的数据豁免;C4 拆成两条(新对象必填 + 存量统计);C5 先跑了三天的归一脚本。二轮违规数降到六十余条,全部可解释、可指派。
第 3 周:让一个动作被拒绝
前两周的价值是「发现了六十余条坏数据」,这一周的价值是「让它不再发生」。
收货动作定义:
Action: ReceiveBatch(收货)
参数:poId, batchNo, supplierId, materialId, qty, arrivalDate
前置条件:
P1 订单存在且状态为「已确认」
P2 供应商当前未冻结(无未解除的 FreezeEvent)
P3 批次号在该订单下不重复
P4 materialId 必须在订单行中(收货不得超出订单范围)
副作用:
- 新建 Batch
- 若 qty > 订单余量,写 OverDelivery 标记(不拒绝,但告警)
后置条件:
- 该批次的 Inspection 必须在 48 小时内创建,否则进入「待质检」对账清单
权限:仓储操作员
结果:成功 / 拒绝(带拒绝码与原因)
P2 正是事故二的解药。实现方式有两种,团队选了第二种:
- 方案一:收货时调用 ERP 的冻结接口。依赖 ERP 团队排期,2 周。
- **方案二:收货前查本体的
FreezeEvent,冻结态由 ERP 单向同步过来(每天 + 事件触发)。** 当天能上,代价是同步延迟最长 1 天。
拒绝时的返回必须是结构化的,否则操作员只会看到「失败」然后找 IT:
{
"result": "rejected",
"code": "SUPPLIER_FROZEN",
"message": "供应商 S-2041 于 2026-10-12 因质量问题被冻结,尚未解除",
"details": {
"supplier": "S-2041",
"freezeEvent": "FE-2026-0043",
"reason": "quality",
"since": "2026-10-12",
"operator": "王××",
"contact": "采购部 张××"
}
}
「contact」这一行是项目里被引用最多的设计。 被拒绝的人需要知道找谁,否则流程会卡在他那里。技术上这是一个字段,组织上这是让约束被接受的前提。
上线三周内,这个动作拒绝了约 20 次,其中一半以上是冻结供应商,其余是超订单范围与重复批次。第 4 次被拒绝之后,采购部自己去更新了冻结流程——因为它第一次有了确定的执行点。
这是全书最重要的一个观察:约束不会因为「被写下来」而被遵守,只会因为「有一个动作会因此失败」而被遵守。
第 4 周:每周对账
对账不是校验的重复。校验看「模型内部是否自洽」,对账看「模型与源系统是否一致」。
三张对账表:
| 对账项 | 比较对象 | 频率 | 差异处理 |
|---|---|---|---|
| 供应商冻结态 | ERP 备注字段 ↔ FreezeEvent | 每日 | 差异 → 工单给采购,48 小时闭环 |
| 订单金额汇总 | 本体视图 ↔ ERP 总账 | 每周 | 差异 > 0.5% → 阻塞发布 |
| 批次-质检配对 | 批次表 ↔ QMS 检验单 | 每周 | 未配对超 48 小时 → 进「待质检」清单 |
第一周对账发现几条冻结态不一致——都是「ERP 里解除了,本体没同步」。这类差异在校验里永远看不见,因为它们各自都自洽。
对账的输出不是报表,是工单。 一张没人处理的差异清单,两周后会变成没人看的清单。所以每一条差异都必须有指派人、有期限、有闭环状态。这一点比技术实现重要得多。
第 5 周:一次兼容性变更
第四周结束时,财务提出要求:MetricDefinition 必须记录「数据来源系统」。这是一个新增必填字段。
如果直接加 sh:minCount 1,结果是:现存一百多个指标定义全部违规,流水线立刻变红,团队会以「先关掉校验」收场。
实际做法是三步:
- 加字段,但先不设必填(第 1 天)。字段可选,只在新建时提示。
- 补数据(第 2–9 天)。一百多个定义逐个人工填来源,其中十来个发现「其实没有稳定来源」,转成「手工维护」标记。
- 设为必填,并给 30 天豁免给存量(第 10 天)。豁免记录带负责人与到期日,到期未补自动变红。
这个流程后来固化成第 5 章的三条底线:新增必填字段必须分两步、豁免必须带期限、变更必须能回答影响面。
Q7(「并入返修件会影响哪些报表」)在这一步得到了回答:影响面计算扫出 3 张报表、1 个 API、2 个下游模型引用该分类。其中 1 张报表是没人知道的——它由一位离职同事两年前建的,还在每天跑。
成本:五周,二十余人日
| 阶段 | 人日 | 谁 |
|---|---|---|
| 收集与合并 CQ | 3–4 | 数据团队 1 人 + 三个部门各 1 天 |
| 建模与评审 | 4–5 | 数据团队 1 人,业务方评审 2 次共 3 小时 |
| 约束编写与两轮修正 | 4–5 | 数据团队 |
| 动作门控与联调 | 4–5 | 数据团队 1.5 + 后端 2.5 |
| 对账与工单流程 | 2 | 数据团队 1 + 运营 1 |
| 变更流程与影响面 | 2 | 数据团队 |
合计二十余人日,没有采购任何软件,没有新建存储。 工具链是 pyshacl + rdflib + 一个 200 行的 Python 生成脚本,加上 GitLab CI 的一个 job。
对照:如果走「买一个知识图谱平台」的路线,仅 POC 阶段通常就要 30+ 人日,且五周内出不了可用的拒绝逻辑。
六个月后回看
奏效的三件事:
- **
MetricDefinition作为一等对象。** 口径争议从「会上吵」变成「查版本」。六个月里发生了 7 次口径变更,每次都有记录,财务的供应商考核不再和运营对不上。 - 收货动作的门控。 冻结供应商供货从「偶发事故」变成「不可能发生」。这一条是唯一被管理层主动提及的成果。
- 每周对账。 差异从第一周的 3 条降到稳定 0–1 条;它成了数据团队与业务之间的固定会议议程。
失效或打折扣的三件事:
- 枚举归一没有坚持。 第 3 个月新来了一个物料分类,又出现了中英混用,因为归一脚本没进 CI。教训:归一必须是自动的,靠人记得就会退化。
- 豁免变成了永久。 30 天豁免到期后没有人处理,系统自动变红,然后有人把到期日改到了下个季度。这暴露了豁免设计的缺陷:到期应该升级给人,而不是变红等人来改。
- 溯源信息没人查。 变更历史记录得很完整,但六个月里只有 2 次被真正查询——都是审计。这说明「四分之一的需求是溯源」这个判断在立项时被高估了。真实的溯源需求是低频、高压的:平时不用,用的时候必须有。
可复用的六件事
如果只从这个案例带走六条:
- 从事故出发,不从类出发。 三个事故定义了范围,比任何建模方法论都有效。
- 先写 20 条问句。 它们同时是范围、验收标准和争议清单。
- 约束第一轮一定会写错。 100% 违规 = 规则错;先跑小样本,再全量。
- 让一个动作失败。 约束的接受度取决于有没有人因此被挡住、以及他知不知道找谁。
- 对账输出工单,不输出报表。 没有指派人的差异清单等于没有。
- 新增必填字段分两步走。 直接加必填,等于邀请别人关掉校验。
这个案例的规模(18 个类、5 条约束、二十余人日)刻意做得很小。它不是「本体能做什么」的上限,而是「本体在多小的投入下能开始产生价值」的下限。 对绝大多数组织来说,先摸到这个下限,比先设计一个完整的蓝图重要得多。