· engineering· 约 18 分钟精读
一条 FAIL 记录之后:四次写入与二十五项判断
TagWorks 把“设备已经修好”拆成三段记录与二十五项条件:维修本身不清除失效,一次新的合格测试也不清除,只有指向那条复试的放行记录才生效。本文按源码走一遍这条链,以及它在日期、身份与导出上的边界。
十月末的下午,仓库工具间。一台角磨机被推到测试台前:Class I,金属外壳,三芯电源线。保护接地导通电阻 128 mΩ,绝缘电阻 2.4 MΩ。绝缘这一项没有问题,接地超过了 100 mΩ 的限值。技术员把两组读数录进 TagWorks,目视选“未发现缺陷”,备注写“电源线护套根部破损,接地线芯氧化”,保存。
一条记录落盘之后,四个界面同时变了:资产详情页顶部出现红色的失败项流程,仪表盘的 Attention 计数加一,PDF 报告里这一行被红底标出,打印按钮的文字从 Print Equipment Status Tag 变成 Print Danger / Out-of-Service Tag。但在模型里,没有任何一个叫 status 的字段被改成 failed。设备当前能不能用,是每次打开页面时从记录流里重新算出来的。
本文按 TestRecord.swift、ReportExporter.swift、AssetDetailView.swift、TestEntryView.swift 与 TagWorksLabelGenerator.swift 的源码,把这条链走一遍。重点不是“应该怎么设计”,而是这套实现里三段判定各自拦住了什么,以及它在日期、身份与导出上留下了哪些边界。
一、四种记录与一个锚点
TestRecordKind 四个成员:test、repair、retest、release。rawValue 就是这四个小写英文,直接写进 register.json 与 CSV,属于冻结的存储格式——改显示名可以,改 rawValue 会让历史记录解不出来。
一条记录除 id、assetID、date、outcome 与三项读数外,还有三个起连接作用的字段:
| 字段 | 类型 | 在链上的作用 |
|---|---|---|
| linkedRecordID | UUID 可选 | 指向被修复或被复试的那条失败记录 |
| releaseAuthorizedBy | String 可选 | 放行记录上的授权人署名 |
| technicianName | String | 每条记录的填表人,解码缺省为空串 |
两个可选字段都用 decodeIfPresent 解码,缺失时为 nil。这保证早期记录仍能读出,但一条没有指针的记录无法参与任何判定。
扫描函数是 ReportExporter.unresolvedFailure(for:records:):把该资产的记录按 date 升序排好,单遍遍历,用一个 activeFailure 记住当前未解除的失效。
| 记录类型 | 扫描时的动作 | 对锚点的影响 |
|---|---|---|
| test | 证据状态为 fail 时设为锚点 | 覆盖 |
| retest | 同上 | 覆盖 |
| repair | 直接跳过 | 无 |
| release | 九项条件全部成立时清空 | 清空 |
有两个地方容易读错。第一,repair 分支是 continue——写一条维修记录,不会让任何状态发生变化。第二,一次新的合格测试也不会清空锚点:test 分支只在失败时赋值,通过时什么都不做。所以“后来又测了一次,过了”在这套模型里不构成放行。
二、第一段判定:修复记录(五项)
validRepair 是一个 filter 加一个 max,五项条件同时成立才算数,对应图上的 G1:
- 属于同一台资产;
- kind 为 repair;
- linkedRecordID 等于那条失败记录的 id;
- 备注去掉首尾空白后非空;
- 日期严格晚于失败记录。
真正承载信息的是第四项。TestEntryView 保存 repair 时,canSave 除技术员名与备注外不再要求任何测量读数——三项读数全部留空也能存下。这符合维修记录的性质:它要说明的是“做了什么”,不是“测出来多少”。备注是这条记录唯一的正文,空白即无效,而无效的修复记录会让整个流程停在第一段。
第五项也值得留一句。日期取 Date.now,比较是严格大于。补录时如果把日期改到失败之前,这条修复不会被选中,界面上的下一步按钮仍然是 Record repair evidence,看起来像什么都没记过。
三、第二段判定:复试(十一项)
validRetest 对应图上的 G2,判定分两层。外层五项:同资产、kind 为 retest、指针指向那条失败、establishesPass 为真、日期晚于失败。内层还有六项,在全部记录里找一条“夹在中间”的修复记录:同资产、kind 为 repair、指针指向同一条失败、备注非空、晚于失败、早于这次复试。
establishesPass 不是布尔字段,而是 evidenceState 四态判定的结果:
| 证据状态 | 成立条件 | 能否确立 PASS |
|---|---|---|
| pass | 目视无缺陷且测量判定通过 | 能 |
| fail | 结果为 fail,或目视发现缺陷,或测量判定失败 | 否 |
| visualNotRecorded | 目视字段为 nil | 否 |
| notMeasured | 目视无缺陷但读数缺失 | 否 |
第三行是历史记录的硬边界。visualInspectionResult 是可选字段,早期记录没有它;目视为 nil 时,这条复试即使读数全部达标,也只能得到 visualNotRecorded,不能确立 PASS。第四行对应 Class I 只测绝缘、没测接地的情形:TestThresholds.evaluate 返回 notMeasured,同样不算通过。
表单层对此有对应约束:手动选 Pass 但测量判定不是 pass 时,canSave 直接返回 false,界面上给出一行说明——A manual PASS cannot replace missing or failed required measurements。
四、第三段判定:放行(九项)
放行条件写在 unresolvedFailure 的 release 分支里,对应图上的 G3:
| 序号 | 条件 | 拦住的东西 |
|---|---|---|
| 1 | 存在未解除的失效 | 无失效时不产生放行语义 |
| 2 | 记录带 linkedRecordID | 没有指向的放行不算 |
| 3 | 存在一条有效复试 | 复试本身要过第二段 |
| 4 | 复试 id 等于该指针 | 指向的必须是那条复试 |
| 5 | 复试日期早于放行日期 | 先复试,后放行 |
| 6 | 放行记录 outcome 为 pass | 放行不能自带失败结论 |
| 7 | 技术员名非空 | 有人 |
| 8 | 备注非空 | 有理由 |
| 9 | releaseAuthorizedBy 非空 | 有署名 |
第 3、4 项说明放行指向的是复试,不是失败记录。这一点在界面上是隐含的:详情页把 validRetest.id 填进 EntryRequest,操作者只看到 Authorize return to service 一个按钮,看不到指针指向谁。维修与复试都指向失败,放行指向复试——三种记录,两种指向。
第 6 项的实际取值也值得核对。TestEntryView.save() 写入的 outcome 是 finalOutcome ?? .pass。放行表单不是检验表单,isInspection 为 false,没有目视选择器;如果操作者没有填任何读数,测量判定是 notMeasured,finalOutcome 为 nil,落盘的 outcome 就是 pass。也就是说,放行记录的 outcome 默认通过,它不是一次测量结论,而是表单缺省值。反过来,如果操作者在放行表单里顺手填了一组不合格读数,落盘的 outcome 会是 fail,这条放行不生效,界面回到放行前的状态。
第 9 项与第 7 项在实现上同源:
releaseAuthorizedBy: kind == .release ? technician : nil
放行授权人直接取技术员输入框的值。同一个人填两个字段,两个字段必然相同,于是第九项判断在真实数据上恒等于第七项——它不会失败,也就拦不住任何东西。这个字段的语义是署名,不是复核。若现场需要职责分离,缺的不是字段,而是第二个输入框,以及一条“两者不相等”的校验。
还有一个界面层的事实:历史列表的 historyStatus 对任何 kind 为 release 的记录都显示 RELEASED,不看它有没有通过这九项。于是一条无效放行会在时间线上显示为已放行,而资产仍挂在失败状态。两处显示不一致时,以状态推导的结果为准。
五、时间序:藏在每个比较符里
三段判定都是集合判断,第四件事是顺序,而它藏在每一个比较运算符里。所有比较都是严格大于:修复晚于失败,复试晚于失败且晚于修复,放行晚于复试。没有一处用大于等于。
扫描本身也依赖顺序:记录按 date 升序排好后单遍重放,锚点只会被后来的失败覆盖。这带来两个现场会遇到的情况。
一是补录。技术员现场用纸记了读数,回到办公室才补录;如果把日期改到失败之前,修复记录不会被选中,流程停在第一段,而界面不会有任何提示。
二是第二次失败。设备修复、复试通过,但还没放行就又测出一次失败,锚点会换成新的那条失败记录。此前那条链的修复与复试都指向旧记录,对新锚点无效,操作者要从新锚点重新走一遍三段。这在语义上是对的——最近一次失效才是当前要解除的对象——但它意味着“补一条放行”解决不了问题。
六、日期仍在走
有一处并行信息容易被误读。TestEntryView.save() 在保存检验记录时,只要目视已记录且测量判定不是 notMeasured,就会把 asset.lastTestDate 更新为这次记录的日期——即使这次记录的结论是 FAIL。
于是失败设备的详情页会出现两行并列的信息:状态是 FAILED,Interval 里的 Next due 却是按失败那次测试的日期加召回周期算出来的未来日期。若这台角磨机登记为建筑环境与高风险,召回周期就是 3 个月,Next due 恰是失败日加三个月。这条日期在 PDF 的 Due 列里也会照常打印。
它不算错——周期确实从最近一次检验起算——但它和“设备已停用”是两件事。状态优先级在代码里是明确的:latestOutcome == .fail 直接返回 failed,不看日期。在纸面上,则要靠读者把红底的 Result 列和 Due 列合起来看。
七、四个出口读的是同一套函数
| 出口 | 读取的函数 | 失败时看到什么 |
|---|---|---|
| 资产详情页 | unresolvedFailure、validRepair、validRetest | 红色流程段与一个下一步按钮 |
| 登记册与仪表盘 | status(of:records:) | FAILED 徽章,计入 Attention |
| PDF 报告 | latestOutcome、latestValidRelease | 红底行;Released by 仅在有效放行时出现 |
| 物理标签 | printableRecord | DANGER — DO NOT USE |
标签这一路值得展开。printableRecord 的逻辑是:有未解除失败就返回失败记录,否则返回最近一次确立 PASS 的检验记录。所以红色标签上的信息来自那条失败记录本身,不是最新记录。TagWorksLabelGenerator 的 FAIL 分支按读数与目视拼出缺陷原因:接地超过 100 mΩ 时写读数与限值,绝缘低于 1 MΩ 时写保留两位小数的读数,目视有缺陷时写 Visual inspection: defect observed,目视为 nil 时写 Visual inspection was not recorded,备注非空时把备注附上;以上都为空时退回一句 Failed or incomplete inspection evidence。
标签右侧固定印一段 LOCKOUT WARNING,说明这台设备未通过安全测试、存在触电或起火风险、不得接通电源,只有具备资质的技术人员或授权维修人员在整改后才能取下标签。这段英文是标签上的原文,也是这条链在物理世界的出口:数字状态说不能用,纸标签说不要接电,两者指向同一条记录。
八、几条边界
第一,链不可撤销。没有删除单条记录的入口,代码里对 records 的写入只有追加与整册替换两处;删资产会连带删掉该资产的全部记录,整册恢复则用快照整体覆盖。误录一条失败,只能走完修复、复试、放行三段。
第二,链搬不完整。register.json 与备份文件带着 linkedRecordID;资产摘要 CSV 每台设备只有一行,RecordKind 与 LinkedRecordID 取自最近一次 test 或 retest,ReleaseAuthorizedBy 与 ReleaseDate 取自有效放行——链在摘要里被压成四个格子。历史 CSV 才逐条导出四种记录与指针。回导只吃资产摘要,不吃历史 CSV。要把一条链完整搬走,只能用备份文件。
第三,判定在渲染时重算。详情页的几个计算属性每次都调用 ReportExporter 的静态函数,每个函数都要过滤一遍全部记录。单台设备、几十条记录没有问题;四百台设备在仪表盘里逐台求状态时,成本是资产数乘以记录数。这个量级可以接受,但它意味着没有缓存,也没有增量。
第四,没有签名,没有外部时间戳。releaseAuthorizedBy 是一个字符串,不是密码学凭证;date 是设备墙钟。这条链解决的是“记录是否成套”,不是“记录是否被篡改”。
九、结语
一条 FAIL 记录之后,要写满四次才能回到在役:失败、修复、复试、放行。三段判定合计二十五项条件——修复五项、复试十一项、放行九项——真正会拦住人的,是备注是否为空、目视是否记录、指针是否指对、日期是否严格靠后这几件小事。
这套实现的价值不在于它多严密,而在于它把“修好了”这个口头结论,拆成了可以被逐条核对的四次写入。它也留下了自己的边界:授权人可以和填表人是同一个名字,链搬不走,日期照常往前走。
参考文献与标准
[1] Standards Australia. AS/NZS 3760:2022, In-service safety inspection and testing of electrical equipment and RCDs. https://store.standards.org.au/product/as-nzs-3760-2022
[2] Safe Work Australia. Model Code of Practice: Managing electrical risks in the workplace. https://www.safeworkaustralia.gov.au/doc/model-code-practice-managing-electrical-risks-workplace
[3] Electrical Regulatory Authorities Council. Electrical safety regulatory guidance. https://www.erac.gov.au/
[4] Standards Australia. AS/NZS 3012:2019, Electrical installations — Construction and demolition sites. https://store.standards.org.au/product/as-nzs-3012-2019
[5] Apple Developer Documentation. Calendar — date calculations and calendar arithmetic. https://developer.apple.com/documentation/foundation/calendar