dsh-engineering-ui 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +64 -4
@@ -0,0 +1,4266 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ r"""part_registry.py — 零件数据台账(尺寸一致性底座)
4
+
5
+ ═══════════════════════════════════════════════════════════════════════════
6
+ 【要解决的问题】零件之间没有任何尺寸台账
7
+ ───────────────────────────────────────────────────────────────────────────
8
+ 改了一个零件的尺寸,没人知道哪些相邻零件会受影响:
9
+ · 各小屋(房间)各自建模,只认自己那份参数表;
10
+ · 轴颈改成 φ25,配对轴承座的孔还是 φ24 —— 直到总装才发现装不上;
11
+ · 事后想回溯"这个尺寸当初是谁定的、改过几次",磁盘上没有任何记录。
12
+
13
+ 本模块提供【唯一的零件数据台账】,作为"总理子代理"的数据底座:
14
+ ① 写入:add / update / link / remove(追加式修订历史,rev 自增)
15
+ ② 查询:get / list / related(按零件反查关联)/ reverse(反查入边)
16
+ ③ 校验:check_adjacency_consistency(★ 相邻零件尺寸一致性,代码级强制)
17
+ ④ 影响面:find_related_for_change(要改某个零件时,谁得跟着改 + 为什么)
18
+ ⑤ 交付:export_docx(导出 Word 台账)
19
+
20
+ ═══════════════════════════════════════════════════════════════════════════
21
+ 【台账文件落点】
22
+ ───────────────────────────────────────────────────────────────────────────
23
+ 默认: <本脚本所在目录>/load_cases/part_registry.json
24
+ (目录不存在会自动创建 —— 工程模式既有约定,见 workflow_gate.py)
25
+ 覆盖优先级(高 → 低):
26
+ ① 环境变量 DSH_PART_REGISTRY(完整文件路径,测试/多实例隔离用)
27
+ ② 函数参数 work_dir(若其 basename 已是 load_cases 则直接用它,
28
+ 否则拼 work_dir/load_cases/part_registry.json)
29
+ ③ 默认落点
30
+
31
+ ═══════════════════════════════════════════════════════════════════════════
32
+ 【一致性判定规则 —— 全部代码化,不是注释里的"应该"】
33
+ ───────────────────────────────────────────────────────────────────────────
34
+ 对每一对【相邻零件】(A, B),把双方 interfaces 归一化
35
+ (id / type / nominal_mm / tolerance)后按下列优先级配对:
36
+
37
+ R1 same_id 两侧接口 id 完全相同 → 配对
38
+ R2 explicit_mate 任一侧用 mates_with / mate_with /
39
+ pairs_with 显式声明对方 id 或对方
40
+ 零件名 → 配对
41
+ R3 affix_id 去掉"轴颈/孔/孔位/座/套/配合…"等
42
+ 噪声词后余下的标识相同(且非空)
43
+ (轴颈1 ↔ 孔1 → 都剩下 "1") → 配对
44
+ R4 unique_shaft_hole 一侧恰好 1 个 shaft、另一侧恰好 1 个
45
+ hole(且都带 nominal) → 配对
46
+
47
+ 配对后判定:
48
+ · 两侧 nominal_mm 都存在且【不相等】→ mismatch(severity=ERROR,
49
+ escalate_to_parent=true)—— 公差带可以不同,基本尺寸必须相同。
50
+ · 同 id 但两侧 type 都是 shaft(或都是 hole)→ warning
51
+ (kind=interface_type_conflict,仍需父代理仲裁)。
52
+ · 两侧 nominal 都缺失 → 该对计入"无接口数据可查",绝不假 PASS。
53
+
54
+ 另外强制校验(同样进 warnings / dangling):
55
+ · 单向邻接(A 把 B 列为 adjacent,B 没回列)→ warning one_way_adjacency
56
+ · 悬空邻接(adjacent 里的零件从未登记) → dangling_adjacency
57
+ · 同零件 dims 与 bbox_mm 包围盒尺寸对不上 → warning self_bbox_mismatch
58
+
59
+ 【禁止假 PASS】返回里恒有 evaluated 字段:
60
+ evaluated=false 表示"没数据可查"(台账空 / 无邻接 / 邻接对全无接口),
61
+ 此时 ok 必为 false 且 reason 说明原因 —— 与"真检查通过"严格区分。
62
+
63
+ ═══════════════════════════════════════════════════════════════════════════
64
+ 【docx 导出:三级策略(本机实测系统 Python 无 python-docx)】
65
+ ───────────────────────────────────────────────────────────────────────────
66
+ ① try: import docx
67
+ ② 失败 → 自动探测随包运行时的 site-packages(~/.dsh/dsh-runtimes 下,
68
+ 含 docx/__init__.py 的 site-packages 目录),加进 sys.path 再试
69
+ ③ 仍失败 → 纯标准库自己写 OOXML(zipfile:
70
+ [Content_Types].xml + _rels/.rels + word/document.xml +
71
+ word/_rels/document.xml.rels + word/styles.xml + docProps/*)
72
+ 任何一步都失败 → 返回 ok=false 并说明卡在哪一步,绝不产出坏文件。
73
+ 可用 strategy 参数强制走某条路径("auto"/"python-docx"/"stdlib"),
74
+ 便于回归测试逐个验证。
75
+
76
+ 【用法(CLI)】
77
+ python part_registry.py add --part "齿轮轴" --room "传动机构" --file "C:/x.SLDPRT" \
78
+ --material "45#钢" --dims "120x40x40" --adjacent "轴承座,联轴器"
79
+ python part_registry.py get --part "齿轮轴" [--json]
80
+ python part_registry.py list [--json]
81
+ python part_registry.py related --part "齿轮轴" [--depth 2] [--json]
82
+ python part_registry.py reverse --part "轴承座" [--json]
83
+ python part_registry.py check-consistency [--json]
84
+ python part_registry.py for-change --part "齿轮轴" [--json]
85
+ python part_registry.py export-docx --out "台账.docx" [--strategy auto]
86
+ python part_registry.py link --a "齿轮轴" --b "轴承座"
87
+ (另有 update / remove 两个便利子命令)
88
+
89
+ 【用法(import)】
90
+ from part_registry import add_part, related_parts, check_adjacency_consistency
91
+ add_part({"name": "齿轮轴", "room": "传动机构", "file": "C:/x.SLDPRT",
92
+ "dims": {"length_mm": 120, "width_mm": 40, "height_mm": 40},
93
+ "interfaces": [{"id": "轴颈1", "type": "shaft", "nominal_mm": 25.0}]})
94
+
95
+ 【退出码】 0=ok / 1=操作失败或未通过校验 / 2=命令行用法错误
96
+ ═══════════════════════════════════════════════════════════════════════════
97
+ """
98
+ from __future__ import annotations
99
+
100
+ import argparse
101
+ import json
102
+ import os
103
+ import sys
104
+ import time
105
+ import zipfile
106
+
107
+ # ── Windows 控制台 UTF-8(与 _utf8.py 同口径,但本模块不依赖它,保持自包含)──
108
+ try:
109
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
110
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
111
+ except Exception:
112
+ pass
113
+
114
+ # ══════════════════════════════════════════════════════════════════════════
115
+ # 常量
116
+ # ══════════════════════════════════════════════════════════════════════════
117
+
118
+ SCHEMA = "dsh-engineering/part-registry@1"
119
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
120
+ LOAD_CASES_DIRNAME = "load_cases"
121
+ REGISTRY_FILENAME = "part_registry.json"
122
+ ENV_REGISTRY = "DSH_PART_REGISTRY"
123
+
124
+ # 修订历史只保留最近 N 条,防台账无限膨胀(台账本身是追加式的)
125
+ MAX_REVISIONS = 2000
126
+
127
+ # ══ 【强制·如实填写】变更说明(reason)══════════════════════════════════
128
+ # 用户要求:"每个零件改一次都要如实填写对应的数据,代码必须执行"。
129
+ # 因此只要【真的改动了已登记的数据】,就必须给出变更说明:
130
+ # · 空串 / 纯空白 / 占位词("无""待定""N/A"…)→ 拒绝落盘;
131
+ # · 太短(少于 MIN_REASON_CHARS 个字符)→ 拒绝("改"这种一个字的说明没有信息量);
132
+ # · 纯标点 / 单一重复字符("aaaaa")→ 拒绝。
133
+ # 说明本身会写进 revisions[].reason,事后可完整回溯"谁在何时为什么改了哪个尺寸"。
134
+ MIN_REASON_CHARS = 5
135
+ _REASON_PLACEHOLDERS = {
136
+ "无", "暂无", "没有", "待定", "待补充", "待确认", "略", "略过", "同上", "如上",
137
+ "不填", "免填", "空", "null", "none", "nil", "n/a", "na", "n.a.", "xxx", "xx",
138
+ "test", "testing", "测试", "临时", "随便", "占位", "-", "--", "---", "...",
139
+ "。", ",", "、", "tbd", "todo", "fixme", "asdf", "aaa", "???",
140
+ }
141
+
142
+ # 需要【变更说明】的写操作(全部落盘型操作都在内)
143
+ REASON_REQUIRED_OPS = ("add", "update", "link", "unlink", "remove")
144
+
145
+ # ══ 记账字段(bookkeeping)══════════════════════════════════════════════
146
+ # 这些字段会随每次调用刷新(例如 reported_at 取当前时间),但它们【不代表
147
+ # 零件数据发生了实质变化】,因此:
148
+ # · 刷新它们【不】算作"改动"(不计入 changed、不触发 rev 增长、不产生修订);
149
+ # · 否则同一份数据重复登记会被当成改过,rev 无意义增长、空改动检测随机失效
150
+ # (实测踩到:同一秒内调用看似正常,跨秒就变成 update,属时序不确定性缺陷)。
151
+ _BOOKKEEPING_FIELDS = ("reported_at",)
152
+
153
+ # add_part 的必填字段(缺任一 → {"ok": False, "error": ...})
154
+ REQUIRED_FIELDS = ("name", "room", "file")
155
+
156
+ # ══ 【Bug⑪】零件 origin(原点)约定:从 <零件>.origin.json 读取 ══════════
157
+ # 现场缺陷(竞速小车任务,装配前必须人工换算坐标):
158
+ # · 后轮 / 电机座:origin 在轮/电机轴心,z=0 为轴面;
159
+ # · 万向轮支架:origin 在【件角】(非中心),顶面 z=0 为装配面,叉口向下;
160
+ # · 底盘:origin 在底盘中心,z=3 为顶面。
161
+ # 三种约定并存 → 总装房必须逐件换算,按"中心对称"直觉装配会全部错位。
162
+ #
163
+ # 约定来源:swapi 在 save()/save_as() 时把 origin 约定写到 <零件>.origin.json
164
+ # (见 swapi.ORIGIN_SCHEMA)。本模块【只读】它,把结论并入台账记录:
165
+ # · 台账里多一个 origin 字段,装配/一致性核对可直接消费;
166
+ # · 缺记录时显式标注 origin_missing=True 并给 warning —— 绝不假装已知。
167
+ ORIGIN_RECORD_SUFFIX = ".origin.json"
168
+ ORIGIN_STANDARD_MODE = "center_bottom" # 标准约定:底面中心
169
+ ORIGIN_STANDARD_CN = "底面中心(标准约定)"
170
+
171
+ # update_part 允许被打补丁的字段白名单(name 不允许改:改名会撕裂邻接图)
172
+ # 【Bug⑪】新增 origin:origin 约定是零件的事实之一,改约定必须走 update
173
+ # 【Bug①】新增 material_consistency:材料一致性是客观事实,可显式覆盖(如人工核实后更正)
174
+ PATCHABLE_FIELDS = ("room", "file", "dims", "bbox_mm", "material", "credentials",
175
+ "interfaces", "adjacent", "reported_by", "reported_at",
176
+ "origin", "material_consistency")
177
+
178
+ # 接口类型同义词 → 归一化类型
179
+ _TYPE_SYNONYMS = {
180
+ "shaft": "shaft", "轴": "shaft", "轴颈": "shaft", "轴头": "shaft",
181
+ "轴伸": "shaft", "journal": "shaft", "轴段": "shaft",
182
+ "hole": "hole", "孔": "hole", "孔位": "hole", "孔径": "hole",
183
+ "bore": "hole", "内孔": "hole", "配合孔": "hole",
184
+ "face": "face", "面": "face", "平面": "face",
185
+ "slot": "slot", "槽": "slot", "键槽": "slot",
186
+ "thread": "thread", "螺纹": "thread",
187
+ "flange": "flange", "法兰": "flange",
188
+ }
189
+
190
+ # R3:配对时先去掉的"噪声词"—— 按接口类型分表(见 _strip_id_noise 的说明)。
191
+ # 之所以不合并成一张表:合并后 "键槽1" 与 "孔径1" 都会被削成 "1",
192
+ # 于是键槽被误当成轴承孔,报出根本不存在的尺寸冲突(实测踩到)。
193
+ _HOLE_NOISE = ("安装孔", "配合孔", "螺栓孔", "孔位", "孔径", "内孔", "中心孔",
194
+ "过孔", "孔", "口")
195
+ _SHAFT_NOISE = ("轴颈", "轴头", "轴伸", "轴段", "轴肩", "轴", "端")
196
+ _GENERIC_NOISE = ("法兰盘", "法兰", "端面", "平面", "键槽", "槽", "面", "座", "套")
197
+ # 兼容别名:旧代码/外部若有引用 _ID_NOISE,仍可用(等价于三类并集)
198
+ _ID_NOISE = _HOLE_NOISE + _SHAFT_NOISE + _GENERIC_NOISE
199
+
200
+ # R2:显式声明配对关系的字段名(大小写不敏感)
201
+ _MATE_FIELDS = ("mates_with", "mate_with", "pairs_with", "mate", "pair_with",
202
+ "mates", "配合对象", "配对")
203
+
204
+ # 公差带字母 → 孔/轴(GB/T 1800 / ISO 286:孔用大写,轴用小写)
205
+ _HOLE_TOL_LETTERS = set("ABCDEFGH")
206
+ _SHAFT_TOL_LETTERS = set("JKMNPRSTUVXYZ") | set("jkmnprstuvxyz") | set("abcdefg")
207
+
208
+
209
+ # ══════════════════════════════════════════════════════════════════════════
210
+ # 通用小工具
211
+ # ══════════════════════════════════════════════════════════════════════════
212
+
213
+ def _now():
214
+ """返回 (时间戳, 'YYYY-mm-dd HH:MM:SS')。"""
215
+ return time.time(), time.strftime("%Y-%m-%d %H:%M:%S")
216
+
217
+
218
+ def _fmt_num(v):
219
+ """数值 → 人类可读字符串(25.0 / 24.5 / 0.125),用于 detail 文案。"""
220
+ try:
221
+ f = float(v)
222
+ except Exception:
223
+ return str(v)
224
+ if abs(f - round(f)) < 1e-9:
225
+ return "%.1f" % f
226
+ s = ("%.4f" % f).rstrip("0").rstrip(".")
227
+ return s or "0"
228
+
229
+
230
+ def _as_float(v):
231
+ """宽松取数:数值或数字字符串 → float;其余返回 None(不抛异常)。"""
232
+ if isinstance(v, bool):
233
+ return None
234
+ if isinstance(v, (int, float)):
235
+ return float(v)
236
+ if isinstance(v, str):
237
+ try:
238
+ return float(v.strip())
239
+ except Exception:
240
+ return None
241
+ return None
242
+
243
+
244
+ def _norm_name(name):
245
+ """零件名归一化:去首尾空白(中文名不做大小写折叠)。"""
246
+ return str(name or "").strip()
247
+
248
+
249
+ def _norm_key(name):
250
+ """零件名比较键:去空白 + casefold(用于宽松命中已登记零件)。"""
251
+ return _norm_name(name).casefold()
252
+
253
+
254
+ def _resolve_key(parts, name):
255
+ """在已登记零件里定位 name 的真实键。
256
+
257
+ 顺序:精确 → 去空白精确 → casefold 精确。找不到返回 None。
258
+ 这样调用方写错大小写/多打空格仍能命中,但绝不模糊匹配到别的零件。
259
+ """
260
+ if not isinstance(parts, dict):
261
+ return None
262
+ raw = _norm_name(name)
263
+ if raw in parts:
264
+ return raw
265
+ target = _norm_key(raw)
266
+ for k in parts:
267
+ if _norm_key(k) == target:
268
+ return k
269
+ return None
270
+
271
+
272
+ def _parse_list_arg(value):
273
+ """'A,B, C' / ['A','B'] / None → ['A','B'](去空、去重、保序)。"""
274
+ if value is None:
275
+ return []
276
+ items = value if isinstance(value, (list, tuple)) else str(value).split(",")
277
+ out = []
278
+ for it in items:
279
+ s = _norm_name(it)
280
+ if s and s not in out:
281
+ out.append(s)
282
+ return out
283
+
284
+
285
+ # ══════════════════════════════════════════════════════════════════════════
286
+ # 台账路径 / 读写
287
+ # ══════════════════════════════════════════════════════════════════════════
288
+
289
+ def registry_path(work_dir=None):
290
+ """解析台账文件绝对路径(不创建目录)。
291
+
292
+ 优先级:DSH_PART_REGISTRY 环境变量 > work_dir 参数 > 脚本目录/load_cases。
293
+ work_dir 若本身就是 load_cases 目录,则直接在其中落文件。
294
+ """
295
+ env = (os.environ.get(ENV_REGISTRY) or "").strip().strip('"').strip("'")
296
+ if env:
297
+ return os.path.abspath(os.path.expanduser(env))
298
+
299
+ if work_dir:
300
+ wd = os.path.abspath(os.path.expanduser(str(work_dir)))
301
+ if os.path.basename(wd).lower() == LOAD_CASES_DIRNAME:
302
+ return os.path.join(wd, REGISTRY_FILENAME)
303
+ return os.path.join(wd, LOAD_CASES_DIRNAME, REGISTRY_FILENAME)
304
+
305
+ return os.path.join(BASE_DIR, LOAD_CASES_DIRNAME, REGISTRY_FILENAME)
306
+
307
+
308
+ def _fresh_registry():
309
+ """空台账骨架。"""
310
+ _ts, _at = _now()
311
+ return {
312
+ "schema": SCHEMA,
313
+ "task_id": None,
314
+ "task_id_source": None,
315
+ "created_at": _at,
316
+ "updated_at": _at,
317
+ "revisions": [],
318
+ # 经用户【如实说明理由】后仍允许保留的已知不一致(不许静默放过)
319
+ "unresolved": [],
320
+ "parts": {},
321
+ }
322
+
323
+
324
+ def _read_task_id(work_dir=None):
325
+ """尽力从 workflow_state.json 取当前任务号,用于给台账打任务戳。
326
+
327
+ 只读、失败即放弃(台账不因缺少门禁状态而不可用)。
328
+ 返回 (task_id, 来源说明)。
329
+ """
330
+ cands = []
331
+ if work_dir:
332
+ cands.append(os.path.join(os.path.abspath(str(work_dir)), "workflow_state.json"))
333
+ cands.append(os.path.join(BASE_DIR, "workflow_state.json"))
334
+ try:
335
+ import _store # 工程模式状态单一事实源(存在则优先用它)
336
+ cands.insert(0, os.path.join(_store.state_dir(), "workflow_state.json"))
337
+ except Exception:
338
+ pass
339
+ for p in cands:
340
+ try:
341
+ if not os.path.isfile(p):
342
+ continue
343
+ with open(p, "r", encoding="utf-8-sig") as f:
344
+ d = json.load(f) or {}
345
+ tid = d.get("task_id")
346
+ if tid:
347
+ return str(tid), p
348
+ except Exception:
349
+ continue
350
+ return None, None
351
+
352
+
353
+ def _load_registry(work_dir=None):
354
+ """读台账。返回 (data, path, error)。
355
+
356
+ ── 损坏保护(代码级强制)────────────────────────────────────────────
357
+ 文件存在但不是合法 JSON / 顶层不是对象 → 返回 error,且【后续写操作
358
+ 一律拒绝落盘】(见 _save_registry 的 error 传递),避免一次误写把
359
+ 用户攒了半天的台账覆盖成空骨架。
360
+ """
361
+ path = registry_path(work_dir)
362
+ if not os.path.isfile(path):
363
+ return _fresh_registry(), path, None
364
+ try:
365
+ with open(path, "r", encoding="utf-8-sig") as f:
366
+ data = json.load(f)
367
+ except Exception as e:
368
+ return _fresh_registry(), path, "台账文件不是合法 JSON:%s(%r)" % (path, e)
369
+ if not isinstance(data, dict):
370
+ return _fresh_registry(), path, "台账文件顶层必须是 JSON 对象:%s" % path
371
+ # 结构自愈:字段缺失/类型不对时补齐(不丢已有数据)
372
+ data.setdefault("schema", SCHEMA)
373
+ data.setdefault("task_id", None)
374
+ data.setdefault("created_at", None)
375
+ data.setdefault("updated_at", None)
376
+ if not isinstance(data.get("revisions"), list):
377
+ data["revisions"] = []
378
+ if not isinstance(data.get("unresolved"), list):
379
+ data["unresolved"] = []
380
+ if not isinstance(data.get("parts"), dict):
381
+ data["parts"] = {}
382
+ return data, path, None
383
+
384
+
385
+ def _save_registry(data, work_dir=None):
386
+ """写台账(原子替换:先写 .tmp 再 os.replace)。
387
+
388
+ 返回 (ok, path, error)。data 为空/非 dict 时拒绝写。
389
+ """
390
+ if not isinstance(data, dict):
391
+ return False, registry_path(work_dir), "内部错误:待写入的台账不是 dict"
392
+ path = registry_path(work_dir)
393
+ _ts, at = _now()
394
+ data["schema"] = SCHEMA
395
+ data["updated_at"] = at
396
+ if len(data.get("revisions") or []) > MAX_REVISIONS:
397
+ data["revisions"] = data["revisions"][-MAX_REVISIONS:]
398
+ try:
399
+ d = os.path.dirname(path)
400
+ if d:
401
+ os.makedirs(d, exist_ok=True)
402
+ tmp = path + ".tmp"
403
+ with open(tmp, "w", encoding="utf-8") as f:
404
+ json.dump(data, f, ensure_ascii=False, indent=2)
405
+ os.replace(tmp, path)
406
+ return True, path, None
407
+ except Exception as e:
408
+ return False, path, "写入台账失败:%r" % (e,)
409
+
410
+
411
+ def _append_revision(data, action, part, changes=None, extra=None, reason=None,
412
+ verification=None, fields=None, stamp_parts=None):
413
+ """向 revisions 追加一条修订记录(追加式历史,永不改写旧记录)。
414
+
415
+ 【强制·如实填写】每条修订都带 reason(变更说明)。调用方(写闸门)已保证
416
+ reason 非空且不是占位词;这里再兜一层,防止有人绕过闸门直接调本函数 ——
417
+ 缺 reason 的记录会被打上 "__UNVERIFIED__" 标记,audit_verification 能查出来。
418
+
419
+ Args:
420
+ stamp_parts: 本次动作【连带改动】的其它零件名列表。像 remove_part 会顺手
421
+ 清理别人的 adjacent、link 会同时改两侧 —— 这些被连带改动的零件也必须
422
+ 刷新指纹,否则 audit 会把这次【合法】改动误报成绕过模块手改(实测踩到)。
423
+ 这里为每个连带零件再追加一条 revisions,记录其改动后的 part_digest。
424
+
425
+ 返回新记录的 rev 序号(即该零件本次的版本号)。
426
+ """
427
+ rev = 1
428
+ rec = data["parts"].get(part)
429
+ if isinstance(rec, dict):
430
+ try:
431
+ rev = int(rec.get("rev") or 1)
432
+ except Exception:
433
+ rev = 1
434
+ _ts, at = _now()
435
+ _reason = str(reason or "").strip()
436
+ entry = {
437
+ "rev": rev,
438
+ "action": action,
439
+ "part": part,
440
+ "ts": _ts,
441
+ "at": at,
442
+ "changes": changes or {},
443
+ "reason": _reason or "__UNVERIFIED__",
444
+ }
445
+ if fields is not None:
446
+ entry["declared_fields"] = list(fields)
447
+ if verification is not None:
448
+ # ── 总理子代理核实的证据(可事后重算 digest 验证真实性)──────────
449
+ # part_digest = 【本记录所属零件】的实质数据指纹,是逐零件审计的依据:
450
+ # 审计时只需找"该零件最后一次被写入"时留下的 part_digest 与现状比对,
451
+ # 就能精确判定它是被合法改过还是被绕过模块手改(不受其它零件影响)。
452
+ _own = data["parts"].get(part)
453
+ entry["verification"] = {
454
+ "verified": verification.get("verified"),
455
+ "evaluated": verification.get("evaluated"),
456
+ "skipped": bool(verification.get("skipped")),
457
+ "scope": verification.get("scope"),
458
+ "neighbors_checked": verification.get("neighbors_checked"),
459
+ "digest": verification.get("digest"),
460
+ "part_digest": (_canonical_digest(_substance(_own))
461
+ if isinstance(_own, dict) else None),
462
+ "mismatch_count": len(verification.get("mismatches") or []),
463
+ "mismatches": verification.get("mismatches") or [],
464
+ "reason": verification.get("reason"),
465
+ "summary": verification.get("summary"),
466
+ }
467
+ if extra:
468
+ entry.update(extra)
469
+ data["revisions"].append(entry)
470
+
471
+ # ── 连带改动的零件也要刷新指纹(否则合法改动会被 audit 误报为手改)──
472
+ # remove_part 清理别人的 adjacent、link 同时改两侧 —— 这些"被动改到"的
473
+ # 零件同样需要一个记录了改动后数据的 part_digest。
474
+ for other in (stamp_parts or []):
475
+ if other == part:
476
+ continue
477
+ _orec = data["parts"].get(other)
478
+ if not isinstance(_orec, dict):
479
+ continue
480
+ try:
481
+ _orev = int(_orec.get("rev") or 1)
482
+ except Exception:
483
+ _orev = 1
484
+ data["revisions"].append({
485
+ "rev": _orev,
486
+ "action": action + ":collateral",
487
+ "part": other,
488
+ "ts": _ts, "at": at,
489
+ "changes": changes or {},
490
+ "reason": _reason or "__UNVERIFIED__",
491
+ "collateral_of": part,
492
+ "verification": {
493
+ "verified": None, "evaluated": False, "skipped": False,
494
+ "scope": [other], "neighbors_checked": 0, "digest": None,
495
+ "part_digest": _canonical_digest(_substance(_orec)),
496
+ "mismatch_count": 0, "mismatches": [],
497
+ "reason": "因 %s 的动作 %s 被连带改动,记录改动后指纹" % (part, action),
498
+ "summary": "连带改动指纹(%s ← %s)" % (other, part),
499
+ },
500
+ })
501
+ return rev
502
+
503
+
504
+ def audit_verification(name=None, work_dir=None):
505
+ """★ 事后审计:证明台账数据没有被绕过模块手改(真实性的可验证性)。
506
+
507
+ ── 审计口径(关键:必须逐零件、只看"最后一次写入")──────────────────
508
+ 早期实现拿每条历史记录的 scope-digest 与现状比,并且"只要范围内任意零件
509
+ 后来被合法改过"就判为正常 —— 结果掩盖了真实篡改:
510
+ 手改了 A,只要 B 后来被合法改过,A 的篡改就被解释掉了(实测踩到漏洞)。
511
+
512
+ 现在改成【逐零件、只认最后一次写入】:
513
+ ① 对每个零件,找出台账里【最后一次写入它】的那条带 part_digest 的记录;
514
+ ② 用当前磁盘数据重算该零件的实质数据指纹;
515
+ ③ 一致 → 该零件自最后写入以来未被改动(无论中间有多少其它改动);
516
+ 不一致 → 该零件在最后一次合法写入之后被改过,而台账里【没有】
517
+ 任何更晚的合法写入能解释它 ⇒ 判定为绕过模块手改。
518
+
519
+ 这样:正常迭代(每次改动都走模块、都会留下更新的 part_digest)永远自洽;
520
+ 只有真正的手改才会暴露。
521
+ """
522
+ data, path, err = _load_registry(work_dir)
523
+ if err:
524
+ return {"ok": False, "error": err, "file": path}
525
+ parts = data.get("parts") or {}
526
+ revs = [r for r in (data.get("revisions") or []) if isinstance(r, dict)]
527
+
528
+ # ── 每个零件最后一次带 part_digest 的写入 ──────────────────────────
529
+ # 注意:remove 记录写的是【被删零件】,它不在 parts 里;要单独处理,
530
+ # 否则一次合法删除会被误判成"绕过模块删除"(实测踩到这个误报)。
531
+ last_write = {}
532
+ removed_by_module = {}
533
+ unverified = []
534
+ for i, r in enumerate(revs):
535
+ v = r.get("verification") or {}
536
+ pt = r.get("part")
537
+ if r.get("deleted_by_module"):
538
+ removed_by_module[pt] = r
539
+ continue
540
+ if not v.get("part_digest"):
541
+ unverified.append({"seq": i, "rev": r.get("rev"), "action": r.get("action"),
542
+ "part": pt, "at": r.get("at"),
543
+ "reason": ("该修订没有零件指纹"
544
+ "(skip_verification、旧记录,或绕过闸门写入)"
545
+ if r.get("reason") != "__UNVERIFIED__"
546
+ else "该修订是绕过闸门写入的(reason=__UNVERIFIED__)")})
547
+ continue
548
+ last_write[pt] = (i, r)
549
+
550
+ tampered, intact_rows = [], []
551
+ for pt, (seq, r) in sorted(last_write.items(), key=lambda kv: str(kv[0])):
552
+ if name and _norm_key(pt) != _norm_key(name):
553
+ continue
554
+ rec = parts.get(pt)
555
+ if rec is None:
556
+ # 零件不在台账里了:若它是在【本次写入之后】被模块合法删除的,属正常;
557
+ # 否则说明有人绕过模块把它抠掉了。
558
+ dele = removed_by_module.get(pt)
559
+ if dele is not None:
560
+ intact_rows.append({"part": pt, "rev": r.get("rev"),
561
+ "last_action": r.get("action"), "at": r.get("at"),
562
+ "reason": r.get("reason"),
563
+ "digest": (r.get("verification") or {}).get("part_digest"),
564
+ "verified": (r.get("verification") or {}).get("verified"),
565
+ "deleted_by_module": True,
566
+ "deleted_at": dele.get("at"),
567
+ "deleted_reason": dele.get("reason")})
568
+ continue
569
+ tampered.append({"part": pt, "verdict": "DELETED_OUTSIDE_MODULE",
570
+ "at": r.get("at"), "reason": r.get("reason"),
571
+ "detail": ("该零件最后一次由模块写入后,又被从台账里删除,"
572
+ "但台账没有对应的 remove 修订记录")})
573
+ continue
574
+ now = _canonical_digest(_substance(rec))
575
+ recorded = (r.get("verification") or {}).get("part_digest")
576
+ if now == recorded:
577
+ intact_rows.append({"part": pt, "rev": r.get("rev"),
578
+ "last_action": r.get("action"), "at": r.get("at"),
579
+ "reason": r.get("reason"), "digest": recorded,
580
+ "verified": (r.get("verification") or {}).get("verified")})
581
+ else:
582
+ tampered.append({
583
+ "part": pt, "verdict": "UNEXPLAINED",
584
+ "last_module_write": {"rev": r.get("rev"), "action": r.get("action"),
585
+ "at": r.get("at"), "reason": r.get("reason")},
586
+ "digest_recorded": recorded, "digest_now": now,
587
+ "detail": ("零件 %s 在最后一次由模块写入之后,实质数据被改动,"
588
+ "但台账里没有更晚的合法写入能解释这次变化" % pt),
589
+ })
590
+
591
+ # 台账里存在、但从未被本模块写过指纹的零件(例如旧格式/外部塞进来的)
592
+ orphans = []
593
+ for pt in sorted(parts.keys()):
594
+ if pt in last_write:
595
+ continue
596
+ if name and _norm_key(pt) != _norm_key(name):
597
+ continue
598
+ orphans.append({"part": pt, "rev": (parts[pt] or {}).get("rev"),
599
+ "detail": "该零件在台账里存在,但没有任何带指纹的模块写入记录"})
600
+
601
+ if tampered:
602
+ verdict_txt = ("发现 %d 个零件的实质数据【无法解释地被改动】—— "
603
+ "台账很可能是绕过本模块被直接改过的,请人工核查"
604
+ % len(tampered))
605
+ elif orphans:
606
+ verdict_txt = ("未发现篡改;但有 %d 个零件缺少指纹记录(旧格式或外部写入),"
607
+ "无法对其真实性背书" % len(orphans))
608
+ else:
609
+ verdict_txt = ("全部完好:%d 个零件的实质数据与其最后一次模块写入时的指纹一致,"
610
+ "未发现绕过模块的手改" % len(intact_rows))
611
+
612
+ return {
613
+ "ok": True,
614
+ "file": path,
615
+ "part_filter": name,
616
+ "parts_in_registry": len(parts),
617
+ "parts_with_fingerprint": len(last_write),
618
+ "intact_count": len(intact_rows),
619
+ "intact": intact_rows,
620
+ "tampered": tampered,
621
+ "tampered_count": len(tampered),
622
+ "parts_without_fingerprint": orphans,
623
+ "records_without_digest": unverified,
624
+ "tamper_suspected": bool(tampered),
625
+ "all_explained": (not tampered),
626
+ "verdict": verdict_txt,
627
+ "summary": ("审计 %d 个零件:%d 个完好、%d 个疑似被手改、%d 个无指纹"
628
+ % (len(parts), len(intact_rows), len(tampered), len(orphans))),
629
+ "note": ("审计是【逐零件、只认最后一次模块写入】的:正常迭代每次都会留下"
630
+ "更新的指纹,因此不会误报;只有「最后一次合法写入之后数据又变了、"
631
+ "且台账里找不到任何更晚的合法写入」才会判为篡改。"
632
+ "指纹只覆盖实质数据(尺寸/接口/邻接/材料等),不含 rev 等记账字段。"),
633
+ }
634
+
635
+
636
+ def _set_task_id(data, work_dir=None):
637
+ """给台账补任务号(仅当尚未打过戳时)。"""
638
+ if data.get("task_id"):
639
+ return
640
+ tid, src = _read_task_id(work_dir)
641
+ if tid:
642
+ data["task_id"] = tid
643
+ data["task_id_source"] = src
644
+
645
+
646
+ # ══════════════════════════════════════════════════════════════════════════
647
+ # 字段校验(全部代码化)
648
+ # ══════════════════════════════════════════════════════════════════════════
649
+
650
+ def _valid_reason(reason, op="update", what=""):
651
+ """★【强制·如实填写】校验变更说明。不通过则返回错误文案,通过返回 None。
652
+
653
+ 这是"每次改动都必须如实填写对应数据"的代码级闸门 —— 不是注释建议。
654
+ 判定(全部强制):
655
+ ① 必须是字符串或 None;None/空/纯空白 → 拒绝
656
+ ② 去掉空白与标点后,实质字符数 < MIN_REASON_CHARS → 拒绝
657
+ ③ 命中占位词表("无""待定""N/A""测试"…)→ 拒绝
658
+ ④ 去掉空白后是【单一字符重复】("aaaaa")→ 拒绝
659
+ Args:
660
+ op: 操作名(add/update/link/remove),用于错误文案
661
+ what: 改了什么(零件名/邻接对),用于错误文案
662
+ Returns:
663
+ None 表示通过;否则返回 {"error","hint","field"} 用的错误字符串
664
+ """
665
+ if reason is None:
666
+ return ("操作 '%s'%s 必须填写变更说明(reason)—— "
667
+ "台账铁律:每改一次都要如实填写对应数据"
668
+ % (op, ("(%s)" % what) if what else ""))
669
+ if not isinstance(reason, str):
670
+ return "reason 必须是字符串,收到 %s" % type(reason).__name__
671
+
672
+ s = reason.strip()
673
+ if not s:
674
+ return ("变更说明不能为空 —— 操作 '%s'%s 必须如实说明改了什么、为什么改"
675
+ % (op, ("(%s)" % what) if what else ""))
676
+
677
+ # 去掉空白与常见标点后再看"实质内容"
678
+ core = "".join(ch for ch in s if not ch.isspace()
679
+ and ch not in ",,。.、;;::!!??~~-—_=+*/\\|'\"`()()[]【】{}<>")
680
+ if len(core) < MIN_REASON_CHARS:
681
+ return ("变更说明过于简短(实质内容 %d 个字符,至少需要 %d 个):%r —— "
682
+ "请写清改了什么、依据是什么"
683
+ % (len(core), MIN_REASON_CHARS, s))
684
+
685
+ if s.lower() in _REASON_PLACEHOLDERS or core.lower() in _REASON_PLACEHOLDERS:
686
+ return ("变更说明是占位词(%r),等于没填 —— 请如实描述本次改动" % s)
687
+
688
+ if len(set(core)) == 1:
689
+ return "变更说明是单一字符重复(%r),不是有效说明" % s
690
+ return None
691
+
692
+
693
+ def _require_reason(reason, op, what=""):
694
+ """_valid_reason 的语法糖:返回可直接并入结果 dict 的错误,或 None。"""
695
+ err = _valid_reason(reason, op=op, what=what)
696
+ if not err:
697
+ return None
698
+ return {
699
+ "ok": False,
700
+ "error": err,
701
+ "field": "reason",
702
+ "op": op,
703
+ "rejected": True,
704
+ "hint": ("所有落盘型改动(add/update/link/remove)都必须带 reason。"
705
+ "CLI 用 --reason \"...\";代码里传 add_part(..., reason=...) / "
706
+ "update_part(name, patch, reason=...) / link_adjacent(a, b, reason=...)。"),
707
+ }
708
+
709
+
710
+ def _diff_fields(old_rec, patch, ignore=("reported_at",)):
711
+ """算出 patch 相对 old_rec 真实改动了哪些字段(字段名列表,已排序)。
712
+
713
+ 用于强制"声明改了哪些字段"与"实际改了哪些字段"完全一致 ——
714
+ 谎报/漏报都会被拒绝落盘。
715
+ """
716
+ changed = []
717
+ if not isinstance(old_rec, dict):
718
+ old_rec = {}
719
+ for k, v in (patch or {}).items():
720
+ if k in ignore:
721
+ continue
722
+ if old_rec.get(k) != v:
723
+ changed.append(k)
724
+ return sorted(changed)
725
+
726
+
727
+ def _parse_fields_arg(value):
728
+ """解析 --fields / fields 参数:'dims,material' 或 ['dims','material'] → 排序列表。"""
729
+ if value is None:
730
+ return None
731
+ items = value if isinstance(value, (list, tuple)) else str(value).split(",")
732
+ out = []
733
+ for it in items:
734
+ s = str(it or "").strip()
735
+ if s and s not in out:
736
+ out.append(s)
737
+ return sorted(out)
738
+
739
+
740
+ def _parse_dims(value):
741
+ """解析尺寸。支持 dict 或 '120x40x40' / '120×40×40' / '120,40,40'。
742
+
743
+ 返回 (dims_dict|None, error|None)。
744
+ 规则(强制):
745
+ · 值必须可转 float 且 >= 0(负数尺寸直接拒绝);
746
+ · 字符串只接受 3 个数字 → length_mm / width_mm / height_mm;
747
+ 给 6 个数字会被拒绝并提示改用 --bbox(避免把包围盒当尺寸)。
748
+ """
749
+ if value is None:
750
+ return None, None
751
+ if isinstance(value, dict):
752
+ if not value:
753
+ return None, None
754
+ out = {}
755
+ for k, v in value.items():
756
+ fv = _as_float(v)
757
+ if fv is None:
758
+ return None, "dims 字段 %r 不是数值:%r" % (k, v)
759
+ if fv < 0:
760
+ return None, "dims 字段 %r 为负数(%s),尺寸不允许为负" % (k, _fmt_num(fv))
761
+ out[str(k)] = fv
762
+ return out, None
763
+
764
+ s = str(value).strip()
765
+ if not s:
766
+ return None, None
767
+ for ch in ("×", "x", "X", "*", ",", ",", ";", " ", "\t"):
768
+ s = s.replace(ch, "|")
769
+ raw = [p for p in s.split("|") if p.strip()]
770
+ nums = []
771
+ for p in raw:
772
+ fv = _as_float(p)
773
+ if fv is None:
774
+ return None, "dims 无法解析出数值:%r(期望如 '120x40x40')" % (value,)
775
+ nums.append(fv)
776
+ if len(nums) == 6:
777
+ return None, ("dims 收到 6 个数字(%r)—— 6 元组是包围盒,"
778
+ "请改用 bbox_mm / --bbox" % (value,))
779
+ if len(nums) != 3:
780
+ return None, "dims 需要 3 个数字(长x宽x高),实际 %d 个:%r" % (len(nums), value)
781
+ if any(n < 0 for n in nums):
782
+ return None, "dims 含负数:%r" % (value,)
783
+ return {"length_mm": nums[0], "width_mm": nums[1], "height_mm": nums[2]}, None
784
+
785
+
786
+ def _parse_bbox(value):
787
+ """解析 SW 真实包围盒 6 元组 [xmin,xmax,ymin,ymax,zmin,zmax]。
788
+
789
+ 强制校验:长度为 6、全为数值、每对 min <= max。
790
+ 返回 (bbox_list|None, error|None)。
791
+ """
792
+ if value is None:
793
+ return None, None
794
+ if isinstance(value, (list, tuple)):
795
+ nums = [_as_float(v) for v in value]
796
+ else:
797
+ s = str(value).strip()
798
+ if not s:
799
+ return None, None
800
+ for ch in ("×", "x", "X", "*", ",", ",", ";", " ", "\t"):
801
+ s = s.replace(ch, "|")
802
+ parts = [p for p in s.split("|") if p.strip()]
803
+ nums = [_as_float(p) for p in parts]
804
+ if any(n is None for n in nums):
805
+ return None, "bbox_mm 必须是 6 个数值:%r" % (value,)
806
+ if len(nums) != 6:
807
+ return None, "bbox_mm 必须是 6 元组 [xmin,xmax,ymin,ymax,zmin,zmax],实际 %d 个" % len(nums)
808
+ for i, label in ((0, "x"), (2, "y"), (4, "z")):
809
+ if nums[i] > nums[i + 1]:
810
+ return None, ("bbox_mm 的 %s 区间非法:min=%s > max=%s"
811
+ % (label, _fmt_num(nums[i]), _fmt_num(nums[i + 1])))
812
+ return nums, None
813
+
814
+
815
+ def _norm_iface_type(t):
816
+ """接口类型归一化(中英文同义词 → shaft/hole/face/...)。"""
817
+ s = str(t or "").strip()
818
+ if not s:
819
+ return "unknown"
820
+ low = s.lower()
821
+ if low in _TYPE_SYNONYMS:
822
+ return _TYPE_SYNONYMS[low]
823
+ return low
824
+
825
+
826
+ def _strip_id_noise(s, typ=None):
827
+ """按【接口类型】去掉 id 里的噪声词,返回余下标识(R3 配对判定用)。
828
+
829
+ ── 为什么必须按类型分开去噪声 ────────────────────────────────────────
830
+ 早期实现用一张全局噪声表,"键槽1" 与 "孔径1" 都会被削成 "1",
831
+ 于是键槽被误判成与轴承孔同一处配合,凭空造出 mismatch(实测踩到)。
832
+ 现在只去掉【与该接口类型相符】的噪声词:
833
+ · hole → 孔/孔径/孔位/内孔/安装孔/配合孔
834
+ · shaft → 轴/轴颈/轴头/轴伸/轴段
835
+ · 其它 → 只去法兰/端面/平面/槽 这类中性词
836
+ 这样 "轴颈1"→"1"、"孔1"→"1" 仍能配对,而 "键槽1"→"键槽1"(类型不符,
837
+ 不去"槽"以外的词),不会再和孔混淆。
838
+ """
839
+ out = str(s or "")
840
+ if typ == "hole":
841
+ words = _HOLE_NOISE
842
+ elif typ == "shaft":
843
+ words = _SHAFT_NOISE
844
+ else:
845
+ words = _GENERIC_NOISE
846
+ changed = True
847
+ while changed:
848
+ changed = False
849
+ for w in words:
850
+ if w and w in out:
851
+ out = out.replace(w, "")
852
+ changed = True
853
+ return out.strip("-_ #.#、,,()()").strip()
854
+
855
+
856
+ def _types_complementary(ta, tb):
857
+ """两个接口类型是否构成【互补配合】(轴+孔)。
858
+
859
+ R3(去噪声后 id 相同)与 R4(唯一轴孔)都要求互补:
860
+ 只有"轴配孔"在物理上才是同一处配合。两个孔(如 轴承座.孔1 与
861
+ 底座.安装孔1)不构成配合对,不能仅凭 id 残余相同就判为同一处。
862
+ """
863
+ return {ta, tb} == {"shaft", "hole"}
864
+
865
+
866
+ def _norm_interfaces(value):
867
+ """把 interfaces 归一化成 [{'id','type','nominal','tolerance','raw'}]。
868
+
869
+ 接收 list[dict] 或 dict({id: {...}});非法的项被【丢弃并记录】到
870
+ 第二个返回值,而不是静默吞掉 —— 调用方可据此告警。
871
+ """
872
+ out, dropped = [], []
873
+ items = []
874
+ if value is None:
875
+ return out, dropped
876
+ if isinstance(value, dict):
877
+ for k, v in value.items():
878
+ if isinstance(v, dict):
879
+ it = dict(v)
880
+ it.setdefault("id", k)
881
+ else:
882
+ it = {"id": k, "nominal_mm": v}
883
+ items.append(it)
884
+ elif isinstance(value, (list, tuple)):
885
+ items = list(value)
886
+ else:
887
+ return out, [{"reason": "interfaces 既不是数组也不是对象", "value": repr(value)}]
888
+
889
+ for idx, it in enumerate(items):
890
+ if not isinstance(it, dict):
891
+ dropped.append({"index": idx, "reason": "接口项不是对象", "value": repr(it)})
892
+ continue
893
+ iid = _norm_name(it.get("id") or it.get("name") or "")
894
+ if not iid:
895
+ dropped.append({"index": idx, "reason": "接口缺少 id", "value": it})
896
+ continue
897
+ nominal = _as_float(it.get("nominal_mm", it.get("nominal")))
898
+ mates = []
899
+ for f in _MATE_FIELDS:
900
+ if f in it:
901
+ mates.extend(_parse_list_arg(it.get(f)))
902
+ out.append({
903
+ "id": iid,
904
+ "type": _norm_iface_type(it.get("type")),
905
+ "nominal": nominal,
906
+ "tolerance": str(it.get("tolerance") or "").strip(),
907
+ "mates": mates,
908
+ "raw": it,
909
+ })
910
+ return out, dropped
911
+
912
+
913
+ def _tolerance_kind(tol):
914
+ """公差带首字母 → 'hole' / 'shaft' / 'unknown'(GB/T 1800 大小写约定)。"""
915
+ s = str(tol or "").strip()
916
+ if not s:
917
+ return "unknown"
918
+ c = s[0]
919
+ if c in _HOLE_TOL_LETTERS:
920
+ return "hole"
921
+ if c in _SHAFT_TOL_LETTERS:
922
+ return "shaft"
923
+ return "unknown"
924
+
925
+
926
+ def _dims_from_bbox(bbox):
927
+ """由包围盒推出外形尺寸 dict。"""
928
+ if not bbox or len(bbox) != 6:
929
+ return None
930
+ return {
931
+ "length_mm": round(float(bbox[1]) - float(bbox[0]), 4),
932
+ "width_mm": round(float(bbox[3]) - float(bbox[2]), 4),
933
+ "height_mm": round(float(bbox[5]) - float(bbox[4]), 4),
934
+ }
935
+
936
+
937
+ # ══════════════════════════════════════════════════════════════════════════
938
+ # ★【总理子代理核实】写入前强制核实(不许自报,必须代码算出)
939
+ # ══════════════════════════════════════════════════════════════════════════
940
+ # 用户要求:"写进代码的时候,总理子代理也要核实相关数据是否一致,必须要保证真实"。
941
+ #
942
+ # 【为什么"自报已核实"不算数】
943
+ # 如果只是让调用方传一个 verified=True,那是【自我声明】,谁都能写 True,
944
+ # 等于没有核实。真实性必须由【代码从磁盘上的真实数据】算出来,并且可复现。
945
+ #
946
+ # 【本模块的做法:核实 = 计算 + 指纹 + 落账,三者缺一不可】
947
+ # ① 计算:写入前,从【磁盘上重新读取】台账(不信调用方传进来的内存对象),
948
+ # 算出本次改动的真实影响范围(该零件 + 所有直接邻接零件),
949
+ # 并对该范围跑一遍真实的接口尺寸一致性比对。
950
+ # ② 指纹:把【改动生效后的】影响范围内各零件的真实记录做规范化 JSON
951
+ # 序列化后取 SHA-256 —— 这个 verified_digest 是"当时到底核对了哪些数据"
952
+ # 的密码学证据,事后任何人都能重算验证。
953
+ # ③ 落账:digest / 范围 / 核对结论 / 核对时间全部写进 revisions[];
954
+ # 以后 audit_verification() 重算 digest,就能证明数据有没有被绕过模块手改。
955
+ #
956
+ # 【硬闸门】核实发现不一致(mismatch)时:
957
+ # · 默认【拒绝落盘】并 escalate_to_parent=true,把冲突交父代理裁决;
958
+ # · 只有显式 allow_mismatch=True 且【如实填写理由】(理由同样受 _valid_reason
959
+ # 强制),才允许带着"已知问题"落盘,并记入 unresolved 台账 ——
960
+ # 绝不静默放过,也绝不允许"填个无"糊弄过去。
961
+
962
+ def _substance(rec):
963
+ """零件记录的【实质数据】投影:只保留会影响装配/配合的字段。
964
+
965
+ ── 为什么 digest 不能覆盖整条记录 ────────────────────────────────────
966
+ rev / reported_at 这类记账字段每次正常写入都会变;如果把它们算进指纹,
967
+ 那么"上一次核实的指纹"在下一次【合法】改动之后必然对不上 ——
968
+ 审计就退化成"永远报警",等于没有审计价值(实测踩到)。
969
+ 因此指纹只覆盖实质数据:尺寸、包围盒、接口、邻接、房间、文件、材料、凭据。
970
+ """
971
+ if not isinstance(rec, dict):
972
+ return rec
973
+ keys = ("name", "room", "file", "dims", "bbox_mm", "material",
974
+ "credentials", "interfaces", "adjacent")
975
+ return {k: rec.get(k) for k in keys if k in rec}
976
+
977
+
978
+ def _substance_payload(parts, scope):
979
+ """影响范围内各零件的实质数据(digest 的输入)。"""
980
+ return {nm: _substance(parts[nm]) for nm in scope if nm in parts}
981
+
982
+
983
+ def _canonical_digest(obj):
984
+ """对任意 JSON 可序列化对象取稳定的 SHA-256 指纹(键排序、紧凑分隔符)。
985
+
986
+ 稳定性要求:同样的数据,无论字典插入顺序、无论跑多少次,指纹必须一致 ——
987
+ 否则"事后重算验证"就失去意义。
988
+ """
989
+ import hashlib
990
+ try:
991
+ blob = json.dumps(obj, ensure_ascii=False, sort_keys=True,
992
+ separators=(",", ":"), default=str)
993
+ except Exception:
994
+ blob = repr(obj)
995
+ return hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()
996
+
997
+
998
+ def _impact_scope(data, name):
999
+ """算出改动 name 的【真实影响范围】:该零件 + 所有直接邻接零件(双向)。
1000
+
1001
+ 为什么用"直接邻接"而不是全图:直接接触/配合的零件才可能因尺寸改动而装不上;
1002
+ 二级以上属于传递影响,由 find_related_for_change 提示,不进强制闸门。
1003
+ """
1004
+ parts = data.get("parts") or {}
1005
+ key = _resolve_key(parts, name)
1006
+ if key is None:
1007
+ return [], []
1008
+ scope = {key}
1009
+ reasons = []
1010
+ # 出边:我列的邻接
1011
+ for raw in _parse_list_arg(parts[key].get("adjacent")):
1012
+ k = _resolve_key(parts, raw)
1013
+ if k and k != key:
1014
+ scope.add(k)
1015
+ reasons.append({"part": k, "why": "%s 的 adjacent 列出了它" % key})
1016
+ # 入边:谁把我列为邻接
1017
+ for src, rec in parts.items():
1018
+ if src == key:
1019
+ continue
1020
+ ks = [_resolve_key(parts, x) or x for x in _parse_list_arg(rec.get("adjacent"))]
1021
+ if key in ks:
1022
+ if src not in scope:
1023
+ reasons.append({"part": src, "why": "%s 把 %s 列为 adjacent" % (src, key)})
1024
+ scope.add(src)
1025
+ return sorted(scope), reasons
1026
+
1027
+
1028
+ def _verify_impact(data, name, work_dir=None, action="update", detail=None):
1029
+ """★ 总理子代理核实:对本次改动的影响范围做真实的一致性核对。
1030
+
1031
+ ── 与调用方"自报"无关 ────────────────────────────────────────────────
1032
+ 本函数只读 data(调用方刚重读过磁盘的台账)+ 真实计算,不接收任何
1033
+ "已核实"标志位。它返回的 mismatches 是从接口数据里真算出来的。
1034
+
1035
+ Returns:
1036
+ {"verified": bool, # 是否真的核对了(有可比对的接口数据)
1037
+ "scope": [零件名...], # 本次核实覆盖的零件
1038
+ "scope_reasons": [...], # 为什么这些零件在范围内
1039
+ "digest": "sha256...", # 影响范围真实数据的指纹(改动生效后重算)
1040
+ "mismatches": [...], # 真实算出的不一致
1041
+ "neighbors_checked": n,
1042
+ "evaluated": bool, "reason": str|None,
1043
+ "summary": "..."}
1044
+ """
1045
+ parts = data.get("parts") or {}
1046
+ key = _resolve_key(parts, name)
1047
+ if key is None:
1048
+ return {"verified": False, "evaluated": False, "scope": [], "scope_reasons": [],
1049
+ "digest": None, "mismatches": [], "neighbors_checked": 0,
1050
+ "reason": "零件未登记,无法核实", "summary": "未能核实:零件未登记"}
1051
+
1052
+ scope, scope_reasons = _impact_scope(data, key)
1053
+ neighbors = [x for x in scope if x != key]
1054
+ # 只核对"该零件 ↔ 每个邻接零件"这些对(定向核对,不外溢到无关零件)
1055
+ pairs = [(key, nb) for nb in neighbors]
1056
+ core = _check_core(data, only_pairs=pairs) if pairs else {
1057
+ "mismatches": [], "pairs_with_interface_data": 0, "pairs": [],
1058
+ "warnings": [], "insufficient": [], "dangling": [], "one_way": [], "self_loops": [],
1059
+ }
1060
+
1061
+ # ── digest 覆盖【影响范围内各零件的实质数据】(改动已生效后的值)──
1062
+ # 只取会影响力/配合判定的字段(见 _substance),不含 rev 等记账字段,
1063
+ # 这样"上一版与这一版实质数据相同"时指纹稳定,审计不会永远误报。
1064
+ digest = _canonical_digest(_substance_payload(parts, scope))
1065
+ record_digest = _canonical_digest(parts.get(key) or {})
1066
+
1067
+ with_data = core.get("pairs_with_interface_data", 0)
1068
+ mismatches = core.get("mismatches") or []
1069
+ evaluated = bool(neighbors) and with_data > 0
1070
+
1071
+ if not neighbors:
1072
+ reason = ("零件 %s 没有任何邻接零件(adjacent 为空且无人引用它)—— "
1073
+ "无相关数据可核实;若它确实与其他零件配合,请先 link_adjacent" % key)
1074
+ elif with_data == 0:
1075
+ reason = ("零件 %s 有 %d 个邻接零件,但没有任何一对声明了可比较的 "
1076
+ "interfaces/nominal_mm —— 相关数据缺失,无法核实一致性(这不是通过)"
1077
+ % (key, len(neighbors)))
1078
+ else:
1079
+ reason = None
1080
+
1081
+ if mismatches:
1082
+ summary = ("核实未通过:影响范围内发现 %d 处尺寸不一致 —— 拒绝落盘,交父代理裁决"
1083
+ % len(mismatches))
1084
+ elif not evaluated:
1085
+ summary = "未能核实:%s" % reason
1086
+ else:
1087
+ summary = ("核实通过:已核对 %s 与 %d 个直接邻接零件的接口尺寸,"
1088
+ "未发现不一致(digest=%s)" % (key, len(neighbors), digest[:16]))
1089
+
1090
+ return {
1091
+ "verified": bool(evaluated and not mismatches),
1092
+ "evaluated": bool(evaluated),
1093
+ "scope": scope,
1094
+ "scope_reasons": scope_reasons,
1095
+ "neighbors_checked": len(neighbors),
1096
+ "digest": digest,
1097
+ "record_digest": record_digest,
1098
+ "mismatches": mismatches,
1099
+ "warnings": core.get("warnings") or [],
1100
+ "insufficient": core.get("insufficient") or [],
1101
+ "dangling_adjacency": core.get("dangling") or [],
1102
+ "one_way_adjacency": core.get("one_way") or [],
1103
+ "reason": reason,
1104
+ "action": action,
1105
+ "detail": detail,
1106
+ "summary": summary,
1107
+ }
1108
+
1109
+
1110
+ def _record_unresolved(data, name, verification, reason, action="update"):
1111
+ """把"经如实说明理由后仍保留的不一致"记入 unresolved 台账(可追溯、不静默)。
1112
+
1113
+ ── 去重(避免同一处不一致被反复登记)────────────────────────────────
1114
+ 一次核实的范围是"该零件 + 全部直接邻接零件",因此既可能查出本次新引入的
1115
+ 不一致,也会再次看到【先前已登记过】的旧不一致。若不去重,同一个问题会随
1116
+ 每次改动重复堆积(实测:link 两次后同一处 A-B 被记了 2 条)。
1117
+ 这里以 (零件对, 接口, 差异描述) 为键,只对仍处于 open 的记录去重。
1118
+ """
1119
+ _ts, at = _now()
1120
+ existing = data.setdefault("unresolved", [])
1121
+ open_keys = set()
1122
+ for u in existing:
1123
+ if not isinstance(u, dict):
1124
+ continue
1125
+ if str(u.get("status") or "open").lower() != "open":
1126
+ continue
1127
+ open_keys.add((tuple(u.get("parts") or []), u.get("interface"), u.get("detail")))
1128
+
1129
+ added = 0
1130
+ for m in verification.get("mismatches") or []:
1131
+ key = (tuple(m.get("parts") or []), m.get("interface"), m.get("detail"))
1132
+ if key in open_keys:
1133
+ continue
1134
+ open_keys.add(key)
1135
+ existing.append({
1136
+ "part": name,
1137
+ "parts": m.get("parts"),
1138
+ "interface": m.get("interface"),
1139
+ "detail": m.get("detail"),
1140
+ "severity": m.get("severity") or "ERROR",
1141
+ "escalate_to_parent": True,
1142
+ "action": action,
1143
+ "reason": reason,
1144
+ "digest": verification.get("digest"),
1145
+ "scope": verification.get("scope"),
1146
+ "ts": _ts, "at": at,
1147
+ "status": "open",
1148
+ })
1149
+ added += 1
1150
+ return added
1151
+
1152
+
1153
+ def _write_gate(data, name, reason, work_dir, action, allow_mismatch=False,
1154
+ skip_verify=False):
1155
+ """★ 所有落盘型改动共用的强制闸门:如实填写 + 总理子代理核实。
1156
+
1157
+ 顺序(任一不过就返回错误,调用方必须直接 return,不得落盘):
1158
+ ① 变更说明如实填写检查(_valid_reason)
1159
+ ② 总理子代理核实(_verify_impact)—— 真实计算,不看调用方声明
1160
+ ③ 核实发现不一致 → 默认拒绝;allow_mismatch=True 时需【再次】如实填写理由,
1161
+ 并记入 unresolved 台账
1162
+
1163
+ Returns: (error_dict | None, verification | None)
1164
+ """
1165
+ err = _require_reason(reason, op=action, what=name)
1166
+ if err:
1167
+ return err, None
1168
+
1169
+ if skip_verify:
1170
+ # 跳过核实也要留下【明确的痕迹】:skipped=True + evaluated=False,
1171
+ # 且不产生 part_digest —— audit 会把该零件列入"无指纹、不背书"清单。
1172
+ return None, {"verified": None, "evaluated": False, "skipped": True,
1173
+ "scope": [], "neighbors_checked": 0, "digest": None,
1174
+ "mismatches": [], "reason": "调用方显式跳过核实(skip_verification=True)",
1175
+ "summary": "已跳过总理子代理核实 —— 本次改动未经验证,可由 audit 查出"}
1176
+ v = _verify_impact(data, name, work_dir=work_dir, action=action, detail=reason)
1177
+ mm = v.get("mismatches") or []
1178
+ if mm:
1179
+ if not allow_mismatch:
1180
+ return {
1181
+ "ok": False,
1182
+ "gate": "CONSISTENCY_VERIFICATION_FAILED",
1183
+ "rejected": True,
1184
+ "part": name,
1185
+ "action": action,
1186
+ "error": ("【核实未通过·拒绝落盘】总理子代理核实发现 %d 处相关数据不一致,"
1187
+ "已阻止本次改动写入台账" % len(mm)),
1188
+ "verification": v,
1189
+ "mismatches": mm,
1190
+ "escalate_to_parent": True,
1191
+ "hint": ("这是保护,不是故障:相邻零件尺寸对不上时先改数据,别先写台账。"
1192
+ "处理方式二选一:\n"
1193
+ " 1) 把相关零件的接口尺寸改一致后重试(推荐);\n"
1194
+ " 2) 确属设计允许(如过渡配合/预留余量)时,显式传 "
1195
+ "allow_mismatch=True 并【如实写明理由】,"
1196
+ "该不一致会记入台账 unresolved 清单,不会静默消失。"),
1197
+ }, None
1198
+ # 允许带病落盘,但理由必须【额外】如实填写一条"为什么可以不一致"
1199
+ err2 = _require_reason(allow_mismatch if isinstance(allow_mismatch, str) else None,
1200
+ op="allow_mismatch", what=name)
1201
+ if err2:
1202
+ err2["error"] = ("allow_mismatch=True 时必须【另外如实写明】"
1203
+ "为什么允许这个不一致(不能只给 True):%s" % err2.get("error"))
1204
+ err2["mismatches"] = mm
1205
+ err2["verification"] = v
1206
+ return err2, None
1207
+ return None, v
1208
+
1209
+
1210
+ # ══════════════════════════════════════════════════════════════════════════
1211
+ # 写入接口
1212
+ # ══════════════════════════════════════════════════════════════════════════
1213
+
1214
+ def origin_record_path(part_path):
1215
+ """【Bug⑪】零件 origin 约定文件路径:<零件>.SLDPRT → <零件>.SLDPRT.origin.json。"""
1216
+ if not part_path:
1217
+ return None
1218
+ return str(part_path) + ORIGIN_RECORD_SUFFIX
1219
+
1220
+
1221
+ def read_origin_record(part_path):
1222
+ """【Bug⑪】读取零件旁的 <零件>.origin.json;不存在/损坏返回 None(只读、不抛异常)。
1223
+
1224
+ 容忍两种入参:零件路径(<part>.SLDPRT)或 origin 文件路径本身。
1225
+ """
1226
+ if not part_path:
1227
+ return None
1228
+ _s = str(part_path)
1229
+ _cands = ([_s] if _s.lower().endswith(ORIGIN_RECORD_SUFFIX)
1230
+ else [_s + ORIGIN_RECORD_SUFFIX])
1231
+ for p in _cands:
1232
+ try:
1233
+ if not os.path.isfile(p):
1234
+ continue
1235
+ with open(p, "r", encoding="utf-8-sig") as f:
1236
+ d = json.load(f)
1237
+ if isinstance(d, dict):
1238
+ return d
1239
+ except Exception:
1240
+ continue
1241
+ return None
1242
+
1243
+
1244
+ def material_consistency(part_path):
1245
+ """【Bug①】从材料凭据里读出"请求材质 vs SW 实际生效材质"的一致性结论。
1246
+
1247
+ 现场缺陷(竞速小车任务):
1248
+ 用户确认 PETG(ρ1270 / σy≈40),但 SolidWorks 材质库【没有 PETG】
1249
+ (只有 PET,ρ1420 / σy≈55)。set_custom_material("PETG", ...) 只写了
1250
+ material.json 凭据签名,**没有**真正写进 SW 零件内部材质:
1251
+ · 凭据层签名有效 → 防线①在签名层通过;
1252
+ · .sldprt 属性栏显示的却是 PET(1420) → 用户拿零件直接打印/加工会被误导。
1253
+ 报告里只有一句"实测 1420 不一致"的 WARNING,看不出"要的是 PETG、实得 PET"。
1254
+
1255
+ 本函数把凭据里【已经签名】的三组事实并列出来,让台账/交付物上能直接看到:
1256
+ requested_material(设计要的)
1257
+ applied_name (SW 实际写入的牌号)
1258
+ applied_density / expected_density(客观密度证据)
1259
+
1260
+ ⚠️ 本模块【只读凭据】,不验签、不改凭据、不阻断任何流程 ——
1261
+ 验签与门禁判定属于 defense_gate 的职责。
1262
+
1263
+ Returns:
1264
+ {"available": bool, "credential_file": str,
1265
+ "requested_material", "applied_material",
1266
+ "requested_density_kg_m3", "applied_density_kg_m3",
1267
+ "density_deviation_pct", "name_matches": bool|None,
1268
+ "consistent": bool|None, "warning": str|None}
1269
+ """
1270
+ out = {"available": False, "credential_file": None,
1271
+ "requested_material": None, "applied_material": None,
1272
+ "requested_density_kg_m3": None, "applied_density_kg_m3": None,
1273
+ "density_deviation_pct": None,
1274
+ "name_matches": None, "consistent": None, "warning": None}
1275
+ if not part_path:
1276
+ return out
1277
+ _p = str(part_path) + ".material.json"
1278
+ out["credential_file"] = _p
1279
+ try:
1280
+ if not os.path.isfile(_p):
1281
+ return out
1282
+ with open(_p, "r", encoding="utf-8-sig") as f:
1283
+ att = json.load(f)
1284
+ if not isinstance(att, dict):
1285
+ return out
1286
+ except Exception:
1287
+ return out
1288
+ out["available"] = True
1289
+ # ── 【Bug①】SW 内部实际材质:先看旁挂记录,再看凭据里可能带的字段 ────
1290
+ # 旁挂记录由 swapi 写入(宿主签名白名单不含这些键,签后补字段会让
1291
+ # HMAC 失配,所以它们只能落在旁挂文件里)。
1292
+ _side = None
1293
+ try:
1294
+ for _c in (str(part_path) + ".material.json.sw_internal.json",
1295
+ str(part_path) + ".sw_internal.json"):
1296
+ if os.path.isfile(_c):
1297
+ with open(_c, "r", encoding="utf-8-sig") as _f:
1298
+ _side = json.load(_f)
1299
+ out["sw_internal_record_file"] = _c
1300
+ break
1301
+ except Exception:
1302
+ _side = None
1303
+ if isinstance(_side, dict):
1304
+ out["sw_internal_material"] = _side.get("sw_internal_material")
1305
+ out["sw_internal_matches_request"] = _side.get("sw_internal_matches_request")
1306
+ out["sw_internal_density_kg_m3"] = _side.get("density_kg_m3")
1307
+ if _side.get("sw_internal_warning"):
1308
+ out["sw_internal_warning"] = _side.get("sw_internal_warning")
1309
+ else:
1310
+ out["sw_internal_material"] = att.get("sw_internal_material")
1311
+ out["sw_internal_matches_request"] = att.get("sw_internal_matches_request")
1312
+ _req = str(att.get("requested_material") or "").strip() or None
1313
+ # applied_name 是 SW 真实写入的牌号;attested_material 是"校验通过的名字"
1314
+ _app = str(att.get("applied_name") or att.get("attested_material") or "").strip() or None
1315
+ out["requested_material"] = _req
1316
+ out["applied_material"] = _app
1317
+ try:
1318
+ _ed = att.get("expected_density_kg_m3")
1319
+ _ad = att.get("applied_density_kg_m3")
1320
+ if _ad is None:
1321
+ _ad = att.get("density_kg_m3")
1322
+ out["requested_density_kg_m3"] = float(_ed) if _ed is not None else None
1323
+ out["applied_density_kg_m3"] = float(_ad) if _ad is not None else None
1324
+ except Exception:
1325
+ pass
1326
+ _name_match = (bool(_req) and bool(_app)
1327
+ and _req.casefold() == _app.casefold())
1328
+ out["name_matches"] = _name_match if (_req and _app) else None
1329
+ # ── 【Bug①】SW 内部实际材质 vs 请求材质(来自旁挂记录,最直接的证据)──
1330
+ _sw_int = str(out.get("sw_internal_material") or "").strip() or None
1331
+ out["sw_internal_material"] = _sw_int
1332
+ if out.get("sw_internal_matches_request") is None and _req and _sw_int:
1333
+ out["sw_internal_matches_request"] = (
1334
+ _req.casefold() == _sw_int.casefold())
1335
+ # 密度偏差:客观证据(名字可能被"标准化"成同一个,密度不会)
1336
+ _dev = None
1337
+ try:
1338
+ if out["requested_density_kg_m3"] and out["applied_density_kg_m3"]:
1339
+ _dev = abs(out["applied_density_kg_m3"]
1340
+ - out["requested_density_kg_m3"]) \
1341
+ / float(out["requested_density_kg_m3"]) * 100.0
1342
+ out["density_deviation_pct"] = round(_dev, 2)
1343
+ except Exception:
1344
+ _dev = None
1345
+ # 一致性判定(三条独立证据,任一不成立即判"不一致"):
1346
+ # ① 名字不同(requested_material vs applied_name);
1347
+ # ② 密度偏差 >5%(客观物理证据,名字可能被"标准化"成同一个);
1348
+ # ③ 【Bug① 核心】SW 内部实际材质与请求材质不同(来自旁挂记录)。
1349
+ _consistent = True
1350
+ if out["name_matches"] is False:
1351
+ _consistent = False
1352
+ if _dev is not None and _dev > 5.0:
1353
+ _consistent = False
1354
+ if out.get("sw_internal_matches_request") is False:
1355
+ _consistent = False
1356
+ out["consistent"] = _consistent
1357
+ if not _consistent:
1358
+ # 优先用旁挂记录里的 warning(它写清了"凭据认 A、SW 实际是 B")
1359
+ out["warning"] = out.get("sw_internal_warning") or (
1360
+ "【Bug①】材料凭据与 SW 实际生效材质不一致:请求 %r"
1361
+ "(ρ≈%s),SW 实际 %r(ρ≈%s),密度偏差 %s%%。"
1362
+ "凭据按请求材质记录,但 .sldprt 打开后属性栏显示的是实际材质 ——"
1363
+ "直接拿零件打印/加工请以 SW 实际材质为准,"
1364
+ "或在 SW 材质库中指定精确存在的牌号。"
1365
+ % (_req, out["requested_density_kg_m3"],
1366
+ _app, out["applied_density_kg_m3"],
1367
+ out["density_deviation_pct"]))
1368
+ return out
1369
+
1370
+
1371
+ def origin_summary(part_path):
1372
+ """【Bug⑪】把 origin 记录压成台账可用的精简结论。
1373
+
1374
+ Returns:
1375
+ {"origin_mode", "origin_mode_cn", "is_standard", "assembly_note",
1376
+ "origin_mm", "bbox_mm", "record_file", "missing": bool}
1377
+ · 没有记录时仍返回对象,但 missing=True(由调用方决定是 warning 还是拦截)。
1378
+ """
1379
+ _rec = read_origin_record(part_path)
1380
+ _p = origin_record_path(part_path)
1381
+ if not _rec:
1382
+ return {"origin_mode": None, "origin_mode_cn": None,
1383
+ "is_standard": None, "assembly_note": None,
1384
+ "origin_mm": None, "bbox_mm": None,
1385
+ "record_file": _p, "missing": True}
1386
+ _mode = _rec.get("origin_mode")
1387
+ # ⚠️ 只取【稳定】字段:at/ts/revision/created_at 这类记账字段每次 save 都变,
1388
+ # 若带进台账 payload,会让"重复登记同一份数据"被误判成"改过"——
1389
+ # 与模块里 _BOOKKEEPING_FIELDS 的设计意图(空改动不落盘)直接冲突。
1390
+ return {
1391
+ "origin_mode": _mode,
1392
+ "origin_mode_cn": _rec.get("origin_mode_cn"),
1393
+ "is_standard": (bool(_mode == ORIGIN_STANDARD_MODE)
1394
+ if _mode is not None else None),
1395
+ "assembly_note": _rec.get("assembly_note"),
1396
+ "origin_mm": _rec.get("origin_mm"),
1397
+ "bbox_mm": _rec.get("bbox_mm"),
1398
+ "record_file": _p,
1399
+ "missing": False,
1400
+ "standard_mode": ORIGIN_STANDARD_MODE,
1401
+ }
1402
+
1403
+
1404
+ def add_part(record, work_dir=None, reason=None, allow_mismatch=False,
1405
+ fields=None, skip_verification=False):
1406
+ """新增或更新一个零件(已存在则视为更新,rev 自增 + 追加修订记录)。
1407
+
1408
+ Args:
1409
+ record: dict,必填 name / room / file;
1410
+ 可选 dims(dict 或 '120x40x40')、bbox_mm、material、
1411
+ credentials(dict)、interfaces(list/dict)、adjacent
1412
+ (list 或 'A,B')、reported_by、reported_at。
1413
+ work_dir: 台账落点覆盖(默认脚本目录/load_cases)。
1414
+ reason: ★【强制·如实填写】本次改动的说明。新增零件时也必须填
1415
+ (说明"这是什么零件、依据什么登记")。缺了或写占位词 → 拒绝落盘。
1416
+ allow_mismatch: 核实发现不一致时,默认拒绝落盘。确属设计允许(过渡配合/
1417
+ 预留余量等)时,传【说明理由的字符串】(不是 True)放行,
1418
+ 该不一致会记入台账 unresolved 清单。
1419
+ fields: 可选。声明本次改了哪些字段('dims,material' 或列表)。
1420
+ ★一旦给出,必须与实际 diff 完全一致 —— 谎报/漏报都拒绝落盘。
1421
+ skip_verification: 仅供测试/离线批处理使用;为 True 时跳过总理子代理核实
1422
+ (revisions 里会留下 evaluated=false 的记录,事后可审计)。
1423
+
1424
+ Returns:
1425
+ {"ok": True, "action": "add"|"update", "part": ..., "rev": n,
1426
+ "file": <台账路径>, "warnings": [...], "part_record": {...},
1427
+ "verification": {...}} ← 总理子代理核实的真实结论与指纹
1428
+ 校验失败 → {"ok": False, "error": "...", ...}(绝不落盘)
1429
+ """
1430
+ if not isinstance(record, dict):
1431
+ return {"ok": False, "error": "record 必须是 dict,收到 %s" % type(record).__name__}
1432
+
1433
+ # ── ① 必填字段校验(硬性)──────────────────────────────────────────
1434
+ missing = [f for f in REQUIRED_FIELDS if not _norm_name(record.get(f))]
1435
+ if missing:
1436
+ return {"ok": False,
1437
+ "error": "缺少必填字段:%s" % ", ".join(missing),
1438
+ "required": list(REQUIRED_FIELDS),
1439
+ "received": sorted([k for k in record.keys()]),
1440
+ "hint": ("add_part 至少需要 name(零件名)、room(所属房间)、"
1441
+ "file(模型文件路径)")}
1442
+
1443
+ name = _norm_name(record.get("name"))
1444
+ room = _norm_name(record.get("room"))
1445
+ file_path = str(record.get("file") or "").strip()
1446
+
1447
+ # ── ①b 【强制】如实填写变更说明(在动任何数据之前就拦)
1448
+ if not skip_verification:
1449
+ err = _require_reason(reason, op="add", what=name)
1450
+ if err:
1451
+ err["error"] = ("登记/更新零件 %r 必须如实填写变更说明(reason):%s"
1452
+ % (name, err.get("error")))
1453
+ return err
1454
+ elif reason is not None:
1455
+ err = _require_reason(reason, op="add", what=name)
1456
+ if err:
1457
+ return err
1458
+
1459
+ # ── ② 可选字段校验 ────────────────────────────────────────────────
1460
+ warnings = []
1461
+
1462
+ dims, err = _parse_dims(record.get("dims"))
1463
+ if err:
1464
+ return {"ok": False, "error": err, "field": "dims", "part": name}
1465
+
1466
+ bbox, err = _parse_bbox(record.get("bbox_mm"))
1467
+ if err:
1468
+ return {"ok": False, "error": err, "field": "bbox_mm", "part": name}
1469
+
1470
+ if bbox and not dims:
1471
+ dims = _dims_from_bbox(bbox)
1472
+ warnings.append("dims 缺失,已由 bbox_mm 推算外形尺寸 %s"
1473
+ % _fmt_dims(dims))
1474
+ elif bbox and dims:
1475
+ derived = _dims_from_bbox(bbox)
1476
+ if derived and _dims_delta(dims, derived) > 0.5:
1477
+ warnings.append("dims 与 bbox_mm 推算尺寸不一致(差 %smm),"
1478
+ "请确认哪一份是真实尺寸" % _fmt_num(_dims_delta(dims, derived)))
1479
+
1480
+ credentials = record.get("credentials")
1481
+ if credentials is not None:
1482
+ if not isinstance(credentials, dict):
1483
+ return {"ok": False, "error": "credentials 必须是 dict(material/physics/domain → 文件路径)",
1484
+ "field": "credentials", "part": name}
1485
+ credentials = {str(k): str(v) for k, v in credentials.items()}
1486
+ for k, v in credentials.items():
1487
+ if v and not os.path.isfile(v):
1488
+ warnings.append("凭据文件不存在:credentials.%s = %s" % (k, v))
1489
+
1490
+ interfaces, dropped = _norm_interfaces(record.get("interfaces"))
1491
+ for d in dropped:
1492
+ warnings.append("接口项被丢弃(%s):%s" % (d.get("reason"), d.get("value")))
1493
+ for it in interfaces:
1494
+ if it["nominal"] is None:
1495
+ warnings.append("接口 %s 缺少 nominal_mm,"
1496
+ "相邻尺寸一致性无法核对这一项" % it["id"])
1497
+ if it["nominal"] is not None and it["nominal"] <= 0:
1498
+ warnings.append("接口 %s 的 nominal_mm=%s 非正数,疑为录入错误"
1499
+ % (it["id"], _fmt_num(it["nominal"])))
1500
+ tk = _tolerance_kind(it["tolerance"])
1501
+ if it["type"] == "hole" and tk == "shaft":
1502
+ warnings.append("接口 %s 类型为孔,但公差带 %r 像轴用公差(GB/T 1800 中孔用大写字母)"
1503
+ % (it["id"], it["tolerance"]))
1504
+ if it["type"] == "shaft" and tk == "hole":
1505
+ warnings.append("接口 %s 类型为轴,但公差带 %r 像孔用公差(GB/T 1800 中轴用小写字母)"
1506
+ % (it["id"], it["tolerance"]))
1507
+
1508
+ adjacent = _parse_list_arg(record.get("adjacent"))
1509
+ if name in adjacent:
1510
+ return {"ok": False, "error": "adjacent 不能包含零件自身(%s)" % name,
1511
+ "field": "adjacent", "part": name}
1512
+
1513
+ if dims and any(float(v) <= 0 for v in dims.values()):
1514
+ warnings.append("尺寸含 0 值:%s —— 通常是漏填或建模失败,请复核" % _fmt_dims(dims))
1515
+
1516
+ if not os.path.isfile(file_path):
1517
+ warnings.append("模型文件当前不存在于磁盘:%s(允许先登记后建模)" % file_path)
1518
+
1519
+ # ── ②b 【Bug⑪】并入零件 origin 约定(来自 <零件>.origin.json)────────
1520
+ # 为什么放在台账里:装配侧(总装/工程图/一致性核对)读的是【台账】。
1521
+ # origin 只躺在零件目录边上的 .origin.json 里没人读,等于没记录。
1522
+ # 缺记录时不硬编造:origin_missing=True + warning,让调用方知道要补。
1523
+ # ── 【Bug①】材料凭据一致性:把"要的材质 vs SW 实际生效的"并入台账 ──
1524
+ # 凭据本身由 defense_gate 验签;这里只做【可见性】——让"零件属性栏显示 PET、
1525
+ # 凭据却写 PETG"这种落差不只躺在 reports/ 里,而能随台账一起交付。
1526
+ _mat_con = material_consistency(file_path)
1527
+ if _mat_con.get("available") and _mat_con.get("consistent") is False:
1528
+ warnings.append(_mat_con.get("warning")
1529
+ or "材料凭据与 SW 实际生效材质不一致")
1530
+
1531
+ origin = origin_summary(file_path)
1532
+ origin_missing = bool(origin.get("missing"))
1533
+ if origin_missing:
1534
+ warnings.append(
1535
+ "该零件缺少 origin 约定记录(%s)—— 装配时无法自动换算基准面。"
1536
+ "请用 swapi.SWModel.set_origin_mode('%s') 声明后重新 save(),"
1537
+ "或用 origin 参数显式登记。" % (os.path.basename(str(file_path))
1538
+ + ORIGIN_RECORD_SUFFIX,
1539
+ ORIGIN_STANDARD_MODE))
1540
+ elif origin.get("is_standard") is False:
1541
+ warnings.append(
1542
+ "该零件 origin 非标准约定(%s):装配请按台账 origin 字段里的"
1543
+ " assembly_note 单独换算;标准约定为「%s」。"
1544
+ % (origin.get("origin_mode"), ORIGIN_STANDARD_CN))
1545
+ # 调用方也可显式覆盖(例如零件尚未落盘、先登记再建模的场景)
1546
+ _rec_origin = record.get("origin")
1547
+ if isinstance(_rec_origin, dict):
1548
+ for _k, _v in _rec_origin.items():
1549
+ if _v is not None:
1550
+ origin[_k] = _v
1551
+ origin["missing"] = False
1552
+ origin["source"] = "explicit(add_part origin=)"
1553
+ elif isinstance(_rec_origin, str) and _rec_origin.strip():
1554
+ origin["origin_mode"] = _rec_origin.strip()
1555
+ origin["mode_cn"] = None
1556
+ origin["is_standard"] = (origin["origin_mode"] == ORIGIN_STANDARD_MODE)
1557
+ origin["missing"] = False
1558
+ origin["source"] = "explicit(add_part origin=)"
1559
+ origin.pop("missing", None)
1560
+ origin["standard_mode"] = ORIGIN_STANDARD_MODE
1561
+
1562
+ # ── ③ 读台账 ──────────────────────────────────────────────────────
1563
+ data, path, err = _load_registry(work_dir)
1564
+ if err:
1565
+ return {"ok": False, "error": err, "file": path,
1566
+ "hint": "台账损坏时拒绝写入,避免覆盖已有数据;请先人工修复或改名备份"}
1567
+
1568
+ _set_task_id(data, work_dir)
1569
+ parts = data["parts"]
1570
+ key = _resolve_key(parts, name)
1571
+ _ts, at = _now()
1572
+
1573
+ payload = {
1574
+ "name": name,
1575
+ "room": room,
1576
+ "file": file_path,
1577
+ }
1578
+ if dims:
1579
+ payload["dims"] = dims
1580
+ if bbox:
1581
+ payload["bbox_mm"] = bbox
1582
+ if record.get("material") is not None:
1583
+ payload["material"] = str(record.get("material") or "").strip()
1584
+ # ── 【Bug⑪】origin 约定随零件记录落盘(装配/一致性核对的输入)────────
1585
+ if origin:
1586
+ payload["origin"] = origin
1587
+ # ── 【Bug①】材料一致性结论随记录落盘(缺失凭据时不写,避免噪音)────────
1588
+ if _mat_con.get("available"):
1589
+ payload["material_consistency"] = {
1590
+ "requested_material": _mat_con.get("requested_material"),
1591
+ "applied_material": _mat_con.get("applied_material"),
1592
+ "requested_density_kg_m3": _mat_con.get("requested_density_kg_m3"),
1593
+ "applied_density_kg_m3": _mat_con.get("applied_density_kg_m3"),
1594
+ "density_deviation_pct": _mat_con.get("density_deviation_pct"),
1595
+ "name_matches": _mat_con.get("name_matches"),
1596
+ "consistent": _mat_con.get("consistent"),
1597
+ "credential_file": _mat_con.get("credential_file"),
1598
+ }
1599
+ if credentials is not None:
1600
+ payload["credentials"] = credentials
1601
+ # interfaces 只在【显式给出】时覆盖 —— 否则 add_part 的常规更新
1602
+ # (例如只改材料)会把已登记的接口数据抹成空,导致一致性检查突然"没数据"。
1603
+ if record.get("interfaces") is not None:
1604
+ payload["interfaces"] = [dict(it["raw"]) for it in interfaces]
1605
+ payload["adjacent"] = adjacent
1606
+ payload["reported_by"] = _norm_name(record.get("reported_by") or room)
1607
+ # ── reported_at 是【记账字段】,不参与"改了什么"的判定 ────────────────
1608
+ # 历史缺陷:原来无条件写 payload["reported_at"] = now(),秒级时间戳每次调用
1609
+ # 都不同 —— 同一份数据重复登记会被当成"改动",于是 rev 无意义增长、空改动
1610
+ # 检测(no-op)随机失效(实测:同一秒内调用看似正常,跨秒就变成 update,
1611
+ # 属典型的时序不确定性缺陷)。
1612
+ # 现在的口径:显式给了就用给的;没给就刷新为当前时间,但【不计入 changed】——
1613
+ # 即"有真实改动时顺带刷新时间戳,只有时间戳变化时不产生修订"。
1614
+ payload["reported_at"] = (str(record.get("reported_at"))
1615
+ if record.get("reported_at") is not None else at)
1616
+
1617
+ # ── ④ 新增 or 更新(先在内存里改,闸门过了才落盘)─────────────────
1618
+ # 【关键】这里先做一次深拷贝作为回滚点:核实不通过时把内存恢复原样,
1619
+ # 保证"拒绝落盘"是真的什么都没写(磁盘+内存都不留痕)。
1620
+ import copy as _copy
1621
+ backup = _copy.deepcopy(data)
1622
+
1623
+ if key is None:
1624
+ # 新记录补齐默认键,保证台账结构统一(缺省 = 空,而不是键不存在)
1625
+ payload.setdefault("credentials", {})
1626
+ payload.setdefault("interfaces", [])
1627
+ payload.setdefault("dims", {})
1628
+ payload.setdefault("material", "")
1629
+ payload["rev"] = 1
1630
+ parts[name] = payload
1631
+ action = "add"
1632
+ changed = {}
1633
+ else:
1634
+ old = parts[key]
1635
+ try:
1636
+ new_rev = int(old.get("rev") or 1) + 1
1637
+ except Exception:
1638
+ new_rev = 2
1639
+ changed = {}
1640
+ merged = dict(old)
1641
+ for k, v in payload.items():
1642
+ if k == "adjacent":
1643
+ # adjacent 是并集语义:add 只追加,不抹掉已有邻接(去邻接请用 remove/link 管理)
1644
+ before = _parse_list_arg(old.get("adjacent"))
1645
+ union = list(before)
1646
+ for x in v:
1647
+ if x not in union:
1648
+ union.append(x)
1649
+ if union != before:
1650
+ changed["adjacent"] = {"from": before, "to": union}
1651
+ merged["adjacent"] = union
1652
+ continue
1653
+ if k == "credentials":
1654
+ before = old.get("credentials") or {}
1655
+ union = dict(before)
1656
+ union.update(v or {})
1657
+ if union != before:
1658
+ changed["credentials"] = {"from": sorted(before.keys()),
1659
+ "to": sorted(union.keys())}
1660
+ merged["credentials"] = union
1661
+ continue
1662
+ if k == "interfaces" and not v and old.get("interfaces"):
1663
+ # 保护:显式传了空 interfaces 会抹掉已有接口数据,导致一致性检查
1664
+ # 突然"没数据可查"。这种情况要求调用方明确用 update_part 清空。
1665
+ merged["interfaces"] = old.get("interfaces")
1666
+ continue
1667
+ before = old.get(k)
1668
+ if k in _BOOKKEEPING_FIELDS:
1669
+ # 记账字段只跟着刷新,不算作"改动"(否则每次调用都像改过)
1670
+ merged[k] = v
1671
+ continue
1672
+ if before != v:
1673
+ changed[k] = {"from": before, "to": v}
1674
+ merged[k] = v
1675
+
1676
+ # ── 空改动:不写无意义的修订(否则重复登记会让 rev 无限增长)────
1677
+ # 这里【不落盘】:既然没有任何实质变化,磁盘上什么都不该动。
1678
+ if not changed:
1679
+ return {
1680
+ "ok": True, "action": "no-op", "part": key,
1681
+ "rev": old.get("rev"), "changed": [], "file": path,
1682
+ "reason": str(reason or "").strip(),
1683
+ "warnings": warnings + ["本次登记的数据与台账现有值完全一致,未产生改动,"
1684
+ "因此没有写入修订记录"],
1685
+ "part_record": old, "parts_count": len(parts),
1686
+ "verification": {"verified": None, "evaluated": False,
1687
+ "summary": "无改动,无需核实"},
1688
+ "note": "空改动不落盘:台账只记录真实发生的变化,避免 rev 无意义增长。",
1689
+ }
1690
+
1691
+ merged["rev"] = new_rev
1692
+ parts[key] = merged
1693
+ action = "update"
1694
+
1695
+ target = _resolve_key(data["parts"], name)
1696
+
1697
+ # ── ④b 【强制】fields 声明必须与实际 diff 完全一致(防止谎报改动范围)──
1698
+ declared = _parse_fields_arg(fields)
1699
+ if declared is not None and action == "update":
1700
+ actual = sorted(changed.keys())
1701
+ if declared != actual:
1702
+ return {
1703
+ "ok": False,
1704
+ "gate": "DECLARED_FIELDS_MISMATCH",
1705
+ "rejected": True,
1706
+ "part": target,
1707
+ "error": ("声明的改动字段与实际情况不一致(声明 %s,实际 %s)—— "
1708
+ "台账要求如实填写改动范围" % (declared or "无", actual or "无")),
1709
+ "declared_fields": declared,
1710
+ "actual_fields": actual,
1711
+ "hint": "要么去掉 fields(由模块自行判定),要么如实列出真实改动的字段。",
1712
+ }
1713
+
1714
+ # ── ④c 【强制】总理子代理核实(真实计算,不看调用方声明)─────────────
1715
+ gate_err, verification = _write_gate(
1716
+ data, target, reason, work_dir, action,
1717
+ allow_mismatch=allow_mismatch, skip_verify=skip_verification)
1718
+ if gate_err:
1719
+ data.clear()
1720
+ data.update(backup) # 回滚:拒绝落盘必须真的什么都没改
1721
+ return gate_err
1722
+
1723
+ # 核实所需的字段名(changes 为空时用动作本身说明)
1724
+ _reason_text = str(reason or "").strip()
1725
+ if action == "add":
1726
+ _append_revision(data, "add", target,
1727
+ changes={"fields": sorted(payload.keys())},
1728
+ reason=_reason_text, verification=verification,
1729
+ fields=declared)
1730
+ else:
1731
+ _append_revision(data, "update", target, changes=changed,
1732
+ reason=_reason_text, verification=verification,
1733
+ fields=declared)
1734
+
1735
+ # 带病落盘时登记 unresolved(不静默)
1736
+ if verification and (verification.get("mismatches") or []):
1737
+ _record_unresolved(data, target, verification, _reason_text, action=action)
1738
+
1739
+ ok, path, werr = _save_registry(data, work_dir)
1740
+ if not ok:
1741
+ return {"ok": False, "error": werr, "file": path}
1742
+ parts = data["parts"]
1743
+
1744
+ # ── ⑤ 邻接登记后的数据体检(缺零件 → 明确的 warning,不闷声)────────
1745
+ for other in sorted(set(adjacent)):
1746
+ if _resolve_key(data["parts"], other) is None:
1747
+ warnings.append("adjacent 中的 %r 尚未登记到台账(悬空邻接),"
1748
+ "请补登记或改用 link_adjacent" % other)
1749
+
1750
+ rec = data["parts"][_resolve_key(data["parts"], name)]
1751
+ out = {
1752
+ "ok": True,
1753
+ "action": action,
1754
+ "part": rec["name"],
1755
+ "rev": rec.get("rev"),
1756
+ "room": rec.get("room"),
1757
+ "file": path,
1758
+ "reason": _reason_text,
1759
+ "warnings": warnings,
1760
+ "revision_count": len(data["revisions"]),
1761
+ "parts_count": len(data["parts"]),
1762
+ "part_record": rec,
1763
+ "note": "已落盘(含修订历史 + 核实指纹);邻接关系建议用 link_adjacent 建立,保证双向。",
1764
+ }
1765
+ if verification:
1766
+ out["verification"] = {
1767
+ "verified": verification.get("verified"),
1768
+ "evaluated": verification.get("evaluated"),
1769
+ "scope": verification.get("scope"),
1770
+ "neighbors_checked": verification.get("neighbors_checked"),
1771
+ "digest": verification.get("digest"),
1772
+ "mismatch_count": len(verification.get("mismatches") or []),
1773
+ "mismatches": verification.get("mismatches") or [],
1774
+ "reason": verification.get("reason"),
1775
+ "summary": verification.get("summary"),
1776
+ }
1777
+ out["unresolved_recorded"] = len(verification.get("mismatches") or [])
1778
+ else:
1779
+ out["verification"] = {"verified": None, "evaluated": False,
1780
+ "summary": "已跳过核实(skip_verification=True)—— "
1781
+ "该记录可被 audit_verification 查出"}
1782
+ return out
1783
+
1784
+
1785
+ def update_part(name, patch, work_dir=None, reason=None, allow_mismatch=False,
1786
+ fields=None, skip_verification=False):
1787
+ """局部更新一个已登记零件(只改 patch 里出现的字段,rev 自增)。
1788
+
1789
+ Args:
1790
+ name: 已登记零件名
1791
+ patch: dict,键必须在 PATCHABLE_FIELDS 内(不允许改名;
1792
+ 改 adjacent 请用 link_adjacent / remove_part 以保证双向一致)
1793
+ reason: ★【强制·如实填写】本次改了什么、为什么改。缺了/占位词 → 拒绝落盘。
1794
+ allow_mismatch: 核实发现不一致时默认拒绝;确属设计允许时传【说明理由的字符串】。
1795
+ fields: 可选。声明本次改了哪些字段;一旦给出必须与实际 diff 完全一致。
1796
+ skip_verification: 仅测试用,跳过核实(会在台账里留痕,可被审计查出)。
1797
+ Returns:
1798
+ 与 add_part 同构的 {"ok":..., "action": "update", ...}
1799
+ """
1800
+ name = _norm_name(name)
1801
+ if not name:
1802
+ return {"ok": False, "error": "缺少零件名 name"}
1803
+ if not isinstance(patch, dict) or not patch:
1804
+ return {"ok": False, "error": "patch 必须是非空 dict"}
1805
+
1806
+ # 【强制】如实填写变更说明(在动任何数据之前就拦)
1807
+ if not skip_verification:
1808
+ err = _require_reason(reason, op="update", what=name)
1809
+ if err:
1810
+ err["error"] = ("更新零件 %r 必须如实填写变更说明(reason):%s"
1811
+ % (name, err.get("error")))
1812
+ return err
1813
+ elif reason is not None:
1814
+ err = _require_reason(reason, op="update", what=name)
1815
+ if err:
1816
+ return err
1817
+
1818
+ data, path, err = _load_registry(work_dir)
1819
+ if err:
1820
+ return {"ok": False, "error": err, "file": path}
1821
+ key = _resolve_key(data["parts"], name)
1822
+ if key is None:
1823
+ return {"ok": False, "error": "零件未登记:%s" % name, "file": path,
1824
+ "hint": "先用 add_part 登记,或用 list_parts 查看已有零件"}
1825
+
1826
+ if "name" in patch and _norm_name(patch.get("name")) != key:
1827
+ return {"ok": False,
1828
+ "error": "update_part 不支持改名(%s → %s)" % (key, _norm_name(patch.get("name"))),
1829
+ "hint": "改名会撕裂邻接图;请 remove_part 旧名后 add_part 新名,并重建 link"}
1830
+
1831
+ # ── 【Bug⑪】origin 补丁归一化 ─────────────────────────────────────
1832
+ # 允许两种写法:字符串(只给 origin_mode)或 dict(完整结论)。
1833
+ # 统一转成与 add_part 落盘同构的 dict,避免台账里出现两种 schema。
1834
+ if "origin" in patch:
1835
+ _po = patch.get("origin")
1836
+ if isinstance(_po, str):
1837
+ patch = dict(patch)
1838
+ patch["origin"] = {"origin_mode": _po.strip(),
1839
+ "is_standard": (_po.strip() == ORIGIN_STANDARD_MODE),
1840
+ "standard_mode": ORIGIN_STANDARD_MODE,
1841
+ "source": "explicit(update origin=)"}
1842
+ elif isinstance(_po, dict):
1843
+ _nd = dict(_po)
1844
+ _nd.setdefault("standard_mode", ORIGIN_STANDARD_MODE)
1845
+ if _nd.get("origin_mode") is not None:
1846
+ _nd.setdefault("is_standard",
1847
+ _nd.get("origin_mode") == ORIGIN_STANDARD_MODE)
1848
+ patch = dict(patch)
1849
+ patch["origin"] = _nd
1850
+ elif _po is None:
1851
+ patch = dict(patch)
1852
+ patch.pop("origin", None)
1853
+
1854
+ unknown = [k for k in patch.keys() if k not in PATCHABLE_FIELDS]
1855
+ if unknown:
1856
+ return {"ok": False,
1857
+ "error": "存在不可更新字段:%s" % ", ".join(sorted(unknown)),
1858
+ "allowed": list(PATCHABLE_FIELDS)}
1859
+
1860
+ warnings = []
1861
+ newvals = {}
1862
+ if "room" in patch:
1863
+ v = _norm_name(patch.get("room"))
1864
+ if not v:
1865
+ return {"ok": False, "error": "room 不能为空"}
1866
+ newvals["room"] = v
1867
+ if "file" in patch:
1868
+ v = str(patch.get("file") or "").strip()
1869
+ if not v:
1870
+ return {"ok": False, "error": "file 不能为空"}
1871
+ newvals["file"] = v
1872
+ if not os.path.isfile(v):
1873
+ warnings.append("模型文件不存在于磁盘:%s" % v)
1874
+ if "dims" in patch:
1875
+ dims, e = _parse_dims(patch.get("dims"))
1876
+ if e:
1877
+ return {"ok": False, "error": e, "field": "dims"}
1878
+ newvals["dims"] = dims
1879
+ if "bbox_mm" in patch:
1880
+ bbox, e = _parse_bbox(patch.get("bbox_mm"))
1881
+ if e:
1882
+ return {"ok": False, "error": e, "field": "bbox_mm"}
1883
+ newvals["bbox_mm"] = bbox
1884
+ if "material" in patch:
1885
+ newvals["material"] = str(patch.get("material") or "").strip()
1886
+ if "credentials" in patch:
1887
+ cr = patch.get("credentials")
1888
+ if cr is not None and not isinstance(cr, dict):
1889
+ return {"ok": False, "error": "credentials 必须是 dict"}
1890
+ newvals["credentials"] = {str(k): str(v) for k, v in (cr or {}).items()}
1891
+ if "origin" in patch:
1892
+ # 【Bug⑪】已在上面归一化为 dict(字符串/None 都转过来了)
1893
+ _ov = patch.get("origin")
1894
+ if not isinstance(_ov, dict):
1895
+ return {"ok": False, "error": "origin 必须是 dict 或 origin_mode 字符串"}
1896
+ newvals["origin"] = _ov
1897
+ if "material_consistency" in patch:
1898
+ # 【Bug①】人工核实后更正材料一致性结论(如已换成 SW 库中精确牌号)
1899
+ _mc = patch.get("material_consistency")
1900
+ if not isinstance(_mc, dict):
1901
+ return {"ok": False, "error": "material_consistency 必须是 dict"}
1902
+ newvals["material_consistency"] = _mc
1903
+ if "interfaces" in patch:
1904
+ ifaces, dropped = _norm_interfaces(patch.get("interfaces"))
1905
+ for d in dropped:
1906
+ warnings.append("接口项被丢弃(%s):%s" % (d.get("reason"), d.get("value")))
1907
+ newvals["interfaces"] = [dict(it["raw"]) for it in ifaces]
1908
+ if "adjacent" in patch:
1909
+ adj = _parse_list_arg(patch.get("adjacent"))
1910
+ if key in adj:
1911
+ return {"ok": False, "error": "adjacent 不能包含零件自身(%s)" % key}
1912
+ newvals["adjacent"] = adj
1913
+ warnings.append("直接覆盖 adjacent 会绕过双向校验;"
1914
+ "推荐用 link_adjacent / remove_part 维护邻接图")
1915
+ if "reported_by" in patch:
1916
+ newvals["reported_by"] = _norm_name(patch.get("reported_by"))
1917
+ if "reported_at" in patch:
1918
+ newvals["reported_at"] = str(patch.get("reported_at") or "")
1919
+
1920
+ import copy as _copy
1921
+ backup = _copy.deepcopy(data)
1922
+
1923
+ old = data["parts"][key]
1924
+ changed = {}
1925
+ merged = dict(old)
1926
+ for k, v in newvals.items():
1927
+ if k in _BOOKKEEPING_FIELDS:
1928
+ # 记账字段(如 reported_at)只刷新,不算作"改动"
1929
+ merged[k] = v
1930
+ continue
1931
+ before = old.get(k)
1932
+ if before != v:
1933
+ changed[k] = {"from": before, "to": v}
1934
+ merged[k] = v
1935
+
1936
+ # ── 空改动直接返回,不写无意义的修订 ──────────────────────────────
1937
+ # 历史缺陷:patch 与现有值完全相同时仍会 rev+1 并追加一条 changes={} 的修订,
1938
+ # 台账里堆满"什么都没改"的垃圾记录,还会污染 audit 的最后写入指纹。
1939
+ if not changed:
1940
+ return {
1941
+ "ok": True, "action": "no-op", "part": key,
1942
+ "rev": old.get("rev"), "changed": [], "file": path,
1943
+ "reason": str(reason or "").strip(),
1944
+ "warnings": warnings + ["patch 与现有值完全一致,未产生任何改动,"
1945
+ "因此没有写入修订记录"],
1946
+ "part_record": old,
1947
+ "verification": {"verified": None, "evaluated": False,
1948
+ "summary": "无改动,无需核实"},
1949
+ "note": "空改动不落盘:台账只记录真实发生的变化,避免无意义的 rev 增长。",
1950
+ }
1951
+
1952
+ try:
1953
+ merged["rev"] = int(old.get("rev") or 1) + 1
1954
+ except Exception:
1955
+ merged["rev"] = 2
1956
+ data["parts"][key] = merged
1957
+
1958
+ # ── 【强制】声明字段必须与实际 diff 完全一致 ──────────────────────
1959
+ declared = _parse_fields_arg(fields)
1960
+ if declared is not None:
1961
+ actual = sorted(changed.keys())
1962
+ if declared != actual:
1963
+ return {
1964
+ "ok": False, "gate": "DECLARED_FIELDS_MISMATCH", "rejected": True,
1965
+ "part": key,
1966
+ "error": ("声明的改动字段与实际情况不一致(声明 %s,实际 %s)—— "
1967
+ "台账要求如实填写改动范围" % (declared or "无", actual or "无")),
1968
+ "declared_fields": declared, "actual_fields": actual,
1969
+ }
1970
+
1971
+ # ── 【强制】总理子代理核实 ────────────────────────────────────────
1972
+ gate_err, verification = _write_gate(
1973
+ data, key, reason, work_dir, "update",
1974
+ allow_mismatch=allow_mismatch, skip_verify=skip_verification)
1975
+ if gate_err:
1976
+ data.clear()
1977
+ data.update(backup) # 回滚:拒绝落盘必须真的什么都没改
1978
+ return gate_err
1979
+
1980
+ _reason_text = str(reason or "").strip()
1981
+ _append_revision(data, "update", key, changes=changed, reason=_reason_text,
1982
+ verification=verification, fields=declared)
1983
+ if verification and (verification.get("mismatches") or []):
1984
+ _record_unresolved(data, key, verification, _reason_text, action="update")
1985
+
1986
+ ok, path, werr = _save_registry(data, work_dir)
1987
+ if not ok:
1988
+ return {"ok": False, "error": werr, "file": path}
1989
+ out = {"ok": True, "action": "update", "part": key, "rev": merged["rev"],
1990
+ "changed": sorted(changed.keys()), "file": path,
1991
+ "reason": _reason_text,
1992
+ "warnings": warnings, "part_record": merged}
1993
+ if verification:
1994
+ out["verification"] = {
1995
+ "verified": verification.get("verified"),
1996
+ "evaluated": verification.get("evaluated"),
1997
+ "scope": verification.get("scope"),
1998
+ "neighbors_checked": verification.get("neighbors_checked"),
1999
+ "digest": verification.get("digest"),
2000
+ "mismatch_count": len(verification.get("mismatches") or []),
2001
+ "mismatches": verification.get("mismatches") or [],
2002
+ "reason": verification.get("reason"),
2003
+ "summary": verification.get("summary"),
2004
+ }
2005
+ out["unresolved_recorded"] = len(verification.get("mismatches") or [])
2006
+ else:
2007
+ out["verification"] = {"verified": None, "evaluated": False,
2008
+ "summary": "已跳过核实(skip_verification=True)"}
2009
+ return out
2010
+
2011
+
2012
+ def link_adjacent(name_a, name_b, work_dir=None, reason=None,
2013
+ allow_mismatch=False, skip_verification=False):
2014
+ """★ 双向建立邻接关系(A→B 与 B→A 都写)。
2015
+
2016
+ 这是尺寸一致性检查的基础:只有双向登记后,
2017
+ related_parts / check_adjacency_consistency 才能把两个零件当成一对。
2018
+
2019
+ 强制校验:
2020
+ · A、B 都必须已登记(否则拒绝,避免造出悬空邻接);
2021
+ · A、B 不能同名(拒绝自环);
2022
+ · ★ 必须如实填写 reason(建立了哪两件的配合关系、依据什么);
2023
+ · ★ 总理子代理核实:建立邻接后立刻核对这一对(及双方其它邻接)的
2024
+ 接口尺寸,发现不一致 → 拒绝落盘(除非 allow_mismatch 并写明理由)。
2025
+ """
2026
+ a = _norm_name(name_a)
2027
+ b = _norm_name(name_b)
2028
+ if not a or not b:
2029
+ return {"ok": False, "error": "缺少零件名(a=%r, b=%r)" % (name_a, name_b)}
2030
+ if _norm_key(a) == _norm_key(b):
2031
+ return {"ok": False, "error": "不能把零件与自己建立邻接(%s)" % a}
2032
+
2033
+ if not skip_verification:
2034
+ err = _require_reason(reason, op="link", what="%s ↔ %s" % (a, b))
2035
+ if err:
2036
+ err["error"] = ("建立邻接关系 %s ↔ %s 必须如实填写变更说明(reason):%s"
2037
+ % (a, b, err.get("error")))
2038
+ return err
2039
+ elif reason is not None:
2040
+ err = _require_reason(reason, op="link", what="%s ↔ %s" % (a, b))
2041
+ if err:
2042
+ return err
2043
+
2044
+ data, path, err = _load_registry(work_dir)
2045
+ if err:
2046
+ return {"ok": False, "error": err, "file": path}
2047
+ ka = _resolve_key(data["parts"], a)
2048
+ kb = _resolve_key(data["parts"], b)
2049
+ missing = [x for x, k in ((a, ka), (b, kb)) if k is None]
2050
+ if missing:
2051
+ return {"ok": False,
2052
+ "error": "零件未登记:%s" % "、".join(missing),
2053
+ "hint": "先 add_part 登记两侧零件,再建立邻接关系",
2054
+ "file": path}
2055
+
2056
+ if kb in _parse_list_arg(data["parts"][ka].get("adjacent")) and \
2057
+ ka in _parse_list_arg(data["parts"][kb].get("adjacent")):
2058
+ # 已经双向登记:幂等返回,不产生新修订(避免无意义的 rev 增长)
2059
+ return {"ok": True, "action": "link", "already_linked": True,
2060
+ "a": ka, "b": kb, "file": path,
2061
+ "adjacent": {ka: data["parts"][ka].get("adjacent", []),
2062
+ kb: data["parts"][kb].get("adjacent", [])},
2063
+ "note": "两侧原本就已互相登记,未做改动(因此不需要 reason)"}
2064
+
2065
+ import copy as _copy
2066
+ backup = _copy.deepcopy(data)
2067
+
2068
+ changed_parts = []
2069
+ for src, dst in ((ka, kb), (kb, ka)):
2070
+ rec = data["parts"][src]
2071
+ adj = _parse_list_arg(rec.get("adjacent"))
2072
+ if dst in adj:
2073
+ continue
2074
+ adj.append(dst)
2075
+ rec["adjacent"] = adj
2076
+ try:
2077
+ rec["rev"] = int(rec.get("rev") or 1) + 1
2078
+ except Exception:
2079
+ rec["rev"] = 2
2080
+ changed_parts.append(src)
2081
+
2082
+ # ── 【强制】总理子代理核实:建立邻接后立即核对这一对 ───────────────
2083
+ _reason_text = str(reason or "").strip()
2084
+ gate_err, verification = _write_gate(
2085
+ data, ka, reason, work_dir, "link",
2086
+ allow_mismatch=allow_mismatch, skip_verify=skip_verification)
2087
+ if gate_err:
2088
+ data.clear()
2089
+ data.update(backup)
2090
+ gate_err["link"] = "%s ↔ %s" % (ka, kb)
2091
+ return gate_err
2092
+
2093
+ for src, dst in ((ka, kb), (kb, ka)):
2094
+ _append_revision(data, "link", src,
2095
+ changes={"adjacent": {"added": dst}},
2096
+ extra={"linked_with": dst},
2097
+ reason=_reason_text, verification=verification)
2098
+ if verification and (verification.get("mismatches") or []):
2099
+ _record_unresolved(data, ka, verification, _reason_text, action="link")
2100
+
2101
+ ok, path, werr = _save_registry(data, work_dir)
2102
+ if not ok:
2103
+ return {"ok": False, "error": werr, "file": path}
2104
+ out = {
2105
+ "ok": True,
2106
+ "action": "link",
2107
+ "a": ka, "b": kb,
2108
+ "bidirectional": True,
2109
+ "updated_parts": changed_parts,
2110
+ "reason": _reason_text,
2111
+ "adjacent": {ka: data["parts"][ka].get("adjacent", []),
2112
+ kb: data["parts"][kb].get("adjacent", [])},
2113
+ "file": path,
2114
+ "note": "已双向登记(A↔B);check_adjacency_consistency 会把它当作一个待核对配合对。",
2115
+ }
2116
+ if verification:
2117
+ out["verification"] = {
2118
+ "verified": verification.get("verified"),
2119
+ "evaluated": verification.get("evaluated"),
2120
+ "scope": verification.get("scope"),
2121
+ "neighbors_checked": verification.get("neighbors_checked"),
2122
+ "digest": verification.get("digest"),
2123
+ "mismatch_count": len(verification.get("mismatches") or []),
2124
+ "mismatches": verification.get("mismatches") or [],
2125
+ "reason": verification.get("reason"),
2126
+ "summary": verification.get("summary"),
2127
+ }
2128
+ return out
2129
+
2130
+
2131
+ def remove_part(name, work_dir=None, reason=None, skip_verification=False):
2132
+ """删除零件,并【清理所有其它零件对它的邻接引用】(避免悬空邻接)。
2133
+
2134
+ ★ 必须如实填写 reason(为什么删、是废弃还是改名重建)——
2135
+ 删除同样会改变台账数据,因此同样受"如实填写"约束。
2136
+ """
2137
+ name = _norm_name(name)
2138
+ if not name:
2139
+ return {"ok": False, "error": "缺少零件名 name"}
2140
+ if not skip_verification:
2141
+ err = _require_reason(reason, op="remove", what=name)
2142
+ if err:
2143
+ err["error"] = ("删除零件 %r 必须如实填写变更说明(reason):%s"
2144
+ % (name, err.get("error")))
2145
+ return err
2146
+ elif reason is not None:
2147
+ err = _require_reason(reason, op="remove", what=name)
2148
+ if err:
2149
+ return err
2150
+
2151
+ data, path, err = _load_registry(work_dir)
2152
+ if err:
2153
+ return {"ok": False, "error": err, "file": path}
2154
+ key = _resolve_key(data["parts"], name)
2155
+ if key is None:
2156
+ return {"ok": False, "error": "零件未登记:%s" % name, "file": path}
2157
+
2158
+ import copy as _copy
2159
+ backup = _copy.deepcopy(data)
2160
+ _reason_text = str(reason or "").strip()
2161
+
2162
+ # 删除前先给"被删零件"留下最后指纹,供 audit 判定是【模块合法删除】
2163
+ # 还是被人从 JSON 里偷偷抠掉(后者不会有 remove 修订记录)。
2164
+ rec = data["parts"][key]
2165
+ _removed_digest = _canonical_digest(_substance(rec))
2166
+
2167
+ data["parts"].pop(key)
2168
+ cleaned = []
2169
+ for other, orec in data["parts"].items():
2170
+ adj = _parse_list_arg(orec.get("adjacent"))
2171
+ if key in adj:
2172
+ orec["adjacent"] = [x for x in adj if x != key]
2173
+ try:
2174
+ orec["rev"] = int(orec.get("rev") or 1) + 1
2175
+ except Exception:
2176
+ orec["rev"] = 2
2177
+ cleaned.append(other)
2178
+ _append_revision(data, "remove", key,
2179
+ changes={"removed": {"room": rec.get("room"), "file": rec.get("file")}},
2180
+ extra={"cleaned_in": cleaned,
2181
+ "removed_part_digest": _removed_digest,
2182
+ "deleted_by_module": True},
2183
+ reason=_reason_text,
2184
+ stamp_parts=cleaned)
2185
+
2186
+ ok, path, werr = _save_registry(data, work_dir)
2187
+ if not ok:
2188
+ data.clear()
2189
+ data.update(backup)
2190
+ return {"ok": False, "error": werr, "file": path}
2191
+ return {"ok": True, "action": "remove", "removed": key,
2192
+ "reason": _reason_text,
2193
+ "cleaned_adjacency_in": sorted(cleaned),
2194
+ "parts_count": len(data["parts"]), "file": path}
2195
+
2196
+
2197
+ # ══════════════════════════════════════════════════════════════════════════
2198
+ # 查询接口
2199
+ # ══════════════════════════════════════════════════════════════════════════
2200
+
2201
+ def _fmt_dims(dims):
2202
+ """尺寸 dict → '120×40×40mm' 风格短串。"""
2203
+ if not isinstance(dims, dict) or not dims:
2204
+ return "-"
2205
+ keys = ("length_mm", "width_mm", "height_mm")
2206
+ if all(k in dims for k in keys):
2207
+ return "×".join(_fmt_num(dims[k]) for k in keys)
2208
+ return " ".join("%s=%s" % (k, _fmt_num(v)) for k, v in sorted(dims.items()))
2209
+
2210
+
2211
+ def _part_brief(rec):
2212
+ """零件记录的摘要视图(列表/报告用,避免刷屏)。"""
2213
+ dims = rec.get("dims") or {}
2214
+ return {
2215
+ "name": rec.get("name"),
2216
+ "room": rec.get("room"),
2217
+ "file": rec.get("file"),
2218
+ "dims": dims,
2219
+ "dims_text": _fmt_dims(dims),
2220
+ "bbox_mm": rec.get("bbox_mm"),
2221
+ "material": rec.get("material"),
2222
+ # 【Bug⑪】origin 约定:装配换基准面的唯一依据(缺失时如实为 None)
2223
+ "origin": rec.get("origin"),
2224
+ "origin_mode": (rec.get("origin") or {}).get("origin_mode")
2225
+ if isinstance(rec.get("origin"), dict) else None,
2226
+ "adjacent": _parse_list_arg(rec.get("adjacent")),
2227
+ "interface_count": len(_norm_interfaces(rec.get("interfaces"))[0]),
2228
+ "interfaces": [{"id": i["id"], "type": i["type"],
2229
+ "nominal_mm": i["nominal"], "tolerance": i["tolerance"]}
2230
+ for i in _norm_interfaces(rec.get("interfaces"))[0]],
2231
+ "reported_by": rec.get("reported_by"),
2232
+ "reported_at": rec.get("reported_at"),
2233
+ "rev": rec.get("rev"),
2234
+ }
2235
+
2236
+
2237
+ def get_part(name, work_dir=None):
2238
+ """取单个零件的完整记录。"""
2239
+ name = _norm_name(name)
2240
+ if not name:
2241
+ return {"ok": False, "error": "缺少零件名 name"}
2242
+ data, path, err = _load_registry(work_dir)
2243
+ if err:
2244
+ return {"ok": False, "error": err, "file": path}
2245
+ key = _resolve_key(data["parts"], name)
2246
+ if key is None:
2247
+ return {"ok": False, "error": "零件未登记:%s" % name,
2248
+ "file": path, "known_parts": sorted(data["parts"].keys()),
2249
+ "hint": "用 list_parts() 查看台账里已有的零件名"}
2250
+ return {"ok": True, "part": key, "file": path,
2251
+ "task_id": data.get("task_id"),
2252
+ "record": data["parts"][key],
2253
+ "brief": _part_brief(data["parts"][key])}
2254
+
2255
+
2256
+ def list_parts(work_dir=None):
2257
+ """列出全部零件(按名称排序,附摘要与统计)。"""
2258
+ data, path, err = _load_registry(work_dir)
2259
+ if err:
2260
+ return {"ok": False, "error": err, "file": path}
2261
+ parts = data["parts"]
2262
+ items = [_part_brief(parts[k]) for k in sorted(parts.keys())]
2263
+ rooms = {}
2264
+ for it in items:
2265
+ r = it.get("room") or "(未填房间)"
2266
+ rooms[r] = rooms.get(r, 0) + 1
2267
+ return {
2268
+ "ok": True,
2269
+ "file": path,
2270
+ "task_id": data.get("task_id"),
2271
+ "updated_at": data.get("updated_at"),
2272
+ "count": len(items),
2273
+ "rooms": rooms,
2274
+ "parts": items,
2275
+ "note": ("台账为空时 count=0 —— 这不是错误,但此时任何一致性检查都"
2276
+ "无法给出结论(见 check_adjacency_consistency 的 evaluated 字段)。"),
2277
+ }
2278
+
2279
+
2280
+ def _edge_map(data):
2281
+ """由 adjacent 构造无向邻接表 + 单向/悬空明细。
2282
+
2283
+ 返回 (adj: {name: set(neighbors)}, one_way: [(a,b)], dangling: [(a,b)])。
2284
+ 只把「台账里已登记」的邻居计入 adj(悬空的另列,避免 BFS 走进不存在的零件)。
2285
+ """
2286
+ parts = data.get("parts") or {}
2287
+ adj = {k: set() for k in parts}
2288
+ one_way, dangling = [], []
2289
+ for src in parts:
2290
+ for dst in _parse_list_arg(parts[src].get("adjacent")):
2291
+ kdst = _resolve_key(parts, dst)
2292
+ if kdst is None:
2293
+ dangling.append((src, dst))
2294
+ continue
2295
+ if kdst == src:
2296
+ continue
2297
+ adj[src].add(kdst)
2298
+ # 回边检查:对方是否把我(用它的真实键)也列进 adjacent
2299
+ back = [_resolve_key(parts, x) or x
2300
+ for x in _parse_list_arg(parts[kdst].get("adjacent"))]
2301
+ if src not in back and (src, kdst) not in one_way:
2302
+ one_way.append((src, kdst))
2303
+ return adj, one_way, dangling
2304
+
2305
+
2306
+ def related_parts(name, depth=1, work_dir=None):
2307
+ """★ 核心:按零件反查关联零件(对 adjacent 图做 BFS,depth 控制层数)。
2308
+
2309
+ 这是用户要求的"后面哪个零件数据错了、或者要改,可以根据这个文件去看
2310
+ 那个是相关联的"的查询入口。
2311
+
2312
+ Returns:
2313
+ {"ok": True, "root": ..., "depth": n, "related": [名称...],
2314
+ "by_distance": {1: [...], 2: [...]}, "related_detail": [...],
2315
+ "unreachable": [...], "file": ...}
2316
+ """
2317
+ name = _norm_name(name)
2318
+ if not name:
2319
+ return {"ok": False, "error": "缺少零件名 name"}
2320
+ try:
2321
+ depth = int(depth)
2322
+ except Exception:
2323
+ return {"ok": False, "error": "depth 必须是整数,收到 %r" % (depth,)}
2324
+ if depth < 0:
2325
+ return {"ok": False, "error": "depth 不能为负:%d" % depth}
2326
+
2327
+ data, path, err = _load_registry(work_dir)
2328
+ if err:
2329
+ return {"ok": False, "error": err, "file": path}
2330
+ parts = data["parts"]
2331
+ root = _resolve_key(parts, name)
2332
+ if root is None:
2333
+ return {"ok": False, "error": "零件未登记:%s" % name, "file": path,
2334
+ "known_parts": sorted(parts.keys())}
2335
+
2336
+ adj, _one_way, _dangling = _edge_map(data)
2337
+
2338
+ # ── BFS:逐层扩散,记录每个零件的 (距离, 经由谁发现) ────────────────
2339
+ by_distance = {}
2340
+ detail = []
2341
+ visited = {root: (0, None)}
2342
+ frontier = [root]
2343
+ for d in range(1, depth + 1):
2344
+ nxt = []
2345
+ layer = []
2346
+ for cur in frontier:
2347
+ for nb in sorted(adj.get(cur, ())):
2348
+ if nb in visited:
2349
+ continue
2350
+ visited[nb] = (d, cur)
2351
+ layer.append(nb)
2352
+ nxt.append(nb)
2353
+ if not layer:
2354
+ break
2355
+ by_distance[d] = sorted(layer)
2356
+ for nm in sorted(layer):
2357
+ rec = parts[nm]
2358
+ detail.append({
2359
+ "name": nm,
2360
+ "distance": d,
2361
+ "via": visited[nm][1],
2362
+ "room": rec.get("room"),
2363
+ "rev": rec.get("rev"),
2364
+ "shared_interfaces": _shared_interface_ids(parts, root, nm),
2365
+ })
2366
+ frontier = nxt
2367
+
2368
+ related = []
2369
+ for d in sorted(by_distance.keys()):
2370
+ related.extend(by_distance[d])
2371
+
2372
+ return {
2373
+ "ok": True,
2374
+ "root": root,
2375
+ "depth": depth,
2376
+ "related": related,
2377
+ "by_distance": by_distance,
2378
+ "related_detail": detail,
2379
+ "related_count": len(related),
2380
+ "unreachable": sorted([k for k in parts if k not in visited]),
2381
+ "file": path,
2382
+ "task_id": data.get("task_id"),
2383
+ "note": ("按 adjacent 图 BFS 反查;depth=%d 表示最多扩到 %d 层。"
2384
+ "距离 1 = 直接邻接(会直接受尺寸改动影响)。" % (depth, depth)),
2385
+ }
2386
+
2387
+
2388
+ def _shared_interface_ids(parts, a, b):
2389
+ """两个零件之间「看起来是同一处配合」的接口对(id 相同 / 去噪声后相同 / 显式声明)。
2390
+
2391
+ 纯信息展示,不做判定(判定在 check_adjacency_consistency 里)。
2392
+ """
2393
+ ia = _norm_interfaces(parts.get(a, {}).get("interfaces"))[0]
2394
+ ib = _norm_interfaces(parts.get(b, {}).get("interfaces"))[0]
2395
+ hits = []
2396
+ for x in ia:
2397
+ for y in ib:
2398
+ why = _ids_related(x, y, a, b)
2399
+ if why:
2400
+ hits.append({"a_interface": x["id"], "b_interface": y["id"],
2401
+ "matched_by": why,
2402
+ "a_nominal_mm": x["nominal"], "b_nominal_mm": y["nominal"],
2403
+ "consistent": (None if (x["nominal"] is None or y["nominal"] is None)
2404
+ else abs(x["nominal"] - y["nominal"]) < 1e-9)})
2405
+ return hits
2406
+
2407
+
2408
+ def reverse_lookup(name, work_dir=None):
2409
+ """反向查:哪些零件把 name 列进了自己的 adjacent(入边)。
2410
+
2411
+ 入边 = "谁依赖我"。改动 name 的尺寸时,入边的零件最可能受牵连,
2412
+ 因此这个查询是 find_related_for_change 的第一层依据。
2413
+ """
2414
+ name = _norm_name(name)
2415
+ if not name:
2416
+ return {"ok": False, "error": "缺少零件名 name"}
2417
+ data, path, err = _load_registry(work_dir)
2418
+ if err:
2419
+ return {"ok": False, "error": err, "file": path}
2420
+ parts = data["parts"]
2421
+ key = _resolve_key(parts, name)
2422
+
2423
+ inbound, outbound = [], []
2424
+ for src, rec in parts.items():
2425
+ adj_keys = [_resolve_key(parts, x) or x for x in _parse_list_arg(rec.get("adjacent"))]
2426
+ if key is not None and key in adj_keys and src != key:
2427
+ declared = [x for x in _parse_list_arg(rec.get("adjacent"))
2428
+ if (_resolve_key(parts, x) or x) == key]
2429
+ # 入边 src→key 是双向的,当且仅当 key 的回边 key→src 存在。
2430
+ # (历史缺陷:这里误写成判断 key 是否列了 key,恒为 False,
2431
+ # 导致所有入边都被错报成单向。)
2432
+ back = src in [_resolve_key(parts, x) or x
2433
+ for x in _parse_list_arg(parts[key].get("adjacent"))]
2434
+ inbound.append({"from": src, "declared_as": declared[0] if declared else key,
2435
+ "room": rec.get("room"), "rev": rec.get("rev"),
2436
+ "bidirectional": bool(back)})
2437
+ if key is not None and src == key:
2438
+ for x in _parse_list_arg(rec.get("adjacent")):
2439
+ kx = _resolve_key(parts, x)
2440
+ back = False
2441
+ if kx is not None:
2442
+ back = key in [_resolve_key(parts, y) or y
2443
+ for y in _parse_list_arg(parts[kx].get("adjacent"))]
2444
+ outbound.append({"to": kx or x, "registered": kx is not None,
2445
+ "bidirectional": bool(back)})
2446
+
2447
+ if key is None:
2448
+ return {"ok": False,
2449
+ "error": "零件未登记:%s" % name,
2450
+ "file": path,
2451
+ "inbound": inbound,
2452
+ "known_parts": sorted(parts.keys()),
2453
+ "hint": ("该名字不在台账中;inbound 列出的是「别的零件提到了这个名字」"
2454
+ "(悬空引用)")}
2455
+
2456
+ return {
2457
+ "ok": True,
2458
+ "part": key,
2459
+ "file": path,
2460
+ "inbound": sorted(inbound, key=lambda x: x["from"]),
2461
+ "inbound_names": sorted([x["from"] for x in inbound]),
2462
+ "in_degree": len(inbound),
2463
+ "outbound": sorted(outbound, key=lambda x: str(x["to"])),
2464
+ "outbound_names": sorted([str(x["to"]) for x in outbound]),
2465
+ "out_degree": len(outbound),
2466
+ "one_way_inbound": sorted([x["from"] for x in inbound if not x["bidirectional"]]),
2467
+ "unregistered_outbound": sorted([str(x["to"]) for x in outbound if not x["registered"]]),
2468
+ "note": ("inbound=谁把我列为邻接(谁依赖我);outbound=我列了谁。"
2469
+ "one_way_inbound / unregistered_outbound 非空说明邻接图登记不全。"),
2470
+ }
2471
+
2472
+
2473
+ # ══════════════════════════════════════════════════════════════════════════
2474
+ # ★ 尺寸一致性检查
2475
+ # ══════════════════════════════════════════════════════════════════════════
2476
+
2477
+ def _ids_related(a, b, part_a="", part_b=""):
2478
+ """判定两个接口是否指同一处配合(返回匹配依据字符串,不匹配返回 None)。
2479
+
2480
+ 规则(与 R1/R2/R3 对应):
2481
+ · "same_id" :id 完全相同
2482
+ · "explicit_mate" :任一侧用 mates_with 等字段声明了对方 id 或对方零件名
2483
+ · "affix_id" :【轴孔互补】且去掉各自类型的噪声词后余下标识相同
2484
+ """
2485
+ if a["id"] == b["id"]:
2486
+ return "same_id"
2487
+
2488
+ for side, other, other_part in ((a, b, part_b), (b, a, part_a)):
2489
+ for m in side.get("mates") or []:
2490
+ if m == other["id"] or (other_part and m == other_part):
2491
+ return "explicit_mate"
2492
+
2493
+ # R3 附加约束:只有轴+孔互补才可能是同一处配合。
2494
+ # (历史缺陷:不判互补时,"键槽1"↔"孔径1" 都会被削成 "1" 而误配对,
2495
+ # 进而报出根本不存在的尺寸冲突。)
2496
+ if not _types_complementary(a["type"], b["type"]):
2497
+ return None
2498
+ ra, rb = _strip_id_noise(a["id"], a["type"]), _strip_id_noise(b["id"], b["type"])
2499
+ if ra and rb and ra == rb:
2500
+ return "affix_id"
2501
+ return None
2502
+
2503
+
2504
+ def _pair_interfaces(ia, ib, name_a, name_b):
2505
+ """按 R1→R2→R3→R4 的优先级为两个零件的接口配对。
2506
+
2507
+ Returns: (matches, unmatched_a, unmatched_b, notes)
2508
+ matches: [{"a": iface, "b": iface, "matched_by": str}]
2509
+ """
2510
+ matches, notes = [], []
2511
+ used_a, used_b = set(), set()
2512
+
2513
+ # ── R1 + R2 + R3:逐对扫描,按依据优先级择优 ──────────────────────
2514
+ cands = []
2515
+ for xa, x in enumerate(ia):
2516
+ for xb, y in enumerate(ib):
2517
+ why = _ids_related(x, y, name_a, name_b)
2518
+ if why:
2519
+ rank = {"same_id": 0, "explicit_mate": 1, "affix_id": 2}[why]
2520
+ cands.append((rank, xa, xb, why))
2521
+ cands.sort(key=lambda t: (t[0], t[1], t[2]))
2522
+ for rank, xa, xb, why in cands:
2523
+ if xa in used_a or xb in used_b:
2524
+ continue
2525
+ used_a.add(xa)
2526
+ used_b.add(xb)
2527
+ matches.append({"a": ia[xa], "b": ib[xb], "matched_by": why})
2528
+
2529
+ # ── R4:一侧恰好 1 个 shaft、另一侧恰好 1 个 hole(都带 nominal)───
2530
+ def _uniq(seq, t):
2531
+ got = [i for i in seq if i["type"] == t and i["nominal"] is not None]
2532
+ return got[0] if len(got) == 1 else None
2533
+
2534
+ for (src, dst, sname, dname, s_idx, d_idx) in (
2535
+ (ia, ib, name_a, name_b, used_a, used_b),
2536
+ (ib, ia, name_b, name_a, used_b, used_a)):
2537
+ s = _uniq(src, "shaft")
2538
+ h = _uniq(dst, "hole")
2539
+ if s is None or h is None:
2540
+ continue
2541
+ xs = src.index(s)
2542
+ xh = dst.index(h)
2543
+ if xs in s_idx or xh in d_idx:
2544
+ continue
2545
+ s_idx.add(xs)
2546
+ d_idx.add(xh)
2547
+ if src is ia:
2548
+ matches.append({"a": s, "b": h, "matched_by": "unique_shaft_hole"})
2549
+ else:
2550
+ matches.append({"a": h, "b": s, "matched_by": "unique_shaft_hole"})
2551
+ notes.append("R4 唯一轴孔推定:%s.%s ↔ %s.%s(两侧各只有一个轴/孔接口,"
2552
+ "按配合对处理)" % (sname, s["id"], dname, h["id"]))
2553
+
2554
+ unmatched_a = [i for n, i in enumerate(ia) if n not in used_a]
2555
+ unmatched_b = [i for n, i in enumerate(ib) if n not in used_b]
2556
+ return matches, unmatched_a, unmatched_b, notes
2557
+
2558
+
2559
+ def _adjacency_pairs(data):
2560
+ """收集全部邻接对(无向去重)+ 单向/悬空明细。
2561
+
2562
+ Returns: (pairs, one_way, dangling, self_loops)
2563
+ pairs: [(a, b)],a、b 均为台账里真实存在的零件名
2564
+ """
2565
+ parts = data.get("parts") or {}
2566
+ seen = set()
2567
+ pairs = []
2568
+ one_way, dangling, self_loops = [], [], []
2569
+ for src in parts:
2570
+ for raw in _parse_list_arg(parts[src].get("adjacent")):
2571
+ dst = _resolve_key(parts, raw)
2572
+ if dst is None:
2573
+ dangling.append({"from": src, "to": raw})
2574
+ continue
2575
+ if dst == src:
2576
+ self_loops.append(src)
2577
+ continue
2578
+ key = tuple(sorted([src, dst]))
2579
+ if key in seen:
2580
+ continue
2581
+ seen.add(key)
2582
+ pairs.append((src, dst))
2583
+ back = [_resolve_key(parts, x) or x
2584
+ for x in _parse_list_arg(parts[dst].get("adjacent"))]
2585
+ if src not in back:
2586
+ one_way.append({"from": src, "to": dst})
2587
+ pairs.sort()
2588
+ return pairs, one_way, dangling, self_loops
2589
+
2590
+
2591
+ def _check_core(data, only_pairs=None):
2592
+ """一致性检查内核(check_adjacency_consistency 与 find_related_for_change 共用)。
2593
+
2594
+ only_pairs: 若给出 [(a,b)],只检查这些对(用于"改某个零件时"的定向复检)。
2595
+ """
2596
+ parts = data.get("parts") or {}
2597
+ pairs, one_way, dangling, self_loops = _adjacency_pairs(data)
2598
+ if only_pairs is not None:
2599
+ want = set(tuple(sorted(p)) for p in only_pairs)
2600
+ pairs = [p for p in pairs if tuple(sorted(p)) in want]
2601
+
2602
+ mismatches, warnings, insufficient, pair_rows = [], [], [], []
2603
+ pairs_with_data = 0
2604
+
2605
+ for a, b in pairs:
2606
+ rec_a, rec_b = parts[a], parts[b]
2607
+ ia, drop_a = _norm_interfaces(rec_a.get("interfaces"))
2608
+ ib, drop_b = _norm_interfaces(rec_b.get("interfaces"))
2609
+ for d in drop_a:
2610
+ warnings.append({"kind": "bad_interface_item", "part": a,
2611
+ "severity": "WARN", "escalate_to_parent": False,
2612
+ "detail": "接口项被忽略(%s)" % d.get("reason")})
2613
+ for d in drop_b:
2614
+ warnings.append({"kind": "bad_interface_item", "part": b,
2615
+ "severity": "WARN", "escalate_to_parent": False,
2616
+ "detail": "接口项被忽略(%s)" % d.get("reason")})
2617
+
2618
+ matches, un_a, un_b, notes = _pair_interfaces(ia, ib, a, b)
2619
+ for n in notes:
2620
+ warnings.append({"kind": "inferred_pair", "parts": [a, b],
2621
+ "severity": "INFO", "escalate_to_parent": False,
2622
+ "detail": n})
2623
+
2624
+ comparable = 0
2625
+ row = {"parts": [a, b], "matched_interfaces": [], "unmatched": [],
2626
+ "status": "no_interface_data"}
2627
+
2628
+ for m in matches:
2629
+ xa, xb = m["a"], m["b"]
2630
+ entry = {"a_interface": xa["id"], "a_type": xa["type"],
2631
+ "a_nominal_mm": xa["nominal"],
2632
+ "b_interface": xb["id"], "b_type": xb["type"],
2633
+ "b_nominal_mm": xb["nominal"], "matched_by": m["matched_by"]}
2634
+ row["matched_interfaces"].append(entry)
2635
+
2636
+ # 判定 1:两侧 nominal 都有 → 必须相等
2637
+ if xa["nominal"] is not None and xb["nominal"] is not None:
2638
+ comparable += 1
2639
+ diff = round(abs(xa["nominal"] - xb["nominal"]), 6)
2640
+ if diff > 1e-9:
2641
+ mismatches.append({
2642
+ "parts": [a, b],
2643
+ "interface": xa["id"],
2644
+ "interfaces": {"%s" % a: {"id": xa["id"], "type": xa["type"],
2645
+ "nominal_mm": xa["nominal"],
2646
+ "tolerance": xa["tolerance"]},
2647
+ "%s" % b: {"id": xb["id"], "type": xb["type"],
2648
+ "nominal_mm": xb["nominal"],
2649
+ "tolerance": xb["tolerance"]}},
2650
+ "matched_by": m["matched_by"],
2651
+ "detail": ("%s %s nominal=%smm,%s %s nominal=%smm,差 %smm"
2652
+ % (a, xa["id"], _fmt_num(xa["nominal"]),
2653
+ b, xb["id"], _fmt_num(xb["nominal"]),
2654
+ _fmt_num(diff))),
2655
+ "severity": "ERROR",
2656
+ "escalate_to_parent": True,
2657
+ "suggestion": ("相邻配合的基本尺寸必须一致:请确认哪一侧是设计值,"
2658
+ "改另一侧或补过渡件;公差带差异不影响本判定。"),
2659
+ })
2660
+ else:
2661
+ # 判定 1':同 id 却缺 nominal —— 不能算通过,明确记账
2662
+ missing_side = a if xa["nominal"] is None else b
2663
+ warnings.append({
2664
+ "kind": "missing_nominal", "parts": [a, b],
2665
+ "interface": xa["id"], "part_missing_nominal": missing_side,
2666
+ "severity": "WARN", "escalate_to_parent": False,
2667
+ "detail": ("接口 %s 在 %s 侧缺少 nominal_mm,无法核对基本尺寸 "
2668
+ "(不判定为通过)" % (xa["id"], missing_side)),
2669
+ })
2670
+
2671
+ # 判定 2:同 id 但两侧类型都是轴/都是孔 → 类型冲突,交父代理仲裁
2672
+ if m["matched_by"] == "same_id" and xa["type"] == xb["type"] \
2673
+ and xa["type"] in ("shaft", "hole"):
2674
+ warnings.append({
2675
+ "kind": "interface_type_conflict", "parts": [a, b],
2676
+ "interface": xa["id"], "severity": "WARN",
2677
+ "escalate_to_parent": True,
2678
+ "detail": ("同名接口 %s 在两侧都是 %s —— 一处配合通常应为"
2679
+ "轴+孔;请确认是否重复登记或类型填错"
2680
+ % (xa["id"], "轴(shaft)" if xa["type"] == "shaft" else "孔(hole)")),
2681
+ })
2682
+
2683
+ # 判定 3:孔用了轴的公差带字母(或反之)→ 记录(不拦流程)
2684
+ for side, it in ((a, xa), (b, xb)):
2685
+ tk = _tolerance_kind(it["tolerance"])
2686
+ if it["type"] == "hole" and tk == "shaft":
2687
+ warnings.append({"kind": "tolerance_letter_suspect", "parts": [a, b],
2688
+ "interface": it["id"], "severity": "WARN",
2689
+ "escalate_to_parent": False,
2690
+ "detail": ("%s 的 %s 为孔,公差带 %r 却是轴用字母"
2691
+ % (side, it["id"], it["tolerance"]))})
2692
+ if it["type"] == "shaft" and tk == "hole":
2693
+ warnings.append({"kind": "tolerance_letter_suspect", "parts": [a, b],
2694
+ "interface": it["id"], "severity": "WARN",
2695
+ "escalate_to_parent": False,
2696
+ "detail": ("%s 的 %s 为轴,公差带 %r 却是孔用字母"
2697
+ % (side, it["id"], it["tolerance"]))})
2698
+
2699
+ if comparable > 0:
2700
+ pairs_with_data += 1
2701
+ row["status"] = "checked"
2702
+ row["comparable_interfaces"] = comparable
2703
+ else:
2704
+ insufficient.append({
2705
+ "parts": [a, b],
2706
+ "reason": ("双方都未声明 interfaces" if (not ia and not ib)
2707
+ else "没有任何一对接口能配对(id 不相关且非唯一轴孔)"
2708
+ if (ia and ib)
2709
+ else "仅一侧声明了 interfaces"),
2710
+ "a_interface_count": len(ia), "b_interface_count": len(ib),
2711
+ })
2712
+ if un_a or un_b:
2713
+ row["unmatched"] = {
2714
+ a: [i["id"] for i in un_a],
2715
+ b: [i["id"] for i in un_b],
2716
+ }
2717
+ pair_rows.append(row)
2718
+
2719
+ # ── 全局体检:单向邻接 / 悬空 / 自环 / dims↔bbox ────────────────────
2720
+ for ow in one_way:
2721
+ warnings.append({
2722
+ "kind": "one_way_adjacency", "parts": [ow["from"], ow["to"]],
2723
+ "severity": "WARN", "escalate_to_parent": True,
2724
+ "detail": ("单向邻接:%s 把 %s 列为 adjacent,但 %s 未回列 —— "
2725
+ "邻接图登记不全,反查会漏零件;请用 link_adjacent 双向建立"
2726
+ % (ow["from"], ow["to"], ow["to"])),
2727
+ })
2728
+ for sl in self_loops:
2729
+ warnings.append({"kind": "self_adjacency", "parts": [sl, sl],
2730
+ "severity": "WARN", "escalate_to_parent": True,
2731
+ "detail": "零件 %s 把自己列为 adjacent(自环),无意义" % sl})
2732
+ for dg in dangling:
2733
+ warnings.append({
2734
+ "kind": "dangling_adjacency", "parts": [dg["from"], dg["to"]],
2735
+ "severity": "WARN", "escalate_to_parent": True,
2736
+ "detail": ("悬空邻接:%s 把 %r 列为 adjacent,但该零件从未登记到台账 —— "
2737
+ "无法参与任何一致性核对" % (dg["from"], dg["to"])),
2738
+ })
2739
+ for nm, rec in sorted(parts.items()):
2740
+ dims = rec.get("dims")
2741
+ bbox = rec.get("bbox_mm")
2742
+ if not isinstance(dims, dict) or not bbox or len(bbox) != 6:
2743
+ continue
2744
+ derived = _dims_from_bbox(bbox)
2745
+ if not derived:
2746
+ continue
2747
+ delta = _dims_delta(dims, derived)
2748
+ if delta > 0.5:
2749
+ warnings.append({
2750
+ "kind": "self_bbox_mismatch", "parts": [nm, nm],
2751
+ "severity": "WARN", "escalate_to_parent": True,
2752
+ "detail": ("%s 的 dims(%s) 与 bbox_mm 推算尺寸(%s) 相差 %smm —— "
2753
+ "两份尺寸数据至少有一份是错的"
2754
+ % (nm, _fmt_dims(dims), _fmt_dims(derived), _fmt_num(delta))),
2755
+ })
2756
+
2757
+ return {
2758
+ "pairs": pairs,
2759
+ "pair_rows": pair_rows,
2760
+ "pairs_with_interface_data": pairs_with_data,
2761
+ "mismatches": mismatches,
2762
+ "warnings": warnings,
2763
+ "insufficient": insufficient,
2764
+ "dangling": dangling,
2765
+ "one_way": one_way,
2766
+ "self_loops": self_loops,
2767
+ }
2768
+
2769
+
2770
+ def _dims_delta(d1, d2):
2771
+ """两份尺寸 dict 的最大分量差(缺键按 0 处理,返回绝对值)。"""
2772
+ keys = set(d1.keys()) | set(d2.keys())
2773
+ worst = 0.0
2774
+ for k in keys:
2775
+ try:
2776
+ a = float(d1.get(k) or 0)
2777
+ b = float(d2.get(k) or 0)
2778
+ except Exception:
2779
+ continue
2780
+ worst = max(worst, abs(a - b))
2781
+ return round(worst, 4)
2782
+
2783
+
2784
+ def check_adjacency_consistency(work_dir=None, only_pairs=None):
2785
+ """★ 遍历所有邻接对,核对直接接触零件的接口尺寸是否配置一致。
2786
+
2787
+ 判定规则见模块 docstring(R1 same_id / R2 explicit_mate / R3 affix_id /
2788
+ R4 unique_shaft_hole 配对;配对后 nominal_mm 必须相等,公差带可不同)。
2789
+
2790
+ Returns(关键字段):
2791
+ ok : 只在 evaluated=true 且无 mismatch 时为 true
2792
+ evaluated : 是否有"真的可查"的数据(无数据 → false,绝不假 PASS)
2793
+ reason : evaluated=false 时的具体原因
2794
+ checked_pairs : 检查的邻接对总数
2795
+ pairs_with_interface_data : 其中真的比过尺寸的对数
2796
+ mismatches : [{"parts","interface","detail","severity"="ERROR",
2797
+ "escalate_to_parent": true, ...}]
2798
+ warnings / insufficient / dangling_adjacency
2799
+ escalate_to_parent : 有 mismatch 或数据完整性问题时为 true(交父代理干涉)
2800
+ summary : 一句话结论
2801
+ """
2802
+ data, path, err = _load_registry(work_dir)
2803
+ if err:
2804
+ return {"ok": False, "evaluated": False, "error": err, "file": path,
2805
+ "checked_pairs": 0, "mismatches": [], "escalate_to_parent": False,
2806
+ "summary": "台账不可读,无法检查。"}
2807
+
2808
+ parts = data["parts"]
2809
+ core = _check_core(data, only_pairs=only_pairs)
2810
+ pairs = core["pairs"]
2811
+ checked = len(pairs)
2812
+ with_data = core["pairs_with_interface_data"]
2813
+ mismatches = core["mismatches"]
2814
+
2815
+ # ── evaluated 判定(区分"真通过"与"没数据")─────────────────────────
2816
+ evaluated = with_data > 0
2817
+ if not parts:
2818
+ reason = "台账为空(parts 为空),没有任何零件可检查"
2819
+ elif checked == 0:
2820
+ reason = ("台账有 %d 个零件,但没有任何邻接关系(adjacent 全为空)—— "
2821
+ "请先用 link_adjacent 建立配合关系" % len(parts))
2822
+ elif with_data == 0:
2823
+ reason = ("%d 对邻接零件中没有一对同时声明了可比较的 interfaces/nominal_mm —— "
2824
+ "接口数据缺失,无法核对尺寸一致性(这不是通过)" % checked)
2825
+ else:
2826
+ reason = None
2827
+
2828
+ escalate = bool(mismatches) or bool(core["dangling"]) or \
2829
+ any(w.get("escalate_to_parent") for w in core["warnings"])
2830
+
2831
+ if not evaluated:
2832
+ summary = "未能检查:%s" % reason
2833
+ elif mismatches:
2834
+ summary = ("发现 %d 处相邻零件尺寸不一致(共检查 %d 对,其中 %d 对可比对)—— "
2835
+ "已标记 escalate_to_parent=true,交父代理仲裁干涉。"
2836
+ % (len(mismatches), checked, with_data))
2837
+ else:
2838
+ summary = ("通过:%d 对相邻零件中 %d 对完成接口尺寸比对,未发现基本尺寸冲突。"
2839
+ % (checked, with_data))
2840
+ if core["insufficient"]:
2841
+ summary += (" 另有 %d 对因缺少接口数据未比对(见 insufficient)。"
2842
+ % len(core["insufficient"]))
2843
+
2844
+ return {
2845
+ "ok": bool(evaluated and not mismatches),
2846
+ "evaluated": bool(evaluated),
2847
+ "reason": reason,
2848
+ "file": path,
2849
+ "task_id": data.get("task_id"),
2850
+ "updated_at": data.get("updated_at"),
2851
+ "parts_count": len(parts),
2852
+ "checked_pairs": checked,
2853
+ "pairs_with_interface_data": with_data,
2854
+ "pairs": [{"parts": list(p)} for p in pairs],
2855
+ "pair_rows": core["pair_rows"],
2856
+ "mismatches": mismatches,
2857
+ "mismatch_count": len(mismatches),
2858
+ "warnings": core["warnings"],
2859
+ "warning_count": len(core["warnings"]),
2860
+ "insufficient": core["insufficient"],
2861
+ "insufficient_count": len(core["insufficient"]),
2862
+ "dangling_adjacency": core["dangling"],
2863
+ "one_way_adjacency": core["one_way"],
2864
+ "self_adjacency": core["self_loops"],
2865
+ # ── 经用户如实说明理由后仍保留的已知不一致(绝不静默)────────────
2866
+ "unresolved": _open_unresolved(data),
2867
+ "unresolved_count": len(_open_unresolved(data)),
2868
+ "escalate_to_parent": bool(escalate),
2869
+ "summary": summary,
2870
+ "note": ("evaluated=false 表示「没数据可查」,与「检查通过」严格区分;"
2871
+ "不一致项一律 severity=ERROR、escalate_to_parent=true。"),
2872
+ }
2873
+
2874
+
2875
+ def _unresolved_is_open(data, u):
2876
+ """判断某条 unresolved 记录【当前是否仍未决】(全模块唯一判定口径)。
2877
+
2878
+ ── 为什么必须只有一个口径 ────────────────────────────────────────────
2879
+ 一条记录的"未决"由两件事共同决定:
2880
+ ① status 仍为 open(没人手工关闭过);
2881
+ ② 该不一致【现在依然存在】(两侧还没被改成一致)。
2882
+ 历史缺陷:list_unresolved 用 ①+②,而 resolve_unresolved 报的 open_indexes
2883
+ 只用 ① —— 于是"改一致后自动消解"的记录仍被算作可关闭,两个接口给出的
2884
+ 未决数量互相矛盾(实测 G3.11 抓到这个不一致)。
2885
+ 现在统一到这里,任何地方判断未决都必须调它。
2886
+ """
2887
+ if not isinstance(u, dict):
2888
+ return False
2889
+ if str(u.get("status") or "open").lower() != "open":
2890
+ return False
2891
+ parts = (data or {}).get("parts") or {}
2892
+ live = _still_inconsistent(parts, u.get("parts") or [], u.get("interface"))
2893
+ return live is not False # False=已一致 → 自动消解;True/None → 仍视为未决
2894
+
2895
+
2896
+ def _open_unresolved(data):
2897
+ """取台账里仍处于未决状态的 unresolved 记录(已放行但仍需处理的已知问题)。"""
2898
+ return [u for u in ((data or {}).get("unresolved") or [])
2899
+ if _unresolved_is_open(data, u)]
2900
+
2901
+
2902
+ def _still_inconsistent(parts, names, iface_id):
2903
+ """判断某个 unresolved 记录现在是否仍然不一致。
2904
+
2905
+ 返回 True=仍不一致 / False=已一致(可自动消解)/ None=无法判断(保留)。
2906
+ """
2907
+ if not names or len(names) != 2:
2908
+ return None
2909
+ a = _resolve_key(parts, names[0])
2910
+ b = _resolve_key(parts, names[1])
2911
+ if a is None or b is None:
2912
+ return None
2913
+ ia, _ = _norm_interfaces(parts[a].get("interfaces"))
2914
+ ib, _ = _norm_interfaces(parts[b].get("interfaces"))
2915
+ found = False
2916
+ for x in ia:
2917
+ for y in ib:
2918
+ if iface_id and x["id"] != iface_id and y["id"] != iface_id:
2919
+ continue
2920
+ if x["nominal"] is None or y["nominal"] is None:
2921
+ continue
2922
+ if not _ids_related(x, y, a, b):
2923
+ continue
2924
+ found = True
2925
+ if abs(x["nominal"] - y["nominal"]) > 1e-9:
2926
+ return True
2927
+ return False if found else None
2928
+
2929
+
2930
+ def list_unresolved(work_dir=None, include_resolved=False):
2931
+ """列出"经如实说明理由后仍保留"的已知不一致(unresolved 台账)。
2932
+
2933
+ 这些不是被遗忘的问题 —— 是【被显式记录并放行】的问题,必须始终可见,
2934
+ 直到有人把它们改一致(自动消解)或显式标记 resolved。
2935
+ """
2936
+ data, path, err = _load_registry(work_dir)
2937
+ if err:
2938
+ return {"ok": False, "error": err, "file": path}
2939
+ allrec = [u for u in (data.get("unresolved") or []) if isinstance(u, dict)]
2940
+ openrec = _open_unresolved(data)
2941
+ resolved = [u for u in allrec
2942
+ if str(u.get("status") or "open").lower() != "open"]
2943
+ out = openrec if not include_resolved else allrec
2944
+ # ── 索引必须与 items 一一对应 ──────────────────────────────────────
2945
+ # 历史缺陷:items 返回的是 open 子集,而 resolve_unresolved 的 index 作用在
2946
+ # 【完整数组】上 —— 一旦有记录已被自动消解(改一致了),用户在清单里看到的
2947
+ # 第 0 条与真正被 resolve 的第 0 条就不是同一条(实测:显示 A-C,却关掉了 A-B)。
2948
+ # 现在给每条【输出项】带上它在完整数组里的真实下标 index。这里用浅拷贝,
2949
+ # 避免把下标写进台账本体(下标会随数组变化,存进去只会误导)。
2950
+ full_index = {id(u): n for n, u in enumerate(allrec)}
2951
+ out = [dict(u, index=full_index.get(id(u))) for u in out]
2952
+ return {
2953
+ "ok": True,
2954
+ "file": path,
2955
+ "count": len(out),
2956
+ "open_count": len(openrec),
2957
+ "resolved_count": len(resolved),
2958
+ "items": out,
2959
+ "summary": ("存在 %d 处【已知并放行】的尺寸不一致,需要处理"
2960
+ % len(openrec)) if openrec else "没有未决的不一致记录",
2961
+ "note": ("unresolved 记录来自 allow_mismatch(带理由放行)或删除/改名等操作;"
2962
+ "当两侧接口尺寸被改成一致后,记录会自动从 open 列表消失。"
2963
+ "关闭某条请用该条的 index 字段(已随记录一并返回),不要用它在"
2964
+ "清单里的位置 —— 自动消解会改变位置。"),
2965
+ }
2966
+
2967
+
2968
+ def resolve_unresolved(index, work_dir=None, reason=None, note=None):
2969
+ """把某条 unresolved 记录标记为 resolved(需如实说明理由)。
2970
+
2971
+ Args:
2972
+ index: 记录在 unresolved 完整数组里的下标(即 list_unresolved 返回的
2973
+ items[*].index)。★ 不要用它在清单里的位置 —— 自动消解会改变位置。
2974
+ reason: ★ 必填,说明为什么可以判定该问题已解决
2975
+ """
2976
+ err = _require_reason(reason, op="resolve_unresolved", what="index=%s" % index)
2977
+ if err:
2978
+ return err
2979
+ data, path, e = _load_registry(work_dir)
2980
+ if e:
2981
+ return {"ok": False, "error": e, "file": path}
2982
+ try:
2983
+ requested = int(index)
2984
+ except Exception:
2985
+ return {"ok": False, "error": "index 必须是整数,收到 %r" % (index,)}
2986
+
2987
+ arr = data.get("unresolved") or []
2988
+ if not arr:
2989
+ return {"ok": False, "error": "台账里没有任何 unresolved 记录", "file": path}
2990
+ if requested < 0 or requested >= len(arr):
2991
+ return {"ok": False,
2992
+ "error": "index 越界:%d(完整数组共 %d 条)" % (requested, len(arr)),
2993
+ "file": path}
2994
+
2995
+ # ── 安全网:拒绝关闭"其实已经不未决"的记录,并给出正确的下标建议 ──────
2996
+ # 这样即使调用方用的是清单位置(而非 index 字段),也不会误关另一条。
2997
+ target = arr[requested]
2998
+ if not _unresolved_is_open(data, target):
2999
+ why = ("该记录 status 已是 resolved" if str(target.get("status") or "open").lower()
3000
+ != "open" else
3001
+ "该记录对应的不一致【已经不存在了】(两侧尺寸已改一致,系统已自动消解)")
3002
+ return {"ok": False,
3003
+ "error": ("index=%d 无需关闭:%s" % (requested, why)),
3004
+ "record": target,
3005
+ "open_indexes": [n for n, x in enumerate(arr)
3006
+ if _unresolved_is_open(data, x)],
3007
+ "file": path}
3008
+
3009
+ _ts, at = _now()
3010
+ arr[requested]["status"] = "resolved"
3011
+ arr[requested]["resolved_reason"] = str(reason).strip()
3012
+ arr[requested]["resolved_at"] = at
3013
+ if note:
3014
+ arr[requested]["resolved_note"] = str(note)
3015
+ data["unresolved"] = arr
3016
+ _append_revision(data, "resolve-unresolved", arr[requested].get("part") or "-",
3017
+ changes={"unresolved_index": requested, "status": "resolved"},
3018
+ reason=str(reason).strip(),
3019
+ extra={"unresolved_detail": arr[requested].get("detail"),
3020
+ "unresolved_parts": arr[requested].get("parts")})
3021
+ ok, path, werr = _save_registry(data, work_dir)
3022
+ if not ok:
3023
+ return {"ok": False, "error": werr, "file": path}
3024
+ return {"ok": True, "action": "resolve-unresolved", "index": requested,
3025
+ "parts": arr[requested].get("parts"),
3026
+ "detail": arr[requested].get("detail"), "file": path,
3027
+ "remaining_open": len([x for x in arr if _unresolved_is_open(data, x)]),
3028
+ "note": "已标记为 resolved;该记录仍保留在台账中可追溯。"}
3029
+
3030
+
3031
+ def find_related_for_change(name, work_dir=None, max_depth=3):
3032
+ """★ 要改某个零件时,返回所有可能需要跟着改的零件(含理由)。
3033
+
3034
+ 理由分类(reasons / reason_code):
3035
+ · direct_adjacency 距离 1:直接接触/配合,尺寸改动直接影响
3036
+ · shared_interface 与该零件存在"同一处配合"的接口(id 相同 / 去噪声后相同 /
3037
+ 显式 mates_with 声明 / 唯一轴孔推定)
3038
+ · transitive_impact 距离 ≥2:二级及以上的传递影响(通过中间零件传导)
3039
+ · same_room 同房间(信息项,便于找到该找谁改)
3040
+
3041
+ 额外附带:针对受影响集合的定向一致性复检结果(改之前先看有没有既有冲突)。
3042
+ """
3043
+ name = _norm_name(name)
3044
+ if not name:
3045
+ return {"ok": False, "error": "缺少零件名 name"}
3046
+ data, path, err = _load_registry(work_dir)
3047
+ if err:
3048
+ return {"ok": False, "error": err, "file": path}
3049
+ parts = data["parts"]
3050
+ root = _resolve_key(parts, name)
3051
+ if root is None:
3052
+ return {"ok": False, "error": "零件未登记:%s" % name, "file": path,
3053
+ "known_parts": sorted(parts.keys())}
3054
+
3055
+ try:
3056
+ max_depth = int(max_depth)
3057
+ except Exception:
3058
+ max_depth = 3
3059
+ max_depth = max(1, min(max_depth, 10))
3060
+
3061
+ adj, one_way, dangling = _edge_map(data)
3062
+
3063
+ # ── BFS 求距离 + 经由 ───────────────────────────────────────────────
3064
+ dist = {root: 0}
3065
+ via = {root: None}
3066
+ frontier = [root]
3067
+ for d in range(1, max_depth + 1):
3068
+ nxt = []
3069
+ for cur in frontier:
3070
+ for nb in sorted(adj.get(cur, ())):
3071
+ if nb in dist:
3072
+ continue
3073
+ dist[nb] = d
3074
+ via[nb] = cur
3075
+ nxt.append(nb)
3076
+ if not nxt:
3077
+ break
3078
+ frontier = nxt
3079
+
3080
+ # ── 受影响集合 ─────────────────────────────────────────────────────
3081
+ impacted = []
3082
+ for nm in sorted([x for x in dist if x != root]):
3083
+ d = dist[nm]
3084
+ reasons, codes = [], []
3085
+ if d == 1:
3086
+ codes.append("direct_adjacency")
3087
+ reasons.append("直接邻接:与 %s 直接接触/配合(距离 1)" % root)
3088
+ else:
3089
+ codes.append("transitive_impact")
3090
+ reasons.append("%d 级传递影响:经由 %s 传导(%s → %s)"
3091
+ % (d, via.get(nm), root, _path_text(via, root, nm)))
3092
+ shared = _shared_interface_ids(parts, root, nm)
3093
+ if shared:
3094
+ codes.append("shared_interface")
3095
+ for h in shared:
3096
+ if h["consistent"] is False:
3097
+ reasons.append("共享接口 %s↔%s 的 nominal 已经不一致(%s vs %smm),"
3098
+ "改动前必须先定哪一侧是设计值"
3099
+ % (h["a_interface"], h["b_interface"],
3100
+ _fmt_num(h["a_nominal_mm"]), _fmt_num(h["b_nominal_mm"])))
3101
+ else:
3102
+ reasons.append("共享接口 %s↔%s(配对依据 %s)"
3103
+ % (h["a_interface"], h["b_interface"], h["matched_by"]))
3104
+ rec = parts[nm]
3105
+ if rec.get("room") and rec.get("room") == parts[root].get("room"):
3106
+ codes.append("same_room")
3107
+ reasons.append("同属房间「%s」,可直接找该小屋改" % rec.get("room"))
3108
+ impacted.append({
3109
+ "name": nm,
3110
+ "distance": d,
3111
+ "via": via.get(nm),
3112
+ "reasons": reasons,
3113
+ "reason_codes": codes,
3114
+ "room": rec.get("room"),
3115
+ "rev": rec.get("rev"),
3116
+ "file": rec.get("file"),
3117
+ "shared_interfaces": shared,
3118
+ "adjacent_to_root": nm in adj.get(root, set()),
3119
+ })
3120
+
3121
+ by_distance = {}
3122
+ for it in impacted:
3123
+ by_distance.setdefault(it["distance"], []).append(it["name"])
3124
+
3125
+ # ── 定向复检:只查"根零件 ↔ 直接邻接"这些对 ─────────────────────────
3126
+ direct_pairs = [(root, nm) for nm in sorted(adj.get(root, ()))]
3127
+ focused = _check_core(data, only_pairs=direct_pairs) if direct_pairs else None
3128
+ focused_out = None
3129
+ if focused is not None:
3130
+ focused_out = {
3131
+ "checked_pairs": len(focused["pairs"]),
3132
+ "pairs_with_interface_data": focused["pairs_with_interface_data"],
3133
+ "evaluated": focused["pairs_with_interface_data"] > 0,
3134
+ "mismatches": focused["mismatches"],
3135
+ }
3136
+
3137
+ my_rec = parts[root]
3138
+ return {
3139
+ "ok": True,
3140
+ "found": True,
3141
+ "part": root,
3142
+ "file": path,
3143
+ "task_id": data.get("task_id"),
3144
+ "part_brief": _part_brief(my_rec),
3145
+ "max_depth": max_depth,
3146
+ "impacted_count": len(impacted),
3147
+ "impacted": impacted,
3148
+ "by_distance": by_distance,
3149
+ "related": [it["name"] for it in impacted],
3150
+ "inbound": sorted(reverse_lookup(root, work_dir=work_dir).get("inbound_names") or []),
3151
+ "direct_adjacency_check": focused_out,
3152
+ "data_integrity": {
3153
+ "dangling_adjacency": dangling,
3154
+ "one_way_adjacency": [{"from": a, "to": b} for a, b in one_way],
3155
+ },
3156
+ "suggested_actions": _suggest_actions(root, impacted, focused_out),
3157
+ "summary": ("改动 %s 前,请同步复核 %d 个关联零件(直接邻接 %d 个、"
3158
+ "传递影响 %d 个)。"
3159
+ % (root, len(impacted),
3160
+ len(by_distance.get(1) or []),
3161
+ len([x for x in impacted if x["distance"] >= 2]))),
3162
+ "note": ("reasons 说明「为什么它需要跟着改」:直接邻接 / 共享接口 / 二级传递。"
3163
+ "距离 1 的必须逐个复核;距离 ≥2 的按需复核。"),
3164
+ }
3165
+
3166
+
3167
+ def _path_text(via, root, target):
3168
+ """把 via 链还原成 'A → B → C' 文本(用于传递影响的说明)。"""
3169
+ chain = []
3170
+ cur = target
3171
+ guard = 0
3172
+ while cur is not None and guard < 32:
3173
+ chain.append(cur)
3174
+ if cur == root:
3175
+ break
3176
+ cur = via.get(cur)
3177
+ guard += 1
3178
+ return " → ".join(reversed(chain))
3179
+
3180
+
3181
+ def _suggest_actions(root, impacted, focused_out):
3182
+ """为「要改这个零件」生成可执行的下一步建议。"""
3183
+ acts = []
3184
+ direct = [x["name"] for x in impacted if x["distance"] == 1]
3185
+ if direct:
3186
+ acts.append("先逐个复核距离 1 的零件:%s" % "、".join(direct))
3187
+ if focused_out and focused_out.get("mismatches"):
3188
+ for m in focused_out["mismatches"]:
3189
+ acts.append("【改前先裁决】%s" % m["detail"])
3190
+ if focused_out and not focused_out.get("evaluated"):
3191
+ acts.append("直接邻接对缺 interfaces/nominal_mm 数据,"
3192
+ "一致性无法核对 —— 请先补 registration 的 interfaces 字段")
3193
+ acts.append("改完后重跑 check-consistency,确认 escalate_to_parent=false")
3194
+ acts.append("把改动写入台账(update_part),保留修订历史以便回溯谁在何时改了哪个尺寸")
3195
+ return acts
3196
+
3197
+
3198
+ # ══════════════════════════════════════════════════════════════════════════
3199
+ # docx 导出:三级策略
3200
+ # ══════════════════════════════════════════════════════════════════════════
3201
+
3202
+ # ── 策略②:探测随包运行时的 site-packages(不写死路径)──────────────────
3203
+
3204
+ def _runtime_search_roots():
3205
+ """产出可能放着随包 Python 依赖的根目录(全部动态推导,无硬编码用户名)。"""
3206
+ roots = []
3207
+ home = os.path.expanduser("~")
3208
+ dsh_home = (os.environ.get("DSH_HOME") or "").strip().strip('"').strip("'")
3209
+ if dsh_home:
3210
+ roots.append(os.path.join(dsh_home, "dsh-runtimes"))
3211
+ roots.append(dsh_home)
3212
+ if home:
3213
+ roots.append(os.path.join(home, ".dsh", "dsh-runtimes"))
3214
+ roots.append(os.path.join(home, ".dsh"))
3215
+ # 本进程解释器自己的 site-packages 之外,也看看同目录的 Lib
3216
+ try:
3217
+ roots.append(os.path.dirname(os.path.dirname(sys.executable)))
3218
+ except Exception:
3219
+ pass
3220
+ out, seen = [], set()
3221
+ for r in roots:
3222
+ try:
3223
+ rp = os.path.abspath(r)
3224
+ except Exception:
3225
+ continue
3226
+ k = os.path.normcase(rp)
3227
+ if k in seen or not os.path.isdir(rp):
3228
+ continue
3229
+ seen.add(k)
3230
+ out.append(rp)
3231
+ return out
3232
+
3233
+
3234
+ def _discover_docx_site_packages(max_depth=6):
3235
+ """在随包运行时目录里搜「含 docx 包」的 site-packages / dist-packages。
3236
+
3237
+ Returns: [{"site_packages":..., "docx":..., "python_exe":...|None}, ...]
3238
+ 深度受限,避免在大目录上无限遍历。
3239
+ """
3240
+ found = []
3241
+ seen = set()
3242
+ for root in _runtime_search_roots():
3243
+ base_depth = root.rstrip("\\/").count(os.sep)
3244
+ for dirpath, dirnames, _files in os.walk(root):
3245
+ if dirpath.count(os.sep) - base_depth > max_depth:
3246
+ dirnames[:] = []
3247
+ continue
3248
+ # 跳过明显无用的巨型目录
3249
+ dirnames[:] = [d for d in dirnames
3250
+ if d.lower() not in ("__pycache__", ".git", "node_modules",
3251
+ "cache", "sessions", "attachments")]
3252
+ if os.path.basename(dirpath).lower() not in ("site-packages", "dist-packages"):
3253
+ continue
3254
+ pkg = os.path.join(dirpath, "docx", "__init__.py")
3255
+ if not os.path.isfile(pkg):
3256
+ continue
3257
+ # 去重:搜索根之间存在包含关系(~/.dsh 与 ~/.dsh/dsh-runtimes),
3258
+ # 不去重会把同一个 site-packages 探测/尝试多次(实测踩到)。
3259
+ k = os.path.normcase(os.path.abspath(dirpath))
3260
+ if k in seen:
3261
+ dirnames[:] = []
3262
+ continue
3263
+ seen.add(k)
3264
+ py = None
3265
+ probe = dirpath
3266
+ for _ in range(5):
3267
+ probe = os.path.dirname(probe)
3268
+ if not probe:
3269
+ break
3270
+ cand = os.path.join(probe, "python.exe")
3271
+ if os.path.isfile(cand):
3272
+ py = cand
3273
+ break
3274
+ found.append({"site_packages": dirpath, "docx": pkg, "python_exe": py})
3275
+ dirnames[:] = []
3276
+ return found
3277
+
3278
+
3279
+ def _try_import_docx(probe=True):
3280
+ """尝试拿到可用的 python-docx 模块。
3281
+
3282
+ Returns: (module|None, strategy|None, diagnostics: dict)
3283
+ strategy: "python-docx"(直接导入成功)
3284
+ "python-docx(sys.path注入)"(注入随包 site-packages 后成功)
3285
+ ── 关键防御(本机实测踩到)──────────────────────────────────────────
3286
+ 直接把随包 site-packages 塞进 sys.path 再 import docx,可能因为
3287
+ lxml 是【另一个 Python 版本编译的 C 扩展】而抛 ImportError(不是
3288
+ ModuleNotFoundError)。因此:
3289
+ · 捕获 Exception(不只 ImportError);
3290
+ · 注入失败必须【把刚加的路径撤掉】,并清掉 sys.modules 里半成品
3291
+ docx* 模块 —— 否则会把宿主的 lxml 等包一起污染。
3292
+ """
3293
+ diag = {"probed": [], "injected": [], "attempts": [], "available": False,
3294
+ "module_path": None, "bundled_python": None}
3295
+
3296
+ # ① 直接 import(probe=False 也要做:它只是禁止遍历文件系统,
3297
+ # 不应该连"当前解释器本来就装了 python-docx"这种情况也一起放弃)
3298
+ try:
3299
+ import docx # noqa: F401
3300
+ diag["available"] = True
3301
+ diag["module_path"] = getattr(docx, "__file__", None)
3302
+ diag["attempts"].append({"step": "direct_import", "ok": True,
3303
+ "path": diag["module_path"]})
3304
+ return docx, "python-docx", diag
3305
+ except Exception as e:
3306
+ diag["attempts"].append({"step": "direct_import", "ok": False,
3307
+ "error": "%s: %s" % (type(e).__name__, e)})
3308
+
3309
+ if not probe:
3310
+ return None, None, diag
3311
+
3312
+ # ② 探测随包 site-packages → 注入 sys.path → 重试
3313
+ cands = _discover_docx_site_packages()
3314
+ diag["probed"] = cands
3315
+ for c in cands:
3316
+ sp = c["site_packages"]
3317
+ if os.path.normcase(sp) in [os.path.normcase(x) for x in sys.path]:
3318
+ continue
3319
+ added = False
3320
+ try:
3321
+ sys.path.append(sp) # 追加而非插队:避免遮蔽标准库/宿主依赖
3322
+ added = True
3323
+ for m in [k for k in list(sys.modules) if k == "docx" or k.startswith("docx.")]:
3324
+ sys.modules.pop(m, None)
3325
+ import docx # noqa: F401
3326
+ diag["available"] = True
3327
+ diag["module_path"] = getattr(docx, "__file__", None)
3328
+ diag["bundled_python"] = c.get("python_exe")
3329
+ diag["injected"].append(sp)
3330
+ diag["attempts"].append({"step": "inject_sys_path", "site_packages": sp, "ok": True,
3331
+ "path": diag["module_path"]})
3332
+ return docx, "python-docx(sys.path注入)", diag
3333
+ except Exception as e:
3334
+ diag["attempts"].append({"step": "inject_sys_path", "site_packages": sp, "ok": False,
3335
+ "error": "%s: %s" % (type(e).__name__, e),
3336
+ "hint": "常见原因:该 site-packages 下的 C 扩展"
3337
+ "(如 lxml)是另一个 Python 版本编译的,"
3338
+ "当前解释器无法加载"})
3339
+ if added:
3340
+ try:
3341
+ sys.path.remove(sp)
3342
+ except ValueError:
3343
+ pass
3344
+ for m in [k for k in list(sys.modules) if k == "docx" or k.startswith("docx.")]:
3345
+ sys.modules.pop(m, None)
3346
+ return None, None, diag
3347
+
3348
+
3349
+ # ── 内容块构造(两种渲染器共用同一份 blocks)────────────────────────────
3350
+
3351
+ def _build_docx_blocks(data, consistency):
3352
+ """把台账整理成中性的内容块列表,供 python-docx / stdlib 两种渲染器共用。
3353
+
3354
+ 块类型:{"kind": "title"|"heading"|"para"|"table", ...}
3355
+ """
3356
+ parts = data.get("parts") or {}
3357
+ ts, at = _now()
3358
+ blocks = []
3359
+ blocks.append({"kind": "title", "text": "DSH 工程模式 · 零件数据台账"})
3360
+
3361
+ # ── 1. 任务信息 ────────────────────────────────────────────────────
3362
+ blocks.append({"kind": "heading", "level": 1, "text": "一、任务信息"})
3363
+ info_rows = [
3364
+ ["任务号 task_id", data.get("task_id") or "(未关联门禁任务)"],
3365
+ ["台账版本 schema", data.get("schema") or SCHEMA],
3366
+ ["台账文件", registry_path(None)],
3367
+ ["创建时间", data.get("created_at") or "-"],
3368
+ ["最近更新", data.get("updated_at") or "-"],
3369
+ ["零件总数", str(len(parts))],
3370
+ ["房间分布", "、".join("%s(%d)" % (k, v) for k, v in
3371
+ sorted(_room_counts(parts).items())) or "-"],
3372
+ ["邻接对总数", str(len(consistency.get("pairs") or []))],
3373
+ ["修订记录条数", str(len(data.get("revisions") or []))],
3374
+ ["文档生成时间", at],
3375
+ ["一致性结论", consistency.get("summary") or "-"],
3376
+ ]
3377
+ blocks.append({"kind": "table", "headers": ["项目", "内容"], "rows": info_rows,
3378
+ "widths": [1.0, 3.0]})
3379
+
3380
+ # ── 2. 零件总表 ────────────────────────────────────────────────────
3381
+ blocks.append({"kind": "heading", "level": 1, "text": "二、零件总表"})
3382
+ if not parts:
3383
+ blocks.append({"kind": "para", "text": "(台账为空,尚未登记任何零件)"})
3384
+ else:
3385
+ rows = []
3386
+ for nm in sorted(parts.keys()):
3387
+ rec = parts[nm]
3388
+ ifaces = _norm_interfaces(rec.get("interfaces"))[0]
3389
+ itxt = ";".join("%s[%s]%s%s" % (
3390
+ i["id"], i["type"],
3391
+ ("φ%s" % _fmt_num(i["nominal"])) if i["nominal"] is not None else "?",
3392
+ ("·" + i["tolerance"]) if i["tolerance"] else "") for i in ifaces) or "-"
3393
+ rows.append([
3394
+ nm,
3395
+ rec.get("room") or "-",
3396
+ _fmt_dims(rec.get("dims") or {}) or "-",
3397
+ rec.get("material") or "-",
3398
+ "、".join(_parse_list_arg(rec.get("adjacent"))) or "-",
3399
+ itxt,
3400
+ str(rec.get("rev") if rec.get("rev") is not None else "-"),
3401
+ ])
3402
+ blocks.append({"kind": "table",
3403
+ "headers": ["名称", "房间", "尺寸(mm)", "材料", "邻接零件",
3404
+ "接口(尺寸/公差)", "版本"],
3405
+ "rows": rows,
3406
+ "widths": [1.1, 0.9, 1.0, 0.8, 1.3, 2.6, 0.5]})
3407
+
3408
+ # ── 3. 邻接关系表 ──────────────────────────────────────────────────
3409
+ blocks.append({"kind": "heading", "level": 1, "text": "三、邻接关系表"})
3410
+ pair_rows = consistency.get("pair_rows") or []
3411
+ if not pair_rows:
3412
+ blocks.append({"kind": "para",
3413
+ "text": "(没有任何邻接关系;请用 link_adjacent 双向登记配合对)"})
3414
+ else:
3415
+ rows = []
3416
+ for pr in pair_rows:
3417
+ a, b = pr["parts"]
3418
+ matched = pr.get("matched_interfaces") or []
3419
+ desc = ";".join("%s(%s)↔%s(%s)[%s]"
3420
+ % (m["a_interface"], _fmt_num(m["a_nominal_mm"])
3421
+ if m["a_nominal_mm"] is not None else "?",
3422
+ m["b_interface"], _fmt_num(m["b_nominal_mm"])
3423
+ if m["b_nominal_mm"] is not None else "?",
3424
+ m["matched_by"]) for m in matched) or "-"
3425
+ bad = [m for m in matched
3426
+ if m["a_nominal_mm"] is not None and m["b_nominal_mm"] is not None
3427
+ and abs(m["a_nominal_mm"] - m["b_nominal_mm"]) > 1e-9]
3428
+ rows.append([a, b, desc,
3429
+ "不一致" if bad else ("已核对" if pr.get("status") == "checked"
3430
+ else "无接口数据")])
3431
+ blocks.append({"kind": "table",
3432
+ "headers": ["零件 A", "零件 B", "接口配对(A↔B)", "结论"],
3433
+ "rows": rows, "widths": [1.0, 1.0, 4.0, 0.9]})
3434
+
3435
+ # ── 4. 一致性问题清单 ──────────────────────────────────────────────
3436
+ blocks.append({"kind": "heading", "level": 1, "text": "四、一致性问题清单"})
3437
+ ms = consistency.get("mismatches") or []
3438
+ if not consistency.get("evaluated"):
3439
+ blocks.append({"kind": "para",
3440
+ "text": "⚠ 本次【未能检查】:%s" % (consistency.get("reason") or "-")})
3441
+ if not ms:
3442
+ blocks.append({"kind": "para",
3443
+ "text": "未发现相邻零件基本尺寸不一致。"
3444
+ "(注意:本结论仅覆盖下方「已比对」的接口)"})
3445
+ else:
3446
+ rows = []
3447
+ for m in ms:
3448
+ rows.append(["、".join(m["parts"]), m.get("interface") or "-",
3449
+ m.get("detail") or "-", m.get("severity") or "ERROR",
3450
+ "是" if m.get("escalate_to_parent") else "否"])
3451
+ blocks.append({"kind": "table",
3452
+ "headers": ["零件对", "接口", "详情", "级别", "上报父代理"],
3453
+ "rows": rows, "widths": [1.1, 0.9, 4.2, 0.6, 0.7]})
3454
+
3455
+ warn = consistency.get("warnings") or []
3456
+ if warn:
3457
+ blocks.append({"kind": "heading", "level": 2,
3458
+ "text": "其它告警 / 数据完整性问题(%d 条)" % len(warn)})
3459
+ rows = []
3460
+ for w in warn:
3461
+ rows.append(["、".join(w.get("parts") or []) or "-",
3462
+ w.get("kind") or "-", w.get("detail") or "-",
3463
+ "是" if w.get("escalate_to_parent") else "否"])
3464
+ blocks.append({"kind": "table",
3465
+ "headers": ["相关零件", "类型", "详情", "上报父代理"],
3466
+ "rows": rows, "widths": [1.2, 1.0, 4.5, 0.7]})
3467
+
3468
+ insuff = consistency.get("insufficient") or []
3469
+ if insuff:
3470
+ blocks.append({"kind": "heading", "level": 2,
3471
+ "text": "未比对清单(缺接口数据,%d 对)" % len(insuff)})
3472
+ rows = [[a, b, i.get("reason") or "-", str(i.get("a_interface_count")),
3473
+ str(i.get("b_interface_count"))]
3474
+ for i in insuff for a, b in [i["parts"]]]
3475
+ blocks.append({"kind": "table",
3476
+ "headers": ["零件 A", "零件 B", "原因", "A接口数", "B接口数"],
3477
+ "rows": rows, "widths": [1.0, 1.0, 3.5, 0.7, 0.7]})
3478
+
3479
+ # ── 5. 修订历史摘要 ────────────────────────────────────────────────
3480
+ blocks.append({"kind": "heading", "level": 1, "text": "五、修订历史摘要"})
3481
+ revs = data.get("revisions") or []
3482
+ if not revs:
3483
+ blocks.append({"kind": "para", "text": "(暂无修订记录)"})
3484
+ else:
3485
+ blocks.append({"kind": "para",
3486
+ "text": "共 %d 条修订记录,下表列出最近 %d 条(完整记录见台账 JSON 的 revisions 数组)。"
3487
+ % (len(revs), min(60, len(revs)))})
3488
+ rows = []
3489
+ for r in revs[-60:]:
3490
+ ch = r.get("changes") or {}
3491
+ if ch:
3492
+ txt = ";".join("%s: %s → %s" % (
3493
+ k, _short(v.get("from") if isinstance(v, dict) else v),
3494
+ _short(v.get("to") if isinstance(v, dict) else v))
3495
+ if isinstance(v, dict) and ("from" in v or "to" in v)
3496
+ else "%s: %s" % (k, _short(v)) for k, v in sorted(ch.items()))
3497
+ else:
3498
+ txt = "-"
3499
+ rows.append([str(r.get("rev", "-")), r.get("action") or "-",
3500
+ r.get("part") or "-", r.get("at") or "-", txt])
3501
+ blocks.append({"kind": "table",
3502
+ "headers": ["版本", "动作", "零件", "时间", "改动内容"],
3503
+ "rows": rows, "widths": [0.4, 0.6, 1.0, 1.2, 5.0]})
3504
+
3505
+ blocks.append({"kind": "para",
3506
+ "text": "—— 本文档由 engineering/tools/part_registry.py 自动生成,"
3507
+ "内容来自 %s ——" % registry_path(None)})
3508
+ return blocks
3509
+
3510
+
3511
+ def _room_counts(parts):
3512
+ out = {}
3513
+ for rec in (parts or {}).values():
3514
+ r = rec.get("room") or "(未填房间)"
3515
+ out[r] = out.get(r, 0) + 1
3516
+ return out
3517
+
3518
+
3519
+ def _short(v, limit=90):
3520
+ """把任意值压成短文本(用于修订历史表格)。"""
3521
+ try:
3522
+ s = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
3523
+ except Exception:
3524
+ s = repr(v)
3525
+ s = str(s).replace("\n", " ")
3526
+ return s if len(s) <= limit else s[:limit - 1] + "…"
3527
+
3528
+
3529
+ # ── 渲染器 A:python-docx(策略①②成功后使用)────────────────────────────
3530
+
3531
+ def _render_with_python_docx(blocks, out_path, docx_mod):
3532
+ """用 python-docx 渲染内容块。"""
3533
+ doc = docx_mod.Document()
3534
+ for blk in blocks:
3535
+ k = blk.get("kind")
3536
+ if k == "title":
3537
+ doc.add_heading(blk.get("text") or "", level=0)
3538
+ elif k == "heading":
3539
+ doc.add_heading(blk.get("text") or "", level=int(blk.get("level") or 1))
3540
+ elif k == "para":
3541
+ doc.add_paragraph(blk.get("text") or "")
3542
+ elif k == "table":
3543
+ headers = list(blk.get("headers") or [])
3544
+ rows = list(blk.get("rows") or [])
3545
+ t = doc.add_table(rows=0, cols=len(headers))
3546
+ try:
3547
+ t.style = "Table Grid"
3548
+ except Exception:
3549
+ pass
3550
+ if headers:
3551
+ cells = t.add_row().cells
3552
+ for i, h in enumerate(headers):
3553
+ cells[i].text = str(h)
3554
+ for r in rows:
3555
+ cells = t.add_row().cells
3556
+ for i in range(len(headers)):
3557
+ cells[i].text = _short(r[i] if i < len(r) else "", 1000)
3558
+ doc.add_paragraph("")
3559
+ doc.save(out_path)
3560
+ return True
3561
+
3562
+
3563
+ # ── 渲染器 B:纯标准库 OOXML(策略③降级)───────────────────────────────
3564
+
3565
+ _XML_BAD = {c: None for c in range(0x20) if c not in (0x09, 0x0A, 0x0D)}
3566
+ _XML_BAD[0x7F] = None
3567
+
3568
+ _OOXML_RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
3569
+ _OOXML_CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
3570
+ _OOXML_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3571
+
3572
+
3573
+ def _xml_escape(s):
3574
+ """XML 文本转义 + 剔除 XML 1.0 非法控制字符(Word 会拒绝打开)。"""
3575
+ t = str(s if s is not None else "").translate(_XML_BAD)
3576
+ return (t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
3577
+ .replace('"', "&quot;").replace("'", "&apos;"))
3578
+
3579
+
3580
+ def _w_p(text, style=None, bold=False):
3581
+ """一个 w:p 段落。"""
3582
+ ppr = ""
3583
+ if style or bold:
3584
+ ppr = "<w:pPr>"
3585
+ if style:
3586
+ ppr += '<w:pStyle w:val="%s"/>' % _xml_escape(style)
3587
+ if bold:
3588
+ ppr += "<w:rPr><w:b/></w:rPr>"
3589
+ ppr += "</w:pPr>"
3590
+ rpr = "<w:rPr><w:b/></w:rPr>" if bold else ""
3591
+ return ('<w:p>%s<w:r>%s<w:t xml:space="preserve">%s</w:t></w:r></w:p>'
3592
+ % (ppr, rpr, _xml_escape(text)))
3593
+
3594
+
3595
+ def _w_tbl(headers, rows, widths=None):
3596
+ """一个带边框的 w:tbl 表格(总宽按 A4 正文宽度 9746 twips 分配)。"""
3597
+ total = 9746
3598
+ n = max(1, len(headers) or (len(rows[0]) if rows else 1))
3599
+ if not widths or len(widths) != n:
3600
+ widths = [1.0] * n
3601
+ s = float(sum(widths)) or 1.0
3602
+ cols = [max(400, int(total * (w / s))) for w in widths]
3603
+ # 修正舍入误差,保证 gridCol 之和 == 表宽
3604
+ cols[-1] += total - sum(cols)
3605
+
3606
+ borders = ("<w:tblBorders>"
3607
+ '<w:top w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3608
+ '<w:left w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3609
+ '<w:bottom w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3610
+ '<w:right w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3611
+ '<w:insideH w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3612
+ '<w:insideV w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
3613
+ "</w:tblBorders>")
3614
+
3615
+ def _cell(text, w, bold):
3616
+ return ('<w:tc><w:tcPr><w:tcW w:w="%d" w:type="dxa"/>%s</w:tcPr>%s</w:tc>'
3617
+ % (w,
3618
+ '<w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>' if bold else "",
3619
+ _w_p(text, bold=bold)))
3620
+
3621
+ out = ['<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/>'
3622
+ '<w:tblW w:w="%d" w:type="dxa"/>%s'
3623
+ '<w:tblLayout w:type="fixed"/></w:tblPr>' % (total, borders)]
3624
+ out.append("<w:tblGrid>%s</w:tblGrid>"
3625
+ % "".join('<w:gridCol w:w="%d"/>' % w for w in cols))
3626
+ if headers:
3627
+ out.append("<w:tr>%s</w:tr>"
3628
+ % "".join(_cell(h, cols[i], True) for i, h in enumerate(headers)))
3629
+ for r in rows:
3630
+ out.append("<w:tr>%s</w:tr>"
3631
+ % "".join(_cell(r[i] if i < len(r) else "", cols[i], False)
3632
+ for i in range(n)))
3633
+ out.append("</w:tbl>")
3634
+ # 表格后必须跟一个段落,否则部分阅读器会丢内容
3635
+ out.append(_w_p(""))
3636
+ return "".join(out)
3637
+
3638
+
3639
+ _STYLES_XML = (
3640
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3641
+ '<w:styles xmlns:w="%s">'
3642
+ "<w:docDefaults><w:rPrDefault><w:rPr>"
3643
+ '<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="\u5b8b\u4f53" w:cs="Calibri"/>'
3644
+ '<w:sz w:val="21"/><w:szCs w:val="21"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/>'
3645
+ "</w:rPr></w:rPrDefault>"
3646
+ "<w:pPrDefault><w:pPr><w:spacing w:after=\"120\" w:line=\"276\" w:lineRule=\"auto\"/>"
3647
+ "</w:pPr></w:pPrDefault></w:docDefaults>"
3648
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal">'
3649
+ '<w:name w:val="Normal"/><w:qFormat/></w:style>'
3650
+ '<w:style w:type="paragraph" w:styleId="Title">'
3651
+ '<w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>'
3652
+ '<w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="240"/></w:pPr>'
3653
+ '<w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/></w:rPr></w:style>'
3654
+ '<w:style w:type="paragraph" w:styleId="Heading1">'
3655
+ '<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/>'
3656
+ '<w:pPr><w:keepNext/><w:outlineLvl w:val="0"/>'
3657
+ '<w:spacing w:before="240" w:after="120"/></w:pPr>'
3658
+ '<w:rPr><w:b/><w:sz w:val="30"/><w:szCs w:val="30"/></w:rPr></w:style>'
3659
+ '<w:style w:type="paragraph" w:styleId="Heading2">'
3660
+ '<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:qFormat/>'
3661
+ '<w:pPr><w:keepNext/><w:outlineLvl w:val="1"/>'
3662
+ '<w:spacing w:before="200" w:after="100"/></w:pPr>'
3663
+ '<w:rPr><w:b/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:style>'
3664
+ '<w:style w:type="table" w:styleId="TableGrid">'
3665
+ '<w:name w:val="Table Grid"/><w:qFormat/></w:style>'
3666
+ "</w:styles>"
3667
+ ) % _OOXML_W_NS
3668
+
3669
+
3670
+ def _render_with_stdlib_ooxml(blocks, out_path):
3671
+ """纯标准库生成 .docx(OOXML = 一个 zip 包)。
3672
+
3673
+ 包内结构(Word/WPS 可打开的最小合法集):
3674
+ [Content_Types].xml
3675
+ _rels/.rels
3676
+ word/document.xml
3677
+ word/_rels/document.xml.rels
3678
+ word/styles.xml
3679
+ docProps/core.xml
3680
+ docProps/app.xml
3681
+ """
3682
+ body = []
3683
+ for blk in blocks:
3684
+ k = blk.get("kind")
3685
+ if k == "title":
3686
+ body.append(_w_p(blk.get("text") or "", style="Title"))
3687
+ elif k == "heading":
3688
+ lvl = int(blk.get("level") or 1)
3689
+ body.append(_w_p(blk.get("text") or "",
3690
+ style="Heading1" if lvl <= 1 else "Heading2"))
3691
+ elif k == "para":
3692
+ body.append(_w_p(blk.get("text") or ""))
3693
+ elif k == "table":
3694
+ body.append(_w_tbl(list(blk.get("headers") or []),
3695
+ list(blk.get("rows") or []),
3696
+ blk.get("widths")))
3697
+ body.append(_w_p(""))
3698
+
3699
+ sect = ('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
3700
+ '<w:pgMar w:top="1440" w:right="1080" w:bottom="1440" w:left="1080"'
3701
+ ' w:header="851" w:footer="992" w:gutter="0"/>'
3702
+ '<w:cols w:space="425"/><w:docGrid w:type="lines" w:linePitch="312"/>'
3703
+ "</w:sectPr>")
3704
+
3705
+ document = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3706
+ '<w:document xmlns:w="%s"><w:body>%s%s</w:body></w:document>'
3707
+ % (_OOXML_W_NS, "".join(body), sect))
3708
+
3709
+ content_types = (
3710
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3711
+ '<Types xmlns="%s">'
3712
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
3713
+ '<Default Extension="xml" ContentType="application/xml"/>'
3714
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
3715
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
3716
+ '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
3717
+ '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
3718
+ "</Types>") % _OOXML_CT_NS
3719
+
3720
+ rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3721
+ '<Relationships xmlns="%s">'
3722
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
3723
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
3724
+ '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
3725
+ "</Relationships>") % _OOXML_RELS_NS
3726
+
3727
+ doc_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3728
+ '<Relationships xmlns="%s">'
3729
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
3730
+ "</Relationships>") % _OOXML_RELS_NS
3731
+
3732
+ ts, at = _now()
3733
+ iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
3734
+ core = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3735
+ '<cp:coreProperties'
3736
+ ' xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"'
3737
+ ' xmlns:dc="http://purl.org/dc/elements/1.1/"'
3738
+ ' xmlns:dcterms="http://purl.org/dc/terms/"'
3739
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
3740
+ "<dc:title>%s</dc:title><dc:creator>DSH part_registry.py</dc:creator>"
3741
+ "<cp:lastModifiedBy>DSH part_registry.py</cp:lastModifiedBy>"
3742
+ '<dcterms:created xsi:type="dcterms:W3CDTF">%s</dcterms:created>'
3743
+ '<dcterms:modified xsi:type="dcterms:W3CDTF">%s</dcterms:modified>'
3744
+ "</cp:coreProperties>"
3745
+ % (_xml_escape("DSH 零件数据台账"), iso, iso))
3746
+
3747
+ app = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3748
+ '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"'
3749
+ ' xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
3750
+ "<Application>DSH engineering part_registry.py</Application>"
3751
+ "<Company>DSH</Company></Properties>")
3752
+
3753
+ d = os.path.dirname(os.path.abspath(out_path))
3754
+ if d:
3755
+ os.makedirs(d, exist_ok=True)
3756
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
3757
+ # [Content_Types].xml 必须第一个写入(部分解析器按序嗅探)
3758
+ z.writestr("[Content_Types].xml", content_types)
3759
+ z.writestr("_rels/.rels", rels)
3760
+ z.writestr("word/document.xml", document)
3761
+ z.writestr("word/_rels/document.xml.rels", doc_rels)
3762
+ z.writestr("word/styles.xml", _STYLES_XML)
3763
+ z.writestr("docProps/core.xml", core)
3764
+ z.writestr("docProps/app.xml", app)
3765
+ return True
3766
+
3767
+
3768
+ # ── 对外接口 ────────────────────────────────────────────────────────────
3769
+
3770
+ def export_docx(out_path, work_dir=None, strategy="auto", probe=True):
3771
+ """把台账导出为 Word 文档。
3772
+
3773
+ Args:
3774
+ out_path: 目标 .docx 路径(目录不存在会自动创建)
3775
+ work_dir: 台账落点覆盖
3776
+ strategy: "auto"(默认,三级自动降级)
3777
+ "python-docx"(强制策略①②,失败即报错)
3778
+ "stdlib"(强制策略③纯标准库 OOXML)
3779
+ probe: auto 模式下是否允许探测随包 site-packages(测试可关)
3780
+
3781
+ Returns:
3782
+ {"ok": True, "out": ..., "strategy": "...", "size_bytes": n,
3783
+ "zip_entries": [...], "has_document_xml": True, "sections": [...],
3784
+ "diagnostics": {...}}
3785
+ 失败 → {"ok": False, "error": ..., "attempts": [...]}
3786
+
3787
+ ── 强制自检(代码化)────────────────────────────────────────────────
3788
+ 生成后立即用 zipfile 重新打开,校验:
3789
+ · 是合法 zip;
3790
+ · 含 [Content_Types].xml 与 word/document.xml;
3791
+ · word/document.xml 非空。
3792
+ 任一条不满足 → ok=false(绝不把坏文件当成功交付)。
3793
+ """
3794
+ if not out_path:
3795
+ return {"ok": False, "error": "缺少输出路径 out_path"}
3796
+ out = os.path.abspath(os.path.expanduser(str(out_path)))
3797
+ if not out.lower().endswith(".docx"):
3798
+ return {"ok": False, "error": "输出文件必须以 .docx 结尾:%s" % out}
3799
+
3800
+ data, path, err = _load_registry(work_dir)
3801
+ if err:
3802
+ return {"ok": False, "error": err, "file": path}
3803
+
3804
+ consistency = check_adjacency_consistency(work_dir=work_dir)
3805
+ blocks = _build_docx_blocks(data, consistency)
3806
+ sections = []
3807
+ for blk in blocks:
3808
+ if blk["kind"] == "table":
3809
+ sections.append("表:%s(%d行)" % ("/".join(blk.get("headers") or []),
3810
+ len(blk.get("rows") or [])))
3811
+ elif blk["kind"] == "heading":
3812
+ sections.append(blk.get("text"))
3813
+ elif blk["kind"] == "title":
3814
+ sections.append(blk.get("text"))
3815
+
3816
+ attempts = []
3817
+ used = None
3818
+ diag = {}
3819
+
3820
+ # ── 策略①②:python-docx ───────────────────────────────────────────
3821
+ if strategy in ("auto", "python-docx"):
3822
+ docx_mod, how, diag = _try_import_docx(probe=probe)
3823
+ attempts.extend(diag.get("attempts") or [])
3824
+ if docx_mod is not None:
3825
+ try:
3826
+ _render_with_python_docx(blocks, out, docx_mod)
3827
+ used = how
3828
+ except Exception as e:
3829
+ attempts.append({"step": "render_python_docx", "ok": False,
3830
+ "error": "%s: %s" % (type(e).__name__, e)})
3831
+ if strategy == "python-docx":
3832
+ return {"ok": False, "error": "python-docx 渲染失败:%r" % (e,),
3833
+ "strategy_requested": strategy, "attempts": attempts}
3834
+ elif strategy == "python-docx":
3835
+ return {"ok": False,
3836
+ "error": "无法导入 python-docx(已尝试直接导入与注入随包 site-packages)",
3837
+ "strategy_requested": strategy, "attempts": attempts,
3838
+ "probed_site_packages": diag.get("probed") or [],
3839
+ "hint": ("本机系统 Python 未安装 python-docx;随包运行时的 "
3840
+ "site-packages 若为其它 Python 版本编译(lxml C 扩展"
3841
+ "ABI 不符)同样无法加载。可用 strategy='stdlib' 走"
3842
+ "纯标准库 OOXML 导出。")}
3843
+
3844
+ # ── 策略③:纯标准库 OOXML ─────────────────────────────────────────
3845
+ if used is None:
3846
+ try:
3847
+ _render_with_stdlib_ooxml(blocks, out)
3848
+ used = "stdlib-ooxml(纯标准库降级)"
3849
+ attempts.append({"step": "stdlib_ooxml", "ok": True, "out": out})
3850
+ except Exception as e:
3851
+ return {"ok": False, "error": "纯标准库 OOXML 生成失败:%r" % (e,),
3852
+ "strategy_requested": strategy, "attempts": attempts}
3853
+
3854
+ # ── 强制自检 ──────────────────────────────────────────────────────
3855
+ check = _verify_docx_file(out)
3856
+ if not check["ok"]:
3857
+ return {"ok": False, "error": "生成的 docx 自检未通过:%s" % check["error"],
3858
+ "out": out, "strategy": used, "attempts": attempts}
3859
+
3860
+ size = os.path.getsize(out)
3861
+ return {
3862
+ "ok": True,
3863
+ "out": out,
3864
+ "file": path,
3865
+ "strategy": used,
3866
+ "size_bytes": size,
3867
+ "size_human": _human_size(size),
3868
+ "zip_entries": check["entries"],
3869
+ "has_document_xml": check["has_document_xml"],
3870
+ "document_xml_bytes": check["document_xml_bytes"],
3871
+ "parts_count": len(data.get("parts") or {}),
3872
+ "mismatch_count": consistency.get("mismatch_count"),
3873
+ "consistency_ok": consistency.get("ok"),
3874
+ "consistency_evaluated": consistency.get("evaluated"),
3875
+ "sections": sections,
3876
+ "attempts": attempts,
3877
+ "python_docx": diag.get("module_path"),
3878
+ "probed_site_packages": [c["site_packages"] for c in (diag.get("probed") or [])],
3879
+ "note": ("docx 本质是 zip 包;已用 zipfile 回读校验,"
3880
+ "确保 Word/WPS 可打开。strategy 字段说明实际走了哪条路径。"),
3881
+ }
3882
+
3883
+
3884
+ def _verify_docx_file(path):
3885
+ """回读校验 .docx:合法 zip + 必需部件齐全。返回 {ok, error, entries, ...}。"""
3886
+ try:
3887
+ if not os.path.isfile(path):
3888
+ return {"ok": False, "error": "文件未生成:%s" % path,
3889
+ "entries": [], "has_document_xml": False,
3890
+ "document_xml_bytes": 0}
3891
+ with zipfile.ZipFile(path, "r") as z:
3892
+ bad = z.testzip()
3893
+ if bad is not None:
3894
+ return {"ok": False, "error": "zip 内条目 CRC 校验失败:%s" % bad,
3895
+ "entries": z.namelist(), "has_document_xml": False,
3896
+ "document_xml_bytes": 0}
3897
+ entries = z.namelist()
3898
+ need = ["[Content_Types].xml", "word/document.xml", "_rels/.rels"]
3899
+ missing = [n for n in need if n not in entries]
3900
+ if missing:
3901
+ return {"ok": False, "error": "缺少必需部件:%s" % ", ".join(missing),
3902
+ "entries": entries, "has_document_xml": "word/document.xml" in entries,
3903
+ "document_xml_bytes": 0}
3904
+ doc_bytes = len(z.read("word/document.xml"))
3905
+ if doc_bytes <= 0:
3906
+ return {"ok": False, "error": "word/document.xml 为空",
3907
+ "entries": entries, "has_document_xml": True,
3908
+ "document_xml_bytes": 0}
3909
+ return {"ok": True, "error": None, "entries": entries,
3910
+ "has_document_xml": True, "document_xml_bytes": doc_bytes}
3911
+ except Exception as e:
3912
+ return {"ok": False, "error": "无法作为 zip 打开:%r" % (e,),
3913
+ "entries": [], "has_document_xml": False, "document_xml_bytes": 0}
3914
+
3915
+
3916
+ def _human_size(n):
3917
+ for unit in ("B", "KB", "MB"):
3918
+ if n < 1024 or unit == "MB":
3919
+ return ("%.1f %s" % (n, unit)) if unit != "B" else ("%d B" % n)
3920
+ n /= 1024.0
3921
+ return "%d B" % n
3922
+
3923
+
3924
+ # ══════════════════════════════════════════════════════════════════════════
3925
+ # CLI
3926
+ # ══════════════════════════════════════════════════════════════════════════
3927
+
3928
+ def _emit(obj):
3929
+ """统一 JSON 输出(ensure_ascii=False + indent=2)。
3930
+
3931
+ 与 mode_gate.emit 同口径:优先用 UTF-8 字节直写,绕开控制台代码页;
3932
+ 编码失败才退回 ASCII 转义 —— 宁可转义,绝不留乱码。
3933
+ """
3934
+ try:
3935
+ text = json.dumps(obj, ensure_ascii=False, indent=2)
3936
+ except Exception:
3937
+ text = json.dumps({"ok": False, "error": "结果序列化失败"}, ensure_ascii=True)
3938
+ try:
3939
+ buf = getattr(sys.stdout, "buffer", None)
3940
+ if buf is not None:
3941
+ buf.write(text.encode("utf-8", "replace"))
3942
+ buf.write(b"\n")
3943
+ buf.flush()
3944
+ return
3945
+ except Exception:
3946
+ pass
3947
+ try:
3948
+ sys.stdout.write(text + "\n")
3949
+ sys.stdout.flush()
3950
+ except UnicodeEncodeError:
3951
+ try:
3952
+ sys.stdout.write(json.dumps(obj, ensure_ascii=True, indent=2) + "\n")
3953
+ sys.stdout.flush()
3954
+ except Exception:
3955
+ pass
3956
+
3957
+
3958
+ def cmd_add(ns):
3959
+ """add 子命令:登记/更新一个零件。"""
3960
+ adj = _parse_list_arg(ns.adjacent)
3961
+ rec = {"name": ns.part, "room": ns.room, "file": ns.file}
3962
+ if ns.material is not None:
3963
+ rec["material"] = ns.material
3964
+ if ns.dims is not None:
3965
+ rec["dims"] = ns.dims
3966
+ if ns.bbox is not None:
3967
+ rec["bbox_mm"] = ns.bbox
3968
+ if adj:
3969
+ rec["adjacent"] = adj
3970
+ if ns.interface:
3971
+ ifaces = []
3972
+ for raw in ns.interface:
3973
+ try:
3974
+ ifaces.append(json.loads(raw))
3975
+ except Exception as e:
3976
+ return {"ok": False, "error": "--interface 不是合法 JSON:%r(%r)" % (raw, e)}
3977
+ rec["interfaces"] = ifaces
3978
+ if ns.credentials:
3979
+ try:
3980
+ rec["credentials"] = json.loads(ns.credentials)
3981
+ except Exception as e:
3982
+ return {"ok": False, "error": "--credentials 不是合法 JSON:%r" % (e,)}
3983
+ if ns.reported_by:
3984
+ rec["reported_by"] = ns.reported_by
3985
+ # 【Bug⑪】origin 约定:显式给了就用;没给则 add_part 内部从 .origin.json 读
3986
+ if getattr(ns, "origin", None):
3987
+ rec["origin"] = ns.origin
3988
+
3989
+ res = add_part(rec, work_dir=ns.work_dir, reason=ns.reason,
3990
+ allow_mismatch=(ns.allow_mismatch or False),
3991
+ fields=ns.fields)
3992
+ if not res.get("ok"):
3993
+ return res
3994
+ # 建立双向邻接(add 里的 adjacent 只写单向;这里补双向,保证图完整)
3995
+ linked = []
3996
+ failed_links = []
3997
+ for other in adj:
3998
+ lr = link_adjacent(ns.part, other, work_dir=ns.work_dir,
3999
+ reason="登记零件 %s 时按其 adjacent 字段建立双向邻接(%s ↔ %s)"
4000
+ % (ns.part, ns.part, other),
4001
+ allow_mismatch=(ns.allow_mismatch or False))
4002
+ if lr.get("ok"):
4003
+ if not lr.get("already_linked"):
4004
+ linked.append(other)
4005
+ else:
4006
+ failed_links.append({"other": other, "error": lr.get("error"),
4007
+ "gate": lr.get("gate"),
4008
+ "mismatches": lr.get("mismatches")})
4009
+ out = dict(res)
4010
+ out["bidirectional_links_created"] = linked
4011
+ if failed_links:
4012
+ out["link_failures"] = failed_links
4013
+ out["warnings"] = list(out.get("warnings") or []) + \
4014
+ ["以下邻接未能双向建立:%s"
4015
+ % "、".join("%s(%s)" % (x["other"], x.get("gate") or x.get("error"))
4016
+ for x in failed_links)]
4017
+ return out
4018
+
4019
+
4020
+ def cmd_get(ns):
4021
+ """get 子命令:读单个零件。"""
4022
+ res = get_part(ns.part, work_dir=ns.work_dir)
4023
+ if not res.get("ok"):
4024
+ return res
4025
+ if ns.json:
4026
+ return res
4027
+ rec = res["record"]
4028
+ return {"ok": True, "part": res["part"], "file": res["file"],
4029
+ "record": rec, "brief": res["brief"]}
4030
+
4031
+
4032
+ def cmd_list(ns):
4033
+ """list 子命令:列全部零件。"""
4034
+ res = list_parts(work_dir=ns.work_dir)
4035
+ if not res.get("ok") or ns.json:
4036
+ return res
4037
+ return {"ok": True, "file": res["file"], "task_id": res["task_id"],
4038
+ "count": res["count"], "rooms": res["rooms"], "parts": res["parts"]}
4039
+
4040
+
4041
+ def cmd_related(ns):
4042
+ """related 子命令:按零件反查关联(BFS)。"""
4043
+ res = related_parts(ns.part, depth=ns.depth, work_dir=ns.work_dir)
4044
+ if not res.get("ok") or ns.json:
4045
+ return res
4046
+ return {"ok": True, "root": res["root"], "depth": res["depth"],
4047
+ "related": res["related"], "related_count": res["related_count"],
4048
+ "by_distance": res["by_distance"], "related_detail": res["related_detail"],
4049
+ "unreachable": res["unreachable"], "file": res["file"],
4050
+ "note": res["note"]}
4051
+
4052
+
4053
+ def cmd_reverse(ns):
4054
+ """reverse 子命令:反查入边。"""
4055
+ return reverse_lookup(ns.part, work_dir=ns.work_dir)
4056
+
4057
+
4058
+ def cmd_check_consistency(ns):
4059
+ """check-consistency 子命令:★ 尺寸一致性检查。"""
4060
+ return check_adjacency_consistency(work_dir=ns.work_dir)
4061
+
4062
+
4063
+ def cmd_for_change(ns):
4064
+ """for-change 子命令:改这个零件要连带改谁。"""
4065
+ return find_related_for_change(ns.part, work_dir=ns.work_dir)
4066
+
4067
+
4068
+ def cmd_link(ns):
4069
+ """link 子命令:双向建立邻接。"""
4070
+ return link_adjacent(ns.a, ns.b, work_dir=ns.work_dir, reason=ns.reason,
4071
+ allow_mismatch=(ns.allow_mismatch or False))
4072
+
4073
+
4074
+ def cmd_update(ns):
4075
+ """update 子命令:局部更新零件字段。"""
4076
+ patch = {}
4077
+ for k, v in (("room", ns.room), ("file", ns.file), ("material", ns.material),
4078
+ ("dims", ns.dims), ("bbox_mm", ns.bbox), ("reported_by", ns.reported_by)):
4079
+ if v is not None:
4080
+ patch[k] = v
4081
+ if ns.adjacent is not None:
4082
+ patch["adjacent"] = _parse_list_arg(ns.adjacent)
4083
+ if ns.interface:
4084
+ ifaces = []
4085
+ for raw in ns.interface:
4086
+ try:
4087
+ ifaces.append(json.loads(raw))
4088
+ except Exception as e:
4089
+ return {"ok": False, "error": "--interface 不是合法 JSON:%r(%r)" % (raw, e)}
4090
+ patch["interfaces"] = ifaces
4091
+ if not patch:
4092
+ return {"ok": False, "error": "没有给出任何要更新的字段",
4093
+ "hint": "可用字段:room/file/material/dims/bbox/adjacent/interface/reported-by"}
4094
+ return update_part(ns.part, patch, work_dir=ns.work_dir, reason=ns.reason,
4095
+ allow_mismatch=(ns.allow_mismatch or False), fields=ns.fields)
4096
+
4097
+
4098
+ def cmd_remove(ns):
4099
+ """remove 子命令:删除零件并清理别人的邻接引用。"""
4100
+ return remove_part(ns.part, work_dir=ns.work_dir, reason=ns.reason)
4101
+
4102
+
4103
+ def cmd_unresolved(ns):
4104
+ """unresolved 子命令:查看/关闭"经说明理由后仍保留"的已知不一致。"""
4105
+ if ns.resolve:
4106
+ return resolve_unresolved(ns.resolve, work_dir=ns.work_dir, reason=ns.reason,
4107
+ note=ns.note)
4108
+ return list_unresolved(work_dir=ns.work_dir)
4109
+
4110
+
4111
+ def cmd_audit(ns):
4112
+ """audit 子命令:★ 重算核实指纹,证明台账数据有没有被绕过模块手改。"""
4113
+ return audit_verification(name=ns.part, work_dir=ns.work_dir)
4114
+
4115
+
4116
+ def cmd_export_docx(ns):
4117
+ """export-docx 子命令:导出 Word 台账。"""
4118
+ return export_docx(ns.out, work_dir=ns.work_dir, strategy=ns.strategy,
4119
+ probe=(not ns.no_probe))
4120
+
4121
+
4122
+ def build_parser():
4123
+ """构造 argparse 解析器(全部子命令)。"""
4124
+ common = argparse.ArgumentParser(add_help=False)
4125
+ common.add_argument("--work-dir", dest="work_dir", default=None,
4126
+ help="台账落点覆盖(默认 <脚本目录>/load_cases/part_registry.json)")
4127
+ common.add_argument("--json", action="store_true",
4128
+ help="输出完整明细(默认输出精简视图;两者都是 JSON)")
4129
+
4130
+ # ★【强制·如实填写】所有落盘型改动共用的参数
4131
+ reason_common = argparse.ArgumentParser(add_help=False)
4132
+ reason_common.add_argument(
4133
+ "--reason", required=True,
4134
+ help="★必填:本次改动的真实说明(改了什么、为什么改)。"
4135
+ "空/占位词(\"无\"\"待定\")会被拒绝落盘")
4136
+ reason_common.add_argument(
4137
+ "--allow-mismatch", dest="allow_mismatch", default=None, metavar="理由",
4138
+ help="核实发现不一致时默认拒绝落盘;确属设计允许时在这里【如实写明理由】才放行"
4139
+ "(会记入 unresolved 清单,不会静默消失)")
4140
+
4141
+ p = argparse.ArgumentParser(
4142
+ prog="part_registry.py",
4143
+ description="DSH 工程模式 · 零件数据台账(尺寸一致性底座)",
4144
+ formatter_class=argparse.RawDescriptionHelpFormatter,
4145
+ epilog=("退出码:0=成功 / 1=操作失败或一致性未通过 / 2=命令行用法错误\n"
4146
+ "示例:\n"
4147
+ " python part_registry.py add --part \"齿轮轴\" --room \"传动机构\" "
4148
+ "--file \"C:/x.SLDPRT\" --dims \"120x40x40\" --adjacent \"轴承座,联轴器\"\n"
4149
+ " python part_registry.py check-consistency --json\n"))
4150
+ sub = p.add_subparsers(dest="cmd", metavar="<命令>")
4151
+
4152
+ s = sub.add_parser("add", parents=[common, reason_common],
4153
+ help="新增/更新零件(name/room/file 必填)")
4154
+ s.add_argument("--part", required=True, help="零件名")
4155
+ s.add_argument("--room", required=True, help="所属房间")
4156
+ s.add_argument("--file", required=True, help="模型文件路径")
4157
+ s.add_argument("--material", default=None, help="材料牌号")
4158
+ s.add_argument("--origin", default=None,
4159
+ help="【Bug⑪】origin 约定(如 center_bottom / center_center / "
4160
+ "corner_top)。不给则从零件旁 <零件>.origin.json 读取;"
4161
+ "缺记录会给出 warning(标准约定=底面中心)")
4162
+ s.add_argument("--dims", default=None, help="关键尺寸,如 \"120x40x40\"(长x宽x高,mm)")
4163
+ s.add_argument("--bbox", default=None, help="SW 真实包围盒 6 元组 xmin,xmax,ymin,ymax,zmin,zmax")
4164
+ s.add_argument("--adjacent", default=None, help="邻接零件名,逗号分隔(会双向建立)")
4165
+ s.add_argument("--interface", action="append", default=None,
4166
+ help="接口 JSON,可重复,如 '{\"id\":\"轴颈1\",\"type\":\"shaft\",\"nominal_mm\":25}'")
4167
+ s.add_argument("--credentials", default=None,
4168
+ help="凭据 JSON,如 '{\"material\":\"...\",\"physics\":\"...\"}'")
4169
+ s.add_argument("--reported-by", dest="reported_by", default=None, help="上报房间")
4170
+ s.add_argument("--fields", default=None,
4171
+ help="可选:声明本次改动字段(逗号分隔)。一旦给出必须与实际 diff 完全一致")
4172
+ s.set_defaults(func=cmd_add)
4173
+
4174
+ s = sub.add_parser("get", parents=[common], help="读取单个零件")
4175
+ s.add_argument("--part", required=True)
4176
+ s.set_defaults(func=cmd_get)
4177
+
4178
+ s = sub.add_parser("list", parents=[common], help="列出全部零件")
4179
+ s.set_defaults(func=cmd_list)
4180
+
4181
+ s = sub.add_parser("related", parents=[common], help="★按零件反查关联零件(BFS)")
4182
+ s.add_argument("--part", required=True)
4183
+ s.add_argument("--depth", type=int, default=1, help="遍历层数(默认 1)")
4184
+ s.set_defaults(func=cmd_related)
4185
+
4186
+ s = sub.add_parser("reverse", parents=[common], help="反查:谁把我列为邻接(入边)")
4187
+ s.add_argument("--part", required=True)
4188
+ s.set_defaults(func=cmd_reverse)
4189
+
4190
+ s = sub.add_parser("check-consistency", parents=[common],
4191
+ help="★相邻零件尺寸一致性检查")
4192
+ s.set_defaults(func=cmd_check_consistency)
4193
+
4194
+ s = sub.add_parser("for-change", parents=[common],
4195
+ help="要改这个零件时,谁需要跟着改(含理由)")
4196
+ s.add_argument("--part", required=True)
4197
+ s.set_defaults(func=cmd_for_change)
4198
+
4199
+ s = sub.add_parser("link", parents=[common, reason_common],
4200
+ help="双向建立邻接关系 A↔B")
4201
+ s.add_argument("--a", required=True)
4202
+ s.add_argument("--b", required=True)
4203
+ s.set_defaults(func=cmd_link)
4204
+
4205
+ s = sub.add_parser("update", parents=[common, reason_common], help="局部更新零件字段")
4206
+ s.add_argument("--part", required=True)
4207
+ s.add_argument("--room", default=None)
4208
+ s.add_argument("--file", default=None)
4209
+ s.add_argument("--material", default=None)
4210
+ s.add_argument("--dims", default=None)
4211
+ s.add_argument("--bbox", default=None)
4212
+ s.add_argument("--adjacent", default=None)
4213
+ s.add_argument("--interface", action="append", default=None)
4214
+ s.add_argument("--reported-by", dest="reported_by", default=None)
4215
+ s.add_argument("--fields", default=None,
4216
+ help="可选:声明本次改动字段(逗号分隔)。一旦给出必须与实际 diff 完全一致")
4217
+ s.set_defaults(func=cmd_update)
4218
+
4219
+ s = sub.add_parser("remove", parents=[common, reason_common],
4220
+ help="删除零件并清理邻接引用")
4221
+ s.add_argument("--part", required=True)
4222
+ s.set_defaults(func=cmd_remove)
4223
+
4224
+ s = sub.add_parser("unresolved", parents=[common],
4225
+ help="查看/关闭「经说明理由后仍保留」的已知不一致")
4226
+ s.add_argument("--resolve", default=None, metavar="序号",
4227
+ help="把指定序号的记录标记为已解决(需同时给 --reason)")
4228
+ s.add_argument("--reason", default=None, help="--resolve 时必填:为什么可以判定已解决")
4229
+ s.add_argument("--note", default=None, help="可选备注")
4230
+ s.set_defaults(func=cmd_unresolved)
4231
+
4232
+ s = sub.add_parser("audit", parents=[common],
4233
+ help="★重算核实指纹,证明台账数据有没有被绕过模块手改")
4234
+ s.add_argument("--part", default=None, help="只审计某个零件(默认全部)")
4235
+ s.set_defaults(func=cmd_audit)
4236
+
4237
+ s = sub.add_parser("export-docx", parents=[common], help="导出 Word 台账")
4238
+ s.add_argument("--out", required=True, help="输出 .docx 路径")
4239
+ s.add_argument("--strategy", default="auto",
4240
+ choices=["auto", "python-docx", "stdlib"],
4241
+ help="导出策略(默认 auto:python-docx → 注入随包 site-packages → 纯标准库 OOXML)")
4242
+ s.add_argument("--no-probe", action="store_true",
4243
+ help="auto 模式下不探测随包 site-packages(直接走 stdlib 降级)")
4244
+ s.set_defaults(func=cmd_export_docx)
4245
+
4246
+ return p
4247
+
4248
+
4249
+ def main(argv=None):
4250
+ """CLI 入口。返回退出码:0=成功 / 1=失败 / 2=用法错误。"""
4251
+ parser = build_parser()
4252
+ args = parser.parse_args(argv)
4253
+ if not args.cmd:
4254
+ parser.print_help()
4255
+ return 2
4256
+ try:
4257
+ result = args.func(args)
4258
+ except Exception as e:
4259
+ result = {"ok": False, "error": "命令执行异常:%s: %s" % (type(e).__name__, e),
4260
+ "command": args.cmd}
4261
+ _emit(result)
4262
+ return 0 if isinstance(result, dict) and result.get("ok") else 1
4263
+
4264
+
4265
+ if __name__ == "__main__":
4266
+ sys.exit(main())