Browse Source

2.12.0 模块化重构收官 P7: 质量门/文档/打包卸载 23件(7426行)迁入 app_qualityGate/{audits,docs,pack}+旧路径CLI壳+根引导脚本不动; 8条门stdout逐字节一致; 七个模块齐备

zhouyang.xie 1 tuần trước cách đây
mục cha
commit
fa11e24583
62 tập tin đã thay đổi với 8197 bổ sung và 7326 xóa
  1. 4 1
      app_common/common/app_common_guanlan/version.py
  2. 39 13
      app_qualityGate/README.md
  3. 33 8
      app_qualityGate/common/app_qualityGate_guanlan/api.py
  4. 2 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/__init__.py
  5. 130 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/audit_chinese_terms.py
  6. 145 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/chain_gap_check.py
  7. 201 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/check_portability.py
  8. 449 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/check_transferable.py
  9. 268 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/config_audit.py
  10. 399 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/detail_deps.py
  11. 85 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/guanlan_baseline_manifest.py
  12. 359 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/inventory_products.py
  13. 272 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/log_audit.py
  14. 195 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/module_boundary_audit.py
  15. 156 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/page_fingerprint.py
  16. 642 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/pages_audit.py
  17. 309 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/pages_sweep.py
  18. 451 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/products_restore_missing.py
  19. 870 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/products_reverse_audit.py
  20. 123 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/products_state.py
  21. 106 0
      app_qualityGate/common/app_qualityGate_guanlan/audits/security_scan.py
  22. 2 0
      app_qualityGate/common/app_qualityGate_guanlan/docs/__init__.py
  23. 700 0
      app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_build.py
  24. 525 0
      app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_figures.py
  25. 107 0
      app_qualityGate/common/app_qualityGate_guanlan/docs/version_log.py
  26. 2 0
      app_qualityGate/common/app_qualityGate_guanlan/pack/__init__.py
  27. 456 0
      app_qualityGate/common/app_qualityGate_guanlan/pack/guanlan_uninstall.py
  28. 564 0
      app_qualityGate/common/app_qualityGate_guanlan/pack/pack_dist.py
  29. 52 0
      app_qualityGate/common/app_qualityGate_guanlan/pack/pack_verify_entry.py
  30. 3 2
      configs/modules.yaml
  31. 1 1
      docs/src/数据要求说明_观澜_2.12.0.md
  32. 11 9
      docs/src/系统设计说明_观澜_2.12.0.md
  33. 21 20
      docs/src/需求分析_观澜_2.12.0.md
  34. BIN
      docs/数据要求说明_观澜_2.11.9.docx
  35. BIN
      docs/数据要求说明_观澜_2.12.0.docx
  36. 6 5
      docs/版本记录.md
  37. BIN
      docs/系统设计说明_观澜_2.12.0.docx
  38. 4 3
      docs/重构方案_模块化_v0.1.md
  39. BIN
      docs/需求分析_观澜_2.12.0.docx
  40. 20 115
      scripts/audit_chinese_terms.py
  41. 29 139
      scripts/chain_gap_check.py
  42. 16 182
      scripts/check_portability.py
  43. 15 429
      scripts/check_transferable.py
  44. 29 262
      scripts/config_audit.py
  45. 29 694
      scripts/delivery_docs_build.py
  46. 15 505
      scripts/delivery_docs_figures.py
  47. 15 379
      scripts/detail_deps.py
  48. 29 79
      scripts/guanlan_baseline_manifest.py
  49. 29 450
      scripts/guanlan_uninstall.py
  50. 29 353
      scripts/inventory_products.py
  51. 15 252
      scripts/log_audit.py
  52. 15 175
      scripts/module_boundary_audit.py
  53. 15 544
      scripts/pack_dist.py
  54. 29 46
      scripts/pack_verify_entry.py
  55. 15 136
      scripts/page_fingerprint.py
  56. 15 622
      scripts/pages_audit.py
  57. 15 289
      scripts/pages_sweep.py
  58. 29 445
      scripts/products_restore_missing.py
  59. 15 850
      scripts/products_reverse_audit.py
  60. 29 117
      scripts/products_state.py
  61. 29 100
      scripts/security_scan.py
  62. 29 101
      scripts/version_log.py

+ 4 - 1
app_common/common/app_common_guanlan/version.py

@@ -24,7 +24,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.11.9'         # ★ 版本只改这里
+VERSION = '2.12.0'         # ★ 版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -54,6 +54,9 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.12.0', date='2026-09-28', level='minor',
+         title='模块化重构收官 P7:质量门与打包安装迁移(23 件 / 7,426 行)——七个模块齐备',
+         note='重构收官的架构里程碑(七个模块目录 + 公开面 + 兼容壳齐备)⇒ 中版本 +1。P7 把质量门与打包安装 23 件 / 7,426 行迁入 app_qualityGate/common/app_qualityGate_guanlan/{audits,docs,pack}/;旧路径 scripts/<件>.py 留 CLI 壳(公开名同面转发 + main(),guanlan.py check 的 importlib 载入照旧)。**根引导脚本 install.ps1/install.sh/uninstall.*/check.bat/pack.*/start|stop.bat 原地不动**("解压即用"引导层,其 Python 实现已迁入模块)。实逮: ① 子包内 __file__ 取根(parents[1])失效 22 处 ⇒ 统一 install_root();② 文件间 bare import 4 处改相对导入;③ audit_chinese_terms 的 here.parent 是仓库根语义 ⇒ 换 install_root();④ 补 P4 遗留:该审计器被审件 scripts/windscada_serve.py 早已是壳 ⇒ 改读实现位置。验收: 8 条质量门 stdout 逐字节等于迁移前 + 23 个 CLI 壳 importlib 公开名同面 + 新路径 -m 可跑 + 打包器从新位置出包并开箱验证 5/5 + 卸载核验通过 + guanlan.py check 全绿。至此 P0–P7 全部完成:公共/数据接入/算法/本体/前端/后端/质量门各一个目录与 api 公开面,旧路径全部留兼容壳。'),
     dict(version='2.11.9', date='2026-09-28', level='patch',
          title='模块化重构 P6:本体与知识层实体迁移(58 模块 / 19,775 行)',
          note='重构推进(源码组织层)⇒ 小版本 +1。本体 20 件 + SOP 38 件迁入 app_ontology/common/app_ontology_guanlan/{ontology,sop}/;旧路径 src/ontology/<件>.py、src/sop/<件>.py 全部留兼容壳 (模块别名 + `python -m` 经 runpy 重跑实现,重算链 ⑦ 步命令不变)。实逮: ① 包内 `from .. import paths`(23 处)改绝对路径;② 19 处 __file__ 取根改 install_root();③ `-m` 入口必须由壳 runpy.run_module(run_name="__main__") 兜住;④ 按源码路径读本层的审计器与登记表(audit_chinese_terms、check_portability 的 ALLOW 键、products_reverse_audit 的生成端、configs/{registry,terms/jargon_rules}.yaml)同步改位置。验收: 58/58 模块新旧路径同体导入 + 取根抽查正确 + 十条本体/SOP 路由响应体逐字节等于迁移前 + `-m` 旧入口冒烟通过 + 全门禁 rc=0 + 开箱验证 5/5。'),

+ 39 - 13
app_qualityGate/README.md

@@ -1,13 +1,39 @@
-# 观澜 · 质量门与审计 + 打包安装(`app_qualityGate/`)
-
-**职责**:审计器(反向呼应/页面归口/配置统一/日志/链路/可移植/安全)、交付文档三检、打包器与开箱验证、安装与卸载入口
-
-**允许依赖**:app_common, app_ETL, app_algorithmModel, app_ontology, app_backEnd, app_frontEnd
-
-**对外公开面**:`common/app_qualityGate_guanlan/api.py` —— 模块间调用**只许**走这里(`scripts/module_boundary_audit.py` 机器检查)。
-
-**当前实现落点(P0 转发目标)**:scripts/{*_audit,check_*,detail_deps,page_fingerprint,pack_dist,pack_verify_entry,guanlan_uninstall,delivery_docs_build,delivery_docs_figures}.py、install.ps1/sh、uninstall.*
-
-**迁移计划**:P7:审计器与打包安装迁入;门禁与开箱验证口径不变。
-
-> 依据:`docs/重构方案_模块化_v0.1.md`(用户令 2026-09-22:只重构目录与接口、零行为变化、逐版本可回滚)。
+# 观澜 · 质量门与审计 + 打包安装(`app_qualityGate/`)
+
+**职责**:审计器(配置统一/页面归口/产物反向呼应/链路缺口/可移植/日志/安全/中文表达/模块边界/页面指纹/产物清点与补缺/依赖台账)、三件交付文档的构建与插图、版本记录、打包器与开箱验证、卸载器
+
+**允许依赖**:app_common, app_ETL, app_algorithmModel, app_ontology, app_backEnd, app_frontEnd
+
+**对外公开面**:`common/app_qualityGate_guanlan/api.py` —— 模块间调用**只许**走这里(`scripts/module_boundary_audit.py` 机器检查)。
+
+**目录(P7 起,2026-09-28)**:
+
+```
+common/app_qualityGate_guanlan/
+  api.py        公开面(23 名:审计 17 + 文档 3 + 打包 3)
+  audits/       audit_chinese_terms · chain_gap_check · check_portability · check_transferable ·
+                config_audit · detail_deps · guanlan_baseline_manifest · inventory_products ·
+                log_audit · module_boundary_audit · page_fingerprint · pages_audit · pages_sweep ·
+                products_restore_missing · products_reverse_audit · products_state · security_scan
+  docs/         delivery_docs_build(三件交付文档构建)· delivery_docs_figures(插图)· version_log(版本记录)
+  pack/         pack_dist(打包器)· pack_verify_entry(开箱验证入口)· guanlan_uninstall(卸载器)
+```
+
+**根引导脚本原地不动**:`install.ps1`、`install.sh`、`uninstall.bat|sh`、`check.bat`、`pack.bat|sh`、`start/stop.bat`
+是"解压即用"的引导层(README、`src/entry_refs.py` 的入口引用闭合门与 `.gitattributes` 的换行/编码约定都钉着它们),
+**它们调用的 Python 实现**已迁入本模块,`scripts/<件>.py` 留 CLI 壳 ⇒ 引导脚本与既有命令行一字不改。
+
+**迁移状态**:**P7 已完成(2026-09-28)** —— 23 个工具 / 7,426 行实体迁入;旧路径 `scripts/<件>.py` 全部留 CLI 壳
+(公开名同面转发 + `main()`;`guanlan.py check` 用 importlib 逐个载入这些路径,照旧可用)。
+
+**本轮实逮**:① 迁入子包后 `__file__` 取根(`parents[1]`)失效(22 处)⇒ 统一公共层 `install_root()`;
+② 文件间 **bare import**(`from products_reverse_audit import FAMILIES`、`import inventory_products as INV`,4 处)
+在包内会断 ⇒ 改相对导入;③ `audit_chinese_terms` 里的 `here.parent` 是"仓库根"语义 ⇒ 换 `install_root()`;
+④ 顺手补上 P4 遗留:该审计器的被审件 `scripts/windscada_serve.py` 早已是壳 ⇒ 改读实现位置
+`app_backEnd/common/app_backEnd_guanlan/serve.py`。
+
+**验收**:8 条质量门 stdout **逐字节等于迁移前**(config / pages / chain_gap / products_reverse / detail_deps /
+version_log / docs_verify / check_transferable),模块边界与可移植性 rc=0;23 个 `scripts/` 壳经 importlib 载入且公开名同面;
+新路径 `python -m app_qualityGate....audits.<件>` 直接可跑;打包器从新位置出包并**开箱验证 5/5 + 卸载核验通过**;`guanlan.py check` 全绿。
+
+> 依据:`docs/重构方案_模块化_v0.1.md`(用户令 2026-09-22:只重构目录与接口、零行为变化、逐版本可回滚)。

+ 33 - 8
app_qualityGate/common/app_qualityGate_guanlan/api.py

@@ -1,21 +1,46 @@
 # -*- coding: utf-8 -*-
-r"""观澜 · 质量门与审计 + 打包安装 —— **对外公开面**(P0 阶段:接口先立,实现仍在既有路径)。
+r"""观澜 · 质量门与审计 + 打包安装 —— **对外公开面**(P7 起:实现已在本模块目录内)。
 
 用户令 2026-09-22:按模块重构源代码目录,**每模块只许经 `api.py` 被别的模块调用**。
-本文件当前把公开名**惰性转发**到既有实现(scripts/{*_audit,check_*,detail_deps,page_fingerprint,pack_dist,pack_verify_entry,guanlan_uninstall,delivery_docs_build,delivery_docs_figures}.py、install.ps1/sh、uninstall.*),因此:
 
-* 调用的行为与重构前完全一致(零行为变化);
-* 后续阶段(见 `docs/重构方案_模块化_v0.1.md`)把实现搬到本模块目录后,**只改这里的目标映射**,
-  调用方一行都不用动;
-* 惰性转发(PEP 562)保证 import 本文件不会连带 import 重依赖。
+本模块负责(P7 完成):
+* `audits/` —— 审计器:配置统一、页面归口、产物反向呼应、链路缺口、可移植性、日志、安全扫描、
+  中文表达、模块边界、页面指纹、产物清点与补缺、依赖台账;
+* `docs/`   —— 三件交付文档的构建与插图、版本记录;
+* `pack/`   —— 打包器与开箱验证入口、卸载器。
+
+**根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.bat|sh / start|stop.bat)原地不动**:
+它们是"解压即用"的引导层(README 与"入口引用闭合"门钉着它们),其 Python 实现已迁入本模块,
+`scripts/<件>.py` 留 CLI 壳 ⇒ 引导脚本与既有命令行一字不改。公开名 → 本模块内的实现模块(PEP 562 惰性转发)。
 """
 from __future__ import annotations
 
 import importlib
 
-# 公开名 → 现有实现(P0 转发目标;P1 起改为本模块内的实现模块)
 _TARGETS: dict[str, str] = {
-    'version_log': 'scripts.version_log',
+    'audit_chinese_terms': 'app_qualityGate.common.app_qualityGate_guanlan.audits.audit_chinese_terms',
+    'chain_gap_check': 'app_qualityGate.common.app_qualityGate_guanlan.audits.chain_gap_check',
+    'check_portability': 'app_qualityGate.common.app_qualityGate_guanlan.audits.check_portability',
+    'check_transferable': 'app_qualityGate.common.app_qualityGate_guanlan.audits.check_transferable',
+    'config_audit': 'app_qualityGate.common.app_qualityGate_guanlan.audits.config_audit',
+    'detail_deps': 'app_qualityGate.common.app_qualityGate_guanlan.audits.detail_deps',
+    'guanlan_baseline_manifest': 'app_qualityGate.common.app_qualityGate_guanlan.audits.guanlan_baseline_manifest',
+    'inventory_products': 'app_qualityGate.common.app_qualityGate_guanlan.audits.inventory_products',
+    'log_audit': 'app_qualityGate.common.app_qualityGate_guanlan.audits.log_audit',
+    'module_boundary_audit': 'app_qualityGate.common.app_qualityGate_guanlan.audits.module_boundary_audit',
+    'page_fingerprint': 'app_qualityGate.common.app_qualityGate_guanlan.audits.page_fingerprint',
+    'pages_audit': 'app_qualityGate.common.app_qualityGate_guanlan.audits.pages_audit',
+    'pages_sweep': 'app_qualityGate.common.app_qualityGate_guanlan.audits.pages_sweep',
+    'products_restore_missing': 'app_qualityGate.common.app_qualityGate_guanlan.audits.products_restore_missing',
+    'products_reverse_audit': 'app_qualityGate.common.app_qualityGate_guanlan.audits.products_reverse_audit',
+    'products_state': 'app_qualityGate.common.app_qualityGate_guanlan.audits.products_state',
+    'security_scan': 'app_qualityGate.common.app_qualityGate_guanlan.audits.security_scan',
+    'delivery_docs_build': 'app_qualityGate.common.app_qualityGate_guanlan.docs.delivery_docs_build',
+    'delivery_docs_figures': 'app_qualityGate.common.app_qualityGate_guanlan.docs.delivery_docs_figures',
+    'version_log': 'app_qualityGate.common.app_qualityGate_guanlan.docs.version_log',
+    'pack_dist': 'app_qualityGate.common.app_qualityGate_guanlan.pack.pack_dist',
+    'pack_verify_entry': 'app_qualityGate.common.app_qualityGate_guanlan.pack.pack_verify_entry',
+    'guanlan_uninstall': 'app_qualityGate.common.app_qualityGate_guanlan.pack.guanlan_uninstall',
 }
 
 __all__ = list(_TARGETS)

+ 2 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""审计器(配置/页面/产物/链路/可移植/日志/安全/表达/边界/指纹/清点)(P7 起)。"""

+ 130 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/audit_chinese_terms.py

@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""中文表达审核 (基准 = OEM 中文文档术语库 configs/terms/terms_baseline.yaml + 规则 configs/terms/jargon_rules.yaml).
+
+机器给候选, 人裁 —— 不自动改文案。两类输出:
+  ① 规则命中: 内部实现词/自造黑话/有更常用说法 (jargon_rules 逐条带建议写法与状态);
+  ② 无根词: 界面文本里的领域词在四家 OEM 中文文档中**一次都没出现过** → 可能是自造词 (按出现次数排, 供人裁)。
+用法: python scripts/audit_chinese_terms.py [--root .] [--out logs/terms_audit.json] [--md] [--strict]
+  --strict: 规则状态=已裁改 的条目仍命中 → exit 2 (CI 闸); 默认只报不拦。"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+import argparse, collections, json, re, sys, time
+from pathlib import Path
+import yaml
+CJK_RUN = re.compile(r"[一-鿿][一-鿿0-9A-Za-z·%°/\-]{1,60}")
+TERM = re.compile(r"[一-鿿]{2,8}")
+DEFAULT_TARGETS = ["app_frontEnd/common/app_frontEnd_guanlan/lang.py",
+                       "app_frontEnd/common/app_frontEnd_guanlan/assets/app.js", "app_backEnd/common/app_backEnd_guanlan/serve.py", "src/windcms/ui.py", "app_ontology/common/app_ontology_guanlan/ontology/fast_agent.py"]
+
+
+def strings_of(p: Path):
+    """逐行取中文串 (含行号); 跳过注释行 —— 注释不进界面."""
+    out = []
+    for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
+        s = line.strip()
+        if s.startswith("#") or s.startswith("//") or s.startswith("*"): continue
+        for m in CJK_RUN.finditer(line):
+            t = m.group(0).strip()
+            if len(t) >= 2: out.append((i, t))
+    return out
+
+
+def main():
+    ap = argparse.ArgumentParser(); ap.add_argument("--root", default="."); ap.add_argument("--baseline"); ap.add_argument("--rules")
+    ap.add_argument("--targets", nargs="*"); ap.add_argument("--out", default="logs/audit/terms_audit.json")   # 统一: 审计流水进 logs/audit/ (用户令 2)
+    ap.add_argument("--md", action="store_true"); ap.add_argument("--strict", action="store_true"); ap.add_argument("--top", type=int, default=40); a = ap.parse_args()
+    root = Path(a.root).resolve(); here = Path(__file__).resolve().parent
+
+    def pick(arg, name):
+        """基准/规则解析序: 显式参数 > 被审仓 configs/terms/ > 脚本同目录 (skill 自带正本)。
+
+        configs/terms/ 那一项走 `src/paths.py` 的配置取用口 (P.config_dir('terms')),
+        不再手拼配置目录字符串 —— 用户令 2「统一配置」口径见 docs §11。
+        """
+        import sys as _sys
+        _sys.path.insert(0, str(_install_root(__file__)))
+        from src import paths as _P
+        cands = ([Path(arg)] if arg else []) + [_P.config_dir('terms') / name, here / name, _install_root(__file__) / "configs" / "terms" / name]  # config-path-allow: 末项是 skill 自带正本
+        for c in cands:
+            if c.exists(): return c
+        raise SystemExit(f"找不到 {name} (给 --baseline/--rules 或放到 configs/terms/)")
+    bp, rp = pick(a.baseline, "terms_baseline.yaml"), pick(a.rules, "jargon_rules.yaml")
+    # 显示层映射: 会在渲染时被换掉的词, **用户看不到** → 只记为 fixed_by_display, 不算违规 (strict 不拦)。
+    try:
+        dm = yaml.safe_load(pick(None, "display_map.yaml").read_text(encoding="utf-8"))
+        DISP = sorted([(str(i["from"]), str(i["to"])) for i in (dm.get("items") or [])], key=lambda kv: -len(kv[0]))
+    except SystemExit: DISP = []
+
+    def humanized(x):
+        for u, v in DISP:
+            if u in x: x = x.replace(u, v)
+        return x
+    base = yaml.safe_load(bp.read_text(encoding="utf-8")); known = {x["term"] for x in base["items"]}
+    known_sub = set()
+    for t in known:                       # 允许基准词作为子串命中 (如「主轴承温度」含「主轴承」「温度」)
+        known_sub.add(t)
+    _rdoc = yaml.safe_load(rp.read_text(encoding="utf-8")); rules = _rdoc["items"]
+    # 豁免面: 模型提示词/内部注释/日志 —— 规则只约束**用户看得见的文本**
+    EXEMPT_PATH = [e for e in (_rdoc.get("exempt") or []) if e.get("path")]
+    EXEMPT_LINE = [(re.compile(e["line_re"]), e["why"]) for e in (_rdoc.get("exempt") or []) if e.get("line_re")]
+    for r in rules: r["_re"] = re.compile(r["pattern"])
+    targets = [root / t for t in (a.targets or DEFAULT_TARGETS)]
+    hits, unknown = [], collections.Counter(); unknown_where = collections.defaultdict(list); n_strings = 0
+    for p in targets:
+        if not p.exists(): continue
+        rel = p.relative_to(root).as_posix()
+        ex_path = next((e["why"] for e in EXEMPT_PATH if e["path"] in rel), None)
+        raw_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
+        for ln, s in strings_of(p):
+            n_strings += 1
+            for r in rules:
+                m = r["_re"].search(s)
+                if not m: continue
+                cur = raw_lines[ln - 1] if ln <= len(raw_lines) else ""
+                ctx = "\n".join(raw_lines[max(0, ln - 3):ln + 2])   # 日志调用常跨行 (print(... 换行 file=sys.stderr))
+                cpos = min([i for i in (cur.find("//"), cur.find("#")) if i >= 0], default=-1)
+                in_comment = cpos >= 0 and cur.find(m.group(0)) > cpos   # 行尾注释里的词不算界面文本
+                ex = ex_path or ("行内注释" if in_comment else None) or next((w for rx, w in EXEMPT_LINE if rx.search(ctx)), None)
+                hits.append(dict(file=rel, line=ln, text=s[:80], matched=m.group(0), exempt=ex,
+                                 fixed_by_display=(humanized(s) != s), **{k: r[k] for k in ("class", "why", "suggest", "status", "pattern")}))
+            for w in TERM.findall(s):
+                if w in known_sub: continue
+                if any(k in w for k in known_sub if len(k) >= 3): continue     # 含已知术语的复合词不算无根
+                unknown[w] += 1
+                if len(unknown_where[w]) < 3: unknown_where[w].append(f"{p.name}:{ln}")
+    ruled = [h for h in hits if h["status"] == "已裁改" and not h["fixed_by_display"] and not h["exempt"]]   # 显示层能换掉的不算违规
+    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), root=str(root), baseline=str(bp), rules=str(rp), baseline_terms=len(known), n_strings=n_strings,
+               n_rule_hits=len(hits), n_fixed_by_display=sum(1 for h in hits if h["fixed_by_display"]), n_exempt=sum(1 for h in hits if h["exempt"]), n_ruled_violations=len(ruled),
+               by_class=dict(collections.Counter(h["class"] for h in hits)),
+               rule_hits=hits, unrooted_terms=[dict(term=w, n=n, where=unknown_where[w]) for w, n in unknown.most_common(a.top)])
+    o = root / a.out; o.parent.mkdir(parents=True, exist_ok=True); o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"扫 {len(targets)} 文件 / {n_strings} 条中文串; 规则命中 {len(hits)} ({rep['by_class']}); 其中显示层已覆盖 {rep['n_fixed_by_display']}, 内部面豁免 {rep['n_exempt']}, 仍待处理 {sum(1 for h in hits if not h['fixed_by_display'] and not h['exempt'])}; 无根词 top{a.top} (共 {len(unknown)})")
+    for h in [x for x in hits if not x["fixed_by_display"] and not x["exempt"]][:12]: print(f"  [{h['class']}·{h['status']}] {h['file']}:{h['line']} 「{h['matched']}」 → 建议: {h['suggest'][:40]}")
+    print("  无根词:", [f"{w}×{n}" for w, n in unknown.most_common(15)])
+    if a.md:
+        L = [f"# 中文表达审核 · {rep['ts']}\n", f"基准 {len(known)} 词 (四家 OEM 中文文档实证) · 扫 {n_strings} 条界面中文串\n",
+             "## 一 规则命中 (逐条裁: 改 / 豁免)\n", "| # | 类 | 文件:行 | 命中 | 现文 | 为什么难懂 | 建议写法 | 状态 |", "|---|---|---|---|---|---|---|---|"]
+        for i, h in enumerate(hits, 1): L.append(f"| {i} | {h['class']} | {h['file']}:{h['line']} | {h['matched']} | {h['text'][:28]} | {h['why'][:34]} | {h['suggest'][:34]} | {h['status']} |")
+        L += ["\n## 二 无根词 (OEM 中文文档零出现; 可能自造, 逐条裁)\n", "| # | 词 | 次数 | 出处 | 裁决 |", "|---|---|---|---|---|"]
+        for i, u in enumerate(rep["unrooted_terms"], 1): L.append(f"| {i} | {u['term']} | {u['n']} | {' '.join(u['where'])} |  |")
+        m = o.with_suffix(".md"); m.write_text("\n".join(L) + "\n", encoding="utf-8"); print("  屏:", m)
+    if a.strict and ruled:
+        print(f"✗ {len(ruled)} 条已裁定必改、且显示层也换不掉的表达仍在界面里:"); [print("   ", h["file"], h["line"], h["matched"]) for h in ruled[:10]]; return 2
+    return 0
+
+
+if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 145 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/chain_gap_check.py

@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链"缺料"检查器(2026-09-17 用户令 #2 的收口件)。
+
+作用只有一个:**把"还缺什么"变成一条命令**,而不是靠人记着哪四个脚本没随包。
+
+    python scripts/chain_gap_check.py            # 人看
+    python scripts/chain_gap_check.py --check    # 只出结论(缺料 → 退出码 5)
+
+判据(全部是"文件在不在"这种可核事实,不含猜测):
+
+  A. 四步脚本是否在位 —— 壳 `src/windcms/pipeline.py::analyze` 会调它们;
+  B. 两件"标准答案"是否在位 —— `model_run_l6.parquet` / `fusion_38.csv`(逐值对拍的前提);
+  C. 台账里这一族还剩多少"✗ 无生成端"(接料后应显著下降)。
+
+取料单:`docs/向振动线取料单_v0.1.md`;接口规格:`docs/振动六层链_接口规格与缺口_v0.1.md`。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import pathlib
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+# 壳里写死的四步脚本(改壳才会变;这里只是把"缺什么"列清楚)
+STEPS = [
+    ('oem_scan', 'scripts/rudong_tcm_oem_scan.py', '特征频率扫描(轴承/齿轮/保持架/叶片 1P)'),
+    ('energy_share', 'scripts/rudong_line_energy_share.py', '线能量占比'),
+    ('model_run', 'scripts/rudong_model_run.py', '六层模型出件(model_run_l6.parquet + 摘要)'),
+    ('fusion', 'scripts/rudong_fusion_run.py', '融合面出件(fusion_38.csv 等)'),
+]
+# 逐值对拍用的"标准答案"(随包件里有,但两件关键件从未随过包)
+#   ★2026-09-19: 这两件现在**本机能自算**(scripts/rudong_model_run.py / rudong_fusion_run.py, 按口径重建)。
+#     于是"文件在不在"再也不能代表"有没有标准答案" —— 本机算出来的就躺在同一个路径上。
+#     判据改成看**台账里的来路**: builder 文本含"按口径重建" ⇒ 那是自算产物, 记 [自算] 不记 [OK]。
+SAMPLES = [
+    ('model_run_l6.parquet', 'model_run 步的标准答案'),
+    ('fusion_38.csv', 'fusion 步的标准答案'),
+]
+
+
+def _selfmade(farm: str) -> set:
+    """台账里标了"按口径重建"的件 —— 它们是**本机自算**, 不是随包标准答案。"""
+    import json as _json
+    out = set()
+    f = P.out_root(farm) / '_provenance.json'
+    if not f.is_file():
+        return out
+    try:
+        files = (_json.loads(f.read_text(encoding='utf-8')) or {}).get('files') or {}
+    except Exception:
+        return out
+    for rel, v in files.items():
+        if '按口径重建' in str((v or {}).get('builder', '')) + str((v or {}).get('why', '')):
+            out.add(pathlib.Path(rel).name)
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链缺料检查')
+    ap.add_argument('--check', action='store_true', help='只出结论(缺料 → rc=5)')
+    ap.add_argument('--farm', default=None)
+    a = ap.parse_args()
+    farm = a.farm or P.farm()
+    m5 = P.m5(farm)
+
+    missing_scripts = [s for s in STEPS if not (ROOT / s[1]).is_file()]
+    partial = []          # 脚本在位、但自报 complete=false ⇒ 只是"部分实现"(分母就位/分子待口径)
+    have_fusion_impl = (ROOT / 'scripts/rudong_fusion_run.py').is_file()
+    missing_samples = [s for s in SAMPLES if not (m5 / s[0]).is_file()]
+
+    print(f'== 六层链缺料检查 · 场站 {farm} ==')
+    print(f'   壳: src/windcms/pipeline.py::analyze(它按固定路径调这四步)')
+    print(f'   规格: docs/振动六层链_接口规格与缺口_v0.1.md | 取料单: docs/向振动线取料单_v0.1.md')
+    print()
+    print('A. 四步脚本:')
+    for name, rel, what in STEPS:
+        p = ROOT / rel
+        ok = p.is_file()
+        tag, extra = ('OK ', '') if ok else ('缺 ', '')
+        if ok:
+            # ★ 脚本在位 ≠ 这一步能出产物: 逆向实现可能是"分母就位、分子待口径"(自报 --status)。
+            #   这里调一次 --status 把真实状态问出来, 免得自查器给出"看起来齐了"的假结论。
+            import json as _json
+            import subprocess as _sp
+            try:
+                r = _sp.run([sys.executable, str(p), '--status'], capture_output=True, text=True,
+                            errors='replace', timeout=120)
+                st = _json.loads((r.stdout or '').strip().splitlines()[-1]) if (r.stdout or '').strip() else {}
+                if st and st.get('complete') is False:
+                    tag = '部分'
+                    extra = '(' + st.get('denominator', '') + ';缺: ' + ';'.join(st.get('missing', []) or []) + ')'
+            except Exception:
+                pass
+        if ok and tag == '部分':
+            partial.append(name)
+        if not ok and name == 'fusion' and have_fusion_impl:
+            extra = '(本包已逆向实现其中一件产物: fleet_scalar_z;源码仍缺)'
+        print(f'   [{tag}] {name:12s} {rel:44s} {what}{extra}')
+    print()
+    print('B. 逐值对拍的标准答案:')
+    selfmade = _selfmade(farm)
+    for nm, why in SAMPLES:
+        ok = (m5 / nm).is_file()
+        if ok and nm in selfmade:
+            print(f'   [自算] {nm:28s} 本机**按口径重建**的产物在位(不是标准答案: 无样件可对拍)  ({P.rel(m5 / nm)})')
+        else:
+            print(f'   [{"OK " if ok else "缺 "}] {nm:28s} {why}  ({P.rel(m5 / nm)})')
+    # 已完成的那件: 用本器自带 verify 报一句现状(不写盘)
+    fr = ROOT / 'scripts/rudong_fusion_run.py'
+    if fr.is_file():
+        print()
+        print('C. 已自研部分(fleet_scalar_z): 跑 python scripts/rudong_fusion_run.py --verify 看逐值对拍')
+    print()
+    rc = 5 if (missing_scripts or partial) else 0
+    selfmade_txt = (';两件关键产物已**按口径重建**(无标准答案可对拍: model_run_l6 / fusion_38)'
+                    if selfmade else '')
+    tail = ('四步脚本齐全且都已落地 ⇒ 六层链可跑' if not (missing_scripts or partial)
+            else (f'{len(missing_scripts)} 个步骤脚本未随包' if missing_scripts else '')
+            + (';' if (missing_scripts and partial) else '')
+            + (f'{len(partial)} 个为"部分实现"(分母就位 / 分子待口径)' if partial else '')
+            + ' ⇒ 六层链跑不动(取料单见上)') + selfmade_txt
+    print(f'   结论: {tail}')
+    if a.check:
+        print(f'[{"OK" if rc == 0 else "X"}] chain_gap_check rc={rc}')
+    return rc
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 201 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/check_portability.py

@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""跨平台/相对路径 门禁 (2026-09-11 用户令) —— 换电脑、换盘、Windows↔Linux 之前先跑它。
+
+## 三条硬规则 (ERROR = 必须修)
+
+1. **不许出现机器相关绝对路径**: `/Users/…`、`/Volumes/…`、`/home/<某人>/…`、`C:\…`/`D:\…`/`F:\…`。
+   部署到另一台电脑/另一个盘时它们必然失配 —— 这类 bug 是**静默**的 (某块页面取不到数, 不报错)。
+2. **不许出现 cwd 相对路径字面量**: `Path('outputs/…')`、`open('data/…')` 这类。
+   相对路径必须相对**安装根**, 由 `src/paths.py` 解析 (`P.store()` / `P.rel()`); 相对 cwd 会随调用位置漂移。
+3. **不许用 `os.sep` 拼库存字符串**: 写进产物/清单/页面的要用 POSIX 形式 (`P.rel()`);
+   `os.sep` 只允许出现在"给人看"的显示函数里。
+
+## 其余是 WARN (不阻塞, 但列出来慢慢收敛)
+
+4. 运行期仍写死 `ROOT / 'outputs/<场名>/…'` → 建议改 `src/paths.py` 的对应函数;
+5. 交付侧离线工具 (`scripts/guanlan_cloud_*` 等) 里的同类写法;
+6. **文本读写没写 `encoding=`** → 这是同一类"换机就坏"的坑: Python 文本 I/O 默认用**系统 locale 编码**,
+   中文 Windows 上是 cp936(GBK), 而本库的 JSON/HTML/索引全是 UTF-8。后果有两种, 都很隐蔽:
+     · 读: UTF-8 文件按 GBK 解 → 中文乱码, 或直接 UnicodeDecodeError;
+     · 写: 内容里出现 GBK 编不出的字符 (零宽空格 `\u200b`、emoji、罕见汉字) → UnicodeEncodeError 崩溃。
+   2026-09-11 从零重算时实逮: `src/ontology/store.py` 写 objects.json 崩在 `\u200b` 上 —— 也就是说
+   **本体层在中文 Windows 上根本不可用**(开发机是 UTF-8 locale, 所以一直没暴露)。
+   本项扫三种形态: `.read_text(...)` / `.write_text(...)`、`open(..., 'w')`(文本模式)、`open(p)`(无 mode = 文本读);
+   二进制模式 (`'rb'`/`'wb'`) 不算。2026-09-11 已把当时存量 18 处全部清零, 新写的代码请一律 `encoding='utf-8'`。
+
+## 例外必须**具名**登记
+
+`ALLOW` 里每一项都要写清"为什么可以例外"; 行内特例用注释 `# portability-allow: 原因`。
+
+用法:
+    python scripts/check_portability.py            # 全量扫描 (默认扫运行期 + 交付侧)
+    python scripts/check_portability.py --runtime  # 只扫运行期 (交付门禁用这个)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import collections
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+
+# 运行期代码 (服务、门禁、启动器、安装器、配置)
+RUNTIME = ['guanlan.py', 'install.ps1', 'install.sh']
+RUNTIME_GLOBS = ['src/**/*.py', 'scripts/*.py']
+# 交付侧 (离线工具; 不进服务路径, 但仍应相对、无机器路径)
+DELIVERY_GLOBS = ['scripts/sim_hub/*.py']
+
+# 机器相关绝对路径 (盘符形式要求前面是空白/引号/括号 —— 否则 'explanation:\n' 这种转义会被误判成 "n:\")
+RE_ABS = re.compile(r"""(/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*|(?<=[\s'"(\[=])[A-Za-z]:[\\/](?![\\/]))""")
+# cwd 相对路径字面量 (相对当前工作目录 —— 必须改成经 paths 解析)
+RE_CWD = re.compile(r"""(?:open|Path|pathlib\.Path)\(\s*['"](outputs|data|release|configs|src|scripts|reference|resources|logs|run|wheels|vendor)/""")
+# os.sep 出现在"拼字符串"里 (显示函数除外)
+RE_OSSEP = re.compile(r"""\+\s*os\.sep""")
+# 文本读写调用 (用于 WARN 项 6: 缺 encoding= 时按系统 locale 编解码, 中文 Windows 上是 GBK)
+# 三种形态都要逮: read_text/write_text · open(文本模式) · open(没给 mode = 文本读)
+RE_TXTIO = re.compile(r"""\.(read_text|write_text)\(|(?<![\w.])open\(""")   # portability-allow: 本门禁的规则源里必须写出这些形态
+# 二进制模式 ('rb'/'wb'/'ab'/'r+b'…) 不需要 encoding
+RE_BINMODE = re.compile(r"""['"][rwa]\+?b['"]|['"][rwa]b\+?['"]""")
+
+
+def scan_encoding(globs, extra_files=()) -> list:
+    """WARN: 找出没给 encoding= 的文本 I/O 调用。   # portability-allow: 说明文字即规则本身
+
+    覆盖 `.read_text(...)` / `.write_text(...)` / `open(..., 'w')` / `open(p)`(无 mode = 文本读);
+    二进制模式跳过。跨行调用 (如 retrieval.py 里分三行写的 write_text) 要把括号配对后再找 encoding,
+    所以不能只看一行。返回 [(相对路径, 行号, 片段)]。
+    """
+    out = []
+    files = []
+    for g in globs:
+        files += sorted(ROOT.glob(g))
+    files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
+    for f in files:
+        rel = f.relative_to(ROOT).as_posix()
+        # 与 ERROR 扫描同一份例外表: 具名登记过的文件整份跳过 (如本门禁自己 —— 它的规则/文档里
+        # 必须写出 `open('data/…')`、`.read_text(...)` 这些形态, 否则规则没法表达)
+        if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
+            continue
+        try:
+            text = f.read_text(encoding='utf-8')
+        except Exception:
+            continue
+        for m in RE_TXTIO.finditer(text):
+            i = m.end()
+            depth, j = 1, i
+            while j < len(text) and depth:
+                if text[j] == '(':
+                    depth += 1
+                elif text[j] == ')':
+                    depth -= 1
+                j += 1
+            call = text[m.start():j]
+            if 'encoding' in call or RE_BINMODE.search(call):
+                continue
+            if 'portability-allow' in text[text.rfind('\n', 0, m.start()) + 1:text.find('\n', m.start())]:
+                continue
+            out.append((rel, text.count('\n', 0, m.start()) + 1, ' '.join(call.split())[:70]))
+    return out
+
+ALLOW = {
+    'app_ontology/common/app_ontology_guanlan/sop/farm_paths.py': '它本身就是"win 盘符 → POSIX 盘"的映射表 (自带用例), 路径即测试数据',
+    'app_ontology/common/app_ontology_guanlan/sop/validate_pair.py': '其它场站 (华电/肥城) 的配置样例值, 非本场运行期路径',
+    'scripts/build_oem_lexicon.py': 'OEM 资料盘路径样例 (离线词库构建), 非运行期',
+    'scripts/ingest_ops_2025.py': '注释/文档里保留历史 mac 路径; 代码已改为 env + 安装目录相对默认',
+    'src/windscada/data_tpl_en.py': '英文映射表的**键**是页面显示形 (<安装目录>\\data\\raw\\…), 不是路径用法',
+    'src/windscada/deid_public.py': '文档字符串举例说明"产物里带着源文件路径"这一问题本身',
+    'src/windscada/maint/framework.py': '注释说明判据来源 (厂商培训分册), 非路径用法',
+    'app_ontology/common/app_ontology_guanlan/sop/discriminators.py': '证据文本里引用复跑脚本位置 (留痕), 非路径用法',
+    'app_ontology/common/app_ontology_guanlan/sop/vib_confidence.py': '证据文本 (与 discriminators 同源)',
+    'app_ontology/common/app_ontology_guanlan/ontology/retrieval.py': '文档字符串说明索引落盘位置',
+    'app_ontology/common/app_ontology_guanlan/ontology/seed_rudong29.py': '文档字符串说明产物位置',
+    'app_frontEnd/common/app_frontEnd_guanlan/pages/snapshot.py': '对外快照的**归一化改写规则** (outputs/rudong → outputs/<site>), 相对模式',
+    'src/windscada/fast_agent.py': '注释里的实测记录出处',
+    'src/paths.py': '本模块的文档字符串在讲这条约定本身',
+    'scripts/check_portability.py': '本门禁自己 (规则里必须写出这些模式)',
+    'scripts/check_transferable.py': '移植性大扫描自己 (规则里必须写出这些模式, 否则没法检出它们)',
+}
+DELIVERY_ALLOW = {
+    'scripts/sim_hub/': '开发期仿真合页生成器 (mac 检出路径), 不进交付/运行路径',
+    'outputs/rudong/sop/': '历史装配脚本随包留档 (含 mac 死路径), 非运行期; 已在文档标注',
+}
+
+
+def scan(globs, extra_files=()):
+    hits = []
+    files = []
+    for g in globs:
+        files += sorted(ROOT.glob(g))
+    files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
+    for f in files:
+        rel = f.relative_to(ROOT).as_posix()
+        if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
+            continue
+        try:
+            lines = f.read_text(encoding='utf-8').splitlines()
+        except Exception:
+            continue
+        for i, line in enumerate(lines, 1):
+            if 'portability-allow' in line or line.lstrip().startswith('#'):
+                continue                      # 行内已具名登记 / 纯注释行 (说明性文字不算路径用法)
+            for kind, rx in (('机器绝对路径', RE_ABS), ('cwd 相对路径', RE_CWD), ('os.sep 拼串', RE_OSSEP)):
+                m = rx.search(line)
+                if m:
+                    hits.append((kind, rel, i, m.group(0)[:60], line.strip()[:90]))
+    return hits
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--runtime', action='store_true', help='只扫运行期 (交付门禁)')
+    a = ap.parse_args()
+
+    print(f'根目录: {ROOT}')
+    print(f'扫描: {"运行期" if a.runtime else "运行期 + 交付侧"}\n')
+    errs = scan(RUNTIME_GLOBS, RUNTIME)
+    if not a.runtime:
+        errs += scan(DELIVERY_GLOBS)
+
+    if not errs:
+        print('  ✔ 无机器相关绝对路径、无 cwd 相对路径、未用 os.sep 拼库存字符串')
+    else:
+        for kind, rel, i, found, line in errs:
+            print(f'  [ERROR] {kind:12s} {rel}:{i}  ← {found}')
+            print(f'          {line}')
+    print(f'\n例外登记 (ALLOW, 共 {len(ALLOW)} 项 + {len(DELIVERY_ALLOW)} 类): 见本文件注释, 每项都写了理由')
+
+    # ── WARN: 文本读写缺 encoding= (项 6; 不阻塞, 但中文 Windows 上会乱码/崩溃) ──
+    warns = scan_encoding(RUNTIME_GLOBS, RUNTIME)
+    if not a.runtime:
+        warns += scan_encoding(DELIVERY_GLOBS)
+    if warns:
+        by_file = collections.Counter(w[0] for w in warns)
+        print(f'\n[WARN] 文本读写缺 encoding= : {len(warns)} 处 / {len(by_file)} 个文件 '
+              f'—— 默认按系统 locale 编解码 (中文 Windows = GBK), 换机/换内容就可能乱码或崩溃;')
+        for rel, n in by_file.most_common(12):
+            print(f'         {n:3d} 处  {rel}')
+        print('       修法: read_text(encoding="utf-8") / write_text(..., encoding="utf-8")')
+
+    if errs:
+        print(f'\n结论: {len(errs)} 处 ERROR —— 换机部署前必须清零')
+        return 1
+    print('\n结论: 通过 ✔ (路径可移植: 只有相对路径 + 经 src/paths.py 解析)')
+    return 0
+
+
+if __name__ == '__main__':
+    # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"通过 ✔"这行 print 会抛
+    # UnicodeEncodeError、退出码变 1 —— 门禁报成失败而实际是通过的 (2026-09-11 踩过)。
+    sys.path.insert(0, str(ROOT))
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 449 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/check_transferable.py

@@ -0,0 +1,449 @@
+# -*- coding: utf-8 -*-
+r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
+
+比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
+
+  ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
+     —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
+  ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
+     机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
+  ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
+  ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
+  ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
+
+用法:
+    python scripts/check_transferable.py            # 全量检查 + 结论
+    python scripts/check_transferable.py --brief    # 只报问题
+    python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
+                                                    # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
+                                                    # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                            # 配置/路径唯一取用口 (2026-09-17 用户令 2)
+
+# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
+RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
+# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
+TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
+SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
+SKIP_PATH_HINT = ('_products_off_prev',)
+# 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
+# 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
+# EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
+# 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
+SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
+# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
+ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
+              'D:/guanlan', '示例', '例:', '例如')
+
+
+def walk_files():
+    for p in ROOT.rglob('*'):
+        if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
+            continue
+        rel = p.relative_to(ROOT).as_posix()
+        parts = set(p.relative_to(ROOT).parts)
+        if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
+            continue
+        if rel.startswith(SKIP_RAW_PREFIX):
+            continue
+        if rel.startswith(SKIP_RAW_PREFIX):
+            continue
+        if p.stat().st_size > 8 * 1024 * 1024:      # 超大文本(如 20MB 门户)另算, 见下
+            continue
+        yield p
+
+
+def scan_paths(verbose=True):
+    hits = []
+    for p in walk_files():
+        try:
+            text = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        for i, line in enumerate(text.splitlines(), 1):
+            if any(a in line for a in ALLOW_LINE):
+                continue
+            if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
+                continue
+            m = RE_ABS.search(line)
+            if m:
+                hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
+    print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
+    if hits and verbose:
+        for rel, i, found, line in hits[:25]:
+            print(f'     {rel}:{i}  ← {found}\n        {line}')
+        if len(hits) > 25:
+            print(f'     … 另有 {len(hits) - 25} 处')
+    return hits
+
+
+def scan_big_text():
+    """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
+    out = []
+    for p in ROOT.rglob('*.html'):
+        rel = p.relative_to(ROOT).as_posix()
+        if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
+            continue
+        try:
+            t = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        n = len(RE_ABS.findall(t))
+        if n:
+            out.append((rel, n, p.stat().st_size))
+    if out:
+        print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
+        for rel, n, sz in out[:10]:
+            print(f'     {rel}  {n} 处 ({sz/1e6:.1f} MB)')
+    else:
+        print('② HTML 里没有烘进的绝对路径')
+    return out
+
+
+def check_venv():
+    cfg = ROOT / '.venv' / 'pyvenv.cfg'
+    print('③ 虚拟环境')
+    if not cfg.is_file():
+        print('     [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
+        return 'absent'
+    kv = {}
+    for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
+        if '=' in l:
+            k, v = l.split('=', 1)
+            kv[k.strip()] = v.strip()          # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
+    home = (kv.get('home') or '').strip()
+    print(f'     home(基础解释器) = {home}')
+    print(f'     版本 {kv.get("version", "?").strip()}')
+    here = pathlib.Path(home).is_dir() if home else False
+    print(f'     本机存在该目录: {here}')
+    print('     ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
+          '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
+    return kv
+
+
+def check_wheels():
+    w = ROOT / 'wheels'
+    print('④ 离线依赖轮子')
+    if not w.is_dir():
+        print('     [X] 没有 wheels/ 目录 —— 装依赖需要联网')
+        return []
+    dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
+    print(f'     平台目录: {dirs or "(无)"}')
+    req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
+    req = [r for r in req if r]
+    names = {r.split('==')[0].lower().replace('-', '_') for r in req}
+    for d in dirs:
+        have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
+        miss = sorted(n for n in names if n not in have)
+        tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
+        print(f'     {d}: {len(list((w / d).glob("*.whl")))} 个轮子  {tag}')
+    plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
+    if plat not in dirs:
+        print(f'     [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
+    return dirs
+
+
+def check_vendor():
+    v = ROOT / 'vendor'
+    print('⑤ 便携运行时 / 离线件')
+    if not v.is_dir():
+        print('     [--] 没有 vendor/')
+        return
+    for p in sorted(v.rglob('*')):
+        if p.is_file():
+            print(f'     {p.relative_to(ROOT).as_posix()}  {p.stat().st_size/1e6:.1f} MB')
+    man = v / 'MANIFEST.json'
+    if man.is_file():
+        m = json.loads(man.read_text(encoding='utf-8'))
+        files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
+        undoc = sorted(files - set(m))
+        if undoc:
+            print(f'     [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
+
+
+def check_configs():
+    print('⑥ 配置与端口')
+    p = P.SERVE_JSON                      # 配置唯一取用口
+    if p.is_file():
+        c = json.loads(p.read_text(encoding='utf-8-sig'))
+        print(f'     serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
+              f'raw_dir={c.get("raw_dir")!r}')
+        for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
+            v = str(c.get(k) or '')
+            if re.match(r'^[A-Za-z]:[\\/]|^/', v):
+                print(f'     [!] {k} 是绝对路径: {v}')
+    # 写死的本机端口/入口(允许出现, 但要知道)
+    n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
+    print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
+
+
+def _free_port(start=28700, tries=40):
+    for p in range(start, start + tries):
+        with socket.socket() as s:
+            try:
+                s.bind(('127.0.0.1', p)); return p
+            except OSError:
+                continue
+    return None
+
+
+def _busy(port, host='127.0.0.1'):
+    with socket.socket() as s:
+        s.settimeout(0.3)
+        return s.connect_ex((host, port)) == 0
+
+
+IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
+                                '_products_off', '_products_off_prev_*', '*.pyc')
+
+
+def install_in(dst: pathlib.Path) -> pathlib.Path | None:
+    """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
+    print(f'   在 {dst} 里跑安装 (离线) …')
+    # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
+    # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
+    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
+           if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
+    t0 = time.time()
+    r = subprocess.run(cmd, cwd=str(dst))
+    print(f'   install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
+    py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
+    if not py.is_file():
+        print(f'   [X] 副本里没有 {py} —— 安装没成')
+        return None
+    v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
+                       capture_output=True, text=True, errors='replace')
+    print(f'   副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
+    return py
+
+
+LAST_GATEWAY_PORT: int | None = None      # run_and_probe 实际用的网关端口(卸载核验要拿它查"释放了没")
+
+
+def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
+    """在副本里起服务并核验页面 —— **完全不动正在跑的实例**: 副本整套换端口跑。
+
+    ★ 2026-09-17 改法 (现场事故驱动): 以前网关 ROUTES 的上游端口是写死的, 所以副本**必须**占用
+    18033/18020/18791/18792/64292 —— 于是"开箱验证"就得先把用户正在跑的服务停掉; 实测后果:
+    用户在验证窗口里点门户「登录」, `/detail/` 报"模块未启动 (127.0.0.1:18033)"。
+    现在 `guanlan_gateway.ROUTES` 的上游端口可被 `configs/serve.json` 覆盖, 于是副本可以**整套**
+    换到空闲端口 (网关 + 5 个组件), 主实例照常跑, 谁也不打扰谁。
+    端口占用仍会兜底: 找不到空闲端口就跳过启动核验 (不计失败)。
+    """
+    global LAST_GATEWAY_PORT
+    comp_keys = ('detail', 'cms', 'sim', 'sim_sys', 'viewer')
+    cfg_p = dst / 'configs' / P.SERVE_JSON.name          # config-path-allow: 改的是**副本**里的那份配置
+    try:
+        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
+    except Exception as e:
+        print(f'   [!] 副本配置读不出来 ({type(e).__name__}) —— 跳过启动核验'); return 0
+
+    # ① 主实例在不在: 决定要不要给副本换端口, 以及能否做"门户同字节"比对
+    main_up = not _busy(base_port)
+    gw = base_port if not _busy(base_port) else _free_port(28700)
+    if gw is None:
+        print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
+    LAST_GATEWAY_PORT = gw
+    changed = {}
+    if gw != base_port:
+        changed['gateway'] = gw
+    for k in comp_keys:
+        want = int(cfg.get(k) or 0)
+        if want and _busy(want):
+            free = _free_port(28800 + 100 * (len(changed) % 5))
+            if free is None:
+                print(f'   [!] 找不到空闲端口给组件 {k} —— 跳过启动核验'); return 0
+            changed[k] = free
+    if changed:
+        cfg.update(changed)
+        cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
+        print('   副本改用独立端口 (不打扰正在跑的实例): ' +
+              ', '.join(f'{k}={v}' for k, v in changed.items()))
+    else:
+        print('   主实例未在跑 → 副本直接用默认端口')
+
+    t0 = time.time()
+    rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
+    print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
+    ok = 0
+    try:
+        # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
+        #   门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
+        #   "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
+        exp_portal = None
+        try:
+            with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
+                exp_portal = len(rr.read())
+                print(f'   参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
+        except Exception as e:
+            print(f'   参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
+        urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
+        for path, exp_size in urls:
+            try:
+                with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
+                    body = rr.read()
+                    tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
+                    if exp_size is not None and len(body) != exp_size:
+                        # 副本换了网关端口时, 门户里的绝对地址(127.0.0.1:<端口>)会被网关改写成副本自己的
+                        # 端口 ⇒ 字节数**必然**不同。这是预期, 不是移植问题 (2026-09-17 加这句, 免得看错)。
+                        tag += f' ← 副本网关端口 {gw} ≠ 主实例 {base_port}, 门户内的绝对地址被改写, 字节不同属预期'
+                    print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
+                    ok += 1
+            except Exception as e:
+                print(f'   {path:10s} 失败: {type(e).__name__} {e}')
+        print(f'   页面可用 {ok}/{len(urls)}')
+    finally:
+        # 只停**副本**自己起的那些 (用副本里的 pids.json), 绝不碰主实例
+        subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
+    return ok
+
+
+def _files_of(p: pathlib.Path) -> int:
+    """目录/文件的件数(卸载核验里"数据原样在位"的比对面)。"""
+    if not p.exists():
+        return -1
+    return 1 if p.is_file() else sum(1 for _ in p.rglob('*') if _.is_file())
+
+
+def uninstall_probe(dst: pathlib.Path, py: pathlib.Path, gw: int | None = None) -> int:
+    """卸载核验(用户令 2026-09-17:增加卸载脚本)——在**副本**上真跑一遍卸载器。
+
+    为什么放在开箱验证里:卸载脚本这类东西"在开发机上跑一次看着没事"不算数,它要证明的是
+    **在刚装好的那份副本上**:① 计划齐全且 --dry-run 一个字节都不动;② 真卸载会把服务/运行态/
+    安装记录/快捷方式拆掉;③ **数据与产物一件不少**(默认口径不碰用户数据)。
+
+    → 返回失败条数(0 = 全过)
+    """
+    bad = 0
+    say = lambda s: print('   ' + s)                                        # noqa: E731
+    # ★ 子进程输出的编码必须点名 utf-8:卸载器写中文, Windows 上默认按 cp936 解会得到
+    #   "�ƻ���ȫ" 这种乱码(2026-09-17 实测: 核验打印成了天书, 证据就没法看了)。
+    sub_env = dict(os.environ, PYTHONUTF8='1')
+    r = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--selftest'], cwd=str(dst),
+                       capture_output=True, text=True, encoding='utf-8', errors='replace',
+                       env=sub_env, timeout=600)
+    for ln in (r.stdout or '').strip().splitlines():
+        if '[FAIL]' in ln or '结论' in ln or '[OK]' in ln:
+            say(ln.strip())
+    if r.returncode != 0:
+        bad += 1
+        say('[X] 卸载器 --selftest 未通过')
+    keep = [d for d in ('data', 'outputs') if (dst / d).exists()]
+    before = {d: _files_of(dst / d) for d in keep}
+    r2 = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--yes'], cwd=str(dst),
+                        capture_output=True, text=True, encoding='utf-8', errors='replace',
+                        env=sub_env, timeout=900)
+    tail = (r2.stdout or '').strip().splitlines()
+    for ln in tail[-8:]:
+        say(ln.rstrip())
+    if r2.returncode != 0:
+        bad += 1
+        say(f'[X] 卸载器退出码 {r2.returncode}')
+    checks = [('安装记录 install-info.json 已删', not (dst / 'install-info.json').exists()),
+              ('运行态 run/ 已清', not (dst / 'run').exists()),
+              ('快捷方式已撤', not list(dst.glob('*.lnk')))]
+    for d in keep:
+        checks.append((f'数据 {d}/ 原样在位({before[d]} 件)', _files_of(dst / d) == before[d]))
+    if gw:
+        checks.append((f'卸载后端口 {gw} 已释放', not _busy(gw)))
+    for name, good in checks:
+        say(('[OK] ' if good else '[X] ') + name)
+        bad += 0 if good else 1
+    return bad
+
+
+def simulate(into=None, keep=False) -> int:
+    """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
+    dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
+    if dst.exists():
+        print(f'  清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
+    print(f'① 拷副本 → {dst}   (排除 data/.git/.venv/暂存区/logs/run)')
+    t0 = time.time()
+    shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
+    n = sum(1 for _ in dst.rglob('*') if _.is_file())
+    mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
+    print(f'   完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
+
+    # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
+    prod_dst = dst / 'outputs' / 'rudong'
+    if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
+        for src in sorted(ROOT.glob('_products_off*/rudong')):
+            if (src / 'windscada' / 'alarms.parquet').is_file():
+                print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
+                shutil.copytree(src, prod_dst, dirs_exist_ok=True)
+                break
+        else:
+            print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
+    else:
+        print('② 副本已带产物 (从主包拷来)')
+
+    print('③ 在副本里跑安装 (离线)')
+    py = install_in(dst)
+    if py is None:
+        return 1
+
+    print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
+    ok = run_and_probe(dst, py)
+
+    print('⑤ 收尾')
+    if keep:
+        print(f'   副本保留在 {dst} (--keep); 用完手工删掉即可')
+    else:
+        shutil.rmtree(dst, ignore_errors=True)
+        print('   副本已删除')
+    print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--brief', action='store_true')
+    ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
+    ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
+    ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
+    a = ap.parse_args()
+    print(f'移植性大扫描 @ {ROOT}\n')
+    if a.simulate:
+        rc = simulate(a.into, a.keep)
+        print()
+    hits = scan_paths(verbose=not a.brief)
+    big = scan_big_text()
+    kv = check_venv()
+    dirs = check_wheels()
+    check_vendor()
+    check_configs()
+    bad = len(hits) + len(big)
+    print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
+          f' 虚拟环境/轮子/便携运行时见上面各节 —— '
+          f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
+    return 0 if not bad else 1
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 268 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/config_audit.py

@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""配置审计 —— 把"配置目录/文件统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
+
+## 查什么 (每条都能指到具体文件/代码行)
+
+    R1 顶层约定    configs/ 顶层只许放 `src/paths.py::TOP_LEVEL_CONFIGS` 里的运行期单件配置 +
+                   已登记的域目录 + README.md/registry.yaml。散件一律报出来。
+    R2 备份垃圾    任何 `*.bak*` `*~` `*.old` `*.orig` `*.tmp` `*.rej` 都是垃圾 (实测 configs/ 里
+                   躺过一个 `serve.json.bak-bomfix`)。
+    R3 命名        配置名不得含空格/中文; 全大写视为"命名不统一"(提示级, 因为场名/机型名有历史约定)。
+    R4 内容绝对路径 配置**内容**里不得出现本机绝对路径 (`C:\` `D:\` `F:\` `/Users/…` `/Volumes/…` `/home/<人名>`)。
+    R5 引用闭合    代码里 `P.config('x')` / `P.config_dir('x')` 指向的文件/目录必须存在; 不存在且**未登记**
+                   在 `configs/registry.yaml::known_missing` 的, 按"悬空引用"报错 (登记过的记 `i`)。
+    R6 手拼路径    代码里不许再拼 `"configs" / …` 字符串 (白名单: src/paths.py 自身、审计脚本、注释/文档串)。
+    R7 无读者      configs/ 下每个文件要么被登记表的 consumers 覆盖, 要么所在域声明了 `no_reader_ok` + 理由。
+    R8 场配置      `configs/farms/` 下每个文件要么是**能加载的场定义**(必需键齐), 要么在登记表里
+                   声明为"机型/物理约束 profile"或"CSV 数据表" —— 否则就是"配了看不见"的东西。
+
+## 退出码 (给 check / rebuild_all 用)
+    0 全部通过 · 5 结构问题(散件/垃圾/未登记域/未登记文件) · 6 悬空引用 · 7 内容含本机绝对路径 · 8 手拼路径 · 9 场配置不合约定
+
+## 用法
+    python scripts/config_audit.py            # 表 + 检查
+    python scripts/config_audit.py --list     # 只打配置目录表
+    python scripts/config_audit.py --json
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import fnmatch
+import json
+import os
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+
+REGISTRY = P.config('registry.yaml')
+CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv', '.example', '.md')
+JUNK = ('*.bak', '*.bak-*', '*~', '*.old', '*.orig', '*.tmp', '*.rej', '*.swp')
+RE_JUNK = re.compile(r'\.(?:bak|old|orig|tmp|rej|swp)(?:$|[-.])|~$')
+RE_BADNAME = re.compile(r'[\s]')                       # 空格会切断脚本参数, 一律不许
+RE_CN_NAME = re.compile(r'[\u4e00-\u9fff]')            # 中文文件名: 项目里大量存在, 只提示不判错
+RE_ABS = re.compile(r'(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*)')
+# 配置调用的参数可能不止一个: P.config('value_assumptions', f'{farm}.yaml') —— 要把字面量参数全抓下来拼成相对路径
+RE_CONFIG_CALL = re.compile(r'P\.config(?:_dir)?\(([^)]*)\)')
+RE_STR_ARG = re.compile(r'[\'"]([^\'"]+)[\'"]')
+RE_HANDMADE = re.compile(r'''(?:ROOT|root|REPO|here|dst)\s*/\s*['"]configs['"]|['"]configs/[\w.-]+['"]''')
+# 说明性字段 (记录"当时数据在哪台机器上") 里的本机路径算**溯源信息**, 不算 bug —— 但只限注释行与这些键
+RE_DESCRIPTIVE_KEY = re.compile(r'^\s*#|^\s*(?:data_location|data_source|source_root|source_note|note|comment|说明|来源)\s*:')
+
+SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
+             'outputs', 'data', 'logs', 'run', 'release', 'resources', 'reference'}
+
+
+def load_registry():
+    import yaml
+    if not REGISTRY.is_file():
+        raise SystemExit(f'缺配置登记表 {P.rel(REGISTRY)}')
+    return yaml.safe_load(REGISTRY.read_text(encoding='utf-8')) or {}
+
+
+def cfg_files():
+    out = []
+    for dp, dn, fns in os.walk(P.CONFIGS):
+        dn[:] = [d for d in dn if d not in {'__pycache__'}]
+        for f in fns:
+            out.append(pathlib.Path(dp) / f)
+    return sorted(out)
+
+
+def py_files():
+    out = []
+    for dp, dn, fns in os.walk(ROOT):
+        dn[:] = [d for d in dn if d not in SKIP_WALK]
+        out += [pathlib.Path(dp) / f for f in fns if f.endswith('.py')]
+    return out
+
+
+def audit():
+    """→ (rc, results, info)。results = [(level, item, note, rc)]。"""
+    reg = load_registry()
+    res: list[tuple[str, str, str, int]] = []
+    top_ok = {t['file'] for t in reg.get('top_level') or []}
+    domain_dirs = {d['dir'] for d in reg.get('domains') or []}
+    known_missing = set()
+    known_why = {}
+    for m in reg.get('known_missing') or []:
+        for name in [m['file']] + list(m.get('also_missing') or []):
+            known_missing.add(name)
+            known_why[name] = m.get('impact', '')
+
+    # ── R1/R2/R3/R4: 文件层
+    for f in cfg_files():
+        rel = f.relative_to(P.CONFIGS).as_posix()
+        if f.name == 'registry.yaml' or f.suffix == '.md':
+            continue
+        if '/' not in rel:
+            if f.name not in top_ok:
+                res.append(('X', rel, 'configs/ 顶层只许放已登记的运行期单件配置 (见登记表 top_level)', 5))
+        else:
+            d = rel.split('/')[0]
+            if d not in domain_dirs:
+                res.append(('X', rel, f'所在域 {d}/ 未在登记表登记 (新域要写明 kind + consumers)', 5))
+        if RE_JUNK.search(f.name) or any(fnmatch.fnmatch(f.name, j) for j in JUNK):
+            res.append(('X', rel, '备份/垃圾文件 (配置文件只留正本; 改历史靠 git)', 5))
+        if RE_BADNAME.search(f.name):
+            res.append(('X', rel, '文件名含空格 —— 空格会切断脚本参数, 配置名一律用 [a-z0-9_.-]', 5))
+        elif RE_CN_NAME.search(f.name):
+            res.append(('?', rel, '文件名含中文 (项目里常见, 不是错; 注意某些工具/编码环境会踩)', 0))
+        elif re.search(r'[A-Z]', f.stem) and '/' in rel:
+            res.append(('?', rel, '文件名含大写 (历史约定, 不是错; 新文件建议小写)', 0))
+        if f.suffix.lower() in ('.yaml', '.yml', '.json', '.csv'):
+            try:
+                body = f.read_text(encoding='utf-8-sig', errors='replace')
+            except OSError:
+                body = ''
+            hit_desc = hit_code = None
+            for ln in body.splitlines():
+                m = RE_ABS.search(ln)
+                if not m:
+                    continue
+                if RE_DESCRIPTIVE_KEY.match(ln):
+                    hit_desc = hit_desc or m.group(0)
+                else:
+                    hit_code = hit_code or m.group(0)
+            if hit_code:
+                res.append(('X', rel, f'内容含本机绝对路径 {hit_code[:24]!r} (在**被读取的字段**里 ⇒ 换机必失配)', 7))
+            elif hit_desc:
+                res.append(('?', rel, f'说明性字段/注释里记了本机路径 {hit_desc[:24]!r} —— 属溯源信息(当时数据在哪台机器), 不算失配', 0))
+
+    # ── R5/R6: 代码层
+    seen_conf = set()
+    for p in py_files():
+        t = p.read_text(encoding='utf-8', errors='replace')
+        if p.name != 'config_audit.py':            # 本脚本的说明文字里就有 P.config('x') 之类的例子, 别自证其罪
+            for args in RE_CONFIG_CALL.findall(t):
+                parts = RE_STR_ARG.findall(args)
+                if not parts:
+                    continue
+                rel_call = '/'.join(parts).replace('\\', '/')
+                # f-string 参数 (P.config(f'analysis_lock_{farm}.yaml')) 在源码里是字面量带花括号:
+                # 把 {…} 当通配符, 只要有任一匹配文件就算"在位", 否则报缺。
+                if '{' in rel_call:
+                    import glob as _glob
+                    pat = str(P.CONFIGS / re.sub(r'\{[^}]*\}', '*', rel_call))
+                    if _glob.glob(pat):
+                        continue
+                    wildcard = rel_call
+                    known = known_missing | {k.replace('<场>', '*') for k in known_missing}
+                    if any(fnmatch.fnmatch(wildcard, k) for k in known):
+                        res.append(('i', f'{P.rel(p)} → configs/{wildcard}',
+                                    '悬空引用(已知): per-场配置未随包 (见登记表 known_missing)', 0))
+                    else:
+                        res.append(('X', f'{P.rel(p)} → configs/{wildcard}',
+                                    '代码引用的 per-场配置一个都不存在, 且未登记 ⇒ 补文件或删引用', 6))
+                    continue
+                seen_conf.add(rel_call)
+                target = P.CONFIGS / rel_call
+                if target.exists():
+                    continue
+                if rel_call in known_missing or any(rel_call.startswith(k.replace('<场>', '')) for k in known_missing):
+                    res.append(('i', f'{P.rel(p)} → configs/{rel_call}',
+                                f'悬空引用(已知): {known_why.get(rel_call, "见登记表 known_missing")[:80]}', 0))
+                else:
+                    res.append(('X', f'{P.rel(p)} → configs/{rel_call}',
+                                '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6))
+        for i, ln in enumerate(t.splitlines(), 1):
+            s = ln.strip()
+            # 豁免"配置取用口自己的定义文件": 原先按旧路径 src/paths.py 判, P1 迁移后它搬到
+            # app_common/common/app_common_guanlan/paths.py —— 改成**按取用口所在文件**判, 位置无关。
+            import sys as _s
+            _accessor = _s.modules.get(P.config.__module__)
+            _accessor_file = pathlib.Path(_accessor.__file__).resolve() if getattr(_accessor, '__file__', None) else None
+            if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py' \
+                    or (_accessor_file is not None and p.resolve() == _accessor_file):
+                continue
+            if 'config-path-allow' in ln or 'portability-allow' in ln:
+                continue
+            if RE_HANDMADE.search(ln):
+                res.append(('!', f'{P.rel(p)}:{i}', f'手拼 configs 路径: {s[:70]} ⇒ 改用 P.config()/P.config_dir()', 8))
+
+    # ── R7: 无读者
+    declared = json.dumps(reg, ensure_ascii=False)
+    for d in reg.get('domains') or []:
+        if (d.get('consumers') or []) or d.get('no_reader_ok'):
+            continue
+        res.append(('X', f'{d["dir"]}/', '未声明 consumers, 也没写 no_reader_ok + 理由', 5))
+    for f in cfg_files():
+        rel = f.relative_to(P.CONFIGS).as_posix()
+        if '/' not in rel or f.suffix == '.md' or f.name in top_ok:
+            continue
+        # 逐件"有没有人提过它"太吵 (170 件里绝大多数靠域级 consumers 覆盖) —— 只查**域级声明**。
+        # 域级没声明 consumers 又没写 no_reader_ok 的, 由上面的域检查报出来。
+        _ = rel
+
+    # ── R8: 场配置
+    from src.windscada import config as WC
+    for f, keys in WC.foreign_farm_files():
+        res.append(('i', f'configs/farms/{f.name}', f'非场定义 (顶层键 {keys}) ⇒ 不参与场加载, 已登记为机型/物理约束 profile', 0))
+    farm_dom = next((d for d in reg.get('domains') or [] if d['dir'] == 'farms'), {})
+    n_prof = sum(1 for f, _ in WC.foreign_farm_files())
+    declared_prof = next((s for s in (farm_dom.get('subkinds') or []) if 'profile' in s.get('name', '')), {})
+    if declared_prof and int(declared_prof.get('present', -1)) not in (-1, n_prof):
+        res.append(('X', 'configs/farms/', f'登记表写 profile {declared_prof.get("present")} 个, 实际 {n_prof} 个', 9))
+    for name, src in WC.available().items():
+        if src != '内置' and not (P.ROOT / src).is_file():
+            res.append(('X', f'configs/farms/{name}', f'available() 列出了 {src}, 但文件不在', 9))
+
+    info = dict(config_files=len(cfg_files()),
+                domains=len(domain_dirs),
+                top_level=sorted(top_ok),
+                known_missing=sorted(known_missing),
+                farm_defs=list(WC.available()),
+                farm_profiles=n_prof)
+    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
+    return (max(rc_map) if rc_map else 0), res, info
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--list', action='store_true', help='只打配置目录表')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+    rc, res, info = audit()
+    if a.json:
+        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
+                              info=info), ensure_ascii=False, indent=1))
+        return rc
+    if a.list or True:
+        print('== 配置目录约定 (configs/registry.yaml) ==')
+        print(f'   运行期单件: {", ".join(info["top_level"])}')
+        for d in load_registry().get('domains') or []:
+            print(f'   {d["dir"]:20s} {d.get("kind", "")[:52]}')
+        print(f'   配置文件合计 {info["config_files"]} 件 · 域 {info["domains"]} 个 · '
+              f'场定义 {len(info["farm_defs"])} 个 · 机型 profile {info["farm_profiles"]} 件')
+    if not a.list:
+        lvl = {}
+        for level, item, note, r in res:
+            lvl[level] = lvl.get(level, 0) + 1
+        print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · '
+              f'已知缺口 {lvl.get("i", 0)} ==')
+        for level, item, note, r in res:
+            if level in ('X', '!'):
+                print(f'   [{level}] {item}: {note}')
+        seen = set()
+        for level, item, note, r in res:
+            if level == 'i' and note not in seen:
+                seen.add(note)
+                print(f'   [i] {item}: {note}')
+        print(f'结论: {"全部符合约定" if rc == 0 else "见上"}; 退出码 {rc}')
+    return rc
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 399 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/detail_deps.py

@@ -0,0 +1,399 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""`/detail` 详细分析工作台 · 页面 → 取数接口 → 产物 → 来源 的**可复算**台账 (2026-09-18 用户令)。
+
+用户令: 「http://127.0.0.1:28084/detail 下的各页面, 不能用旧版的产出补, 只能参照旧版产物的呈现
+样式内容, 必须基于输入数据重算」。本器把这条令变成一份**每次都能重跑出来的清单**:
+
+    页面(页签)  ←  取数接口(在线 /api/*)  ←  产物件  →  来源(台账) + 生成端(族表) + 缺口处置
+
+证据不是印象, 两头都对着源码核:
+  · 页面 → 接口: 在 `app_frontEnd/.../assets/app.js` 里逐个搜到该接口 (页签表 TABS 与各 fetch 调用);
+  · 接口 → 产物: 声明时给出 `scripts/windscada_serve.py` 里的函数名, 本器逐一核对该符号存在;
+  · 产物 → 来源: 读 `outputs/<场>/_provenance.json`(逐件来源台账, 由 products_restore_missing
+    --refresh 维护) 与 `scripts/products_reverse_audit.py` 的族表(生成端)。
+任一条核不上 → 本器报 `[核不上]` 并 rc=5 —— 页面改了接口而清单没跟, 或产物换了生成端, 当场露出来。
+
+`/detail` 是**网关**(scripts/guanlan_gateway.py, 默认 28084)把请求转到**组件服务**
+(scripts/windscada_serve.py, 默认 18033)的 `/v2` 前端; 两边都是同一套 `/api/*`, 所以本清单对
+在线页与单文件快照 (app_frontEnd/.../pages/snapshot.py) 同时成立。
+
+用法:
+    python scripts/detail_deps.py                # 打印清单 (人看)
+    python scripts/detail_deps.py --probe        # 额外实测在线接口 (需服务在跑; 只打印, 不写盘)
+    python scripts/detail_deps.py --write        # 写 docs/detail_页面依赖与重算台账_v0.1.md
+    python scripts/detail_deps.py --check        # 文档是否与现场一致 (rc=5 = 该重写)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import fnmatch
+import json
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+APPJS = ROOT / 'app_frontEnd' / 'common' / 'app_frontEnd_guanlan' / 'assets' / 'app.js'
+# ★P4(2026-09-22): 分析组件实现已迁入 app_backEnd 模块目录(scripts/ 下只剩兼容壳)——
+#   校验路由符号要读**真实现**,故新位置优先、旧路径兜底。
+SERVE = ROOT / 'app_backEnd' / 'common' / 'app_backEnd_guanlan' / 'serve.py'
+if not SERVE.is_file():
+    SERVE = ROOT / 'scripts' / 'windscada_serve.py'
+DOC = ROOT / 'docs' / 'detail_页面依赖与重算台账_v0.1.md'
+BEGIN, END = '<!-- DETAIL-DEPS:BEGIN -->', '<!-- DETAIL-DEPS:END -->'
+PBEGIN, PEND = '<!-- DETAIL-PROBE:BEGIN -->', '<!-- DETAIL-PROBE:END -->'
+
+# ── 页签 → 取数接口 (证据: app.js 里能搜到该接口) ────────────────────────────────
+#   ★实测校准 (2026-09-18): 所有页签的**底数**都是 `/api/fleet` (app.js:1029 `load()`), 其余按页签
+#     各自取: decision→ont_chain(403) · report→rpt_compose/facts(586) · system→maint_survey(728)
+#     · generation→curves(752) · vibration/cms→vibcms(931) · assistant→ask*/facts(425/413)。
+#     下钻浮层 (`/api/problem` `/api/turbine`, app.js:995) 从任意页签都能触发, 挂在部件页。
+PAGES: list[tuple[str, str, list[str]]] = [
+    ('overview', '总览', ['/api/fleet']),
+    ('energy', '发电与损失', ['/api/fleet']),
+    ('component', '部件与系统', ['/api/fleet', '/api/problem', '/api/turbine']),
+    ('vibration', '振动评估(CMS)', ['/api/fleet', '/api/vibcms']),
+    ('generation', '特性曲线', ['/api/curves']),
+    ('fault', '故障与可用率', ['/api/fleet']),
+    ('decision', '决策链', ['/api/fleet', '/api/ont_chain']),
+    ('assistant', '问答', ['/api/fleet', '/api/ask', '/api/ask_status', '/api/ask_models',
+                           '/api/facts', '/api/facts/claim']),
+    ('report', '报告', ['/api/fleet', '/api/facts', '/api/rpt_compose']),
+    ('system', '系统自查', ['/api/fleet', '/api/maint_survey']),
+]
+
+# ── 接口 → 产物 → 生成端 (证据: windscada_serve.py 里的符号名, 本器核对存在) ──────
+#   req=True  = 缺了这页就出不来 (接口回 no_products / err);
+#   req=False = 接口内有 .exists() 兜底, 缺了只是少一块 (页面仍出, 但内容不全)。
+APIS: list[dict] = [
+    dict(path='/api/fleet', fn='fleet_view', req=[
+        'windscada/temp_monthly.parquet', 'windscada/alarms.parquet', 'windscada/loss_monthly.parquet',
+        'windscada/powercurve_bins.parquet', 'windscada/powercurve_dev.parquet'],
+        opt=['windscada/*.parquet', 'ontology/turbine_params.parquet',
+             'm5_cms_tcm/handoff_vibration_v2.json', 'm5_cms_tcm/fleet_scalar_z.parquet',
+             'pitch/pitch_daily.parquet', 'pitch/pitch_zero_monthly.parquet'],
+        note='硬缺件在 `_load()` 里直接抛 ProductsMissing ⇒ 接口回 err=no_products'),
+    dict(path='/api/curves', fn='curve_view',
+        req=['windscada/powercurve_bins.parquet'],
+        opt=['windscada/curve_lenses.parquet', 'windscada/curve_liveness.parquet',
+             'windscada/control_profile.parquet', 'windscada/hydraulic_accum.parquet'],
+        note='曲线镜头读 10min 原始档 + 已重算的派生件'),
+    dict(path='/api/problem', fn='single_problem', req=['windscada/alarms.parquet'],
+        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='部件下钻浮层'),
+    dict(path='/api/turbine', fn='turbine_problems', req=['windscada/alarms.parquet'],
+        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='逐台下钻浮层'),
+    dict(path='/api/vibcms', fn='vibcms_results',
+        req=['windcms/报告_CMS振动状态评估报告_*.md'],
+        opt=['m5_cms_tcm/handoff_vibration_v2.json', 'windcms/cache/*'],
+        note='报告是振动六层链 report 步的出件; 缺它本接口如实回 no_report(不再抛 IndexError)'),
+    dict(path='/api/maint_survey', fn='maint_framework_view', req=['ontology/objects.json'], opt=[],
+        note='维护面盘点读本体对象库 (/v2 的「系统自查」页签取它)'),
+    dict(path='/api/maint_std', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
+        opt=[], note='四判据+成熟度 (经典页 `/?…` 用, /v2 不取)'),
+    dict(path='/api/maint_framework', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
+        opt=[], note='运维体系三维 (经典页用, /v2 不取)'),
+    dict(path='/api/dq_findings', fn='dq_findings_view', ui='classic', req=['ontology/objects.json'], opt=[],
+        note='只认 finding/data_quality|caliber|system_coverage|tier_trend 四族对象; 这四族**不在重算链上** '
+             '(唯一写方 scripts/ingest_ops_2025.py 读 data/raw/工作库, 该目录不在现场数据里)'),
+    dict(path='/api/ont_chain', fn='ontology_view', req=['ontology/objects.json'], opt=[], note='故障链/预防链/故障树'),
+    dict(path='/api/ontology', fn='ontology_view', ui='classic', req=['ontology/objects.json'], opt=[],
+        note='本体总表 (经典页用, /v2 用 fleet 里的本体投影)'),
+    dict(path='/api/facts', fn=None,
+        req=['guanlan/facts_contract_v0.json', 'guanlan/derived/detail_cards.json'],
+        opt=['guanlan/derived/qa_refs.json', 'guanlan/derived/portal_claims.json'],
+        note='契约链: 输入是 sop/findings.json + paradigm_r1 三份**人裁底稿** ⇒ 与 data/raw 隔着不止一跳'),
+    dict(path='/api/ask', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/ask_status', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/ask_models', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/rpt_compose', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+]
+
+
+def _expand_braces(glob) -> list[str]:
+    """族 glob (str 或 list) → 展开 `{a,b}` 后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
+    out = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            out += [head + o + rest for o in opts.split(',')]
+        else:
+            out.append(g)
+    return out
+
+
+def _pat_match(rel: str, glob) -> bool:
+    """rel 是否落在族 glob 里 (深度对齐: 模式无 `**` 时 `/` 个数必须相等 —— fnmatch 的 `*` 跨 `/`)。"""
+    for pt in _expand_braces(glob):
+        if '**' not in pt and rel.count('/') != pt.count('/'):
+            continue
+        if rel == pt or fnmatch.fnmatch(rel, pt):
+            return True
+    return False
+
+
+# ── 环境读取 ────────────────────────────────────────────────────────────────────
+def load_env(farm: str | None = None):
+    root = P.out_root(farm)
+    ledger = {}
+    lf = root / '_provenance.json'
+    if lf.is_file():
+        try:
+            ledger = (json.loads(lf.read_text(encoding='utf-8')) or {}).get('files') or {}
+        except Exception:
+            ledger = {}
+    disk = {p.relative_to(root).as_posix(): p.stat().st_size
+            for p in root.rglob('*') if p.is_file()}
+    try:
+        from .products_reverse_audit import FAMILIES
+    except Exception:
+        FAMILIES = []
+
+    def family_of(rel: str):
+        for fam in FAMILIES:
+            if _pat_match(rel, fam['glob']):
+                return fam
+        return None
+
+    return dict(farm=farm or P.farm(), root=root, ledger=ledger, disk=disk, family_of=family_of,
+                fams=FAMILIES)
+
+
+def expand(pat: str, env) -> list[str]:
+    got = [r for r in env['disk'] if fnmatch.fnmatch(r, pat)]
+    return sorted(got)
+
+
+def row_of(pat: str, env) -> dict:
+    """一条产物声明 → 在位情况 + 来源 + 生成端。"""
+    hits = expand(pat, env)
+    if hits:
+        srcs = sorted({(env['ledger'].get(r) or {}).get('source', '(未登记)') for r in hits})
+        fams = []
+        for r in hits[:40]:
+            f = env['family_of'](r)
+            if f and f['id'] not in fams:
+                fams.append(f['id'])
+        gen = next((env['family_of'](r) for r in hits if env['family_of'](r)), None)
+        return dict(pat=pat, n=len(hits), bytes=sum(env['disk'][r] for r in hits),
+                    src='·'.join(srcs), builder=(gen or {}).get('gen') or '—',
+                    family='·'.join(fams) or '—', ok=True)
+    f = env['family_of'](pat) if '*' not in pat else None
+    return dict(pat=pat, n=0, bytes=0, src='—', builder='—',
+                family=(f or {}).get('id') or '—', ok=False)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--probe', action='store_true', help='实测在线接口 (需服务在跑; 只打印)')
+    ap.add_argument('--write', action='store_true', help='写回文档')
+    ap.add_argument('--check', action='store_true', help='核对文档与现场一致 (rc=5 = 该重写)')
+    ap.add_argument('--farm', default=None)
+    a = ap.parse_args()
+    env = load_env(a.farm)
+    appjs = APPJS.read_text(encoding='utf-8')
+    serve = SERVE.read_text(encoding='utf-8')
+
+    # ① 证据核对: 页面端接口在 app.js 里找得到吗; 接口函数在 serve 里找得到吗
+    bad = []
+    for pid, name, eps in PAGES:
+        if f"'{pid}'" not in appjs:
+            bad.append(f'页签 {pid} 不在 app.js 的 TABS 里')
+        for ep in eps:
+            if f"'{ep}'" not in appjs and f"`{ep}" not in appjs and ep not in appjs:
+                bad.append(f'页签 {pid} 声明的 {ep} 在 app.js 里搜不到')
+    for it in APIS:
+        if it.get('ui') == 'classic':
+            # 经典页 (/ 老前端) 的接口: 出处是 serve 里的路由分支, 不是 app.js
+            if f"'{it['path']}'" not in serve:
+                bad.append(f"经典页接口 {it['path']} 在 windscada_serve.py 的路由里搜不到")
+        elif f"'{it['path']}'" not in appjs and it['path'] not in appjs:
+            bad.append(f"{it['path']} 声明给 /v2 用, 但 app.js 里搜不到它")
+        if it['fn'] and f"def {it['fn']}(" not in serve:
+            bad.append(f"{it['path']} 声明的实现函数 {it['fn']}() 在 windscada_serve.py 里不存在")
+
+    probe = {}
+    if a.probe:
+        probe = {it['path']: _probe(it['path']) for it in APIS}
+
+    L = []
+    L.append(f'# /detail 页面依赖与重算台账 (自动生成, 禁手改)')
+    L.append('')
+    L.append(f'> 生成端: `python scripts/detail_deps.py --write`|场站 `{env["farm"]}`|'
+             f'产物仓 `{P.rel(env["root"])}`|盘上 {len(env["disk"]):,} 件')
+    L.append('>')
+    L.append('> 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, '
+             '数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。')
+    L.append('')
+    L.append('## 1 页面 → 取数接口')
+    L.append('')
+    L.append('| 页签 | 页面 | 取数接口 |')
+    L.append('|---|---|---|')
+    for pid, name, eps in PAGES:
+        for i, ep in enumerate(eps):
+            L.append(f'| {pid if i == 0 else ""} | {name if i == 0 else ""} | `{ep}` |')
+    L.append('')
+    L.append('注: `/api/maint_std` `/api/maint_framework` `/api/dq_findings` `/api/ontology` 属**经典页** '
+             '(`/?…`, 组件根), `/v2` 工作台不取它们 —— 但同一批本体产物 (ontology/objects.json) 仍然决定着'
+             '它们能不能出数, 故一并列在 §2。')
+    L.append('')
+    L.append('## 2 接口 → 产物 → 来源 / 生成端')
+    L.append('')
+    L.append('| 接口 | 产物 | 件 | 大小 | 台账来源 | 生成端(族) | 判定 |')
+    L.append('|---|---|---:|---:|---|---|---|')
+    gaps = []
+    for it in APIS:
+        for kind, label in (('req', '✅必需'), ('opt', '○可选')):
+            for pat in it[kind]:
+                r = row_of(pat, env)
+                verdict = ('在位' if r['ok'] else ('**缺**' if kind == 'req' else '缺(页面降级)'))
+                if not r['ok']:
+                    gaps.append((it['path'], pat, kind, r['family']))
+                L.append(f"| {it['path'] if pat == it[kind][0] else ''} | `{pat}` {label} | {r['n']} | "
+                         f"{r['bytes'] / 1e6:.1f} MB | {r['src']} | {r['builder']} ({r['family']}) | {verdict} |")
+    L.append('')
+    L.append('## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)')
+    L.append('')
+    if not gaps:
+        L.append('无缺口: 表内每件产物都在位且来源已登记。')
+    else:
+        L.append('| 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |')
+        L.append('|---|---|---|---|---|')
+        for ep, pat, kind, fam in gaps:
+            g = _gen_of(env, pat)
+            L.append(f"| `{ep}` | `{pat}` | {'是' if kind == 'req' else '否'} | {g['gen']} | {g['how']} |")
+    L.append('')
+    L.append('## 4 与输入数据的对应 (重算口径)')
+    L.append('')
+    L.append('| 产物族 | 输入 (data/raw/<场站>/…) | 生成端 |')
+    L.append('|---|---|---|')
+    seen = set()
+    for it in APIS:
+        for pat in it['req'] + it['opt']:
+            f = env['family_of'](pat) if '*' not in pat else None
+            if not f or f['id'] in seen:
+                continue
+            seen.add(f['id'])
+            L.append(f"| {f['id']} | {f.get('input') or '—'} | `{f.get('gen') or '—'}` |")
+    L.append('')
+    L.append('## 5 复现')
+    L.append('')
+    L.append('```')
+    L.append('python scripts/detail_deps.py --probe        # 实测在线接口 (页面同路径: 网关 → 组件 /v2)')
+    L.append('python scripts/products_restore_missing.py --refresh   # 重算后维护逐件来源台账')
+    L.append('python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)')
+    L.append('```')
+    body = '\n'.join(L)
+    text = f'{BEGIN}\n{body}\n{END}\n'
+
+    # ── 实测段: 与"清单"分开标记 —— 它的数随服务在场与否变, 不该让 --check 抖动 ─────────
+    ptext = ''
+    if probe:
+        P_ = ['## P 实测 (本次运行, 走网关 28084 → 组件 /v2)', '',
+              '| 页签 | 接口 | 判定 | 实测 |', '|---|---|---|---|']
+        for pid, name, eps in PAGES:
+            for i, ep in enumerate(eps):
+                r = probe.get(ep) or {}
+                st = (f"HTTP {r['http']}" if r.get('http') else '—') + \
+                     (f" · {r['err'][:70]}" if r.get('err') else (f" · {r['n']:,} B" if r.get('n') else ''))
+                P_.append(f"| {pid if i == 0 else ''} | `{ep}` | {r.get('verdict', '—')} | {st} |")
+        P_ += ['', '判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; '
+                   '`/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。']
+        ptext = f'{PBEGIN}\n' + '\n'.join(P_) + f'\n{PEND}\n'
+
+    if bad:
+        print('证据核对不通过 —— 清单与源码/现场脱节:')
+        for b in bad:
+            print('   [核不上] ' + b)
+    print(f'清单: {len(PAGES)} 页签 · {len(APIS)} 接口 · 缺口 {len(gaps)} 件 · '
+          f'产物仓 {len(env["disk"]):,} 件')
+    if a.write:
+        old = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
+        if PBEGIN in old and PEND in old:                 # 旧实测段先整段摘掉, 再按需重写
+            old = old[:old.index(PBEGIN)].rstrip() + '\n' + old[old.index(PEND) + len(PEND):].lstrip('\n')
+        if BEGIN in old and END in old:
+            new = old[:old.index(BEGIN)] + text + old[old.index(END) + len(END):].lstrip('\n')
+        else:
+            new = (old.rstrip() + '\n\n' if old.strip() else
+                   '# /detail 页面依赖与重算台账 v0.1\n\n'
+                   '用户令 (2026-09-18): `/detail` 各页面不许用旧版产出补, 必须由 `data/raw` 重算;\n'
+                   '旧版产物只可参照**呈现样式与内容**。逐页依赖见下表 (自动生成段)。\n\n') + text
+        new = new.rstrip() + '\n\n' + ptext if ptext else new
+        DOC.write_text(new, encoding='utf-8')
+        print(f'已写 {P.rel(DOC)}' + ('' if ptext else ' (未带实测段; 加 --probe 可一并写入)'))
+    if a.check:
+        cur = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
+        seg = cur[cur.index(BEGIN):cur.index(END) + len(END)] if (BEGIN in cur and END in cur) else ''
+        if seg.strip() != text.strip():
+            print('[X] 文档与现场不一致 ⇒ 跑 --write 重写')
+            return 5
+        print('[OK] 文档与现场一致 (清单段; 实测段随服务状态变, 不参与比对)')
+    return 5 if bad else 0
+
+
+def _gen_of(env, pat: str) -> dict:
+    """缺口件 → 生成端与"能否从 data/raw 重算"的如实判断 (依据: 族表的 kind/algo/why)。"""
+    f = next((fam for fam in env['fams'] if _pat_match(pat, fam['glob'])), None)
+    if not f:
+        return dict(gen='—', how='族表里没有这一族 ⇒ 需人工归口')
+    if f.get('gen'):
+        return dict(gen=f"`{f['gen']}`", how='能: 放原始件到 data/raw 后 `rebuild_all.py`')
+    return dict(gen='(无生成端)', how=f"**不能**: {str(f.get('why') or '')[:60]} ⇒ 需研发补生成端")
+
+
+def _probe(ep: str) -> dict:
+    """实测一个接口 (走网关, 与页面同路径)。判读只认**顶层**的 err/error/no_products/no_report ——
+    ★2026-09-18 首版按正则全文搜 `"err":` 判读, 结果把 fleet 响应里某个以 err 结尾的字段的**值**
+    (per_turbine) 当成了报错, 页面明明好着却显示"有缺件痕迹"。判读不许靠猜文本。"""
+    import urllib.error
+    import urllib.request
+    base = 'http://127.0.0.1:28084/detail'
+    q = {'/api/fleet': '?win=2026%E5%B9%B4',
+         '/api/turbine': '?t=WTG01&win=2026%E5%B9%B4',
+         '/api/problem': '?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
+         '/api/ont_chain': '?kind=prev&system=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F',
+         '/api/facts/claim': '?id=RD-000'}.get(ep, '')
+    try:
+        with urllib.request.urlopen(base + ep + q, timeout=60) as r:
+            raw, code = r.read(), r.status
+    except urllib.error.HTTPError as e:
+        raw, code = e.read(), e.code
+    except Exception as e:
+        return dict(http=None, n=0, err=f'{type(e).__name__}: {e}', verdict='连不上')
+    if ep == '/api/ask':      # 只收 POST (页面是表单提交), GET 探测必 404 —— 不是缺件
+        return dict(http=code, n=len(raw), err='', verdict='不适用(POST 接口)')
+    try:
+        obj = json.loads(raw.decode('utf-8', 'replace'))
+    except Exception:
+        return dict(http=code, n=len(raw), err='(非 JSON)', verdict='?')
+    if not isinstance(obj, dict):
+        return dict(http=code, n=len(raw), err='', verdict='有数')
+    msg = next((str(obj[k]) for k in ('err', 'error') if obj.get(k)), '')
+    # 本机模型 (Ollama) 没起 / 未接: 这不是"重算缺件", 标成不适用 —— 与 `guanlan.py check` 的口径一致
+    if ep in ('/api/ask_status', '/api/ask_models', '/api/rpt_compose'):
+        return dict(http=code, n=len(raw), err=msg,
+                    verdict='不适用(本机模型未启动)' if msg or code >= 400 else '有数')
+    # 503 + '缺失/No such file' = 产物不在位(如 /api/facts 的契约派生件) ⇒ 记无产物, 不记报错
+    _missing = (obj.get('no_products') or obj.get('no_report')
+                or (ep == '/api/facts/claim' and code >= 400)
+                or (code == 503 and ('缺失' in msg or 'No such file' in msg)))
+    if _missing:
+        return dict(http=code, n=len(raw), err=msg or 'no_products', verdict='无产物(如实)')
+    if msg:
+        return dict(http=code, n=len(raw), err=msg, verdict='报错')
+    return dict(http=code, n=len(raw), err='', verdict='有数')
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 85 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/guanlan_baseline_manifest.py

@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""观澜 详细分析工作台 视觉基线 (detail_style_baseline) 清单构建/校验 (用户裁决 2026-09-06: 确认 C1 = /v2, 删末块与门户一致, 补返回门户后冻结).
+build: 从 src/windscada/ui 源重建 zh 页 → 摘要 {css_tokens, nav, dom, sha256, source blobs} → outputs/rudong/windscada/release-manifest.json (detail_style_baseline 段)
+check: 重建后摘要 vs 清单; 任一差异 = 样式/框架变化 = FAIL (除非用户批准新版本并重写清单).
+用法: python scripts/guanlan_baseline_manifest.py build|check [--html <path>]"""
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+import hashlib, datetime as dt, hashlib, json, re, subprocess, sys
+from pathlib import Path
+
+ROOT = _install_root(__file__); MAN = ROOT / "outputs/rudong/windscada/release-manifest.json"
+UI = ROOT / "app_frontEnd/common/app_frontEnd_guanlan" / "assets"; SRC_FILES = ["app_frontEnd/common/app_frontEnd_guanlan/pages/shell.html",
+                 "app_frontEnd/common/app_frontEnd_guanlan/assets/app.css",
+                 "app_frontEnd/common/app_frontEnd_guanlan/assets/app.js", "src/windscada/ui/charts.js", "src/windscada/ui/build.py", "src/windscada/lang.py"]
+TOKENS = ("--ground", "--panel", "--ink", "--muted", "--line", "--hair", "--accent", "--accent-deep", "--accent-soft", "--wash", "--critical", "--warning", "--good", "--excellent", "--sans", "--mono")
+sha = lambda b: hashlib.sha256(b).hexdigest()
+
+
+def render_zh() -> str:
+    sys.path.insert(0, str(ROOT)); from src.windscada.ui import build; return build.render("zh")
+
+
+def summarize(html: str) -> dict:
+    css = "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", html, flags=re.S))   # 全部 style 块按出现顺序 (服务端注入在后 → 覆盖)
+    roots = [b for sel, b in re.findall(r"(?:^|[}\s;])(:root[^{]*)\{([^}]*)\}", css) if ":root" in sel]; eff = {}   # 任何含 :root 的选择器块 (含 ':root,:root[data-theme]' 这种注入写法)
+    for blk in roots:                                  # 后块覆盖前块 = 浏览器生效值 (末块已删, 中段已删; 若有人再加覆盖, 摘要就变)
+        for k, v in re.findall(r"(--[a-z-]+)\s*:\s*([^;]+)", blk): eff[k] = v.strip()
+    tokens = {k: eff.get(k) for k in TOKENS}
+    js = html[html.rfind("<script>") + 8: html.rfind("</script>")]
+    tabs = re.search(r"const TABS = \[([^\]]*)\]", js).group(1); tabs = re.findall(r"'([a-z_]+)'", tabs)
+    pack = json.loads(re.search(r"window\.__L=(\{.*?\});</script>", html, flags=re.S).group(1))
+    nav = [pack.get("tab." + t) for t in tabs]; actions = {k: pack.get(k) for k in ("ui.back_portal", "ui.reselect", "ui.back_to", "win.label")}
+    dom = {"shell_ids": re.findall(r'id="(top|tabs|root|foot)"', html), "header_controls": ["backPortal" if 'id="backPortal"' in html else None, "reselect" if 'id="reselect"' in html else None, "win" if 'id="win"' in html else None],
+           "theme_attr": bool(re.search(r'<html[^>]*data-theme', html)), "lang": re.search(r'<html lang="([a-z]+)"', html).group(1)}
+    return {"css_tokens": tokens, "n_root_blocks": len(roots), "tabs": tabs, "nav_zh": nav, "actions": actions, "dom": dom, "sha256": sha(html.encode("utf-8")), "size": len(html.encode("utf-8")),
+            "fingerprint": sha(json.dumps({"t": tokens, "n": nav, "d": dom, "a": actions}, sort_keys=True, ensure_ascii=False).encode("utf-8"))[:16]}
+
+
+def live_check(url: str, man: dict) -> list:
+    """线上 /v2 实际响应 (含服务端注入的样式) 的摘要必须等于基线 —— 服务端注入 (如 guanlan-unified-review) 改令牌 = 红."""
+    import urllib.request
+    html = urllib.request.urlopen(url, timeout=10).read().decode("utf-8"); s = summarize(html); diffs = []
+    for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom"):
+        if man[k] != s[k]: diffs.append(f"live {k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:100]} ≠ 线上 {json.dumps(s[k], ensure_ascii=False)[:100]}")
+    inj = re.findall(r'<style id="([^"]+)"', html)
+    if inj: diffs.append(f"live 服务端注入 <style id=…>: {inj} (基线构建物无)")
+    return diffs
+
+
+def main():
+    mode = sys.argv[1] if len(sys.argv) > 1 else "check"; html = render_zh(); s = summarize(html)
+    if mode == "check" and "--live" in sys.argv:
+        url = sys.argv[sys.argv.index("--live") + 1]; man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; d = live_check(url, man)
+        print(("PASS" if not d else "FAIL") + f" live {url}"); [print("  -", x) for x in d]; return 0 if not d else 2
+    git = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
+    blobs = {f: subprocess.run(["git", "-C", str(ROOT), "ls-tree", "HEAD", "--", f], capture_output=True, text=True).stdout.split()[2][:16] for f in SRC_FILES}
+    if mode == "build":
+        def portal_sha(prev):  # 门户参考 = 桌面固定单文件 (用户裁定; 2026-09-06 契约段就地注入后变更), 不在库内 → 缺失时沿用清单旧值, 禁写死
+            rel = (ROOT / "release/portal.html"); pp = rel if rel.exists() else Path.home() / "Desktop/观澜_如东样板_门户_单文件.html"   # 发布目录优先 (用户裁 2026-09-06), 回退桌面
+            return hashlib.sha256(pp.read_bytes()).hexdigest() if pp.exists() else (prev.get("detail_style_baseline") or {}).get("portal_reference_sha256")
+        man = json.loads(MAN.read_text(encoding="utf-8")) if MAN.exists() else {"schema": "release-manifest/v0"}
+        man["detail_style_baseline"] = {"confirmed_by": "用户裁决 2026-09-06 (确认 C1; 删末块与门户一致; 补返回门户/重新选择后冻结)", "route": "/v2 (8033) → 目标架构 /detail/", "source": "src/windscada/ui + src/windscada/lang.py", "git_commit": git, "source_blobs_sha16": blobs,
+                                        "build_cmd": "python -m src.windscada.ui.build zh", "built": dt.datetime.now().isoformat(timespec="minutes"), "artifact_sha256": s["sha256"], "artifact_size": s["size"],
+                                        "portal_reference_sha256": portal_sha(man), "portal_tokens": {"--ground": "#F5F7F8", "--ink": "#1B2430", "--accent": "#1F6F8B", "--line": "#DDE3E8"},
+                                        "css_tokens": s["css_tokens"], "n_root_blocks": s["n_root_blocks"], "tabs": s["tabs"], "nav_zh": s["nav_zh"], "actions": s["actions"], "dom": s["dom"], "fingerprint": s["fingerprint"],
+                                        "screenshot_note": "2026-09-06 浏览器实测 computed style: --ground #F5F7F8 / --ink #1B2430 / --accent #1F6F8B / --line #DDE3E8, 10 tab, 返回门户 href=/ (?portal= 可覆盖), 重新选择 存在; 像素级截图指纹未建 (无基线截图机制), fingerprint = 令牌+导航+DOM 摘要 sha",
+                                        "frozen": ["色彩令牌", "字体", "字号层级/留白/卡片/边框 (app.css)", "顶部导航与十模块顺序", "返回门户/重新选择 位置与语义", "板块框架 (总览…系统维护)"],
+                                        "allowed_changes": "仅结构化事实契约驱动的数据/时间窗/结论/图表数据/报告文本; 不得改 DOM 布局/CSS 令牌/导航顺序/按钮层级"}
+        MAN.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding="utf-8"); print("wrote", MAN.relative_to(ROOT), "commit", git, "sha", s["sha256"][:16], "fp", s["fingerprint"]); return 0
+    man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; diffs = []
+    for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom", "n_root_blocks"):
+        if man[k] != s[k]: diffs.append(f"{k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:120]} ≠ 现 {json.dumps(s[k], ensure_ascii=False)[:120]}")
+    for k, v in man["css_tokens"].items():
+        if k in man["portal_tokens"] and s["css_tokens"].get(k) != man["portal_tokens"][k]: diffs.append(f"{k} ≠ 门户 {man['portal_tokens'][k]}")
+    print(("PASS" if not diffs else "FAIL") + f" detail_style_baseline fp={s['fingerprint']} (基线 {man['fingerprint']}) sha={s['sha256'][:16]} (基线 {man['artifact_sha256'][:16]})"); [print("  -", d) for d in diffs]
+    return 0 if not diffs else 2
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 359 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/inventory_products.py

@@ -0,0 +1,359 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""产物清点与「输入 ↔ 产物」呼应校验 (2026-09-16 用户令 1、2)。
+
+用户令:
+  1. 基于观澜源码, 认真检查、梳理**所有产物**(产物类型/功用/输出路径)确保无遗漏; 输出路径不统一的改源码统一;
+     完善到 `<安装目录>\docs\系统设计说明.md`。
+  2. 确保 `data/raw` 下的输入数据**可用于重算**, 且**产物与输入数据呼应**。
+
+本脚本是三件事的**单一实现** (别在文档里手抄第二份, 会飘):
+  · 清点: 每个产物仓的件数/大小/类型, 以及**逐件来源**(raw-derived / shipped, 读 `_provenance.json`
+    与 `_derived_manifest.json`);
+  · 呼应: 对每类输入算它的**数据跨度/条数**, 与它喂出来的产物**逐项对拍** (产物跨度必须落在输入跨度内,
+    且输入更新时产物不该明显落后);
+  · 写档: `--write-doc` 把清单块写进 `docs\系统设计说明.md` 的
+    `<!-- INVENTORY:BEGIN --> … <!-- INVENTORY:END -->` 之间 (文档其余部分手工维护)。
+
+用法:
+    python scripts/inventory_products.py                # 打清单 + 呼应结论 (人看)
+    python scripts/inventory_products.py --check        # 只做呼应校验 (有问题 → 退出码 5)
+    python scripts/inventory_products.py --write-doc    # 把清单块写进 docs/系统设计说明.md
+    python scripts/inventory_products.py --json         # 机器可读
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import datetime as dt
+import json
+import pathlib
+import re
+import sys
+from collections import OrderedDict
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src.console import soft  # noqa: E402
+from src import paths as P    # noqa: E402
+
+soft()
+DOC = P.DOCS / '系统设计说明.md'
+MARK_B, MARK_E = '<!-- INVENTORY:BEGIN', '<!-- INVENTORY:END -->'
+
+# ── 产物仓: 路径助手 → (仓名, 功用, 谁生成, 谁消费) ───────────────────────────────
+# ★路径一律用 src/paths.py 的助手 (唯一真源); 这里写的是**相对安装根**的形式, 便于跨平台显示。
+STORES = OrderedDict([
+    ('windscada',  dict(helper='P.store()',            purpose='L0 标准仓: SCADA/台账/派生分析的全部 parquet (页面主取数处)',
+                        producer='rebuild_from_raw.py (三门台账) + --scada (10 个构建器) + windscada_monthly_build.py',
+                        consumer='scripts/windscada_serve.py 各视图 · src/windscada/taxonomy · subsys/fusion')),
+    ('ontology',   dict(helper='P.ont()',              purpose='本体对象库: 码表/手册/工单展开/失效树 + 检索索引 + 实机参数',
+                        producer='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest → retrieval.build',
+                        consumer='脚本 windscada_serve.py 本体页/问答 · scripts/guanlan_facts_contract.py')),
+    ('windcms',    dict(helper='P.cms()',              purpose='CMS 振动诊断产物: 状态评估报告/逐台页/工作台页/知识库 + 厂家报告转录',
+                        producer='scripts/windcms.py report/kb · scripts/vib_reports_build.py',
+                        consumer='自服务 :18020 (每请求现读) · taxonomy.system_matrix (转录设备状态)')),
+    ('m5_cms_tcm', dict(helper='P.m5()',               purpose='振动线出件与窗级分析: handoff 接口 + 窗索引/谱库 + TCM 兼容件',
+                        producer='scripts/vib_raw_build.py (窗索引/谱) · 振动线出件 (handoff, 随包快照)',
+                        consumer='src/windscada/subsys/fusion.py · src/windcms/data.py · scripts/windcms.py report')),
+    ('tcm_compatible_replay', dict(helper='P.tcm_replay()', purpose='TCM 兼容链回放资产 (模型表/掩码阈值/裁决记录)',
+                        producer='随包快照 (无生成端)', consumer='src/windcms/report*.py · config.mask_thresholds')),
+    ('sop',        dict(helper='P.sop()',              purpose='SOP 中间件/评审/台账与事实契约底稿',
+                        producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py · 门户结论段')),
+    ('guanlan',    dict(helper='P.guanlan()',          purpose='事实契约与对外派生 (可上云面孔)',
+                        producer='scripts/guanlan_facts_contract.py', consumer='门户 #findings · /api/facts')),
+    ('pitch',      dict(helper='P.pitch()',            purpose='变桨侧派生件 (日粒度液压/润滑/柱塞 + 零位三口径月表)',
+                        producer='scripts/pitch_face_build.py (重算链 ④c; scada_10min + scada_1min)',
+                        consumer='脚本 windscada_serve.py 变桨面 · src/windscada/subsys/pitch.py')),
+    ('paradigm_r1', dict(helper='(无助手: P.out_root()/paradigm_r1)', purpose='范式实验件 (E3/E5/E8 底稿, 事实契约输入)',
+                        producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py')),
+])
+
+# ── 输入类 → (放什么, 喂哪些产物, 跨度判据) ────────────────────────────────────────
+# span: 'csv_col' = 逐台 CSV 的首/末数据行; 'fname' = 从文件名年份/日期推; 'dir' = 目录名 (年/月)
+INPUTS = OrderedDict([
+    ('scada_10min', dict(what='SCADA 10min 导出 (逐台 WTG01.csv…WTG38.csv)', span='csv_col', time_col='Time',
+                         feeds=['windscada/powercurve*.parquet', 'windscada/loss_monthly.parquet', 'windscada/temp_monthly.parquet',
+                                'windscada/temp_bins.parquet', 'windscada/stop_events.parquet', 'windscada/yaw_daily.parquet',
+                                'windscada/hydraulic_accum.parquet', 'windscada/thermal_chain.parquet',
+                                'windscada/curve_*.parquet', 'windscada/control_*.parquet', 'windscada/system_aux.parquet',
+                                'pitch/pitch_daily.parquet (日粒度液压/润滑/柱塞)'])),
+    ('scada_1min', dict(what='1min 导出 (逐台内部台号 01E.csv…38B.csv)', span='csv_col', time_col='occur_time',
+                        feeds=['windscada/watch_channels_monthly.parquet (watch 月度)',
+                               'pitch/pitch_daily.parquet (越卸荷/跌启泵分钟计数)',
+                               'pitch/pitch_zero_monthly.parquet (停机顺桨段/满发段零位两口径)'])),
+    ('故障报警',   dict(what='报警事件导出 (SpreadsheetML *.xls)', span='fname_year',
+                        feeds=['windscada/alarms.parquet'])),
+    ('风机故障记录', dict(what='检修工单台账 (*.xls/xlsx)', span='fname_year',
+                        feeds=['windscada/workorders.parquet', 'windscada/mblub_monthly.parquet'])),
+    ('油样报告',   dict(what='油液化验报告 (*.pdf)', span='fname_date',
+                        feeds=['windscada/oil_samples_index.parquet'])),
+    ('windcms',    dict(what='CMS 原始测量导出 (Brande TCM *_decode.json) + 厂家评估报告', span='dir',
+                        feeds=['m5_cms_tcm/windows/<窗>/{index.parquet,spectra/*}', 'windcms/厂家报告提取_*.json'])),
+    ('m5_cms_tcm', dict(what='振动线出件与 TCM 侧报告', span='none',
+                        feeds=['m5_cms_tcm/handoff_vibration_v2.json (现场正本优先)', 'windcms/报告_TCM传动链振动分析_*.md'])),
+])
+# 机理层 (不在场站目录下)
+TECH_INPUT = ('西门子4.0技术资料', '厂商技术资料 (故障处理手册/维护 WI/图纸/对译表)',
+              ['ontology/objects.json', 'ontology/turbine_params.parquet', 'ontology/retrieval_index.json'])
+
+
+def _rows_of(path: pathlib.Path, probe: int = 1):
+    """只读首/末若干行, 不整表载入 (scada csv 单文件几百 MB)。"""
+    try:
+        with open(path, 'rb') as f:
+            head = f.readline()
+            f.seek(max(0, path.stat().st_size - 65536))
+            tail = f.read().splitlines()
+        return head, (tail[-1] if tail else b'')
+    except Exception:
+        return b'', b''
+
+
+def input_span(station: pathlib.Path, sub: str, spec: dict):
+    """→ (起, 止, 件数, 说明, 粒度)。粒度 ∈ {'日','月','年'}: 只有'日/月'才参与"产物落后"判定 ——
+    按文件名年份推出来的跨度天然是**年粒度**, 拿它当"输入到 2026-12"会造出假缺口 (本脚本第一版就这么
+    误报过 2 条: 报警/工单"落后 5 个月"), 故显式带粒度、按粒度决定能不能比。"""
+    d = station / sub
+    if not d.is_dir():
+        return None, None, 0, '目录不存在', '年'
+    files = [p for p in d.rglob('*') if p.is_file()]
+    kind = spec.get('span')
+    if kind == 'csv_col':
+        col = spec.get('time_col')
+        starts, ends = [], []
+        for p in files[:60]:
+            try:
+                with open(p, 'rb') as f:
+                    first = f.readline()          # 表头
+                    first = f.readline()          # 第一条数据行
+                    f.seek(max(0, p.stat().st_size - 65536))
+                    last = f.read().splitlines()[-1]
+                hdr = first.decode('utf-8', 'replace').strip().split(',')
+                i = hdr.index(col) if col in hdr else 0
+                starts.append(hdr[i])
+                ends.append(last.decode('utf-8', 'replace').split(',')[0])
+            except Exception:
+                continue
+        if starts and ends:
+            return min(starts), max(ends), len(files), f'{len(files)} 件 (抽样 {len(starts)} 件首/末数据行)', '日'
+        return None, None, len(files), 'CSV 首/末行解析失败', '年'
+    if kind == 'fname_year':
+        ys = sorted({int(m.group()) for p in files for m in [re.search(r'(20\d\d)', p.name)] if m})
+        return (f'{ys[0]}-01' if ys else None), (f'{ys[-1]}-12' if ys else None), len(files), \
+               f'{len(files)} 件 (文件名年份, 年粒度)', '年'
+    if kind == 'fname_date':
+        ds = []
+        for p in files:
+            m = re.search(r'(\d{2})(\d{2})(20\d\d)', p.name)
+            if m:
+                ds.append(f'{m.group(3)}-{m.group(2)}-{m.group(1)}')
+        return (min(ds) if ds else None), (max(ds) if ds else None), len(files), \
+               f'{len(files)} 件 (文件名日期)', '日'
+    if kind == 'dir':
+        months = sorted({f'{m.group(1)}-{m.group(2)}' for p in d.rglob('*')
+                         for m in [re.search(r'[/\\](20\d\d)[/\\](\d{2})[/\\]', str(p))] if m})
+        return (months[0] if months else None), (months[-1] if months else None), len(files), \
+               f'{len(files)} 件 (目录年月 {", ".join(months) or "未识别"})', '月'
+    return None, None, len(files), '按件数清点 (无跨度判据)', '年'
+
+
+def product_stats(farm: str):
+    """→ {仓: dict(n, bytes, files: [(名, 类型, 大小)], kinds)}。"""
+    out = {}
+    for name in STORES:
+        d = P.out_root(farm) / name
+        if not d.is_dir():
+            out[name] = dict(n=0, bytes=0, kinds={}, newest=None)
+            continue
+        files = [p for p in d.rglob('*') if p.is_file()]
+        kinds = {}
+        for p in files:
+            k = p.suffix.lower() or '(无扩展名)'
+            kinds[k] = kinds.get(k, 0) + 1
+        newest = max((p.stat().st_mtime for p in files), default=None)
+        out[name] = dict(n=len(files), bytes=sum(p.stat().st_size for p in files), kinds=kinds,
+                         newest=(dt.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M') if newest else None))
+    return out
+
+
+def provenance(farm: str):
+    root = P.out_root(farm)
+    prov = {}
+    for fn in ('_provenance.json', '_derived_manifest.json'):
+        f = root / fn
+        if not f.exists():
+            continue
+        try:
+            d = json.loads(f.read_text(encoding='utf-8'))
+        except Exception:
+            continue
+        for rel, v in (d.get('files') or {}).items():
+            src = v.get('source') if isinstance(v, dict) else None
+            prov[rel] = dict(source=src or 'raw-derived',
+                             builder=(v.get('builder') if isinstance(v, dict) else str(v)) or '')
+    return prov
+
+
+def span_check(farm: str, station: pathlib.Path, verbose=True):
+    """逐类输入: 算输入跨度 + 对应产物的跨度/条数, 报联动结论。→ (rows, problems)"""
+    import pandas as pd
+    rows, problems = [], []
+    ST = P.store(farm)
+
+    def pspan(p: pathlib.Path, col=None, path_cols=()):
+        if p is None or not p.exists():
+            return None, None, 0
+        try:
+            if p.suffix == '.parquet':
+                d = pd.read_parquet(p)
+                n = len(d)
+                c = col if col in d.columns else next((c for c in path_cols if c in d.columns), None)
+                if c is None:
+                    return None, None, n
+                s = pd.to_datetime(d[c], errors='coerce')
+                return ((str(s.min())[:10] if s.notna().any() else None),
+                        (str(s.max())[:10] if s.notna().any() else None), n)
+        except Exception:
+            return None, None, 0
+        return None, None, 0
+
+    # 输入类 → 它喂出来的产物 (路径, 时间列)
+    PAIRS = {
+        'scada_10min': [('windscada/temp_monthly.parquet', ST / 'temp_monthly.parquet', 'month'),
+                        ('windscada/loss_monthly.parquet', ST / 'loss_monthly.parquet', 'month'),
+                        ('windscada/powercurve_bins.parquet', ST / 'powercurve_bins.parquet', None)],
+        '故障报警': [('windscada/alarms.parquet', ST / 'alarms.parquet', 't_on')],
+        '风机故障记录': [('windscada/workorders.parquet', ST / 'workorders.parquet', 't_report')],
+        '油样报告': [('windscada/oil_samples_index.parquet', ST / 'oil_samples_index.parquet', 'date')],
+    }
+    for sub, spec in INPUTS.items():
+        if sub == 'm5_cms_tcm':
+            continue                                     # 出件不是"测量输入", 无跨度判据
+        i_s, i_e, i_n, note, gran = input_span(station, sub, spec)
+        comparable = gran in ('日', '月')                 # 年粒度不参与"落后"判定 (见 input_span 注释)
+        if sub == 'windcms':
+            # ★glob 必须写 `w[0-9][0-9][0-9][0-9]`: `w[????]` 是"字符类里含 ? 之一", 匹配不到任何窗名
+            #  (本脚本第一版就这么写, windcms 那行因此整行不出现 —— 静默漏项, 不是报错)
+            wins = [w for w in sorted((P.m5(farm) / 'windows').glob('w[0-9][0-9][0-9][0-9]'))
+                    if (w / 'index.parquet').exists()]
+            if wins:
+                w = wins[-1]
+                p_s, p_e, p_n = pspan(w / 'index.parquet', 'trigger_time')
+                rows.append(dict(input=sub, note=note, in_span=f'{i_s} ~ {i_e}', files=i_n,
+                                 product=f'm5_cms_tcm/windows/{w.name}/index.parquet',
+                                 prod_span=f'{p_s} ~ {p_e}', rows_n=p_n))
+                if p_s and i_s and p_s[:7] < i_s[:7]:
+                    problems.append(f'{sub}: 窗 {w.name} 索引起点 {p_s} 早于输入最早月 {i_s} (跨窗混入?)')
+            continue
+        for label, path, col in PAIRS.get(sub, []):
+            p_s, p_e, p_n = pspan(path, col)
+            rows.append(dict(input=sub, note=note, in_span=f'{i_s} ~ {i_e}', files=i_n, gran=gran,
+                             product=label, prod_span=f'{p_s} ~ {p_e}', rows_n=p_n))
+            if comparable and i_e and p_e and _months_between(p_e[:7], i_e[:7]) >= 2:
+                problems.append(f'{sub}: 产物 {label} 只到 {p_e}, 输入到 {i_e} '
+                                f'(落后 {_months_between(p_e[:7], i_e[:7])} 个月) → 未重算 或 该产物无生成端')
+            elif not comparable and p_e:
+                pass                                     # 年粒度: 跨度只作参考, 不判落后 (避免假缺口)
+    if verbose:
+        for r in rows:
+            print(f"  {r['input']:12s} 输入 {r['in_span']:26s} {r['files']:6d} 件 | "
+                  f"{r['product']:52s} {r['rows_n']:8d} 行 {r['prod_span']}")
+    return rows, problems
+
+
+def _months_between(a: str, b: str) -> int:
+    try:
+        ya, ma = int(a[:4]), int(a[5:7])
+        yb, mb = int(b[:4]), int(b[5:7])
+        return (yb - ya) * 12 + (mb - ma)
+    except Exception:
+        return 0
+
+
+def doc_block(farm: str) -> str:
+    """生成写进 docs/系统设计说明.md 的清单块 (Markdown)。"""
+    ps = product_stats(farm)
+    prov = provenance(farm)
+    from collections import Counter
+    by_store = Counter()
+    for rel, v in prov.items():
+        by_store[(rel.split('/')[0], v['source'])] += 1
+    L = [f'<!-- INVENTORY:BEGIN (由 scripts/inventory_products.py --write-doc 生成, 勿手改) -->',
+         f'*自动生成于 {dt.datetime.now():%Y-%m-%d %H:%M};数据源: `outputs/{farm}/_provenance.json` + `_derived_manifest.json` + 实际文件*', '',
+         '| 产物仓 | 输出路径 (相对安装根) | 功用 | 生成端 | 消费端 | 件数 | 大小 | 来源(raw 重算/随包) |',
+         '|---|---|---|---|---|---|---|---|']
+    for name, meta in STORES.items():
+        st = ps[name]
+        rd = by_store.get((name, 'raw-derived'), 0)
+        sh = by_store.get((name, 'shipped'), 0)
+        path = f'outputs/{farm}/{name}/'
+        L.append(f"| `{name}` | `{path}` | {meta['purpose']} | {meta['producer']} | {meta['consumer']} | "
+                 f"{st['n']} | {st['bytes'] / 1048576:.1f} MB | {rd} / {sh} |")
+    L += ['', f'合计 {sum(s["n"] for s in ps.values())} 件 / {sum(s["bytes"] for s in ps.values()) / 1048576:.1f} MB;'
+              f'其中 raw 重算 {sum(v for (_, k), v in by_store.items() if k == "raw-derived")} 件、'
+              f'随包补齐 {sum(v for (_, k), v in by_store.items() if k == "shipped")} 件。', MARK_E]
+    return '\n'.join(L)
+
+
+def write_doc(farm: str) -> int:
+    block = doc_block(farm)
+    txt = DOC.read_text(encoding='utf-8') if DOC.exists() else ''
+    if MARK_B in txt and MARK_E in txt:
+        head = txt.split(MARK_B)[0]
+        tail = txt.split(MARK_E)[1]
+        new = head + block + tail
+    else:
+        new = (txt + '\n\n## 附: 产物清单 (自动生成)\n\n' + block + '\n') if txt else block + '\n'
+    DOC.parent.mkdir(parents=True, exist_ok=True)
+    DOC.write_text(new, encoding='utf-8')
+    print(f'已写产物清单 → {P.rel(DOC)}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--farm', default=os_farm())
+    ap.add_argument('--check', action='store_true', help='只做呼应校验 (有问题退出码 5)')
+    ap.add_argument('--write-doc', action='store_true', help='把清单块写进 docs/系统设计说明.md')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+    from src.windscada.config import farm as _farm, raw_station_dir
+    station = pathlib.Path(raw_station_dir(a.farm))
+    ps = product_stats(a.farm)
+
+    if a.write_doc:
+        return write_doc(a.farm)
+
+    print(f'=== 产物仓 ({len(STORES)} 个) @ outputs/{a.farm}/ ===')
+    for name, meta in STORES.items():
+        st = ps[name]
+        kinds = ' '.join(f'{k}×{v}' for k, v in sorted(st['kinds'].items(), key=lambda kv: -kv[1])[:5])
+        print(f"  {name:24s} {st['n']:6d} 件 {st['bytes'] / 1048576:9.1f} MB  {meta['helper']:28s} {kinds}")
+        print(f"     功用: {meta['purpose']}")
+
+    print(f'\n=== 输入 ↔ 产物 呼应 (输入根 {P.rel(station)}) ===')
+    rows, problems = span_check(a.farm, station)
+    if not rows:
+        print('  (无可对拍项)')
+    print(f'\n=== 结论: {"呼应正常" if not problems else str(len(problems)) + " 项要处理"} ===')
+    for p in problems:
+        print(f'  [!] {p}')
+    if a.json:
+        print(json.dumps(dict(products={k: {kk: vv for kk, vv in v.items() if kk != "kinds"} for k, v in ps.items()},
+                              spans=rows, problems=problems), ensure_ascii=False, indent=1))
+    return 5 if (a.check and problems) else 0
+
+
+def os_farm() -> str:
+    import os
+    return os.environ.get('WINDSCADA_FARM') or 'rudong'
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 272 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/log_audit.py

@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""日志审计 —— 把"日志目录/命名/格式统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
+
+## 五条规则
+
+    L1 只在 logs/ 下    全仓的 `*.log` / `*.jsonl` 只许出现在 `logs/` (白名单: 产物里的
+                        `analyze_stdout.log` 属**产物附件**, 与运行日志不是一回事, 见登记理由)。
+    L2 命名              `logs/<组件>.log`(组件 = configs/serve.json 的服务键 + gateway/serve/start_hidden)、
+                        `logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log`、`logs/audit/<名字>.jsonl`。别的名字报出来。
+    L3 行格式            每一行必须匹配 `YYYY-MM-DD HH:MM:SS LEVEL 组件 消息` (src/logfile.py::LINE_RE)。
+                        **按文件末尾 200 行判**: 启用统一格式之前的旧行不算数 (这批已归档到 logs/legacy/)。
+    L4 行尾与颜色        logs/ 下的文本日志不许有 CRLF、不许有 ANSI 颜色码 (重定向到文件后颜色码是垃圾字节)。
+    L5 保留策略          `logs/ops/` 只留最近 20 份 / 30 天; 超出即报 (并可用 --prune 就地清理)。
+                        `logs/audit/*.jsonl` 每行必须是合法 JSON。
+
+## 退出码
+    0 全部通过 · 5 日志跑到 logs/ 外面 · 6 命名不合口径 · 7 行格式不合规 · 8 行尾/颜色问题 · 9 保留策略超限
+
+## 用法
+    python scripts/log_audit.py            # 检查
+    python scripts/log_audit.py --prune    # 检查并就地清理超限的动作日志
+    python scripts/log_audit.py --json
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import logfile as L                                          # noqa: E402
+from src import paths as P                                            # noqa: E402
+
+SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor', '.git-*'}
+# logs/ 之外的日志白名单: (正则, 理由)。两类:
+#   ① 产物附件: CMS 插件把"这次分析的 stdout"当产物附件留在产物目录;
+#   ② 第三方工具的缓存/配置库: npm 缓存、Puppeteer(Chromium) 的 QA profile —— 它们的 000003.log
+#      是 LevelDB 内部文件, 不是本系统的运行日志 (删不删是另一件事, 见 docs §11 的清理建议)。
+OUTSIDE_OK = [
+    (re.compile(r'^outputs/.*/windcms/.*analyze_stdout\.log$'),
+     'CMS 插件把"这次分析的 stdout"当作产物附件留在产物目录 (属产物, 不是运行日志)'),
+    (re.compile(r'^(?:.*/)?(?:\.npm-cache|\.qa-profile)/'),
+     '第三方工具 (npm 缓存 / Chromium QA profile) 自带的状态文件, 非本系统日志'),
+    (re.compile(r'^(?:.*/)?node_modules/'),
+     'node_modules 里的第三方包自带日志'),
+]
+TAIL_LINES = 200
+
+
+def scan_outside() -> list[pathlib.Path]:
+    found = []
+    for dp, dn, fns in os.walk(ROOT):
+        dn[:] = [d for d in dn if d not in SKIP_WALK and not d.startswith('.git')]
+        rel_dir = pathlib.Path(dp).relative_to(ROOT).as_posix()
+        if rel_dir == 'logs' or rel_dir.startswith('logs/'):
+            dn[:] = []
+            continue
+        for f in fns:
+            if f.endswith(('.log', '.jsonl')):
+                found.append(pathlib.Path(dp) / f)
+    return found
+
+
+def tail_lines(p: pathlib.Path, n: int = TAIL_LINES) -> list[str]:
+    try:
+        data = p.read_bytes()
+    except OSError:
+        return []
+    txt = data.decode('utf-8', 'replace')
+    lines = txt.replace('\r\n', '\n').split('\n')
+    return lines[-n:]
+
+
+def migrate_product_logs(dry: bool = True) -> list:
+    """把产物目录里的日志搬进 `logs/build/`, 并同步台账 (用户令 2: 日志不许留在产物里)。
+
+    为什么要搬: 交付包里 39 个 `.log` 躺在 `outputs/<场>/…` 下, 还被 `_provenance.json`
+    登记成 `shipped` 随包件 —— 产物台账里混着日志, 排障时也找不到。搬迁后:
+      · 文件去 `logs/build/<场>/<原相对路径>`;
+      · 台账里对应条目**删掉并重算计数**, 并写明"因日志归位而移出" (只搬文件不改台账 = 台账失真)。
+    返回 (moved, ledger_notes)。
+    """
+    moved, notes = [], []
+    out = P.ROOT / 'outputs'
+    if not out.is_dir():
+        return moved, notes
+    for f in sorted(out.rglob('*.log')):
+        rel = f.relative_to(out)                       # 如 rudong/windscada/temp_build.log
+        dst = L.LOGS / 'build' / rel
+        moved.append((f, dst))
+        if dry:
+            continue
+        dst.parent.mkdir(parents=True, exist_ok=True)
+        if dst.exists():
+            dst = dst.with_name(f'{dst.stem}_{int(dst.stat().st_mtime)}{dst.suffix}')
+        f.replace(dst)
+    if dry or not moved:
+        return moved, notes
+    for farm_dir in sorted(x for x in out.iterdir() if x.is_dir()):
+        prov_p = farm_dir / '_provenance.json'
+        if not prov_p.is_file():
+            continue
+        prov = json.loads(prov_p.read_text(encoding='utf-8'))
+        files = prov.get('files') or {}
+        drop = [k for k in files if str(k).endswith('.log')]
+        for k in drop:
+            files.pop(k, None)
+        if drop:
+            counts = {}
+            for v in files.values():
+                s = v.get('source') if isinstance(v, dict) else '?'
+                counts[s] = counts.get(s, 0) + 1
+            prov['counts'] = counts
+            prov['files'] = files
+            prov['note'] = (str(prov.get('note', '')) +
+                            f' | 2026-09-17 因「日志归位」(用户令 2) 移出 {len(drop)} 个 .log 条目: '
+                            f'那批是构建日志, 现位于 logs/build/{farm_dir.name}/ 下')
+            prov_p.write_text(json.dumps(prov, ensure_ascii=False, indent=1), encoding='utf-8')
+            notes.append(f'{farm_dir.name}/_provenance.json: 台账移出 {len(drop)} 个 .log 条目并重算计数')
+    return moved, notes
+
+
+def audit() -> tuple[int, list, dict]:
+    res: list[tuple[str, str, str, int]] = []
+    L.ensure_dirs()
+    n_svc = n_ops = n_audit = n_build = 0
+
+    # L1 只在 logs/ 下
+    for f in scan_outside():
+        rel = f.relative_to(ROOT).as_posix()
+        if any(r.match(rel) for r, _ in OUTSIDE_OK):
+            why = next(w for r, w in OUTSIDE_OK if r.match(rel))
+            res.append(('i', rel, f'白名单: {why}', 0))
+        else:
+            res.append(('X', rel, '日志跑到 logs/ 外面了 (用户令 2: 运行日志只在 logs/)', 5))
+
+    # L2/L3/L4/L5
+    for f in sorted(L.LOGS.rglob('*')):
+        if not f.is_file():
+            continue
+        rel = f.relative_to(ROOT).as_posix()
+        if 'legacy' in f.parts:
+            continue
+        if f.suffix == '.jsonl':
+            n_audit += 1
+            for i, ln in enumerate(tail_lines(f), 1):
+                if not ln.strip():
+                    continue
+                try:
+                    json.loads(ln)
+                except Exception:
+                    res.append(('X', rel, f'审计流水第 {i} 行不是合法 JSON (jsonl 一行一条)', 7))
+                    break
+            if f.parent != L.AUDIT_DIR:
+                res.append(('!', rel, '审计流水应放 logs/audit/ 下', 6))
+            continue
+        if f.suffix != '.log':
+            if f.name.endswith('.json'):
+                n_audit += 1
+                if f.parent != L.AUDIT_DIR:
+                    res.append(('!', rel, '审计/报告类 JSON 应放 logs/audit/ 下', 6))
+            continue
+        if f.parent == L.OPS_DIR:
+            n_ops += 1
+            if not re.match(r'^[\w\-]+_\d{8}-\d{6}\.log$', f.name):
+                res.append(('!', rel, '动作日志命名应为 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log', 6))
+        elif L.BUILD_DIR in f.parents:
+            n_build += 1
+            # 构建日志: 允许按产物子路径归档 (logs/build/<场>/<原相对路径>.log)。
+            # 这些是**从产物目录归位过来的历史文件**, 由未随包的构建器写成, 无法追溯重排格式 ⇒ 只提示。
+            lines = [ln for ln in tail_lines(f) if ln.strip()]
+            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
+            if bad:
+                res.append(('?', rel, f'历史构建日志 {len(bad)}/{len(lines)} 行不是统一格式 '
+                                       f'(写它的人不在本包; 新构建器请用 logfile.build_log() + logfile.line())', 0))
+            continue                      # 历史构建日志不再套"服务日志"那套严格检查 (没法追溯重排)
+        elif f.parent == L.LOGS:
+            n_svc += 1
+            if not re.match(r'^[\w\u4e00-\u9fff.\-]+\.log$', f.name):
+                res.append(('!', rel, '服务日志命名应为 logs/<组件>.log', 6))
+        else:
+            res.append(('!', rel, 'logs/ 下只允许 服务日志(顶层)、ops/、audit/、build/ 四处', 6))
+
+        raw = f.read_bytes()
+        if b'\r\n' in raw:
+            res.append(('?', rel, f'含 CRLF ({raw.count(bytes([13, 10]))} 处) —— 新写日志统一 LF', 0))
+        if L.ANSI_RE.search(raw.decode('utf-8', 'replace')):
+            res.append(('?', rel, '含 ANSI 颜色码 (历史行; 重定向到文件后是垃圾字节)', 0))
+        lines = [ln for ln in tail_lines(f) if ln.strip()]
+        if lines:
+            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
+            if len(bad) == len(lines):
+                res.append(('X', rel, f'末尾 {len(lines)} 行都不符合统一格式 (应为 "时间戳 级别 组件 消息")', 7))
+            elif bad:
+                res.append(('?', rel, f'末尾 {len(lines)} 行里 {len(bad)} 行不符格式 (多为启用前的旧行)', 0))
+
+    # L5 保留策略
+    over = L.prune_actions(dry=True)
+    if over:
+        res.append(('X', f'{P.rel(L.OPS_DIR)}', f'{len(over)} 份动作日志超出保留策略 (最近 {L.ACTION_KEEP} 份 / {L.ACTION_DAYS} 天)', 9))
+
+    info = dict(service_logs=n_svc, ops_logs=n_ops, audit_files=n_audit, build_logs=n_build,
+                ops_over_quota=len(over), logs_dir=str(L.LOGS.relative_to(ROOT)))
+    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
+    return (max(rc_map) if rc_map else 0), res, info
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--prune', action='store_true', help='就地清理超限的动作日志')
+    ap.add_argument('--migrate', action='store_true', help='把产物目录里的日志搬到 logs/build/ 并同步台账')
+    ap.add_argument('--yes', action='store_true', help='--migrate 时真正执行 (默认只预演)')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+    rc, res, info = audit()
+    if a.migrate:
+        moved, notes = migrate_product_logs(dry=not a.yes)
+        print(f'{"预演" if not a.yes else "已执行"}日志归位: {len(moved)} 个文件 '
+              f'(产物目录 → logs/build/)')
+        for src, dst in moved[:5]:
+            print(f'   {src.relative_to(ROOT).as_posix()}  →  {dst.relative_to(ROOT).as_posix()}')
+        if len(moved) > 5:
+            print(f'   … 另有 {len(moved) - 5} 个')
+        for n in notes:
+            print(f'   台账: {n}')
+        if not a.yes:
+            print('   (要真搬请加 --yes)')
+        rc, res, info = audit()
+    if a.prune:
+        gone = L.prune_actions()
+        print(f'已清理动作日志 {len(gone)} 份: {[g.name for g in gone[:5]]}{" …" if len(gone) > 5 else ""}')
+        rc, res, info = audit()
+    if a.json:
+        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
+                              info=info), ensure_ascii=False, indent=1))
+        return rc
+    print('== 日志口径 (src/logfile.py, 用户令 2) ==')
+    print(f'   服务日志 {info["service_logs"]} 份 (logs/<组件>.log) · 动作日志 {info["ops_logs"]} 份 '
+          f'(logs/ops/, 保留 {L.ACTION_KEEP} 份/{L.ACTION_DAYS} 天) · 审计流水 {info["audit_files"]} 份 (logs/audit/) · '
+          f'构建日志 {info["build_logs"]} 份 (logs/build/<场>/…)')
+    print(f'   行格式   {L.line("INFO", "示例", "一行长这样")}')
+    lvl = {}
+    for level, item, note, r in res:
+        lvl[level] = lvl.get(level, 0) + 1
+    print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · 白名单 {lvl.get("i", 0)} ==')
+    for level, item, note, r in res:
+        if level in ('X', '!'):
+            print(f'   [{level}] {item}: {note}')
+    seen = set()
+    for level, item, note, r in res:
+        if level == '?' and note not in seen:
+            seen.add(note)
+            print(f'   [?] {item}: {note}')
+    print(f'结论: {"全部符合口径" if rc == 0 else "见上"}; 退出码 {rc}')
+    return rc
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 195 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/module_boundary_audit.py

@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""模块边界审计(用户令 2026-09-22:按算法/数据接入管理/前端/后端等模块重构,组件化、高内聚低耦合、可插拔)。
+
+## 它查什么(每条都由 `configs/modules.yaml` 驱动)
+
+| 规则 | 内容 | 退出码 |
+|---|---|---|
+| R1 结构 | 每个登记模块目录含 `common/<pkg>/__init__.py`、`common/<pkg>/api.py`、`README.md` | 5 |
+| R2 边界 | 模块之间**只许经对方 `api.py`**:`app_x/**/*.py` 里 import 另一个 `app_y` 的实现包(非 `api`)即违规 | 6 |
+| R3 依赖方向 | 只许 import `allow` 里列出的模块;反向依赖/成环即违规 | 7 |
+| R4 公共层纯净 | `app_common/**` 不得 import 任何其它业务模块 | 7 |
+| R5 接口可用 | 每个 `api.py` 的 `_TARGETS` 转发目标(既有实现的文件路径)必须真实存在 | 8 |
+
+另外**统计迁移进度**(不算失败):`src/**`、`scripts/**` 里还有多少件尚未迁进模块目录(P1–P7 的度量)。
+
+## 用法
+    python scripts/module_boundary_audit.py            # 审计(rc=0 通过)
+    python scripts/module_boundary_audit.py --brief    # 只打结论与进度
+退出码: 0 通过 · 5 结构缺失 · 6 边界违规 · 7 依赖违规 · 8 接口目标缺失
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import ast
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+
+from src import paths as P                                                  # noqa: E402
+
+
+def load_registry() -> dict:
+    import yaml
+    return yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
+
+
+def module_pkg(name: str) -> str:
+    return f'{name}_guanlan'
+
+
+def _imports_of(path: pathlib.Path) -> list[str]:
+    """→ 该文件里所有 import 的模块名(`import a.b` 与 `from a.b import c` 都算 a.b)。"""
+    try:
+        tree = ast.parse(path.read_text(encoding='utf-8'))
+    except SyntaxError:
+        return []
+    out = []
+    for n in ast.walk(tree):
+        if isinstance(n, ast.Import):
+            out += [a.name for a in n.names]
+        elif isinstance(n, ast.ImportFrom) and n.module:
+            out.append(n.module)
+    return out
+
+
+def audit(verbose: bool = True):
+    reg = load_registry()
+    mods = {m['name']: m for m in reg['modules']}
+    problems: list[tuple[str, str]] = []          # (规则, 说明)
+    notes: list[str] = []
+
+    # R1 结构
+    for name, m in mods.items():
+        base = ROOT / name
+        for rel in (f'common/__init__.py', f'common/{module_pkg(name)}/__init__.py',
+                    f'common/{module_pkg(name)}/api.py', 'README.md'):
+            if not (base / rel).is_file():
+                problems.append(('R1', f'{name}/ 缺 {rel}'))
+    # R5 接口目标存在
+    for name in mods:
+        api = ROOT / name / 'common' / module_pkg(name) / 'api.py'
+        if not api.is_file():
+            continue
+        try:
+            tree = ast.parse(api.read_text(encoding='utf-8'))
+        except SyntaxError as e:
+            problems.append(('R1', f'{name}/api.py 语法错误: {e}'))
+            continue
+        tg = {}
+        for n in tree.body:
+            if isinstance(n, ast.Assign) and getattr(n.targets[0], 'id', '') == '_TARGETS':
+                try:
+                    tg = ast.literal_eval(n.value)
+                except Exception:  # noqa: BLE001
+                    tg = {}
+        for pub, target in tg.items():
+            rel = pathlib.Path(*target.split('.'))
+            if not ((ROOT / rel).with_suffix('.py').is_file() or (ROOT / rel / '__init__.py').is_file()):
+                problems.append(('R5', f'{name}.api.{pub} → {target} 的转发目标不存在'))
+
+    # R2/R3/R4 边界与依赖
+    for name, m in mods.items():
+        allow = set(m.get('allow') or [])
+        for f in (ROOT / name).rglob('*.py'):
+            for imp in _imports_of(f):
+                parts = imp.split('.')
+                if parts[0] not in mods or parts[0] == name:
+                    continue
+                other = parts[0]
+                # 只许 app_<other>.common.app_<other>_guanlan.api
+                ok_face = (len(parts) >= 4 and parts[1] == 'common'
+                           and parts[3] == 'api' and parts[2] == module_pkg(other))
+                if not ok_face:
+                    problems.append(('R2', f'{f.relative_to(ROOT).as_posix()} 直接 import {imp}'
+                                           f'(应走 {other}.common.{module_pkg(other)}.api)'))
+                if other not in allow:
+                    problems.append(('R3', f'{f.relative_to(ROOT).as_posix()} import {other}'
+                                           f',但 {name} 的 allow 未包含它'))
+                if name == 'app_common':
+                    problems.append(('R4', f'{f.relative_to(ROOT).as_posix()} 公共层依赖了业务模块 {other}'))
+
+    # R6 入口与布局解耦: 安装器必须能在当前布局下解析出包版本
+    _ver_cands = ['app_common/common/app_common_guanlan/version.py', 'src/version.py']
+    _found = None
+    for _c in _ver_cands:
+        _f = ROOT / _c
+        if _f.is_file() and re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M):
+            _found = (_c, re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M).group(1))
+            break
+    if not _found:
+        problems.append(('R6', f'当前布局下没有任何候选版本文件含 `VERSION = \'…\'` 字面量(候选: {_ver_cands})'
+                               ' ⇒ install.ps1/install.sh 会读不到包版本'))
+    else:
+        _ps1 = (ROOT / 'install.ps1').read_text(encoding='utf-8') if (ROOT / 'install.ps1').is_file() else ''
+        _sh = (ROOT / 'install.sh').read_text(encoding='utf-8') if (ROOT / 'install.sh').is_file() else ''
+        if _ps1 and 'app_common' not in _ps1:
+            problems.append(('R6', 'install.ps1 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
+        if _sh and 'app_common' not in _sh:
+            problems.append(('R6', 'install.sh 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
+
+    # R7 入口读 JSON 必须指定 UTF-8(PS 5.1 默认 ANSI)
+    _ps1f = ROOT / 'install.ps1'
+    if _ps1f.is_file():
+        for _i, _ln in enumerate(_ps1f.read_text(encoding='utf-8').splitlines(), 1):
+            if _ln.strip().startswith('#'):
+                continue          # 注释里提到 Get-Content/.json 不算
+            if 'Get-Content' in _ln and '.json' in _ln and '-Encoding' not in _ln:
+                problems.append(('R7', f'install.ps1:{_i} 读 JSON 未指定 -Encoding UTF8 ⇒ PS 5.1 会按 ANSI 解码中文'))
+
+    # R8 CLI 兼容壳必须同面转发实现的公开名(否则 importlib 调用其函数会 AttributeError)
+    for _shim in sorted((ROOT / 'scripts').glob('*.py')):
+        _txt = _shim.read_text(encoding='utf-8')
+        if '兼容转发(P' not in _txt:
+            continue
+        if 'globals().setdefault' not in _txt and 'sys.modules[__name__]' not in _txt:
+            problems.append(('R8', f'scripts/{_shim.name} 是兼容壳但未转发实现公开名 '
+                                   '⇒ 用 importlib 调其函数的调用方会 AttributeError'))
+
+    # 迁移进度(不算失败)
+    legacy = [p for p in list((ROOT / 'src').rglob('*.py')) + list((ROOT / 'scripts').rglob('*.py'))
+              if p.is_file()]
+    notes.append(f'尚未迁入模块目录的既有实现件: src/** {len(list((ROOT / "src").rglob("*.py")))} 件 · '
+                 f'scripts/** {len(list((ROOT / "scripts").rglob("*.py")))} 件(迁移进度按 docs/重构方案_模块化_v0.1.md 的 P1–P7 推进)')
+
+    rc = 0
+    if any(p[0] in ('R1',) for p in problems):
+        rc = 5
+    if any(p[0] == 'R2' for p in problems):
+        rc = rc or 6
+    if any(p[0] in ('R3', 'R4', 'R6', 'R7', 'R8') for p in problems):
+        rc = rc or 7
+    if any(p[0] == 'R5' for p in problems):
+        rc = rc or 8
+    if verbose:
+        print('模块边界审计 · 登记模块 %d 个(%s)' % (len(mods), '、'.join(mods)))
+        for n in notes:
+            print('  [i]', n)
+        if problems:
+            for rule, msg in problems:
+                print('  [X] %s %s' % (rule, msg))
+        else:
+            print('  [OK] 结构齐全 · 模块间只经 api 调用 · 依赖方向合规 · 公共层纯净 · 接口目标全在位')
+    return rc, problems, notes, mods
+
+
+def main() -> int:
+    brief = '--brief' in sys.argv
+    rc, problems, notes, mods = audit(verbose=not brief)
+    if brief:
+        print('[OK] module_boundary_audit rc=0(登记 %d 模块)' % len(mods) if rc == 0
+              else '[X] module_boundary_audit rc=%d(%d 条违规)' % (rc, len(problems)))
+    return rc
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 156 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/page_fingerprint.py

@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""页面/接口指纹 —— 改造前后与换机部署的回归判据 (2026-09-11)。
+
+## 为什么要有
+
+路径收敛、换机器部署这类改动,**危险在静默**: 某处路径漏改 → 页面某块空掉/取不到数, 但不报错。
+所以需要一把"改前改后必须一模一样"的尺子。
+
+指纹 = 对一组页面与接口取 `status + 字节数 + sha256`; 另存一份**归一化** sha256:
+把安装根路径 (本机绝对路径, 正/反斜杠两种写法) 统一替换成 `<ROOT>` 后再算 —— 这样
+"换了机器所以绝对路径前缀不同"不会误报成内容变化, 而"内容真的变了"仍然抓得到。
+
+用法:
+    python scripts/page_fingerprint.py --save .fingerprint_before.json    # 取基线 (相对安装根)
+    python scripts/page_fingerprint.py --diff .fingerprint_before.json    # 比对 (不一致则退出码 3)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import hashlib
+import json
+import pathlib
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置唯一取用口
+
+# 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
+ENDPOINTS = [
+    ('gateway.healthz', 'http://127.0.0.1:{gateway}/healthz'),
+    ('gateway.version', 'http://127.0.0.1:{gateway}/api/version'),
+    ('portal.home', 'http://127.0.0.1:{gateway}/'),
+    ('detail.v2', 'http://127.0.0.1:{detail}/v2'),
+    ('detail.maint_survey', 'http://127.0.0.1:{detail}/api/maint_survey'),
+    ('detail.query.actpower', 'http://127.0.0.1:{detail}/api/query?t=WTG01&ch=grd_wtc_ActPower_mean&win=2026%E5%B9%B4&agg=med'),
+    ('detail.turbine_problems', 'http://127.0.0.1:{detail}/api/turbine?t=WTG01&win=2026%E5%B9%B4'),
+    ('cms.home', 'http://127.0.0.1:{cms}/?theme=light'),
+    ('sim_sys.page', 'http://127.0.0.1:{sim_sys}/' + urllib.parse.quote('0_四系统合页.html')),
+    ('viewer.index', 'http://127.0.0.1:{viewer}/index.html'),
+]
+
+
+def ports() -> dict:
+    """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
+    default = dict(gateway=28084, detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292, ollama=11434)
+    p = P.SERVE_JSON                      # 配置唯一取用口
+    cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
+    return {**default, **{k: v for k, v in cfg.items() if isinstance(v, int)}}
+
+
+# 设计上就会变的字段 (实测: healthz 的 checked/ms 每次不同; version 的 repo_head 随提交变) ——
+# 归一化时剔除, 否则每次比对都是噪音; 真实数据/页面内容不在其列。
+VOLATILE = {'checked', 'ms', 'repo_head', 'elapsed', 'uptime', 'uptime_s', 'pid', 'generated_at', 'asof'}
+
+
+def _strip(node):
+    if isinstance(node, dict):
+        return {k: _strip(v) for k, v in node.items() if k not in VOLATILE}
+    if isinstance(node, list):
+        return [_strip(v) for v in node]
+    return node
+
+
+def normalize(body: bytes) -> bytes:
+    """→ 用于指纹的字节: JSON 先剔易变字段再按键排序; 非 JSON 原样 (页面类实测稳定)。
+    另外把安装根 (绝对路径, 两种斜杠写法) 归一成 <ROOT>, 消除"换机器"的前缀差异。"""
+    t = body.decode('utf-8', 'replace')
+    try:
+        j = json.loads(t)
+        t = json.dumps(_strip(j), ensure_ascii=False, sort_keys=True)
+    except Exception:
+        pass
+    for form in (str(ROOT), str(ROOT).replace('\\', '/'), str(ROOT).replace('/', '\\')):
+        t = t.replace(form, '<ROOT>')
+    return t.encode('utf-8')
+
+
+def fetch(url: str):
+    try:
+        with urllib.request.urlopen(url, timeout=120) as r:
+            return r.status, r.read()
+    except urllib.error.HTTPError as e:
+        return e.code, e.read()
+    except Exception as e:
+        return None, f'{type(e).__name__}: {e}'.encode()
+
+
+def take() -> dict:
+    P = ports()
+    out = {}
+    for name, tpl in ENDPOINTS:
+        url = tpl.format(**P)
+        status, body = fetch(url)
+        out[name] = dict(url=url, status=status, bytes=len(body),
+                         sha256=hashlib.sha256(body).hexdigest()[:16],
+                         norm_sha256=hashlib.sha256(normalize(body)).hexdigest()[:16],
+                         norm_bytes=len(normalize(body)))
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--save', default=None, help='把指纹写到这个 JSON')
+    ap.add_argument('--diff', default=None, help='与这个基线 JSON 比对')
+    a = ap.parse_args()
+    now = take()
+
+    if a.save:
+        p = pathlib.Path(a.save)
+        p.parent.mkdir(parents=True, exist_ok=True)
+        p.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'已保存 {len(now)} 个端点指纹 → {p}')
+        for k, v in now.items():
+            print(f'  {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B  {v["sha256"]}  norm {v["norm_sha256"]}')
+        return 0
+
+    if a.diff:
+        base = json.loads(pathlib.Path(a.diff).read_text(encoding='utf-8'))
+        bad = 0
+        print(f'{"端点":26s} {"状态":>10s} {"归一化字节":>24s}  norm_sha (基线→现在)')
+        for k, v in now.items():
+            b = base.get(k)
+            if b is None:
+                print(f'{k:26s} {"(基线无)":>10s}'); bad += 1; continue
+            same = v['norm_sha256'] == b.get('norm_sha256')
+            flag = '一致' if same else '★变化'
+            if not same:
+                bad += 1
+            print(f'{k:26s} {str(b["status"])+"→"+str(v["status"]):>10s} '
+                  f'{b.get("norm_bytes", b["bytes"]):>11,}→{v["norm_bytes"]:<12,} '
+                  f'{b.get("norm_sha256")}→{v["norm_sha256"]}  {flag}')
+        print(f'\n结论: {"全部一致 ✔ (页面与取数面零变化)" if not bad else f"{bad} 个端点有变化"}')
+        return 0 if not bad else 3
+
+    for k, v in now.items():
+        print(f'  {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B  {v["sha256"]}  norm {v["norm_sha256"]}')
+    return 0
+
+
+if __name__ == '__main__':
+    # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"全部一致"这一行 print 会抛
+    # UnicodeEncodeError、退出码变 1 —— 闸门报成失败而实际是通过的 (2026-09-11 踩过)。
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 642 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/pages_audit.py

@@ -0,0 +1,642 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。
+
+## 背景(用户的问题)
+
+    http://127.0.0.1:28084/#findings  #sim  #documents 及它们包含的子页,
+    "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。
+
+答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告),
+还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。
+本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是:
+
+  · static            —— 正文不得引用产物(setting 变了就该改分类);
+  · live              —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务;
+  · data-citing       —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉);
+  · data-derived      —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物
+                         逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来);
+  · frozen-delivery   —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。
+
+## 为什么要有"陈旧检测"
+
+页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。
+数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。
+
+## 退出码 (给 rebuild_all / check 用)
+
+    0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误
+
+## 用法
+
+    python scripts/pages_audit.py                 # 表 + 检查(默认)
+    python scripts/pages_audit.py --list          # 只打表
+    python scripts/pages_audit.py --check         # 只检查(安静模式, 只打结论)
+    python scripts/pages_audit.py --write-doc     # 把表写进 docs/系统设计说明.md (标记区间内)
+    python scripts/pages_audit.py --json          # 机器可读结果
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import fnmatch
+import glob as globmod
+import hashlib
+import html as htmllib
+import json
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+
+REG = P.CONFIGS / 'portal_pages.yaml'
+DOC = P.ROOT / 'docs' / '系统设计说明.md'
+
+
+def dt_from(ts: float | None) -> str:
+    """mtime → 'YYYY-mm-dd HH:MM' (报告里给人看的时间)。"""
+    if not ts:
+        return '?'
+    import datetime as _dt
+    return _dt.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
+DOC_BEGIN, DOC_END = '<!-- PAGES:BEGIN -->', '<!-- PAGES:END -->'
+
+RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9
+KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery')
+
+RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+')
+# 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。
+# ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。
+RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版')
+RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}')
+RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)')
+
+
+def load(path=None):
+    import yaml
+    reg_p = pathlib.Path(path) if path else REG
+    if not reg_p.is_file():
+        raise SystemExit(f'缺登记表 {P.rel(reg_p)}')
+    reg = yaml.safe_load(reg_p.read_text(encoding='utf-8'))
+    reg['_file'] = str(reg_p)
+    return reg
+
+
+def sha256(p: pathlib.Path) -> str | None:
+    try:
+        return hashlib.sha256(p.read_bytes()).hexdigest()
+    except OSError:
+        return None
+
+
+def expand(entry) -> list[pathlib.Path]:
+    """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。"""
+    pat = P.ROOT / entry['file']
+    s = str(pat)
+    if any(c in s for c in '*?['):
+        return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True))
+    return [pat] if pat.is_file() else []
+
+
+def texts(files):
+    for f in files:
+        try:
+            yield f, f.read_text(encoding='utf-8', errors='replace')
+        except OSError:
+            continue
+
+
+def read_embedded_json(path: pathlib.Path, var: str):
+    """抓页面里内嵌的 window.<var> = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。"""
+    try:
+        t = path.read_text(encoding='utf-8', errors='replace')
+    except OSError:
+        return None
+    m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t)
+    if not m:
+        return None
+    raw, depth = '', 0
+    for ch in t[m.start(1):]:
+        raw += ch
+        if ch == '{':
+            depth += 1
+        elif ch == '}':
+            depth -= 1
+            if depth == 0:
+                break
+    for cand in (raw, htmllib.unescape(raw)):
+        try:
+            return json.loads(cand)
+        except Exception:
+            continue
+    return None
+
+
+def check_entry(e, results):
+    """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。
+
+    level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。
+    ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life),
+      页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处,
+      所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。
+    """
+    eid = e.get('id') or e.get('name') or '?'
+    kind = e.get('kind')
+    gap = e.get('known_gap') or {}
+    if kind not in KINDS:
+        results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND))
+        return
+    files = expand(e)
+    if not files and not e.get('members_in_zip'):
+        results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS))
+        return
+
+    # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查)
+    for f, t in texts(files):
+        cited = set(e.get('cites') or [])
+        if kind in ('data-derived', 'data-citing'):
+            cited |= set(RE_OUT.findall(t))
+        for c in sorted(cited):
+            if (P.ROOT / c).exists():
+                continue
+            if gap and c in (gap.get('missing') or []):
+                results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK))
+            else:
+                results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS))
+
+    # ② 分类规则
+    if kind == 'static':
+        for f, t in texts(files):
+            if RE_OUT.search(t):
+                results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived',
+                                RC_KIND))
+
+    elif kind == 'live':
+        svc = {}
+        try:
+            svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
+        except Exception:
+            pass
+        ports = {int(v) for k, v in svc.items() if isinstance(v, int)}
+        tgt = e.get('live_target') or ''
+        m = re.search(r':(\d{2,5})', tgt)
+        if m and ports and int(m.group(1)) not in ports:
+            results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS))
+
+    elif kind == 'data-derived':
+        src = e.get('source')
+        var = e.get('embedded_json_var')
+        skey, shkey = e.get('source_key'), e.get('source_sha_key')
+        found_sha, found_src, found_gen = None, src, None
+        if var and skey:
+            for f in files:
+                obj = read_embedded_json(f, var)
+                if isinstance(obj, dict):
+                    found_src = obj.get(skey) or found_src
+                    found_sha = obj.get(shkey) if shkey else None
+                    break
+        for f, t in texts(files):
+            if found_gen is None:
+                g = RE_GEN.search(t)
+                if g:
+                    found_gen = g.group(1)
+        if not found_src:
+            if gap:
+                results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK))
+            else:
+                results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV))
+        if not (found_sha or found_gen or e.get('generated_at_key')):
+            if e.get('generated_at_source') == 'mtime':
+                # 页面件自己就是生成物: 用"它自己的 mtime"当生成时间, 与 source 的 mtime 比 ⇒ 可做陈旧代理判据
+                src = e.get('source')
+                mt = None
+                for f in files:
+                    try:
+                        mt = f.stat().st_mtime
+                        break
+                    except OSError:
+                        continue
+                sp = (P.ROOT / src) if isinstance(src, str) else None
+                if mt and sp is not None and sp.is_file() and (sp.stat().st_mtime - mt) > 86400:
+                    results.append(('X', eid, f'**页面件疑似陈旧**: {P.rel(files[0])} 写于 '
+                                              f'{dt_from(mt)}, 而它的源 {src} 更新于 {dt_from(sp.stat().st_mtime)} '
+                                              f'⇒ 源换了、这份页面件没重生成', RC_STALE))
+                else:
+                    results.append(('OK', eid, f'按 mtime 判: 页面件与源 {src or "(未登记)"} 的新旧关系正常 '
+                                                f'(页面件 {dt_from(mt) if mt else "?"})', RC_OK))
+                if not found_src:
+                    pass
+            elif not gap:
+                results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV))
+        if found_src:
+            sp = P.ROOT / found_src
+            if not sp.exists():
+                if gap and found_src in (gap.get('missing') or []):
+                    results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK))
+                else:
+                    results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS))
+            elif found_sha:
+                cur = sha256(sp)
+                if cur and cur.lower() != str(found_sha).lower():
+                    results.append(('X', eid,
+                                    f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 '
+                                    f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成',
+                                    RC_STALE))
+                else:
+                    results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK))
+            elif e.get('generated_at_source') != 'mtime':
+                results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 '
+                                          f'(记录: {found_gen or "无"})', RC_PROV))
+
+    elif kind == 'frozen-delivery':
+        ok_ver = ok_date = False
+        for f in files:
+            name = f.name
+            ok_ver |= bool(RE_VER.search(name))
+            ok_date |= bool(RE_DATE.search(name))
+            if not (ok_ver and ok_date):
+                t = f.read_text(encoding='utf-8', errors='replace')      # 版本号/日期可能在正文深处
+                ok_ver |= bool(RE_VER.search(t))
+                ok_date |= bool(RE_DATE.search(t))
+        if e.get('version'):
+            ok_ver = True
+        if not ok_ver:
+            results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版',
+                            RC_VER))
+        if not ok_date and not e.get('version'):
+            results.append(('?', eid, '冻结交付件没有日期标记', RC_VER))
+
+    # ③ 子项递归
+    for c in e.get('children') or []:
+        check_entry(c, results)
+
+
+def declared_patterns(reg) -> list[str]:
+    """登记表里所有 file 字段 (含通配) —— 覆盖检查用。"""
+    pats = []
+
+    def walk(e):
+        f = e.get('file')
+        if f:
+            pats.append(str(f))
+        for c in e.get('children') or []:
+            walk(c)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return pats
+
+
+def coverage_gaps(reg) -> list[str]:
+    """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。"""
+    pats = declared_patterns(reg)
+    gaps = []
+    tdir = P.RELEASE / 'portal_src' / 'templates'
+    for f in sorted(tdir.glob('*.html')):
+        rel = f.relative_to(P.ROOT).as_posix()
+        if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats):
+            gaps.append(rel)
+    return gaps
+
+
+def render(reg, results) -> str:
+    lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|']
+    def walk(e, depth=0):
+        eid = e.get('id') or e.get('name') or '?'
+        title = e.get('title') or ''
+        pre = '&nbsp;&nbsp;└ ' * depth
+        lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | "
+                     f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |")
+        for c in e.get('children') or []:
+            walk(c, depth + 1)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return '\n'.join(lines)
+
+
+def inventory() -> list[dict]:
+    """**所有网页**总清点(用户令 2026-09-17 第 2 问):把系统会呈现的每一页/每个子页列出来,
+    并判定"是不是产物(= 由 data/raw 重算出来的输出数据)"。
+
+    来源四路,各有依据(不靠猜):
+      ① 门户锚点   `release/portal_src/shell.html` 的 `<div class="pg" id=… data-title=…>`
+      ② 工作台标签  `app_frontEnd/.../assets/app.js` 的 `TABS` 常量(SPA tab,实时读产物)
+      ③ 组件产物页  `outputs/<场>/**/*.html|*.md` —— **这些本身就是产物**
+      ④ 交付/仿真/三维页 `release/如东/**/*.html`、仿真资料包 zip 内的页面、`release/viewer/*.html`、内嵌件
+    """
+    reg = load()
+    entries = _all_entries(reg)
+    reg_files = {str(e.get('file') or '') for e in entries}
+    reg_ids = {str(e.get('id') or e.get('name') or '') for e in entries}
+    rows: list[dict] = []
+
+    # ① 门户锚点
+    shell = P.RELEASE / 'portal_src' / 'shell.html'
+    if shell.is_file():
+        t = shell.read_text(encoding='utf-8', errors='replace')
+        for m in re.finditer(r'<div class="pg"[^>]*id="([^"]+)"[^>]*data-title="([^"]*)"', t):
+            rows.append(dict(source='门户锚点', name=f'#{m.group(1)}', title=m.group(2), kind='shell',
+                             product=False, registered=m.group(1) in reg_ids,
+                             evidence='外壳页(静态/iframe/链接);逐条判定见 §10 与 §12'))
+    # ② 工作台 SPA 标签
+    appjs = P.ROOT / 'app_frontEnd' / 'common' / 'app_frontEnd_guanlan' / 'assets' / 'app.js'
+    if appjs.is_file():
+        t = appjs.read_text(encoding='utf-8', errors='replace')
+        m = re.search(r"const TABS = \[([^\]]+)\]", t)
+        if m:
+            for tab in re.findall(r"'([a-z_]+)'", m.group(1)):
+                rows.append(dict(source='工作台标签', name=f'tab={tab}', title='', kind='live', product=False,
+                                 registered=_tab_registered(tab, entries),
+                                 evidence='SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照'))
+    # ③ 组件产物页(本身就是产物)
+    out = P.out_root()
+    ledger = _ledger_keys()
+    REPORT_HINT = ('报告', '收敛', '诊断', '评估', '结论', 'report', 'summary', 'findings')
+    if out.is_dir():
+        for f in sorted(list(out.rglob('*.html')) + list(out.rglob('*.md'))):
+            rel = f.relative_to(out).as_posix()
+            if rel.startswith('_'):
+                continue
+            full = f'{out.name}/{rel}'
+            if f.suffix == '.html':
+                src, prod, why = '组件页面件 (.html)', True, '位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件'
+            elif any(k in f.name.lower() for k in REPORT_HINT):
+                src, prod, why = '组件结论件 (.md,页面会渲染)', True, '产物(结论文本);工作台 report tab / CMS report.html 会渲染它'
+            else:
+                src, prod, why = '产物里的过程留痕 (.md,非网页)', True, '产物,但没有任何页面呈现它(给下游线的交接/复跑记录)'
+            rows.append(dict(source=src, name=rel, title='', kind='product-page', product=prod,
+                             registered=(rel in ledger), evidence=why + f';台账: {"在" if rel in ledger else "缺"}'))
+    # ④ 交付/仿真/三维/内嵌
+    # 已登记判定: 精确名 或 **通配模式**(登记表里常按 wildcard 收一族文件, 如 tpl-report-*.html.html)
+    _ = None
+    for pat, src, kind, prod, why in (
+            ('如东/**/*.html', '交付页 (release/如东)', 'frozen-delivery', False, '客户交付件,按版本冻结'),
+            ('viewer/*.html', '三维页 (release/viewer)', 'static', False, '图纸/模型派生的三维资产'),
+            ('portal_src/templates/*.html', '门户内嵌件', 'embed', None, '内嵌交付件(§10 逐条判定;个别为数据派生)')):
+        for f in sorted(P.RELEASE.glob(pat)):
+            if f.name.startswith('.'):
+                continue
+            rel = f.relative_to(P.RELEASE).as_posix()
+            rows.append(dict(source=src, name=rel, title='', kind=kind, product=prod,
+                             registered=_is_registered(rel, f'outputs/{rel}', entries), evidence=why))
+    # 仿真资料包里的页面
+    z = P.RELEASE / '如东SWT40_控制律仿真台_20260906.zip'
+    if z.is_file():
+        import zipfile
+        with zipfile.ZipFile(z) as zf:
+            for n in zf.namelist():
+                if not n.endswith('.html'):
+                    continue
+                try:
+                    nm = n.encode('cp437').decode('utf-8')
+                except Exception:
+                    nm = n
+                rows.append(dict(source='仿真资料包 (zip)', name=nm.split('/')[-1], title='', kind='frozen-delivery',
+                                 product=False, registered=True, evidence='图纸派生的冻结资料包(sim_sys_server 从 zip 现读)'))
+    return rows
+
+
+def _declared_patterns(entries: list[dict]) -> list[str]:
+    """登记表里所有 `file:` 取值(含通配)—— 判定"这一页/这一件有没有被登记"用。"""
+    return [str(e.get('file') or '') for e in entries if e.get('file')]
+
+
+def _is_registered(rel: str, full: str, entries: list[dict]) -> bool:
+    """精确 或 通配 命中页面登记表 → 已登记(通配用全路径与 basename 两种都比一次)。"""
+    import fnmatch
+    names = {rel, full, pathlib.Path(rel).name}
+    pats = _declared_patterns(entries)
+    pats += [pathlib.Path(x).name for x in pats]
+    for n in names:
+        if n in pats:
+            return True
+        if any(fnmatch.fnmatch(n, x) for x in pats):
+            return True
+    return False
+
+
+def _tab_registered(tab: str, entries: list[dict]) -> bool:
+    for e in entries:
+        if e.get('id') == 'detail_tabs' and tab in (e.get('tabs') or []):
+            return True
+    return False
+
+
+def _ledger_keys() -> set:
+    """产物台账里的键(相对 outputs/<场>/),判定"产物有没有被管起来"用。"""
+    prov_p = P.out_root() / '_provenance.json'
+    if not prov_p.is_file():
+        return set()
+    try:
+        return set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
+    except Exception:
+        return set()
+
+
+def _all_entries(reg) -> list[dict]:
+    out = []
+
+    def walk(e):
+        out.append(e)
+        for c in e.get('children') or []:
+            walk(c)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return out
+
+
+def render_inventory(rows: list[dict]) -> str:
+    head = ['| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |', '|---|---|---|---|---|']
+    body = []
+    for r in rows:
+        p = '**是**' if r['product'] else ('—(逐条判定)' if r['product'] is None else '不是')
+        body.append(f"| {r['source']} | `{r['name']}`{' ' + r['title'] if r.get('title') else ''} | {p} | "
+                    f"{r['evidence']} | {'✔' if r['registered'] else '**✘ 未登记**'} |")
+    return '\n'.join(head + body)
+
+
+def audit(registry=None):
+    """→ (rc, results, unregistered)。
+
+    供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程:
+    子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。
+    """
+    reg = load(registry)
+    results: list[tuple[str, str, str, int]] = []
+    for e in reg.get('pages') or []:
+        check_entry(e, results)
+    results += ledger_coverage(reg)
+    rc_map = {r[3] for r in results if r[0] not in ('OK', 'i', '?') and r[3]}
+    return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg)
+
+
+def ledger_coverage(reg) -> list:
+    """页面侧产物的**台账覆盖**检查 (用户令 2026-09-17: 是产物就要纳入产物管理)。
+
+    凡登记表里指向 `outputs/<场>/…` 的路径 (`file` / `source`), 都必须出现在该场的
+    `_provenance.json::files` 里 —— 否则它虽然被页面用了, 却没进产物台账 (来源/类型无从查)。
+    """
+    out: list[tuple[str, str, str, int]] = []
+    prov_p = P.out_root() / '_provenance.json'
+    if not prov_p.is_file():
+        return out
+    try:
+        prov = set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
+    except Exception:
+        return out
+    prefix = P.out_root().relative_to(P.ROOT).as_posix() + '/'      # outputs/<场>/
+
+    def walk(e):
+        eid = e.get('id') or e.get('name') or '?'
+        gap = e.get('known_gap') or {}
+        gap_missing = set(gap.get('missing') or [])
+        for key in ('file', 'source'):
+            v = e.get(key)
+            if not isinstance(v, str) or not v.startswith(prefix) or any(c in v for c in '*?['):
+                continue
+            if v in gap_missing or any(v.endswith(g.split('/')[-1]) for g in gap_missing):
+                out.append(('i', eid, f'页面侧产物 {v} 不在本包(已知缺口): {gap.get("why", "")[:60]}', RC_OK))
+                continue
+            rel = v[len(prefix):]
+            if rel not in prov:
+                out.append(('X', eid, f'页面侧产物 {v} 未进产物台账 (_provenance.json 里没有 {rel}) '
+                                      f'⇒ 来源/类型无从查, 不算"纳入产物管理"', RC_PROV))
+        for c in e.get('children') or []:
+            walk(c)
+    for e in reg.get('pages') or []:
+        walk(e)
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--list', action='store_true', help='只打表')
+    ap.add_argument('--check', action='store_true', help='只检查')
+    ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md')
+    ap.add_argument('--inventory', action='store_true',
+                    help='**所有网页**总清点: 门户锚点/工作台标签/组件产物页/交付页/三维页/内嵌件/仿真页, 逐条判定是否产物')
+    ap.add_argument('--json', action='store_true', help='机器可读输出')
+    ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
+    a = ap.parse_args()
+    reg = load(a.registry)
+    rc, results, gaps = audit(a.registry)
+
+    if a.json:
+        print(json.dumps(dict(rc=rc,
+                              results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results],
+                              unregistered=gaps), ensure_ascii=False, indent=1))
+        return rc
+
+    if a.inventory:
+        rows = inventory()
+        prod = [r for r in rows if r['product'] is True]
+        un = [r for r in rows if not r['registered']]
+        print(f'== 所有网页总清点: {len(rows)} 项 ==')
+        print(f'   判定为产物(= 输入数据重算出来的输出数据): {len(prod)} 项; 未被管起来: {len(un)} 项')
+        cur = None
+        for r in rows:
+            if r['source'] != cur:
+                cur = r['source']
+                print(f'\n  ▸ {cur}')
+            flag = '产物' if r['product'] is True else ('逐条判定' if r['product'] is None else '非产物')
+            print(f'     [{"✔" if r["registered"] else "✘"}] {r["name"][:74]:74s} {flag}')
+        if un:
+            print('\n   未被管起来的条目:')
+            for r in un:
+                print(f'     ✘ {r["source"]} | {r["name"]}')
+        print('\n   判定依据: 产物板(组件页面件/结论件/过程留痕) = 位置在 outputs/<场>/ 下; 页面侧 = 登记表 kind 与 §10/§12 的逐条依据')
+        if a.write_doc:
+            write_doc(load())
+        return RC_OK
+
+    if a.list or not a.check:
+        print('== 门户页面归口 (configs/portal_pages.yaml) ==')
+        for e in reg.get('pages') or []:
+            def walk(x, d=0):
+                eid = x.get('id') or x.get('name')
+                print(f'   {"  " * d}{eid:34s} {x.get("kind"):16s} '
+                      f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}')
+                for c in x.get('children') or []:
+                    walk(c, d + 1)
+            walk(e)
+
+    if not a.check or True:
+        lvl = {}
+        for level, item, note, r in results:
+            lvl[level] = lvl.get(level, 0) + 1
+        print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, '
+              f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==')
+        for level, item, note, r in results:
+            if level not in ('OK', 'i'):
+                print(f'   [{level}] {item}: {note}')
+        if lvl.get('i'):
+            print(f'   [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):')
+            seen = set()
+            for level, item, note, r in results:
+                if level == 'i' and item not in seen:
+                    seen.add(item)
+                    print(f'        · {item}')
+        if gaps:
+            print(f'   [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):')
+            for g in gaps[:8]:
+                print(f'        · {g}')
+        print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}')
+        if a.write_doc:
+            write_doc(reg)
+        return rc
+    return rc
+
+
+INV_BEGIN, INV_END = '<!-- PAGES-INV:BEGIN -->', '<!-- PAGES-INV:END -->'
+
+
+def write_inventory_doc() -> None:
+    """把"所有网页总清点"写进 §12.2(自动块, 放在 §13 之前)。"""
+    rows = inventory()
+    prod = sum(1 for r in rows if r['product'] is True)
+    un = [r for r in rows if not r['registered']]
+    head = (f'### 12.2 全部网页总清点(自动生成,勿手改)\n\n'
+            f'共 **{len(rows)}** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / '
+            f'交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 {prod} 项;'
+            f'未被管起来的 **{len(un)}** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。\n')
+    block = f'{INV_BEGIN}\n{head}\n{render_inventory(rows)}\n\n{INV_END}'
+    if not DOC.is_file():
+        return
+    t = DOC.read_text(encoding='utf-8')
+    if INV_BEGIN in t and INV_END in t:
+        t = re.sub(re.escape(INV_BEGIN) + r'.*?' + re.escape(INV_END), lambda m: block, t, flags=re.S)
+    else:
+        anchor = '\n---\n\n## 13.'
+        t = t.replace(anchor, f'\n\n{block}\n{anchor}', 1) if anchor in t else t.rstrip() + '\n\n' + block + '\n'
+    DOC.write_text(t, encoding='utf-8')
+    print('   已写入 §12.2 全部网页总清点 (自动块)')
+
+
+def write_doc(reg) -> None:
+    if not DOC.is_file():
+        print(f'   (缺 {P.rel(DOC)}, 跳过写文档)')
+        return
+    t = DOC.read_text(encoding='utf-8')
+    block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n'
+             f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n'
+             f'{render(reg, None)}\n\n{DOC_END}')
+    if DOC_BEGIN in t and DOC_END in t:
+        t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S)
+    else:
+        t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n'
+    DOC.write_text(t, encoding='utf-8')
+    print(f'   已写入 {P.rel(DOC)} (§10 页面归口)')
+    write_inventory_doc()
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 309 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/pages_sweep.py

@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""观澜各页面「有没有输出」体检(用户令 2026-09-19)。
+
+背景: 清过产物 + 从 `data/raw` 重算之后, 页面出现"没有输出"的现象。本器**逐页面(含嵌套页)**走一遍
+网关入口(默认 http://127.0.0.1:28084), 对每一页给出:
+
+    HTTP 码 · 字节数 · 判定(有输出 / 空 / 缺件 / 降级 / 报错 / 静态资源)· 证据(页面里那句话)· 疑似原因
+
+覆盖面:
+  · 门户 `/`(含内嵌面板是否有内容)· `/healthz` `/api/version` `/ops`
+  · `/release/*` 本体发布层(随包件, 清产物后必缺)
+  · `/detail/*` 综合详细分析工作台(v2 的 10 个页签取数接口 + 经典嵌套页 `/turbine/<台>` `/problem/<台>/<系统>`)
+  · `/cms/*` CMS 振动诊断(总览/逐台页/报告页)
+  · `/sim/` `/sim/sys/` `/viewer/` 三个静态模块的入口
+  · `release/` 下**随包静态页**(取数单等, 按入口页里出现的链接发现)
+
+判定口径(不猜): 先看 HTTP 与字节数; 再看**页面自己认账的那句话**(缺件/无产物/加载失败/…);
+JSON 则解析顶层 `err/error/no_products/no_report` 与空数组。每条判定都带证据片段, 便于人工复核。
+
+用法:
+    python scripts/pages_sweep.py                 # 控制台摘要 + 写 UTF-8 报告
+    python scripts/pages_sweep.py --base http://127.0.0.1:28084
+    python scripts/pages_sweep.py --write docs/页面输出体检_v0.1.md
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+# ── 页面清单 ────────────────────────────────────────────────────────────────────
+#   kind: page=人看的页面 · api=取数接口 · static=静态资源
+#   why : 这一页的产物依赖(用于把"空"归因到"缺哪件")
+PAGES: list[dict] = [
+    dict(path='/', kind='page', why='门户单文件 (release/portal.html), 内嵌结论段由产物渲染',
+         dep='guanlan/derived/portal_claims.json'),
+    dict(path='/healthz', kind='api', why='模块探活'),
+    dict(path='/api/version', kind='api', why='版本/契约指纹'),
+    dict(path='/ops', kind='page', why='运维控制台 (网关自提供)'),
+    dict(path='/release/', kind='api', why='本体发布层 manifest (随包件 ontology/release_*)'),
+    dict(path='/release/r1/manifest.json', kind='api', why='本体发布层 r1', dep='ontology/release_r1/manifest.json'),
+    dict(path='/release/r2/manifest.json', kind='api', why='本体发布层 r2', dep='ontology/release_r2/manifest.json'),
+
+    # /detail 工作台: v2 页签底数 + 各自取数
+    dict(path='/detail/', kind='page', why='v2 工作台外壳 (10 页签)'),
+    dict(path='/detail/api/fleet?win=2026%E5%B9%B4', kind='api', why='全部页签底数: windscada/*.parquet + ontology', dep='windscada/temp_monthly.parquet'),
+    dict(path='/detail/api/curves', kind='api', why='特性曲线页', dep='windscada/curve_lenses.parquet'),
+    dict(path='/detail/api/vibcms', kind='api', why='振动评估页: windcms/报告_CMS*.md', dep='windcms/报告_CMS振动状态评估报告_2026-09-19.md'),
+    dict(path='/detail/api/maint_survey', kind='api', why='系统自查页: ontology/objects.json', dep='ontology/objects.json'),
+    dict(path='/detail/api/ont_chain?kind=tree&system=%E9%BD%BF%E8%BD%AE%E7%AE%B1', kind='api', why='决策链页', dep='ontology/objects.json'),
+    dict(path='/detail/api/facts', kind='api', why='问答/报告页: guanlan/ 事实契约 (人裁底稿派生)', dep='guanlan/derived/detail_cards.json'),
+    dict(path='/detail/api/ask_status', kind='api', why='问答页: 本机模型'),
+    # 经典嵌套页(/detail/<rest> → 组件根路径)
+    dict(path='/detail/turbine/WTG01', kind='page', why='逐台页 (经典)'),
+    dict(path='/detail/problem/WTG01/%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F', kind='page', why='问题页 (经典)'),
+    dict(path='/detail/api/turbine?t=WTG01&win=2026%E5%B9%B4', kind='api', why='逐台下钻浮层'),
+    dict(path='/detail/api/problem?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
+         kind='api', why='问题下钻浮层'),
+
+    # /cms CMS 振动诊断
+    dict(path='/cms/', kind='page', why='CMS 首页 (windcms/index.html)', dep='windcms/index.html'),
+    dict(path='/cms/overview.html', kind='page', why='CMS 总览', dep='windcms/overview.html'),
+    dict(path='/cms/turbines/WTG01.html', kind='page', why='CMS 逐台页', dep='windcms/turbines/WTG01.html'),
+    dict(path='/cms/report.md', kind='page', why='CMS 结构化层报告', dep='windcms/report.md'),
+
+    # 静态模块入口
+    dict(path='/sim/', kind='page', why='仿真与回放 (随包资产)'),
+    dict(path='/sim/sys/', kind='page', why='仿真·四系统合页 (随包资产)'),
+    dict(path='/viewer/', kind='page', why='三维拆装工作台 (随包资产)'),
+]
+
+# ★2026-09-19 收紧: 只认"这一页确实没数据"的**具体话术**。首版把 不可用/缺件/无数据 这类通用词也算命中,
+#   结果门户图例、i18n 词表、运维页说明文字都被算成"缺件" ⇒ 噪声淹掉真问题。
+EMPTY_MARKERS = [
+    ('无产物', '页面自报"无产物"'), ('产物未生成', '页面自报"产物未生成"'),
+    ('还没有生成', '页面自报"还没有生成"'), ('本体库缺失', '本体库缺失'),
+    ('报告解析失败', '报告解析失败'), ('模块不可用: ', '网关的下线页'),
+    ('加载失败', '页面自报加载失败'), ('读取失败', '页面自报读取失败'),
+    ('Traceback', '页面里带 traceback'), ('no_products', '接口回 no_products'),
+    ('not found', '404/未找到'),
+]
+SPA_SHELLS = {'/', '/detail/'}          # 单页应用壳: 正文由 JS 取数渲染, 不能按"可见文本"判空
+
+
+def fetch(base: str, path: str, timeout: int = 60):
+    url = base + path
+    try:
+        with urllib.request.urlopen(url, timeout=timeout) as r:
+            return r.status, r.headers.get('Content-Type', ''), r.read()
+    except urllib.error.HTTPError as e:
+        return e.code, e.headers.get('Content-Type', '') if e.headers else '', e.read()
+    except Exception as e:
+        return None, f'{type(e).__name__}', str(e).encode('utf-8')
+
+
+_TAG = re.compile(r'<[^>]+>')
+_SCRIPT = re.compile(r'<(script|style)\b.*?</\1>', re.S | re.I)
+_COMMENT = re.compile(r'<!--.*?-->', re.S)
+
+
+def visible_text(html: str) -> str:
+    """页面**可见文本**(去掉 script/style/注释/标签)。
+
+    ★为什么必须先去脚本: 首版直接在整页文本里找"不可用/undefined/缺件"这类词, 命中的全是 JS 里的
+    i18n 词表与 CSS 类名(例如逐台页里 `'监测不可用':'var(--unknown)'`)⇒ **好页面被误判成缺件**。
+    判定只许看人能看到的那部分。
+    """
+    t = _SCRIPT.sub(' ', html)
+    t = _COMMENT.sub(' ', t)
+    t = _TAG.sub(' ', t)
+    return re.sub(r'\s+', ' ', t).strip()
+
+
+def judge(path: str, kind: str, code, ctype: str, body: bytes) -> dict:
+    raw = body.decode('utf-8', 'replace')
+    n = len(body)
+    is_html = ('html' in (ctype or '').lower()) or raw.lstrip()[:1] == '<'
+    txt = visible_text(raw) if is_html else raw
+    hits = [why for mk, why in EMPTY_MARKERS if mk in txt]
+    err_msg = ''
+    if 'json' in (ctype or '') and n < 400000:
+        try:
+            o = json.loads(raw)
+            if isinstance(o, dict):
+                err_msg = str(o.get('err') or o.get('error') or '')
+                if o.get('no_products') or o.get('no_report'):
+                    hits.append('接口自报无产物')
+                empties = [k for k, v in o.items() if isinstance(v, list) and not v]
+                if empties and len(empties) >= max(1, len(o) // 2):
+                    hits.append('顶层多数数组为空: ' + ','.join(empties[:4]))
+        except Exception:
+            pass
+    if code is None:
+        verdict = '连不上'
+    elif code >= 500:
+        verdict = '报错'
+    elif code >= 400:
+        verdict = '报错' if code != 404 else '未找到'
+    elif kind == 'static':
+        verdict = '静态资源'
+    elif n < 200:
+        verdict = '空'
+    elif hits:
+        verdict = '缺件/降级'
+    elif path in SPA_SHELLS:
+        verdict = '有输出(单页壳)'      # 壳本身只有标题, 正文由 JS 从 /api/* 取 —— 判空要看它的接口
+    elif is_html and len(txt) < 120 and n < 60000:
+        # ★阈值要带字节数: 200 KB 的逐台/问题页可见文本本来就少(正文全是 JS 渲染的),
+        #   只看"可见文本 <120 字"会把它们误判成空页(2026-09-19 实逮)。
+        verdict = '空(可见文本极少)'
+    else:
+        verdict = '有输出'
+    ev = err_msg[:90] or (hits[0] if hits else txt[:70])
+    return dict(verdict=verdict, code=code, n=n, ctype=(ctype or '')[:40], hits=hits[:3],
+                evidence=ev, visible=len(txt))
+
+
+CAUSE_RUNNING = '重算进行中(跑完自愈)'
+CAUSE_NOGEN = '该族无生成端(重算也补不出)'
+CAUSE_HUMAN = '依赖人裁底稿(非机械可算)'
+CAUSE_EXTERNAL = '外部组件(本机模型)'
+CAUSE_STATIC = '随包静态资产(与重算无关)'
+CAUSE_OK = '有输出'
+
+
+def _families() -> tuple:
+    """→ (族表, 是否正在重算)。族表用来判"缺的这一族有没有生成端"; 在跑与否决定能不能自愈。"""
+    fams = []
+    try:
+        from .products_reverse_audit import FAMILIES
+        fams = FAMILIES
+    except Exception:
+        pass
+    running = False
+    try:
+        from src import opsjob
+        running = bool(opsjob.current().get('running'))
+    except Exception:
+        pass
+    return fams, running
+
+
+def family_of(fams, rel: str):
+    import fnmatch
+    for fam in fams:
+        pats = [fam.get('glob')] if isinstance(fam.get('glob'), str) else list(fam.get('glob') or [])
+        for g in pats:
+            if '{' in g:
+                head, tail = g.split('{', 1)
+                opts, rest = tail.split('}', 1)
+                exp = [head + o + rest for o in opts.split(',')]
+            else:
+                exp = [g]
+            for pt in exp:
+                if '**' not in pt and rel.count('/') != pt.count('/'):
+                    continue
+                if rel == pt or fnmatch.fnmatch(rel, pt):
+                    return fam
+    return None
+
+
+def cause_of(path: str, verdict: str, dep: str | None, fams, running: bool) -> str:
+    """把"空/报错"翻成原因: 依赖族有没有生成端 → 会不会自愈。判据只有事实, 不猜。"""
+    if verdict == '有输出':
+        return CAUSE_OK
+    if verdict == '静态资源':
+        return CAUSE_STATIC
+    if path.startswith('/local-ai') or '/ask' in path:
+        return CAUSE_EXTERNAL
+    f = family_of(fams, dep) if dep else None
+    if f is None:
+        return CAUSE_RUNNING if running else '依赖件不在位(放原始件后重算)'
+    if f.get('kind') in ('shipped', 'human') and not f.get('gen'):
+        # 本体发布层/带版本交付件 = 研发出件(不是人裁底稿,别把两类混成一句)
+        if f.get('id') in ('ontology_releases', 'windscada_pages', 'tcm_replay'):
+            return '包内无生成端(研发出件)'
+        return CAUSE_HUMAN if f.get('id') in ('guanlan_contract', 'sop_workspace', 'paradigm_r1',
+                                             'human_deliverables') else CAUSE_NOGEN
+    return CAUSE_RUNNING if running else '依赖件不在位(重算未覆盖到)'
+
+
+def discover_links(base: str, prefix: str, html: bytes, limit: int = 25) -> list[str]:
+    """从页面里发现**同前缀**的嵌套页(href/src),用于"含嵌套页面"的覆盖。"""
+    txt = html.decode('utf-8', 'replace')
+    got, seen = [], set()
+    for m in re.finditer(r'(?:href|src)=["\']([^"\'#?]+)["\']', txt):
+        u = m.group(1)
+        if not u.startswith('/') or not u.startswith(prefix):
+            continue
+        if u.endswith(('.js', '.css', '.png', '.jpg', '.svg', '.woff', '.woff2', '.ico', '.map')):
+            continue
+        if u in seen or '${' in u:          # JS 模板字面量不是页面
+            continue
+        seen.add(u)
+        got.append(u)
+        if len(got) >= limit:
+            break
+    return got
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--base', default='http://127.0.0.1:28084')
+    ap.add_argument('--write', default=None)
+    ap.add_argument('--links', type=int, default=12, help='每个模块再抽几个嵌套页')
+    a = ap.parse_args()
+
+    tasks = list(PAGES)
+    # 嵌套页: 先抓每个模块入口, 从中发现同前缀链接
+    for prefix in ('/detail', '/cms', '/sim', '/sim/sys', '/viewer', '/ops'):
+        code, ct, body = fetch(a.base, prefix + '/')
+        if not body:
+            continue
+        for u in discover_links(a.base, prefix, body, a.links):
+            tasks.append(dict(path=u, kind='page', why=f'{prefix} 内的嵌套页 (从入口页发现)'))
+
+    fams, running = _families()
+    with ThreadPoolExecutor(max_workers=8) as ex:
+        res = list(ex.map(lambda t: (t, fetch(a.base, t['path'])), tasks))
+    rows = []
+    for t, (code, ctype, body) in res:
+        j = judge(t['path'], t['kind'], code, ctype, body)
+        j.update(path=t['path'], kind=t['kind'], why=t['why'], dep=t.get('dep'))
+        j['cause'] = cause_of(t['path'], j['verdict'], t.get('dep'), fams, running)
+        rows.append(j)
+
+    order = {'报错': 0, '未找到': 1, '空': 2, '缺件/降级': 3, '连不上': 4, '有输出': 5, '静态资源': 6}
+    rows.sort(key=lambda r: (order.get(r['verdict'], 9), r['path']))
+    L = [f'# 观澜页面输出体检 · {a.base}', '',
+         f'共 {len(rows)} 页(含从各模块入口发现的嵌套页)· 判定口径: HTTP + 字节数 + 页面自己认账的那句话',
+         f'· 重算是否正在跑: **{"是(产物会随步骤长回来)" if running else "否"}**', '',
+         '| 判定 | 路径 | 码 | 字节 | 原因 | 证据 | 产物依赖 |', '|---|---|---:|---:|---|---|---|']
+    for r in rows:
+        L.append(f"| {r['verdict']} | `{r['path']}` | {r['code']} | {r['n']:,} | {r['cause']} | "
+                 f"{str(r['evidence'])[:60]} | {r['why']} |")
+    rep = '\n'.join(L)
+    if a.write:
+        pathlib.Path(a.write).write_text(rep + '\n', encoding='utf-8')
+    from collections import Counter
+    cnt = Counter(r['verdict'] for r in rows)
+    print('判定分布: ' + ' · '.join(f'{k}={v}' for k, v in cnt.most_common()))
+    for r in rows:
+        if r['verdict'] not in ('有输出', '静态资源'):
+            print(f"  [{r['verdict']}] {r['path']}  HTTP {r['code']}  {r['n']:,}B  {str(r['evidence'])[:70]}")
+    if a.write:
+        print(f'已写 {a.write}')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 451 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/products_restore_missing.py

@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""补齐"包内没有生成端"的产物 —— 只补缺件, 不动 raw 重算件; 并落一份**来源台账** (2026-09-12)。
+
+## ★ 本器已退出运行链(用户令 2026-09-17 #1)
+
+**"观澜系统运行不要从含产物的交付包补齐"**。所以:
+
+· `rebuild_all.py` 的第 ⑤ 步**不再调用本器**(改成"反向呼应审计",只报账不搬运);
+  `products_state` / 门户 / 各组件页里的提示也不再指向"拿交付包补";
+· 本器现在**只作研发/离线人工补救**:某台机器上确实缺了一批"包内无生成端"的件、且当场无法重算时,
+  由人显式指定来源跑一次。任何自动化的"看旁边有没有 zip"都去掉了 —— 那会把"包"变成隐式依赖。
+· 运行期遇到缺件的正确处置是(按优先级):
+    ① 放原始件到 `data/raw/<场站>/` → `python scripts/rebuild_all.py`(有生成端的那些);
+    ② 没有生成端的那些 → **由研发补生成端**,逐族可逆性见 `docs/系统设计说明.md` §13.6;
+    ③ 仍缺 → 页面/自检会如实列出缺哪件(结构化缺件页 + `guanlan.py check` 的「反向呼应」一行)。
+
+## 为什么它当初存在
+
+`rebuild_from_raw.py` 能从 `data/raw` 重算出一部分产物; 但随包里另有一批产物**全库只有读取方、
+0 处写入方** —— 没有生成端, 从零重算就是拿不到 (工作台主数据 `/api/fleet` 一缺
+`pitch/pitch_daily.parquet` / `temp_monthly.parquet` 就整个回 `err=no_products`, 页面看着像"没数据")。
+2026-09-12 的口径是"证明不了的用随包件补齐、但标明它不是重算件";2026-09-17 用户令把这条
+**从运行期拿掉**:宁可如实显示缺件,也不让系统依赖交付包。
+
+## 规则 (保守优先)
+
+对随包产物来源里的每个文件:
+  · 目标**已存在** → 跳过 (那是 raw 重算出来的, 不许被随包件覆盖);
+  · 目标不存在 → 拷过去, 记为 `shipped`(台账 `_provenance.json` 里一眼看得出它不是重算件);
+  · `.log/.jsonl` → 按用户令 2 落到 `logs/build/<场>/`,不算产物。
+另: 只处理"产物目录"(outputs/<场>/ 下的仓), 不碰 release/ 与 data/。
+
+## 用法(**人工**,不在重算链里)
+
+    python scripts/products_restore_missing.py --dry-run          # 只报要补什么
+    python scripts/products_restore_missing.py --stash <dir>      # 从随包产物目录补
+    python scripts/products_restore_missing.py --stash <包.zip>   # 从含产物的 zip 补(离线补救)
+    python scripts/products_restore_missing.py --find             # 只看旁边哪个 zip 里真有产物
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import fnmatch
+import json
+import pathlib
+import shutil
+import sys
+import time
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+
+from src import paths as P                                     # noqa: E402
+
+# 由 data/raw 重算出来的 (重建器 + 验证依据) —— 这些**永不被随包件覆盖**
+RAW_DERIVED = {
+    # 三门台账 (rebuild_from_raw.py; --verify 与随包基线逐值比对过)
+    'windscada/alarms.parquet': 'scripts/windscada_alarms_ingest.py',
+    'windscada/workorders.parquet': 'scripts/windscada_workorder_ingest.py',
+    'windscada/oil_samples_index.parquet': 'scripts/windscada_watch_channels_build.py',
+    # SCADA 侧 10 个构建器 (rebuild_from_raw.py --scada)
+    'windscada/powercurve_dev.parquet': 'src.windscada.perf.powercurve',
+    'windscada/powercurve_bins.parquet': 'src.windscada.perf.powercurve',
+    'windscada/loss_monthly.parquet': 'src.windscada.perf.availability',
+    'windscada/curve_lenses.parquet': 'src.windscada.perf.curves',
+    'windscada/curve_liveness.parquet': 'src.windscada.perf.curves',
+    'windscada/control_profile.parquet': 'src.windscada.perf.control',
+    'windscada/control_schedule.parquet': 'src.windscada.perf.control',
+    'windscada/stop_events.parquet': 'src.windscada.perf.faults',
+    'windscada/temp_bins.parquet': 'src.windscada.subsys.temp_nbm',
+    'windscada/yaw_daily.parquet': 'src.windscada.subsys.yaw',
+    'windscada/hydraulic_accum.parquet': 'src.windscada.subsys.hydraulic',
+    'windscada/thermal_chain.parquet': 'src.windscada.subsys.thermal_chain',
+    'windscada/system_aux.parquet': 'src.windscada.taxonomy',
+    # 月度派生件 (本轮新增; 逐值对齐随包件后才算数)
+    'windscada/temp_monthly.parquet': 'scripts/windscada_monthly_build.py (19494/19494 逐值一致)',
+    # 本体层 (kb_ingest → populate → chain_ingest → trend_ingest; 全链跑完后 audit 0 问题)
+    'ontology/objects.json': 'python -m src.ontology.kb_ingest + populate + chain_ingest + trend_ingest (audit 0 问题)',
+    'ontology/retrieval_index.json': 'src.ontology.retrieval.build',
+    'ontology/turbine_params.parquet': 'src.ontology.maintenance.refresh_params',
+}
+
+
+def zip_candidates() -> list[pathlib.Path]:
+    """可能含产物的包(本仓根 + 上一级)—— **仅供离线人工诊断**,运行链不用它。
+
+    ★ 用户令 2026-09-17 #1「观澜系统运行不要从含产物的交付包补齐」:本器已**退出运行链**
+      (`rebuild_all.py` 第 ⑤ 步改成反向呼应审计,只报账不搬运)。自动找包的逻辑一并去掉 ——
+      留着它等于让"配一个 zip 在旁边"变成隐式依赖,与"目标机放数据自己重算"这条主线冲突。
+      本函数现在只服务于 `--find`(人工看一眼"旁边哪个包里有产物")。
+    """
+    out = []
+    for base in (ROOT, ROOT.parent):
+        try:
+            out += [p for p in sorted(base.glob('*.zip')) if p.is_file()]
+        except OSError:
+            pass
+    return list(dict.fromkeys(out))
+
+
+def find_stash_zip() -> tuple[pathlib.Path, int] | None:
+    """→ (含产物的包, 该场产物件数);找不到就 None。"""
+    import zipfile
+    marker = f'outputs/{P.farm()}/windscada/temp_monthly.parquet'
+    pref = f'outputs/{P.farm()}/'
+    best = None
+    for z in zip_candidates():
+        try:
+            with zipfile.ZipFile(z) as zf:
+                names = set(zf.namelist())
+        except Exception:                                    # 不是 zip / 读不动 / 加密: 跳过, 不中断
+            continue
+        if marker not in names:
+            continue
+        n = sum(1 for x in names if x.startswith(pref))
+        if best is None or n > best[1]:
+            best = (z, n)
+    return best
+
+
+def stash_dir(explicit=None) -> pathlib.Path:
+    """随包产物原件在哪。
+
+    四个地方都可能是它(取决于用过哪些开关), 所以按"像不像随包整份产物"打分挑:
+      · `_products_off/<场>/`                     = products_state --off 挪走的当前产物;
+      · `_products_off_prev_<时间戳>/<场>/`         = --off --archive-old 存档的上一代(= 随包原件);
+      · `--stash` 显式指定 (目录或交付包 zip);
+      · **含产物的交付包 zip, 自动找** (2026-09-17 加: 本仓根与上一级 `.zip` 里挑真含随包件的那个)。
+    判据用**只在随包里有的件**(windscada/temp_monthly.parquet 这类"包内无生成端"的产物):
+    含它的目录才是"标准答案"来源。原先只看第一个候选, 于是 --on 还原后暂存区空了 → 台账被写成 0/0。
+    """
+    if explicit:
+        # 必须 resolve: 传相对路径时下面 `stash.relative_to(ROOT)` 会 ValueError (2026-09-12 实逮)
+        return pathlib.Path(explicit).resolve()
+    marker = 'windscada/temp_monthly.parquet'
+    cands = []
+    for pat in ('_products_off/*', '_products_off_prev_*/*'):
+        cands += [p for p in sorted(ROOT.glob(pat)) if p.is_dir() and p.name != '_baseline_kept']
+    scored = []
+    for c in cands:
+        n = sum(1 for _ in c.rglob('*') if _.is_file())
+        scored.append(((1 if (c / marker).is_file() else 0), n, c))
+    scored.sort(key=lambda x: (-x[0], -x[1]))
+    if scored and scored[0][0]:                       # 只有真含随包件的目录才算数
+        return scored[0][2]
+    raise StashMissing('找不到随包产物原件 (暂存区 _products_off/**、或 --stash 显式指定)')
+
+
+class StashMissing(SystemExit):
+    """暂存区不存在 —— **不是崩溃, 是"这一步没得做"**。
+
+    2026-09-16 用户令"系统运行中/未运行都能重算"时实逮: `rebuild_all.py` 的第⑤步 (补齐"包内没有生成端"
+    的产物) 在**没有暂存区**的检出上必抛 SystemExit → 重建链当场中断, 后面的 ⑦本体链 与 ⑧审计**全不执行**
+    (脚本的中断语义是"后面的步骤依赖它")。而暂存区只有"清除产物"动作会产生 —— 首次全量重算、或
+    清完又还原过的机器上就都没有, 于是"从零重算"在交付包上跑不完。
+    处置: 本类带退出码 6, 由 rebuild_all.py 显式容忍并打印"跳过随包补齐" —— 缺的是"随包件"这一路,
+    不是重算本身; 如实报出来比假装通过好, 也比把整条链打断好。
+    """
+
+    exit_code = 6
+
+
+def _family_patterns(glob) -> list[str]:
+    """族 glob(str 或 list)→ 展开花括号后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
+    out = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            out += [head + o + rest for o in opts.split(',')]
+        else:
+            out.append(g)
+    return out
+
+
+def _attribute(rel: str) -> dict:
+    """盘上有、无人登记的件 → 按**族表**归口 (2026-09-18)。
+
+    为什么必须做这一步: 生成端**自登记**(`_derived_manifest.json`) 并非全覆盖 —— 实测
+    `windscada/*.parquet`(17 件, 由 `rebuild_from_raw.py --scada` 的 10 个构建器写)、
+    `ontology/*`(3 件, 由本体的 kb_ingest/populate/retrieval 写) 都不自登记。此前本模式
+    **只**照抄自登记件 ⇒ 台账 1705 条、盘上 1728 件, 差的 23 件既不在账上也不被报出
+    (账实不符的另一个方向, 而且正好是"页面主取数"那一批)。现在按族表补归口, 归不上
+    的**如实记 `unregistered`** —— 不猜来源。
+
+    深度对齐规则同 `products_reverse_audit._files_of`: 模式无 `**` 时, `/` 个数必须相等
+    (fnmatch 的 `*` 跨 `/`, 不显式挡会让 `windscada/*.parquet` 吞掉子目录里的同名件)。
+    """
+    try:
+        from .products_reverse_audit import FAMILIES
+    except Exception as e:                                      # 族表读不到: 全部按未归类记
+        return dict(source='unregistered', builder='?',
+                    why=f'盘上有、生成端未自登记, 且族表读不到({type(e).__name__}) ⇒ 需人工归口')
+    for fam in FAMILIES:
+        for pt in _family_patterns(fam.get('glob')):
+            if '**' not in pt and rel.count('/') != pt.count('/'):
+                continue
+            if rel == pt or fnmatch.fnmatch(rel, pt):
+                kind = fam.get('kind') or 'raw-derived'
+                src = {'raw-derived': 'raw-derived', 'shipped': 'shipped', 'human': 'human',
+                       'source-derived': 'source-derived', 'not-product': 'not-product'}.get(kind, kind)
+                return dict(source=src, builder=fam.get('gen') or '(族表登记: 无生成端)',
+                            why=f'盘上在位但生成端未自登记 → 按族表 {fam.get("id")} 归口'
+                                f'({fam.get("func") or ""})')
+    return dict(source='unregistered', builder='?',
+                why='盘上有、生成端未自登记、族表也未登记 ⇒ 需人工归口 '
+                    '(本条是软件兜底记账, 不是真来源; 见到了要么补自登记, 要么补族表)')
+
+
+def _reattr_needed(v: dict) -> bool:
+    """这条账是不是"族表归口的产物"(可随族表改进而重写),而不是真来源(自登记/人工补入)。"""
+    return v.get('source') == 'unregistered' or '按族表' in str(v.get('why') or '')
+
+
+def refresh_ledger(farm: str | None = None) -> int:
+    r"""**只维护台账**(不需要任何随包件/交付包):把盘上已经不存在的条目从 `_provenance.json` 里去掉。
+
+    为什么需要它(用户令 2026-09-17 #3):把"非产物"的杂物从产物仓移走后,台账里还留着它们的条目 ——
+    于是反向呼应审计仍把它们当产物统计("帐上 2292 件、盘上 2275 件"这种不一致正是这套台账最该避免的)。
+    本模式只做"账实相符":**在位且来源已登记的照抄,盘上没了的删掉**,不引入任何新来源。
+    """
+    farm = farm or P.farm()
+    root = P.out_root(farm)
+    old = {}
+    f = root / '_provenance.json'
+    if f.is_file():
+        try:
+            old = (json.loads(f.read_text(encoding='utf-8')) or {}).get('files') or {}
+        except Exception:
+            old = {}
+    kept, dropped = {}, []
+    for rel, v in old.items():
+        if (root / rel).is_file():
+            kept[rel] = v
+        else:
+            dropped.append(rel)
+    # 自登记件 (_derived_manifest.json): 在位且台账里没有的补进来
+    added = 0
+    try:
+        from src.derived_manifest import load as _load
+        for rel, info in ((_load(root).get('files') or {})).items():
+            if (root / rel).is_file() and rel not in kept:
+                kept[rel] = dict(source='raw-derived',
+                                 builder=f"{info.get('builder', '?')} (自登记 by {info.get('by', '?')})")
+                added += 1
+    except Exception:
+        pass
+    # ── 另一个方向: 盘上有、账上没有的件 → 按族表归口 (绝不静默漏掉) ──────────────
+    #    台账两本 (_provenance.json / _derived_manifest.json) 都是**这本台账自己**:
+    #    它们不是产物, 不登记 (否则每次写盘都会让"账实相符"永远差两件)。
+    swept: list[str] = []
+    for p in sorted(root.rglob('*')):
+        if not p.is_file():
+            continue
+        rel = p.relative_to(root).as_posix()
+        if rel in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):   # 链上台账/快照, 不是产物
+            kept.pop(rel, None)          # ★旧账里可能留有这条(早先没排): 顺手出清, 免得"账实相符"永远差一件
+            continue
+        # 只补"没人登记"的; 但**族表归口过的条目要重跑** —— 否则族表改了(例如给 windcms/cache
+        # 单列一族)这条账会一直停在旧归口上, 而它并不是谁自登记/谁补进来的真来源。
+        if rel in kept and not _reattr_needed(kept[rel]):
+            continue
+        kept[rel] = _attribute(rel)
+        swept.append(rel)
+    by = {}
+    for v in kept.values():
+        by[v.get('source', '?')] = by.get(v.get('source', '?'), 0) + 1
+    print(f'台账: {len(old)} 条 → {len(kept)} 条(盘上已删 {len(dropped)} 条, 自登记补入 {added} 条, '
+          f'按族表归口 {len(swept)} 条)')
+    for r in dropped[:12]:
+        print(f'   - 移出/删除: {r}')
+    if len(dropped) > 12:
+        print(f'   … 另有 {len(dropped) - 12} 条')
+    for r in swept[:8]:
+        print(f'   + 归口: {r}  ← {kept[r].get("builder")}')
+    if len(swept) > 8:
+        print(f'   … 另有 {len(swept) - 8} 条归口')
+    unk = [r for r in swept if kept[r].get('source') == 'unregistered']
+    if unk:
+        print(f'   ★ {len(unk)} 件**归不上族**: ' + ', '.join(unk[:6]) + (' …' if len(unk) > 6 else ''))
+    print('   来源分布: ' + ' · '.join(f'{k}={v}' for k, v in sorted(by.items())))
+    f.write_text(json.dumps(dict(
+        at=time.strftime('%Y-%m-%d %H:%M:%S'),
+        note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端(历史随包件); '
+             'source-derived = 由源码重建(如发布清单); human = 人工正本/交证件; '
+             'unregistered = 盘上有但无人登记(需人工归口); '
+             'log-relocated = 包内日志按用户令 2 落到 logs/build/',
+        counts=dict(by), files=kept), ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'已写 {P.rel(f)}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--stash', default=None)
+    ap.add_argument('--find', action='store_true',
+                    help='只看旁边的 .zip 里哪个真含产物 (人工诊断用, 不写盘)')
+    ap.add_argument('--refresh', action='store_true',
+                    help='只维护台账: 把盘上已不存在的条目从 _provenance.json 去掉 (不需要任何随包件)')
+    a = ap.parse_args()
+    if a.refresh:
+        return refresh_ledger()
+    if a.find:
+        found = find_stash_zip()
+        if not found:
+            print('旁边的 .zip 里没有含' + f'outputs/{P.farm()}/ 产物的包')
+            return 0
+        print(f'含产物的包: {P.rel(found[0])}({found[1]} 件 outputs/{P.farm()}/ 条目)')
+        print('  注意: 本器只作**离线人工补救**; 运行期不要从交付包补齐(用户令 2026-09-17)')
+        return 0
+    try:
+        stash = stash_dir(a.stash)
+    except StashMissing as e:
+        # 没有暂存区 → 这一步没得做。**不许静默、也不许把整条重算链打断**(见 StashMissing 的说明)。
+        print(f'[跳过] {e}')
+        print('       处置(运行期): ① 放原始件到 data/raw/<场站>/ 后 python scripts/rebuild_all.py;'
+              ' ② 没有生成端的族由研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6)。')
+        print('       处置(离线人工补救, 非运行链): --stash <随包产物目录 或 含产物的包.zip>')
+        return StashMissing.exit_code
+    # ★2026-09-16: 原写法 `P.STORE if hasattr(P,'STORE') else ROOT/'outputs'/'rudong'` —— P.STORE 根本
+    #   不存在, 于是**恒**落到硬编码的 rudong, 多场部署下会把 rudong 的随包件补进别的场 (静默串场)。
+    dest_root = P.out_root()
+    print(f'随包件: {P.rel(stash)}')
+    print(f'产物仓: {dest_root.relative_to(ROOT)}\n')
+
+    prov = {}
+
+    def take(rel: str, src_path=None, src_bytes=None):
+        """补齐一件 (或在位则只记账)。src_path=目录来源, src_bytes=zip 来源。
+
+        ★ 日志不补进产物仓 (2026-09-17): 历史交付包里把构建日志也当产物发过 (如
+          `m5_cms_tcm/shared_component_bandpower.log`)。用户令 2 定的是"运行日志只在 logs/",
+          所以这里把它直接落到 `logs/build/<场>/…` —— 数据没丢, 但不再算产物、也不进台账
+          (否则每从旧包补一次, `guanlan.py check` 的「日志统一」就红一次: 日志跑到 logs/ 外面了)。
+        """
+        low = rel.lower()
+        if low.endswith(('.log', '.jsonl')):
+            from src import logfile as _lf
+            dst_log = _lf.build_log(rel, P.farm())
+            prov[rel] = dict(source='log-relocated', why='包内日志: 按用户令 2 落到 logs/build/, 不算产物')
+            if not a.dry_run:
+                dst_log.parent.mkdir(parents=True, exist_ok=True)
+                if src_bytes is not None:
+                    dst_log.write_bytes(src_bytes)
+                elif src_path is not None:
+                    shutil.copy2(src_path, dst_log)
+            return
+        dst = dest_root / rel
+        if dst.exists():
+            # 已存在的件分两类: 我们自己重算的(raw-derived) 与 上轮已补齐的(shipped)。
+            # ★不能因为"本轮没拷"就不记账 —— 早先写成"存在即不列", 于是重复跑一次台账就被清成 0/0,
+            #   页面上的来源统计跟着一起错 (2026-09-12 实逮)。
+            if rel in RAW_DERIVED:
+                prov[rel] = dict(source='raw-derived', builder=RAW_DERIVED[rel])
+            else:
+                prov[rel] = dict(source='shipped', why='包内无生成端 / 规则未复现 → 随包件补齐 (本轮已在位)')
+            return
+        prov[rel] = dict(source='shipped', why='包内无生成端 / 规则未复现 → 用随包件补齐')
+        if a.dry_run:
+            return
+        dst.parent.mkdir(parents=True, exist_ok=True)
+        if src_bytes is not None:
+            dst.write_bytes(src_bytes)
+        else:
+            shutil.copy2(src_path, dst)
+
+    if stash.is_file():
+        # 交付包 zip 形态 (2026-09-16): 按需从包里取 outputs/<场>/** 补齐 ——
+        # 这是"清除不留备份"之后**唯一**的随包件来源, 所以把它做成一等输入。
+        import zipfile
+        with zipfile.ZipFile(stash) as zf:
+            pref = f'outputs/{P.farm()}/'
+            members = [n for n in zf.namelist() if n.startswith(pref) and not n.endswith('/')]
+            print(f'  从交付包读取 {len(members)} 件 (前缀 {pref})')
+            for name in sorted(members):
+                if a.dry_run:
+                    take(name[len(pref):])
+                else:
+                    take(name[len(pref):], src_bytes=zf.read(name))
+    else:
+        for src in sorted(stash.rglob('*')):
+            if not src.is_file():
+                continue
+            take(src.relative_to(stash).as_posix(), src_path=src)
+
+    # 重算件里有些**不在随包件里**(如我们新造的 turbine_params.parquet), 也要记进台账
+    for rel, builder in RAW_DERIVED.items():
+        if rel not in prov and (dest_root / rel).exists():
+            prov[rel] = dict(source='raw-derived', builder=builder)
+
+    # 构建脚本的**自登记** (src/derived_manifest.py): 振动侧的产物名随窗/分片变, 写不进上面的精确表,
+    # 按名字通配又会误伤同名旧件 → 由"谁算的谁登记", 这里只认登记。缺失不影响其余记账。
+    try:
+        from src.derived_manifest import load as _load_manifest, prune as _prune_manifest
+        _gone = _prune_manifest(dest_root)      # 先清掉"登记了但盘上已删"的条目 (如被删的 _reimport 窗)
+        if _gone:
+            print(f'  自登记清理: {_gone} 条指向已删文件的条目')
+        _man = (_load_manifest(dest_root).get('files') or {})
+    except Exception:
+        _man = {}
+    for rel, info in _man.items():
+        if (dest_root / rel).exists():
+            prov[rel] = dict(source='raw-derived',
+                             builder=f"{info.get('builder', '?')} (自登记 by {info.get('by', '?')})")
+        else:
+            prov[rel] = dict(source='raw-derived',
+                             builder=f"{info.get('builder', '?')} (自登记, 但该件当前不在盘上)")
+
+    by_store = {}
+    for rel, m in prov.items():
+        store = rel.split('/')[0]
+        d = by_store.setdefault(store, {'raw-derived': 0, 'shipped': 0, 'log-relocated': 0})
+        d.setdefault(m['source'], 0)
+        d[m['source']] += 1
+    moved = sum(d['log-relocated'] for d in by_store.values())
+    print(f'{"仓":26s} {"raw 重算":>9s} {"随包补齐":>9s}' + (f' {"日志归位":>9s}' if moved else ''))
+    for store, d in sorted(by_store.items()):
+        print(f'  {store:24s} {d["raw-derived"]:9d} {d["shipped"]:9d}'
+              + (f' {d["log-relocated"]:9d}' if moved else ''))
+    tot = {k: sum(d[k] for d in by_store.values()) for k in ('raw-derived', 'shipped', 'log-relocated')}
+    print(f'  {"合计":24s} {tot["raw-derived"]:9d} {tot["shipped"]:9d}'
+          + (f' {tot["log-relocated"]:9d}' if moved else ''))
+    if moved:
+        print(f'  (日志不落产物仓: {moved} 件按用户令 2 落到 logs/build/, 不计入产物台账)')
+
+    if a.dry_run:
+        print('\n(dry-run, 未写盘)')
+        return 0
+    out = dest_root / '_provenance.json'
+    out.write_text(json.dumps(dict(
+        at=time.strftime('%Y-%m-%d %H:%M:%S'),
+        note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端, 用随包件补齐',
+        counts=tot, files=prov), ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'\n已写来源台账 {P.rel(out)}')
+    return 0
+
+
+if __name__ == '__main__':
+    # 控制台可能是 GBK: print 里的非 GBK 字形编不出来会抛异常, 干成了却退出码 1
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 870 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/products_reverse_audit.py

@@ -0,0 +1,870 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""**反向**呼应审计:自输出 → 功能与算法 → 输入(用户令 2026-09-17)。
+
+## 与 `inventory_products.py` 的区别(两个方向,各管一件事)
+
+· `inventory_products.py` 是**正向**:从 `data/raw/<场>/` 的每一类输入出发,找它喂出来的产物,
+  比跨度/条数("输入到了 2026-07,产物只到 2026-04 → 未重算")。它只覆盖 5 类输入、十几件产物。
+· 本器是**反向**:从 `outputs/<场>/` 的**每一件产物**出发,问三个问题:
+    ① 它是谁算出来的?(功能与算法 = 生成端)
+    ② 它从哪份输入算出来的?(`data/raw/<场>/` 的哪一类)
+    ③ 这条"输出 ↔ 输入"的呼应关系**成立吗**?(生成端在位 ∧ 输入在位 ∧ 可机检的对应判据通过)
+  逐件判定,最后给出"成立 / 不成立(无生成端)/ 无法验证"三类账。
+
+## 为什么必须做反向
+
+正向只查"我关心的输入有没有被算成产物",查不出**"盘上这件产物到底有没有来路"**。
+2026-09-17 现场就是栽在这里:清了产物之后,页面缺的 `windscada/index.html`、
+`m5_cms_tcm/handoff_vibration_v2.json` 这类件**根本没有生成端**(全库 0 处写入方),
+正向那几条跨度判据一条都不会报 —— 它们压根不在 `INPUTS` 的 `feeds` 里。
+反向一对账就清楚了:**有来路的件**(生成端 + 输入 + 判据都过)与**没来路的件**(只能从交付包补)。
+
+## 判定口径(每条都写进结果里,不含糊)
+
+    ✓ 呼应成立          生成端在位 ∧ 输入在位 ∧ 判据通过(跨度 ⊆ 输入 / 键集 ⊆ 输入 / 计数一致 / 行数 > 0)
+    ~ 输入不在位         生成端知道,但 `data/raw/<场>/<类>` 不在 ⇒ **现在无法验证**(放数据后复跑本器)
+    ✗ 无生成端           全库 0 处写入方 ⇒ **输出↔输入的呼应在原理上不成立**:这件产物无法由输入推导出来,
+                        **无法由重算生出来**;按用户令 2026-09-17 #1 运行期也不从交付包补齐 ⇒ 只能由研发补生成端(本器 --feasibility 给出逐族可逆性)
+    ? 未归类             既不在族表里、台账里也没有 —— 需要人工认领(本器把它当缺口报出来)
+
+## 用法
+
+    python scripts/products_reverse_audit.py            # 全量反向审计(人看)
+    python scripts/products_reverse_audit.py --check    # 只出结论(有 ✗/? 时退出码 5)
+    python scripts/products_reverse_audit.py --write-doc # 把族表写进 docs/系统设计说明.md §13.6
+
+退出码: 0 全部成立(允许 ✗,只要它们都在台账里如实标了 shipped)· 5 有未归类件或判据失败
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import fnmatch
+import json
+import pathlib
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+from . import inventory_products as INV                                        # noqa: E402
+
+DOC_BEGIN, DOC_END = '<!-- REVERSE-AUDIT:BEGIN -->', '<!-- REVERSE-AUDIT:END -->'
+
+# ── 族表: 输出(glob) → 功能 / 算法(生成端) / 输入类 / 判据 ────────────────────────────
+#   pred: span=产物时间跨度必须落在输入跨度内; turbines=产物机组集 ⊆ 输入机组集;
+#         rows=行数>0; count=件数/计数与说明一致; manifest=与清单自记一致
+FAMILIES: list[dict] = [
+    dict(id='vib_window_index', glob='m5_cms_tcm/windows/*/index.parquet', kind='raw-derived',
+         func='振动摄入 · 窗索引', algo='scripts/rudong_tcm_index.py(逐 decode JSON 解析 54 列:turbine/'
+              'sensor_name/meas_name/trigger_time/rpm/condition_key/alarm_type)',
+         gen='scripts/rudong_tcm_index.py', input='windcms', pred=('span', 'turbines', 'rows'),
+         time_col='trigger_time'),
+    dict(id='vib_spectra', glob=['m5_cms_tcm/windows/*/spectra/**', 'm5_cms_tcm/windows/*/spectra_meta.parquet'],
+         kind='raw-derived',
+         func='振动摄入 · 谱库', algo='scripts/rudong_tcm_spectra.py(FFT_ 测量 → npz 幅值数组, 分片存 `spectra/p<NN>/`)',
+         gen='scripts/rudong_tcm_spectra.py', input='windcms', pred=('npz',)),
+    dict(id='vib_raw_manifest', glob='m5_cms_tcm/vib_raw_manifest.json', kind='raw-derived',
+         func='振动摄入 · 清单', algo='scripts/vib_raw_build.py(汇总窗/谱件数、时间跨度、missing_chain 缺口)',
+         gen='scripts/vib_raw_build.py', input='windcms', pred=('manifest',)),
+    dict(id='scada_slim10min', glob='windscada/slim10min/*', kind='raw-derived',
+         func='按窗重算底座 · SCADA 10min 窄仓',
+         algo='scripts/scada_slim_build.py(逐台 load_10min 的公共列子集约 46 列:功率/限电命令/风况/转速/'
+              '桨距/温度 _mean 27/偏航/液压;不裁剪行。用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座:'
+              '一次全量扫 ≈2 分钟,之后任何窗的判级与七镜头重算都只是窄仓上的过滤+分组)',
+         gen='scripts/scada_slim_build.py', input='scada_10min', pred=('rows', 'turbines'),
+         time_col='ts'),
+    dict(id='scada_alarms', glob='windscada/alarms.parquet', kind='raw-derived',
+         func='三门台账 · 报警', algo='scripts/windscada_alarms_ingest.py(SpreadsheetML *.xls → 事件表)',
+         gen='scripts/windscada_alarms_ingest.py', input='故障报警', pred=('span', 'rows'), time_col='t_on'),
+    dict(id='scada_workorders', glob='windscada/workorders.parquet', kind='raw-derived',
+         func='三门台账 · 工单', algo='scripts/windscada_workorder_ingest.py(检修台账 → 工单表)',
+         gen='scripts/windscada_workorder_ingest.py', input='风机故障记录', pred=('span', 'rows'),
+         time_col='t_report'),
+    dict(id='scada_oil', glob='windscada/oil_samples_index.parquet', kind='raw-derived',
+         func='三门台账 · 油样', algo='scripts/windscada_watch_channels_build.py(油样 PDF → 索引)',
+         gen='scripts/windscada_watch_channels_build.py', input='油样报告', pred=('span', 'rows'),
+         time_col='date'),
+    dict(id='scada_monthly', glob='windscada/temp_monthly.parquet', kind='raw-derived',
+         func='月度派生件', algo='scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494)',
+         gen='scripts/windscada_monthly_build.py', input='scada_10min', pred=('span', 'rows'), time_col='month'),
+    dict(id='scada_derived', glob='windscada/*.parquet', kind='raw-derived',
+         func='SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助)',
+         algo='src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,'
+              'hydraulic,thermal_chain} + taxonomy.py',
+         gen='src/windscada/perf/powercurve.py', input='scada_10min', pred=('rows',)),
+    dict(id='ontology_core', glob='ontology/{objects.json,retrieval_index.json,turbine_params.parquet}',
+         kind='raw-derived',
+         func='本体层 · 码表/对象库/检索索引/实机参数',
+         algo='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;'
+              'src.ontology.retrieval.build;src.ontology.maintenance.refresh_params',
+         gen='app_ontology/common/app_ontology_guanlan/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
+
+    dict(id='ontology_aux_build', glob=['ontology/_id_alias.json', 'ontology/retrieval_vec.npy'],
+         kind='raw-derived',
+         func='本体层 · 别名表与向量索引缓存', algo='src.ontology.kb_ingest(别名)/ '
+              'src.ontology.retrieval.build(use_vec=True)(向量)',
+         gen='app_ontology/common/app_ontology_guanlan/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
+    dict(id='ontology_domain', glob=['ontology/oil_2026H2_huabiao.json', 'ontology/parts_cost_public.json',
+                                     'ontology/scenario_29_repair.json'],
+         kind='shipped',
+         func='本体层 · 领域数据件(油样台账/备件价格/场景修复)', algo='(无生成端, 领域正本出件)',
+         gen=None, input=None, pred=(), why='领域正本随包发来, 不由 data/raw 推导'),
+
+    # ── 以下族: 全库 0 处写入方 ⇒ 反向呼应**在原理上不成立**(如实记账, 不假装成立)────
+    #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
+    #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
+    #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
+    dict(id='vib_model_run_l6', glob=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json'],
+         kind='raw-derived',
+         func='六层链 model_run 步 · L6 过闸谱线表 + 层小结',
+         algo='scripts/rudong_model_run.py(候选线=oem_scan_plan 的 11 部件×{BPFI,BPFO,BSF}; 过闸='
+              'src/sop/discriminators.py::spectral_line_gates G1–G8; 定级=vib_verdict_and_writeback)',
+         gen='scripts/rudong_model_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 按口径重建(**无标准答案**: 这两件从未随包, 无法逐值对拍); 峰值拾取仍是本器自定'
+             '(目标频率 ±2 bin 取最大), 不得当"复现"用 —— 见 docs/振动六层链_接口规格与缺口_v0.1.md §3'),
+    dict(id='vib_fusion_38', glob='m5_cms_tcm/fusion_38.csv', kind='raw-derived',
+         func='六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源)',
+         algo='scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 '
+              'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
+    # ★2026-09-19 源代码化(第三批):三族由观澜自算顶上。★族表是**首个命中生效**,故这几族必须
+    #   排在通配族(`vib_handoff_and_scans` = `m5_cms_tcm/*.{json,parquet,csv,md,txt}`、`tcm_replay`、
+    #   旧的 `ontology_releases`)**之前**,否则新件会被旧族认领成"无生成端"。
+    dict(id='vib_line_energy_share', glob='m5_cms_tcm/line_energy_share.parquet', kind='raw-derived',
+         func='六层链 energy_share 步 · 线能量占比',
+         algo='scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan)',
+         gen='scripts/rudong_line_energy_share.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-19 补上: 该步脚本此前未随包(chain_gap_check 报"1 个步骤脚本未随包"); 只报占比不定级'),
+    dict(id='vib_mask_thresholds', glob=['tcm_compatible_replay/model/mask_thresholds.tsv',
+                                         'tcm_compatible_replay/model/mask_thresholds.说明.json'],
+         kind='raw-derived', func='TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线)',
+         algo='scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; '
+              '调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现)',
+         gen='scripts/tcm_mask_thresholds_build.py', input='m5_cms_tcm', pred=('rows',),
+         why='★**观澜自算**阈值, 不是厂商阈值: 现场导出件里没有掩码定义(25,679 个 decode 只有 NumberOfMasks: 175) '
+             '⇒ 厂商阈值无法复原; 口径写在同目录 mask_thresholds.说明.json, 页面据此标注(workbench.py 分支)'),
+    dict(id='ontology_releases', glob='ontology/release_*/*', kind='raw-derived',
+         func='本体发布层 r1/r2(网关 /release/ 只读暴露)',
+         algo='scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; '
+              'r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库)',
+         gen='scripts/ontology_release_build.py', input='西门子4.0技术资料', pred=('rows',),
+         why='2026-09-19 由观澜自算顶上: 原为随包件(0 处写入方) ⇒ 新机器 /release/ 两件必 503; '
+             'manifest 里写 source.sha16, 谁都能核出两层是同一份对象库的投影'),
+    dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
+         func='振动线 · 全场标量 z 值 (fusion 步出件)',
+         algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
+              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
+         # input 写**原始件目录名**(族表口径: data/raw/<场>/<input>); 真实取数经窗索引一跳,
+         # 而窗索引本身是 raw-derived(vib_window_index 族) —— 写 windows/_*.parquet 会让审计判"输入不在位"。
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
+             '使 /api/fleet 的振动标量 z 面不再空白'),
+    dict(id='vib_fusion_handoff', glob='m5_cms_tcm/handoff_vibration_v2.json', kind='raw-derived',
+         func='融合面 handoff (观澜自算, 同结构于振动线手交件)',
+         algo='scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, '
+              'meta.from 标明"观澜自算")',
+         gen='scripts/rudong_fusion_handoff.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
+             '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
+             '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这两件此前 kind=shipped(振动线人交手交件),
+    #   新机器上 ⑦ 的 `src.ontology.trend_ingest` 报"源缺失"、CMS 报告的三层基线卡为空。
+    #   现由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环; 窗不足时如实为空
+    #   并把数据边界逐条写进 meta) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
+    #   ★必须排在通配族 `vib_handoff_and_scans` **之前** (族表首个命中生效), 否则又被认领成"无生成端"。
+    dict(id='vib_history_baseline',
+         glob=['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json'],
+         kind='raw-derived',
+         func='振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对)',
+         algo='scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; '
+              '窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + '
+              'relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限)',
+         gen='scripts/component_history_build.py', input='windcms', pred=('rows',),
+         why='输入是 CMS 原始导出的**重算产物**(窗索引), 其 raw 祖先是 data/raw/<场>/windcms; '
+             '旧随包件是振动线手交件(全库 0 处写入方); 两件都在重算链 ④d/④e, 口径与数据边界写进件内 meta'),
+    dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
+         kind='shipped',
+         func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
+         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
+                                            '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
+                                            '或由现场正本随原始件进 data/raw 后重算'),
+    # (2026-09-19: `line_energy_share` / `mask_thresholds` / `ontology_releases` 三族已移到前面按
+    #  raw-derived 记账 —— 族表是**首个命中生效**, 必须排在通配族 `vib_handoff_and_scans` / `tcm_replay`
+    #  / `ontology_releases(旧)` 之前, 否则新件会被旧族认领成"无生成端")
+    dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
+         func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='随包快照(振动线出件)'),
+    # ★2026-09-19 拆族 (用户令「所有的计算均要形成观澜的源代码」): `windscada/index.html` 是
+    #   `/detail/v2` 的「全场状态总览」iframe 目标, 此前整族记为"无生成端" ⇒ 新机器上那块必然显示
+    #   "产物不在位"。现在它由 `scripts/windscada_overview_build.py` 用**组件同一个快照烘焙器**
+    #   (src/windscada/ui/snapshot.py::bake, /v2/snapshot 用的就是它)从重算产物生成 ⇒ 单列一族。
+    dict(id='windscada_overview', glob='windscada/index.html', kind='raw-derived',
+         func='总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开)',
+         algo='scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 '
+              'src/windscada/ui/snapshot.py::bake 烘焙)',
+         gen='scripts/windscada_overview_build.py', input='scada_10min', pred=('rows',),
+         why='2026-09-19 用户令后上链: 该页此前是随包件(全库 0 处写入方), 清过产物的机器上 iframe 只能'
+             '显示"产物不在位"; 现在由观澜自算, 每次重算随数据更新'),
+    dict(id='windscada_pages', glob='windscada/{turbines/*,review/*}', kind='shipped',
+         func='逐台静态页 + 评审记录(当前由组件按请求动态出页, 静态件仍属随包快照)', algo='(无生成端)',
+         gen=None, input=None, pred=(), why='逐台页在运行期由 /turbine/<台> 动态生成, 静态副本无写入方; '
+                                             '运行期不从交付包补(用户令 2026-09-17)'),
+    # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
+    #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
+    #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。
+    dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
+         func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
+              '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
+    # ★2026-09-18 补 (用户令"页面只能基于输入数据重算"之后): `windcms/**` 整族原先一律按
+    #   "无生成端"记账 —— 但**报告两步的生成端就在包内**(scripts/windcms.py report → src/windcms/
+    #   report.py + report_std.py), 只要 data/raw/<场>/windcms 里有 decode 导出就出得来。
+    #   实测(2026-09-18): 从重算出的窗 w0316 一键跑出标准模版报告 + 38 台逐台页 + 总览, 判级 38 台
+    #   (报警 3 / 优秀 35), 缺的只有 model_run/fusion 两步的融合级列(如实写 —)。故拆成两族按 raw-derived 记。
+    dict(id='windcms_report_std', glob=['windcms/报告_CMS振动状态评估报告_*.md',
+                                        'windcms/报告_CMS振动状态评估报告_*.docx',
+                                        'windcms/登记_三轴_38台.csv'], kind='raw-derived',
+         func='CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议)',
+         algo='src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; '
+              '由 scripts/windcms.py report 调用)',
+         gen='src/windcms/report_std.py', input='windcms', pred=('rows',),
+         why='★同名件有两种来源: 观澜自算(本族: 从 data/raw/<场>/windcms 的 *_decode.json 算) 与 '
+             '厂家报告转录(scripts/vib_reports_build.py, 输入是现场振动分析报告 PDF)。按名字认领不了'
+             '这层区别 ⇒ 以"是否由本步生成"为准, 判据看 m5_cms_tcm/vib_raw_manifest.json 的 steps'),
+    dict(id='windcms_report_overview', glob=['windcms/report.md', 'windcms/index.html',
+                                             'windcms/index_eng.html', 'windcms/overview.html',
+                                             'windcms/turbines/*'], kind='raw-derived',
+         func='CMS 诊断总览页 + 逐台页 + 结构化层报告',
+         algo='src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + '
+              'report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—")',
+         gen='src/windcms/report.py', input='windcms', pred=('rows',)),
+    dict(id='windcms_kb', glob='windcms/kb.json', kind='raw-derived',
+         func='CMS 知识库索引 (问答检索用)',
+         algo='src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成)',
+         gen='src/windcms/knowledge.py', input='windcms', pred=('rows',)),
+    dict(id='windcms_pages', glob='windcms/**', kind='shipped',
+         func='windcms 里其余件 (厂家报告转录 / 知识库出件等)',
+         algo='(无随包生成端)', gen=None, input=None, pred=(),
+         why='上述两族与 cache 之外剩下的 windcms 件: 运行期不从交付包补(用户令 2026-09-17), '
+             '要出件须研发补生成端'),
+    # ★2026-09-19 用户令「要让观澜从重算台账生成 claim」: findings 与三份底稿改由**观澜自算**
+    #   (scripts/sop_findings_from_ledger.py 从 alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6
+    #   算 claim, 按契约构建器要的字段结构写) ⇒ 单列一族; 契约四件随之由 scripts/guanlan_facts_contract.py 生成。
+    dict(id='ledger_claims', glob=['sop/findings.json', 'paradigm_r1/experiments/*/底稿*.json'],
+         kind='raw-derived',
+         func='事实契约的 claim 本体与底稿 (观澜自算, 替代人裁底稿)',
+         algo='scripts/sop_findings_from_ledger.py(六族 claim: 报警集中/重复检修/停机损失/温度离群/功率曲线偏离/振动过闸线; '
+              '每条带 coverage·缺证据·证伪判据, 相对判据封顶「候选」)',
+         gen='scripts/sop_findings_from_ledger.py', input='scada_10min', pred=('rows',),
+         why='输入是**重算产物**(windscada/*.parquet + m5_cms_tcm/*): 其 raw 祖先是 故障报警/风机故障记录/scada_10min; '
+             '本族单列以区别于旧的人裁底稿(同路径旧件无生成端)'),
+    dict(id='sop_workspace', glob='sop/**', kind='shipped',
+         func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='评审过程件, 属人工作业留痕'),
+    dict(id='tcm_replay', glob='tcm_compatible_replay/**', kind='shipped',
+         func='TCM 兼容链回放资产(模型表/掩码阈值/裁决记录)', algo='(无生成端)', gen=None, input=None,
+         pred=(), why='随包快照'),
+    dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
+         func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='实验底稿, 事实契约的输入'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 变桨面两件原来 kind=shipped(无生成端)
+    #   ⇒ 换台机器重算后必然缺件、变桨面恒"不可判"。现由 scripts/pitch_face_build.py 从 data/raw 的
+    #   scada_10min(液压/润滑/柱塞/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 并进重算链 ④c。
+    dict(id='pitch_face', glob='pitch/**', kind='raw-derived',
+         func='变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表)',
+         algo='scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 '
+              '三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json)',
+         gen='scripts/pitch_face_build.py', input='scada_10min', pred=('rows',),
+         why='输入是 10min 与 1min 两路 SCADA 原始导出件(scada_source: CSV 优先/MDB 回落);'
+             '零位按用户令两口径分列(停机顺桨段/满发段),并据实测反推补上**运行段同工况分档**这一判据轴'
+             '(它才能把现场确诊的 19# 单独拎出来)'),
+    # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
+    #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
+    #   这一族从"无生成端"改成 raw-derived —— 生成端 guanlan_facts_contract.py 本来就在包内,
+    #   原来缺的是**它的输入**(人裁底稿); 现在输入由上游族 ledger_claims 自算
+    #   (scripts/sop_findings_from_ledger.py) ⇒ 新机器上重算即得, /api/facts 不再 503。
+    dict(id='guanlan_contract', glob='guanlan/**', kind='raw-derived',
+         func='事实契约与对外派生(门户结论段/取数)',
+         algo='scripts/guanlan_facts_contract.py(build→render: 契约 + portal_claims/detail_cards/qa_refs/'
+              'report_summary; 输入=上游族 ledger_claims 自算的 sop/findings.json + 三份底稿)',
+         gen='scripts/guanlan_facts_contract.py', input='scada_10min', pred=('rows',),
+         why='输入与 data/raw 隔着两跳(重算台账 → claims → 契约), 但**两跳的生成端都在包内**; '
+             'claim 的判据/缺证据/证伪逐条写在件里, 相对判据封顶「候选」'),
+    dict(id='windscada_release_manifest', glob='windscada/release-manifest.json', kind='source-derived',
+         func='发布清单快照(`/healthz` 与版本信息读它)',
+         algo='scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 '
+              '{css_tokens, nav, dom, sha256, source blobs})',
+         gen='scripts/guanlan_baseline_manifest.py', input=None, pred=(),
+         why='★ 2026-09-17 修正: 它一度被我按"交证件"错分成非产物; 实际**包内有生成端**'
+             '(guanlan_baseline_manifest.py 就写这个路径), 且 guanlan_gateway.py 运行期读它 ⇒ 是产物'),
+    dict(id='human_deliverables',
+         glob=['windscada/mblub_alarm_cross.json', 'windscada/review_presented.json',
+               'windscada/给振动线_温度轴回复_20260826.json', 'windscada/给振动线_温度轴回复_20260826.md',
+               'windscada/送审_系统结论与判定_20260828.md'],
+         kind='human',
+         func='**人工正本/交证件**:与振动线的跨线通知、送审结论、发布评审快照',
+         algo='(人写的, 不是算出来的)', gen=None, input=None, pred=(),
+         why='这几件是"人对人的交证件/回执", 内容由分析结论抄写与人判断坐实而成; '
+             'mblub_alarm_cross.json 还被 src/ontology/populate.py 当作**证据来源**引用 ⇒ '
+             '必须随包留在原位, 但**不参与呼应校验**(它本来就没有生成端, 也不该有)'),
+    dict(id='scratch_in_products', glob=['**/_*.js', '**/_*audit*.md', '**/*.out', '**/*.err',
+                                        '**/*_test_*.parquet', '**/*_test_*.json'],
+         kind='not-product',
+         func='**非产物**:自审脚本/记录与模型测试输出(躺在产物仓里)', algo='(工具脚本与运行痕迹, 不是产物)',
+         gen=None, input=None, pred=(),
+         why='2026-09-17: 17 件已移出到 reference/非产物留痕_20260917/ 并留了 _来源说明.json ⇒ '
+             '本族现在应为空; 一旦再出现(新脚本往产物仓里写 .out/_audit.js 之类), 本器当场报出来'),
+]
+
+
+def _files_of(glob) -> list[str]:
+    """族 glob(str 或 list)→ 台账里的相对键(支持 `{a,b}` 花括号与 `*`)。
+
+    ★ 深度必须**显式对齐**(2026-09-17 第一版踩过): `fnmatch` 的 `*` 是**跨 `/`** 的, 于是
+      `windscada/*.parquet` 会把 `windscada/turbines/xx.parquet`、`windscada/_pre_rebuild_*/xx.parquet`
+      一起吞进来 —— 反向审计最怕"族把不该管的件认领了", 那样账就假了。
+      规则: 模式里没有 `**` 时, 要求的 `/` 个数必须与键的 `/` 个数**相等**(逐条展开后各自判)。
+    """
+    pats = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            pats += [head + o + rest for o in opts.split(',')]
+        else:
+            pats.append(g)
+    out = []
+    for rel in LEDGER:
+        for pt in pats:
+            if '**' not in pt and rel.count('/') != pt.count('/'):
+                continue
+            if fnmatch.fnmatch(rel, pt) or rel == pt:
+                out.append(rel)
+                break
+    return sorted(out)
+
+
+LEDGER: dict[str, dict] = {}
+
+
+def load_ledger(farm: str) -> dict[str, dict]:
+    """台账 = `_provenance.json`(逐件来源) + `_derived_manifest.json`(生成端自登记)。"""
+    out: dict[str, dict] = {}
+    root = P.out_root(farm)
+    for fn in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):
+        f = root / fn
+        if not f.is_file():
+            continue
+        try:
+            d = json.loads(f.read_text(encoding='utf-8'))
+        except Exception:
+            continue
+        for rel, v in (d.get('files') or {}).items():
+            if isinstance(v, dict):
+                out.setdefault(rel, dict(source=v.get('source') or 'raw-derived',
+                                         builder=v.get('builder') or v.get('by') or '',
+                                         why=v.get('why') or '',))
+    return out
+
+
+def _input_home(station: pathlib.Path, sub: str) -> pathlib.Path | None:
+    """输入类目录在哪 —— 优先场站目录下, 其次 raw 根下。
+
+    ★ 机理层资料按 A2 约定**不在场站目录下**(`data/raw/西门子4.0技术资料/`, 见 src/windscada/config.py)。
+      只查场站目录会把本体层判成"输入不在位"(2026-09-17 第一版就这么误报过 3 件)。
+    """
+    for base in (station, station.parent):
+        d = base / sub
+        if d.is_dir():
+            return base
+    return None
+
+
+def _parquet_span(p: pathlib.Path, col: str | None):
+    import pandas as pd
+    try:
+        d = pd.read_parquet(p)
+    except Exception:
+        return None, None, 0
+    n = len(d)
+    if col and col in d.columns:
+        s = pd.to_datetime(d[col], errors='coerce')
+        if s.notna().any():
+            return str(s.min())[:10], str(s.max())[:10], n
+    return None, None, n
+
+
+def _npz_ok(p: pathlib.Path) -> bool:
+    """振动谱件: npz 能读出来且非空(列数不是"行", 不能拿 parquet 的行数判它)。"""
+    try:
+        import numpy as np
+        with np.load(p, allow_pickle=False) as z:
+            return any(z[k].size > 0 for k in z.files)
+    except Exception:
+        return False
+
+
+# 已知"含人工裁决/校准"的随包快照(按用户令 2026-09-17 #3 显式登记为人工件)。依据是文档里已经写明的性质:
+# docs §7「handoff_vibration_v2.json / component_history.json 是随包快照, 该件含人工裁决/校准更新,
+# 不是测量数据的函数」; baseline_38.json 的三层基线 self/absolute 两层由人工坐实; findings.json 是判级发现台账。
+HUMAN_SNAPSHOTS = {
+    'm5_cms_tcm/component_history.json',
+    'm5_cms_tcm/baseline_38.json',
+    'm5_cms_tcm/handoff_vibration_v1.json',
+    'm5_cms_tcm/findings.json',
+}
+
+# ── 逆向工程可行性 (用户令 2026-09-17 #2) ────────────────────────────────────────────
+# 问的是: "这批没有生成端的产物, 能不能从原始件/在包生成端**推**出来?"
+# 判定不靠印象, 靠两条机器证据:
+#   ① 生成端在不在包内(在 → 只是没接上, 跑一次就有);
+#   ② 产物正文里有没有**人工判断**的痕迹("裁决/审核/校准/评审/经验"这类字段)——
+#      含人工判断的件不是任何输入的纯函数, 逆向工程推不出来, 只能把那一步人工工作重做。
+import re as _re                                                         # noqa: E402
+JUDGE_PAT = _re.compile(r'人工|裁决|审核|复核|校准|评审|经验值|专家|judg|review|manual|calibrat|verdict|sign[_ ]?off',
+                        _re.I)
+# ★ 只认"**字段**叫这个名字"(`"裁决": …` / `裁决,`),不认正文里顺嘴提一句 ——
+#   2026-09-17 第一版拿整篇子串匹配, 结果把**页面**里的展示标签(`index.html` 里的"评审/人工")当成
+#   人工判断, 于是把"页面"整族误判成不可逆。这属于"判据太糙 → 结论假"。
+JUDGE_KEY_PAT = _re.compile(r'["\']?[^"\',:]{0,24}(人工|裁决|审核|复核|校准|评审|经验值|专家|'
+                            r'judg|review|manual|calibrat|verdict|sign[_ ]?off)[^"\',:]{0,24}["\']?\s*[:=]',
+                            _re.I)
+
+
+def judgement_evidence(farm: str, rels: list[str], cap: int = 8) -> tuple[int, list[str], int]:
+    """→ (含人工判断字段的抽样比例%, 样例, 被检查件数)。
+
+    只查**数据/文本件**(.json/.csv/.md/.txt); **页面(.html/.js)不算** —— 页面是产物的渲染,
+    里面出现"评审/人工"是展示标签, 不代表这份件内含判断。
+    """
+    hit, samples, checked, pages = 0, [], 0, 0
+    for rel in rels[:cap]:
+        p = P.out_root(farm) / rel
+        suf = p.suffix.lower()
+        if suf in ('.html', '.js', '.css', '.htm'):
+            pages += 1
+            continue
+        try:
+            if suf == '.json':
+                txt = p.read_text(encoding='utf-8', errors='replace')[:200000]
+                pat = JUDGE_KEY_PAT
+            elif suf in ('.csv', '.txt'):
+                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
+                pat = JUDGE_KEY_PAT
+            elif suf == '.md':
+                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
+                pat = JUDGE_PAT
+            else:
+                continue
+        except Exception:
+            continue
+        checked += 1
+        if pat.search(txt):
+            hit += 1
+            samples.append(rel)
+    return (100 * hit // checked if checked else 0), samples, pages
+
+
+def feasibility(fam: dict, rels: list[str], gen_ok: bool, in_ok: bool,
+                judge_pct: int, judge_samples: list[str], pages: int = 0) -> tuple[str, str]:
+    """→ (可逆性判定, 依据)。四类: 可逆(直接) / 可逆(需反推口径) / 不可逆(人工判断) / 应移出产物仓。"""
+    if fam['kind'] == 'not-product':
+        return '应移出产物仓', '它本就不是产物(工具脚本/交证件/测试输出) ⇒ 该从产物仓移走, 而不是"补生成端"'
+    if gen_ok and in_ok:
+        return '可逆(直接)', f"生成端在包内({fam['gen']})且输入在位 ⇒ 接上/重跑即可"
+    if pages and pages >= max(3, len(rels[:8]) // 2):
+        return '可逆(页面可再生)', ('这一族主要是**页面**(.html): 页面是产物的渲染, 不是独立数据 ⇒ '
+                                    '接上在包的页面生成端重出即可(未必逐字节同, 能力与数据一致)')
+    m_n = sum(1 for r in rels if r.endswith(('.parquet', '.csv', '.npz')))
+    t_n = len(rels) - m_n
+    split = f'({m_n} 件测量形态 / {t_n} 件文本·判断件)' if (m_n and t_n) else ''
+    # 两类都占相当比重时, 结论必须是"混合"而不是挑一类代表全族 —— 否则一句判定就把另一半骗过去了
+    if m_n and t_n and min(m_n, t_n) * 4 >= len(rels):
+        return '混合(测量件可反推 / 文本件需人定)', split + \
+               '测量形态的件可由 data/raw 反推口径 + 逐值对拍; 文本/判断件(评审、裁决、回复)推不出来'
+    if judge_pct >= 50 and judge_samples:
+        return '不可逆(人工判断)', (f"抽样 {len(judge_samples)} 件里都写着人工判断字段(如 {judge_samples[0]}) "
+                                    f"⇒ 不是输入的纯函数, 逆向工程推不出来")
+    if m_n and m_n * 2 >= len(rels):
+        return '可逆(需反推口径)', split + '测量形态的件是测量数据的函数 ⇒ 可从 data/raw 反推口径 + 逐值对拍' \
+                                        '(做法同 temp_monthly: 反推 → 逐值一致才敢用)'
+    if gen_ok:
+        return '半可逆(生成端在包, 输入不足)', '生成端在包内, 缺的是上游输入 ⇒ 上游补齐后即可重出'
+    if judge_pct > 0:
+        return '半可逆(需人工裁定)', f'抽样里 {judge_pct}% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定'
+    return '需人工裁定', '既无生成端、形态也不是测量函数 ⇒ 需研发给口径或领域正本'
+
+
+def _turbines_of(p: pathlib.Path, col: str = 'turbine', cap: int = 200000):
+    import pandas as pd
+    try:
+        d = pd.read_parquet(p, columns=[col]) if col else pd.read_parquet(p)
+    except Exception:
+        return None
+    try:
+        return {str(x).upper() for x in d[col].dropna().unique()} if col in d.columns else None
+    except Exception:
+        return None
+
+
+def _input_turbines(station: pathlib.Path, sub: str):
+    """输入侧机组集合: 逐台 CSV 文件名 / 目录名里的 WTG 号。"""
+    d = station / sub
+    if not d.is_dir():
+        return None
+    out = set()
+    for p in d.rglob('*'):
+        if p.is_file():
+            for m in __import__('re').finditer(r'(WTG\s?\d{1,2})', p.name.upper()):
+                out.add(m.group(1).replace(' ', ''))
+    return out or None
+
+
+def classify_human(farm: str, rels: list[str], cap: int = 600) -> set[str]:
+    """逐件判"是不是人工件"(用户令 2026-09-17 #3:人工件不再按自动产物参与呼应校验)。
+
+    判据:**非测量形态**的件(.md/.json/.csv/.txt)里出现人工判断字段(按字段名匹配)。
+    测量形态件(.parquet/.csv 数值、.npz)一律不算人工件 —— 它们是数据的函数,属"可反推"那一路。
+    """
+    human: set[str] = set()
+    for rel in rels[:cap]:
+        if rel in HUMAN_SNAPSHOTS:            # ① 文档里已写明含人工裁决/校准的随包快照
+            human.add(rel)
+            continue
+        suf = pathlib.Path(rel).suffix.lower()
+        if suf == '.md':                      # ② 产物仓里的 .md 一律是报告/交证/评审记录(人写的)
+            human.add(rel)
+            continue
+        if suf == '.txt':
+            pat = JUDGE_PAT
+        elif suf in ('.json', '.csv'):
+            pat = JUDGE_KEY_PAT
+        else:
+            continue
+        p = P.out_root(farm) / rel
+        try:
+            txt = p.read_text(encoding='utf-8', errors='replace')[:120000]
+        except Exception:
+            continue
+        if pat.search(txt):                   # ③ 正文里有"人工判断字段"
+            human.add(rel)
+    return human
+
+
+def write_human_manifest(farm: str | None = None) -> int:
+    """把人工件逐件落成 `outputs/<场>/_human_artifacts.json`(机器台账,供自检与审计引用)。"""
+    farm = farm or P.farm()
+    global LEDGER
+    LEDGER = load_ledger(farm)
+    entries: dict[str, dict] = {}
+    for fam in FAMILIES:
+        if fam['kind'] not in ('shipped', 'human', 'not-product'):
+            continue
+        rels = [r for r in _files_of(fam['glob']) if r not in entries]
+        if not rels:
+            continue
+        if fam['kind'] == 'human':
+            for r in rels:
+                entries[r] = dict(kind='human', family=fam['id'], evidence='族内显式登记为人工正本/交证件')
+            continue
+        for r in classify_human(farm, rels):
+            if r not in entries:
+                entries[r] = dict(kind='human', family=fam['id'],
+                                  evidence='件内含人工判断字段(按字段名匹配),不是输入的纯函数')
+    out = P.out_root(farm) / '_human_artifacts.json'
+    out.write_text(json.dumps(dict(
+        at=__import__('time').strftime('%Y-%m-%d %H:%M:%S'),
+        note='人工件台账(用户令 2026-09-17 #3):这些件由人写成/坐实,不是任何输入的纯函数 ⇒ '
+             '不参与"输出↔输入呼应"校验;随包留在原位,重算不会也不该重写它们。'
+             '本文件由 scripts/products_reverse_audit.py --write-human-manifest 生成。',
+        count=len(entries), files=entries), ensure_ascii=False, indent=1), encoding='utf-8')
+    by_fam: dict[str, int] = {}
+    for v in entries.values():
+        by_fam[v['family']] = by_fam.get(v['family'], 0) + 1
+    print(f'人工件台账: {len(entries)} 件 → {P.rel(out)}')
+    for k, v in sorted(by_fam.items(), key=lambda kv: -kv[1]):
+        print(f'   {k:24s} {v:4d} 件')
+    return 0
+
+
+def audit(farm: str | None = None, verbose: bool = True):
+    farm = farm or P.farm()
+    global LEDGER
+    LEDGER = load_ledger(farm)
+    # ★ 场站原始件目录用唯一取用口 (场站名可与 farm 键不同: 键 = rudong, 目录 = data/raw/如东)
+    from src.windscada.config import raw_station_dir
+    station = pathlib.Path(raw_station_dir(farm))
+    verdict: dict[str, dict] = {}
+    fam_rows = []
+    fails: list[str] = []
+
+    for fam in FAMILIES:
+        # ★ 族按**声明顺序**认领, 先声明的先拿 (否则 `m5_cms_tcm/*` 这种宽通配会把 windows/ 下
+        #   1704 件也吞进来 —— fnmatch 的 `*` 是跨 `/` 的, 2026-09-17 第一版就这么误判过)
+        rels = [r for r in _files_of(fam['glob']) if r not in verdict]
+        rels = [r for r in rels if not r.lower().endswith(('.log', '.jsonl'))]
+        if not rels:
+            continue
+        row = dict(id=fam['id'], n=len(rels), func=fam['func'], algo=fam['algo'], input=fam['input'],
+                   pred='+'.join(fam['pred']) or '—', verdict='', note='')
+        if fam['kind'] == 'not-product':
+            # ★ 不是产物, 却躺在产物仓里: 反向审计对它的结论是"它压根不该按产物管" ——
+            #   既不需要生成端, 也谈不上与输入呼应(这一类最该被人看见, 故单独一类, 不当失败计)。
+            jp, js, pg = judgement_evidence(farm, rels)
+            fv, fw = feasibility(fam, rels, False, False, jp, js, pg)
+            row['verdict'] = '✗ 非产物'
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = fv, fw
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict='✗',
+                                  why='非产物(过程留痕/工具脚本躺在产物仓里): ' + fam.get('why', ''))
+        elif fam['kind'] == 'human':
+            # ★ 人工正本/交证件 (用户令 2026-09-17 #3): 由人写、被运行期当证据引用,
+            #   **不参与呼应校验**——它本来就没有、也不该有生成端; 单独计数, 不混进"无生成端"的失败堆里。
+            row['verdict'] = '◆ 人工件'
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = '人工件(正本/交证)', '人写的, 不参与呼应校验; 随包留在原位'
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict='◆',
+                                  why='人工件(人工正本/交证件): ' + fam.get('why', ''))
+        elif fam['kind'] == 'shipped':
+            jp, js, pg = judgement_evidence(farm, rels)
+            gen_file = ROOT / fam['gen'] if fam.get('gen') else None
+            gen_ok0 = bool(gen_file and gen_file.is_file())
+            # ★ 逐件分人工件 (用户令 2026-09-17 #3): 含人工判断字段的件**不按自动产物参与呼应校验**,
+            #   单独计 ◆; 余下才是真正的"无生成端"。
+            human = classify_human(farm, rels)
+            rest = [r for r in rels if r not in human]
+            fv, fw = feasibility(fam, rest or rels, gen_ok0, False, jp, js, pg)
+            row['verdict'] = '✗ 无生成端' if not human else f'✗ 无生成端 + ◆ 人工件 {len(human)}'
+            row['n'] = len(rest)
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = fv, fw
+            if jp:
+                row['rev_why'] += f';人工判断字段抽样命中 {jp}%' + (f'(如 {js[0]})' if js else '')
+            for r in rest:
+                verdict[r] = dict(fam=fam['id'], verdict='✗', why='无生成端(全库 0 处写入方): ' + fam['why'])
+            for r in human:
+                verdict[r] = dict(fam=fam['id'], verdict='◆',
+                                  why='人工件(含人工判断字段): 不参与呼应校验, 随包留在原位')
+        else:
+            # ① 生成端在位
+            gen = ROOT / fam['gen'] if fam.get('gen') else None
+            gen_ok = bool(gen and gen.is_file())
+            # ② 输入在位(场站目录优先, 机理层资料在 raw 根下 —— 见 _input_home)
+            spec = INV.INPUTS.get(fam['input'] or '')
+            i_s = i_e = None
+            i_n = 0
+            i_gran = '年'
+            home = _input_home(station, fam['input']) if fam['input'] else None
+            if home and spec:
+                i_s, i_e, i_n, _note, i_gran = INV.input_span(home, fam['input'], spec)
+            elif home:
+                # 机理层资料这类"没有跨度判据"的输入: 按件数清点即算在位(INV.INPUTS 里没有它们的
+                # 跨度口径 —— 技术资料是文档而不是时序数据, 拿跨度比毫无意义)
+                i_n = sum(1 for p in (home / fam['input']).rglob('*') if p.is_file())
+                i_gran = '年'
+            elif fam['kind'] == 'source-derived':
+                # ★ 输入是**包内源码**而不是 data/raw(如发布清单快照: 从 src/windscada/ui 重建页再摘要)。
+                #   生成端在位 = 输入在位, 这类产物的"呼应"是对源码的, 不是对原始件的。
+                i_n = 1
+                i_s = i_e = '包内源码'
+            in_ok = i_n > 0
+            passed, notes = [], []
+            if fam['pred'] and in_ok:
+                if 'span' in fam['pred']:
+                    p_s, p_e, n = _parquet_span(P.out_root(farm) / rels[0], fam.get('time_col'))
+                    if p_s and i_s and (p_s[:7] < i_s[:7]):
+                        # ★ 年粒度输入(按文件名年份推的)不判失败: 与正向检查同一条教训 ——
+                        #   台账 xls 里可能含比文件名年份更早的历史记录, 拿年粒度当"输入起点"会造假缺口
+                        #   (2026-09-17 实测: 工单产物起点 2020-01 而输入文件名最早 2021 ⇒ 不是数据串了)。
+                        msg = f'产物起点 {p_s} 早于输入起点 {i_s}'
+                        if i_gran in ('日', '月'):
+                            fails.append(f"{fam['id']}: {msg}(跨窗混入 / 键错)")
+                            notes.append('⚠ ' + msg)
+                        else:
+                            notes.append(f'{msg}(输入为年粒度, 只作参考: 台账可能含更早的历史行)')
+                    passed.append(f'跨度 {p_s}~{p_e} ⊆ 输入 {i_s}~{i_e}')
+                if 'turbines' in fam['pred']:
+                    pt = _turbines_of(P.out_root(farm) / rels[0])
+                    it = _input_turbines(home, fam['input']) if home else None
+                    if pt and it:
+                        extra = pt - it
+                        if extra:
+                            fails.append(f"{fam['id']}: 产物含输入里没有的机组 {sorted(extra)[:5]}")
+                        passed.append(f'机组 {len(pt)} 台 ⊆ 输入 {len(it)} 台')
+                if 'rows' in fam['pred']:
+                    tot = 0
+                    for r in rels[:400]:
+                        _s, _e, n = _parquet_span(P.out_root(farm) / r, None) if r.endswith('.parquet') \
+                            else (None, None, 1)
+                        tot += n
+                    if tot <= 0:
+                        fails.append(f"{fam['id']}: 产物行数合计为 0")
+                    passed.append(f'件内数据行合计 {tot:,}' + ('(抽样 400 件)' if len(rels) > 400 else ''))
+                if 'npz' in fam['pred']:
+                    sample = rels[:5]
+                    bad = [r for r in sample if not _npz_ok(P.out_root(farm) / r)]
+                    if bad:
+                        fails.append(f"{fam['id']}: 抽样 {len(bad)}/{len(sample)} 件 npz 读不出或为空")
+                    passed.append(f'{len(rels)} 件谱(抽样 {len(sample)} 件 npz 均可读非空)· 输入源 {i_n} 件')
+                if 'manifest' in fam['pred']:
+                    try:
+                        mf = json.loads((P.out_root(farm) / rels[0]).read_text(encoding='utf-8'))
+                        passed.append('清单自记: ' + ', '.join(f'{k}={v}' for k, v in list(mf.items())[:3]))
+                    except Exception as e:
+                        fails.append(f"{fam['id']}: 清单读不出来 ({type(e).__name__})")
+            if not gen_ok:
+                row['verdict'] = '✗ 生成端不在位'
+                row['note'] = f"缺 {fam['gen']}"
+                fails.append(f"{fam['id']}: 生成端不在位 {fam['gen']}")
+            elif not in_ok:
+                row['verdict'] = '~ 输入不在位'
+                row['note'] = (f"缺 {(home / fam['input']) if home else station / str(fam['input'])}"
+                               ' ⇒ 现在无法验证(放数据后复跑本器)')
+            else:
+                row['verdict'] = '✓ 呼应成立'
+                row['note'] = ';'.join(notes + passed)
+                row['rev'], row['rev_why'] = '已在重算链上', '生成端在位、输入在位、判据通过 —— 无需逆向工程'
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict=row['verdict'][0],
+                                  why=row['note'] if row['verdict'][0] != '✓' else row['note'][:120])
+        fam_rows.append(row)
+
+    # 未归类件 (台账里有, 但没有任何族接住)
+    unclassified = []
+    for rel, v in LEDGER.items():
+        if rel.lower().endswith(('.log', '.jsonl')):
+            continue
+        if rel in verdict:
+            continue
+        # 产物走通配没接住的, 也按台账来源判定
+        unclassified.append(rel)
+        verdict[rel] = dict(fam='(未归类)', verdict='?', why='既不在族表、也无法从台账判定来路')
+
+    counts = {}
+    for v in verdict.values():
+        counts[v['verdict']] = counts.get(v['verdict'], 0) + 1
+    if verbose:
+        print(f'== 反向呼应审计 · 场站 {farm} · 台账 {len(LEDGER)} 件 · 判定 {len(verdict)} 件 ==')
+        print(f'   输入根: {P.rel(station)}')
+        print()
+        print(f'{"族":26s} {"件数":>6s} {"判定":14s} {"输入类":12s} {"功能 / 算法 / 依据"}')
+        for r in fam_rows:
+            print(f'  {r["id"]:24s} {r["n"]:6d} {r["verdict"]:14s} {str(r["input"] or "—"):12s} '
+                  f'{r["func"][:34]}')
+            if r['note']:
+                print(f'      └─ {r["note"][:150]}')
+        if unclassified:
+            print(f'\n  【未归类 {len(unclassified)} 件】')
+            for r in unclassified[:20]:
+                print(f'      ? {r}')
+        print('\n  判定汇总: ' + ' · '.join(f'{k}={v}' for k, v in sorted(counts.items())))
+        print(f'  判据失败 {len(fails)} 条' + (':' + ';'.join(fails[:3]) if fails else ''))
+        ok = counts.get('✓', 0)
+        print(f'  结论: 输出↔输入呼应**成立** {ok} 件 / **不成立(无生成端)** {counts.get("✗", 0)} 件 / '
+              f'**人工件** {counts.get("◆", 0)} 件 / 无法验证 {counts.get("~", 0)} 件 / 未归类 {counts.get("?", 0)} 件')
+        # ── 逆向工程可行性 (用户令 #2): 没有呼应关系的那些, 能不能推出来 ──
+        import collections as _c
+        by_rev = _c.Counter()
+        for r in fam_rows:
+            by_rev[r.get('rev', '?')] += r['n']
+        print()
+        print('== 反向「可逆性」矩阵(用户令 2026-09-17 #2: 无生成端的件能否从原件推出来)==')
+        print(f'{"族":26s} {"件数":>6s} {"可逆性":22s} 依据')
+        for r in fam_rows:
+            if r['verdict'][0] == '✓':
+                continue
+            print(f'  {r["id"]:24s} {r["n"]:6d} {r.get("rev", "?"):22s} {r.get("rev_why", "")[:96]}')
+        print('  按件数汇总: ' + ' · '.join(f'{k}={v}' for k, v in by_rev.most_common()))
+    rc = 5 if (fails or unclassified) else 0
+    return fam_rows, verdict, counts, fails, unclassified, rc
+
+
+def doc_block(farm: str) -> str:
+    fam_rows, verdict, counts, fails, unclassified, _rc = audit(farm, verbose=False)
+    L = [DOC_BEGIN, '### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)', '',
+         f'台账 {len(verdict)} 件产物逐件回溯:**呼应成立 {counts.get("✓", 0)} 件** · '
+         f'**不成立(无生成端){counts.get("✗", 0)} 件** · 无法验证 {counts.get("~", 0)} 件 · '
+         f'未归类 {counts.get("?", 0)} 件。'
+         '判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。', '',
+         '| 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |',
+         '|---|---:|---|---|---|---|---|---|']
+    for r in fam_rows:
+        fam = next(f for f in FAMILIES if f['id'] == r['id'])
+        L.append(f'| `{fam["glob"]}` | {r["n"]} | {r["verdict"]} | {r["func"]} | {r["algo"]} | '
+                 f'{r["input"] or "—(无生成端)"} | {r["pred"]} | {r["note"][:160]} |')
+    if unclassified:
+        L += ['', f'**未归类 {len(unclassified)} 件**(需人工认领):' + '、'.join(f'`{x}`' for x in unclassified[:12])]
+    L += ['', '#### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)', '',
+          '判据两条机器证据:① 生成端在不在包内 ② 产物正文里有没有**人工判断**字段'
+          '(`人工/裁决/审核/校准/评审/经验/judge/review/calibrat/verdict`,抽样读件统计命中率)。'
+          '含人工判断的件不是任何输入的纯函数 ⇒ 逆向工程推不出来,只能把那一步人工工作重做。', '',
+          '| 输出族 | 件数 | 可逆性 | 依据 |', '|---|---:|---|---|']
+    for r in fam_rows:
+        if r['verdict'][0] == '✓':
+            continue
+        L.append(f'| `{r["id"]}` | {r["n"]} | {r.get("rev", "?")} | {r.get("rev_why", "")} |')
+    import collections as _c
+    by_rev = _c.Counter()
+    for r in fam_rows:
+        by_rev[r.get('rev', '?')] += r['n']
+    L += ['', '**按件数汇总**:' + ' · '.join(f'{k} = {v} 件' for k, v in by_rev.most_common()), '',
+          '**人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),'
+          '它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:'
+          'python scripts/products_reverse_audit.py --write-human-manifest。', '',
+          '> 结论口径:`可逆(直接)` = 包内已有生成端,接上即可;`可逆(需反推口径)` = 内容是测量数据的函数,'
+          '照 `temp_monthly` 的办法反推 + 逐值对拍;`不可逆(人工判断)` = 逆向工程推不出来,'
+          '要么由研发补生成端、要么承认它是人工件(不该按产物管)。', DOC_END]
+    return '\n'.join(L)
+
+
+def write_doc(farm: str) -> int:
+    doc = ROOT / 'docs' / '系统设计说明.md'
+    txt = doc.read_text(encoding='utf-8')
+    blk = doc_block(farm)
+    i, j = txt.find(DOC_BEGIN), txt.find(DOC_END)
+    if i >= 0 and j > i:
+        txt = txt[:i] + blk + txt[j + len(DOC_END):]
+    else:                                             # 首次: 追加到文末
+        txt = txt.rstrip('\n') + '\n\n' + blk + '\n'
+    doc.write_text(txt, encoding='utf-8', newline='\n')
+    print(f'已写入 {P.rel(doc)} (§13.6 反向呼应审计块)')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='反向呼应审计: 输出 → 功能与算法 → 输入')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--check', action='store_true', help='只出结论 (有未归类/判据失败 → rc=5)')
+    ap.add_argument('--write-doc', action='store_true', help='把族表写进 docs/系统设计说明.md §13.6')
+    ap.add_argument('--write-human-manifest', action='store_true',
+                    help='生成人工件台账 outputs/<场>/_human_artifacts.json (用户令 2026-09-17 #3)')
+    a = ap.parse_args()
+    if a.write_human_manifest:
+        return write_human_manifest(a.farm)
+    if a.write_doc:
+        return write_doc(a.farm or P.farm())
+    _rows, _v, counts, fails, uncl, rc = audit(a.farm, verbose=True)
+    if a.check:
+        print(f'[{"OK" if rc == 0 else "X"}] 反向呼应审计 rc={rc}'
+              + (f'(未归类 {len(uncl)} 件 / 判据失败 {len(fails)} 条)' if rc else '(未归类 0 件、判据无失败)'))
+    return rc
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 123 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/products_state.py

@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""产物开关 —— 把 outputs\<场>\ 下的产物**直接清掉** / 看当前状态 (人工检查页面空状态用)。
+
+## 2026-09-16 用户令: 清除产物**不留备份**
+
+原设计是"移动到 `_products_off/<场>/` + 清单留痕, 一条命令还原"。用户令改为**不备份**:
+清掉就是清掉。于是本脚本:
+  · `--off --yes`  真删除 (rmtree), 打印删了什么; **不再产生 `_products_off*` 目录**;
+  · `--status`     看产物在位/已清 (清掉后就是"已清", 没有备份清单可列);
+  · `--on`         已**移除**: 没有备份就没有"挪回"。恢复缺失的**随包件**按用户令 2026-09-17
+                   **不由交付包补齐**: 放原始件到 `data/raw/<场站>/` 后 `python scripts/rebuild_all.py`;
+                   没有生成端的族由研发补生成端 (逐族可逆性见 `docs/系统设计说明.md` §13.6)。
+                   (离线人工补救的口子仍在 `scripts/products_restore_missing.py --stash <…>`,
+                    但它**不在运行链里**, 也不该被运行期依赖。)
+
+## 保留不动的
+
+- `data\raw\` 现场数据 (检查的是"产物没了会怎样", 不是"数据没了会怎样")
+- `release\` 门户与三维/交付件 (那是外壳, 不是数据产物)
+- `logs\` `run\` 运行期文件
+- ★ `outputs\<场>\windscada\_pre_rebuild_20260911\` **不再特殊保留**: 它曾是"随包基线备份"而在清除时豁免,
+  现在按"不留备份"一并删掉 —— 等价验收因此暂时没有标准答案(如实报 rc=5, 不假装通过);
+  要恢复验收能力, 就用**当前在位且已核验的产物**重新落一份基线(见 docs §7 那条)。
+
+用法:
+    python scripts/products_state.py --status            # 看当前产物状态
+    python scripts/products_state.py --off --yes         # 清掉全部产物 (真删除, 不可恢复; --yes 是防手滑)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import pathlib
+import shutil
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                        # noqa: E402
+
+LEGACY_OFF = ROOT / '_products_off'                                # 旧版暂存区 (只用于提示, 不再写入)
+
+
+def farms() -> list[pathlib.Path]:
+    out = ROOT / 'outputs'
+    return sorted(p for p in out.iterdir() if p.is_dir()) if out.is_dir() else []
+
+
+def plan() -> list[pathlib.Path]:
+    """→ 待清除的产物目录列表 (产物仓本身)。"""
+    return [d for fam in farms() for d in sorted(fam.iterdir())]
+
+
+def count(p: pathlib.Path) -> tuple[int, float]:
+    if p.is_file():
+        return 1, p.stat().st_size / 1024 / 1024
+    fs = [f for f in p.rglob('*') if f.is_file()]
+    return len(fs), sum(f.stat().st_size for f in fs) / 1024 / 1024
+
+
+def do_off(assume_yes=False) -> int:
+    items = [p for p in plan() if p.exists()]
+    if not items:
+        print('产物已经是空的 (用 --status 看)')
+        return 0
+    total_n = total_mb = 0
+    rows = []
+    for src in items:
+        n, mb = count(src)
+        rows.append((P.rel(src), n, mb))
+        total_n += n
+        total_mb += mb
+    print('即将**删除**以下产物 (按用户令: 不留备份, 删除后不可恢复):')
+    for rel, n, mb in rows:
+        print(f'  {rel:34s} {n:5d} 件 {mb:8.1f} MB')
+    print(f'  合计 {len(rows)} 项 / {total_n} 件 / {total_mb:.1f} MB')
+    print('  恢复办法: 放原始件到 data/raw/<场站>/ 后 python scripts/rebuild_all.py; 缺生成端的族由研发补 (docs §13.6)')
+    if not assume_yes:
+        print('\n[X] 未执行: 这是不可恢复操作, 请显式加 --yes (运维控制台里点按钮时已由前端确认过一次)')
+        return 2
+    for src in items:
+        shutil.rmtree(src, ignore_errors=True) if src.is_dir() else src.unlink(missing_ok=True)
+    if LEGACY_OFF.exists():
+        print(f'  [!] 检测到旧版暂存区 {P.rel(LEGACY_OFF)} —— 按"不留备份"的口径, 本次**未删除**它; '
+              f'确认不需要后请手工删掉 (它是旧设计留下的产物备份)')
+    print(f'\n已删除 {len(rows)} 项 / {total_n} 件 / {total_mb:.1f} MB (无备份)')
+    return 0
+
+
+def do_status() -> int:
+    items = [p for p in plan() if p.exists()]
+    if not items:
+        print('当前: 产物**已清空** (页面应呈空状态)')
+        print('  恢复: python scripts/rebuild_all.py (缺生成端的族由研发补, 见 docs §13.6)')
+        return 0
+    print(f'当前: 产物**在位** ({len(items)} 项)')
+    for src in items:
+        n, mb = count(src)
+        print(f'    {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
+    print('  说明: 按 2026-09-16 用户令, 清除产物不留备份 (没有 _products_off/ 可还原)')
+    return 0
+
+
+if __name__ == '__main__':
+    ap = argparse.ArgumentParser()
+    g = ap.add_mutually_exclusive_group(required=True)
+    g.add_argument('--off', action='store_true', help='清掉全部产物 (**直接删除, 不留备份**)')
+    g.add_argument('--status', action='store_true', help='看当前状态')
+    g.add_argument('--on', action='store_true', help='[已移除] 旧版的"挪回" —— 见文件头说明')
+    ap.add_argument('--yes', action='store_true', help='确认执行不可恢复的清除 (--off 必带)')
+    a = ap.parse_args()
+    if a.on:
+        print('[X] --on 已移除: 按用户令"清除产物不留备份", 没有备份可挪回。\n'
+              '    运行期恢复: 放原始件后 python scripts/rebuild_all.py (缺生成端的族由研发补, docs §13.6)\n'
+              '    要重新算出数据面产物: python scripts/rebuild_all.py (SCADA 侧加 --scada)')
+        sys.exit(3)
+    sys.exit(do_off(assume_yes=a.yes) if a.off else do_status())

+ 106 - 0
app_qualityGate/common/app_qualityGate_guanlan/audits/security_scan.py

@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""交付前安全核查 (出"安全包"用). 扫**将要发出去的文件**, 报四类:
+  S1 密钥/凭据: API key / token / 私钥 / .env / 口令
+  S2 个人信息: 邮箱 / 手机号 / 身份证 / 家目录用户名
+  S3 D3 敏感: 业主与集团名 / 经济数字(¥·万元) / OEM 原件
+  S4 内部件: 内部文档、日志、测试快照、开发机绝对路径、git 元数据
+用法: python scripts/security_scan.py [--root .] [--zip <包>] [--out logs/security_scan.json] [--strict]
+  --strict: S1 任一命中, 或 S3 在**对外面孔**(门户/报告/契约)命中 → exit 2。
+判据说明: 本系统是给业主自己用的内网软件, 所以"场名/台号/业主名"在**包内产物**里是正常的 (那就是他们的数据);
+真正不能出的是 S1, 以及把 S3 放进**要上公网或转发第三方**的面孔。两者分开报, 不混。"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+import argparse, json, re, sys, time, zipfile
+from pathlib import Path
+ROOT_DEFAULT = _install_root(__file__)
+PAT = {
+    # AIza 真 key = 39 位且两侧不是 base64 字符 (2026-09-08 实逮: 内联 base64 里 104 位的巧合子串 = 假阳性)
+    "S1.api_key": re.compile(r"(?<![A-Za-z0-9+/=])(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{35})(?![A-Za-z0-9+/=])"),
+    "S1.secret_kv": re.compile(r"(?i)\b(api[_-]?key|secret|token|passwd|password|access[_-]?key)\b\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{12,}"),
+    "S1.private_key": re.compile(r"-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----"),
+    "S2.email": re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
+    # 手机/身份证: 只在**像人写的文本**里算 (前后有中文或常见标签词); 纯数字数据列 (时间戳/ID/哈希) 不算
+    "S2.phone_cn": re.compile(r"(电话|手机|联系方式|Tel|Phone)\D{0,6}(1[3-9]\d{9})(?!\d)"),
+    "S2.idcard_cn": re.compile(r"(身份证|证件号|ID ?No)\D{0,6}(\d{17}[\dXx])(?!\d)"),
+    "S2.home_path": re.compile(r"/Users/[a-z][a-z0-9_\-]*|C:\\Users\\[A-Za-z][A-Za-z0-9_\-]*|/home/[a-z][a-z0-9_\-]*"),
+    "S3.owner": re.compile(r"中广核|华电|国电投|国电|大唐|华能|三峡|龙源|中节能|京能|华润"),
+    # 真金额: ¥ 或 万元/亿元/元每千瓦; **不含** 万kWh/万行/万m³ (那是电量与计数单位, 2026-09-09 实逮误判)
+    "S3.money": re.compile(r"[¥¥]\s?\d|\d[\d,.]*\s?(万元|亿元|元/kW|元/千瓦)"),
+    "S4.internal_doc": re.compile(r"TASKS\.md|SESSION_LOG|violations_log|CLAUDE\.md|AGENTS\.md"),
+}
+TEXT_EXT = {".py", ".js", ".json", ".yaml", ".yml", ".md", ".txt", ".html", ".htm", ".css", ".csv", ".sh", ".ps1", ".bat", ".cfg", ".ini", ".toml"}
+# 对外面孔: 会被转发/上公网的东西 (门户/报告/事实契约/说明书)
+PUBLIC_FACE = re.compile(r"release/portal\.html|report.*\.(html|md|docx)|facts_contract|说明书|docs/.*\.md")
+SKIP = re.compile(r"(^|/)(\.git|__pycache__|\.venv|wheels|vendor|runtime|dist|node_modules)(/|$)")
+# 白名单: 脱敏词表本身当然含业主名 (它就是用来拦这些词的), 不算泄露
+WHITELIST = re.compile(r"scripts/guanlan_facts_contract\.py|scripts/security_scan\.py|src/windscada/deid.*\.py|configs/terms/")
+
+
+def scan_text(name: str, text: str, out: list, cap=200):
+    if WHITELIST.search(name): return
+    face = bool(PUBLIC_FACE.search(name))
+    for k, rx in PAT.items():
+        hits = rx.findall(text)
+        if not hits: continue
+        flat = [h if isinstance(h, str) else next((x for x in h if x), "") for h in hits]
+        uniq = sorted({str(x)[:60] for x in flat if x})[:5]
+        out.append(dict(file=name, rule=k, n=len(hits), samples=uniq, public_face=face))
+
+
+def iter_files(root: Path):
+    for p in sorted(root.rglob("*")):
+        rel = p.relative_to(root).as_posix()
+        if p.is_dir() or SKIP.search(rel): continue
+        yield rel, p
+
+
+def main():
+    ap = argparse.ArgumentParser(); ap.add_argument("--root", default=str(ROOT_DEFAULT)); ap.add_argument("--zip"); ap.add_argument("--out", default="logs/security_scan.json"); ap.add_argument("--strict", action="store_true"); a = ap.parse_args()
+    root = Path(a.root).resolve(); found = []; n_files = n_text = 0; big = []
+    if a.zip:
+        with zipfile.ZipFile(a.zip) as z:
+            for i in z.infolist():
+                if i.is_dir() or SKIP.search(i.filename): continue
+                n_files += 1
+                if i.file_size > 50_000_000: big.append((i.filename, i.file_size))
+                if Path(i.filename).suffix.lower() in TEXT_EXT and i.file_size < 8_000_000:
+                    n_text += 1; scan_text(i.filename, z.read(i).decode("utf-8", "ignore"), found)
+    else:
+        for rel, p in iter_files(root):
+            n_files += 1
+            if p.stat().st_size > 50_000_000: big.append((rel, p.stat().st_size))
+            if p.suffix.lower() in TEXT_EXT and p.stat().st_size < 8_000_000:
+                n_text += 1; scan_text(rel, p.read_text(encoding="utf-8", errors="ignore"), found)
+    by = {}
+    for f in found: by.setdefault(f["rule"], []).append(f)
+    s1 = [f for f in found if f["rule"].startswith("S1")]
+    s3_face = [f for f in found if f["rule"].startswith("S3") and f["public_face"]]
+    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), target=a.zip or str(root), n_files=n_files, n_text_scanned=n_text,
+               by_rule={k: dict(files=len({x["file"] for x in v}), hits=sum(x["n"] for x in v)) for k, v in sorted(by.items())},
+               blockers=dict(S1_secrets=len(s1), S3_on_public_face=len(s3_face)),
+               big_files=[dict(file=f, mb=round(s / 1e6, 1)) for f, s in sorted(big, key=lambda x: -x[1])[:10]], findings=found)
+    o = Path(a.out) if Path(a.out).is_absolute() else root / a.out; o.parent.mkdir(parents=True, exist_ok=True)
+    o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"扫 {n_files} 文件 (文本 {n_text}) → {o}")
+    for k, v in rep["by_rule"].items(): print(f"  {k:20s} 文件 {v['files']:4d}  命中 {v['hits']:6d}")
+    print(f"  阻断项: 密钥 {len(s1)} · 对外面孔上的 D3 {len(s3_face)}")
+    for f in (s1 + s3_face)[:8]: print(f"    ✗ {f['rule']} {f['file']} {f['samples'][:2]}")
+    if a.strict and (s1 or s3_face): return 2
+    return 0
+
+
+if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 2 - 0
app_qualityGate/common/app_qualityGate_guanlan/docs/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""交付文档三检与版本记录(P7 起)。"""

+ 700 - 0
app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_build.py

@@ -0,0 +1,700 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""交付文档构建器:`docs/src/*.md` → `docs/*.docx`(用户令 2026-09-22)。
+
+## 为什么要有它(而不是拿 Word 手排)
+用户令要求三份文档「区分章节目录、文表图并茂、字体字号分类统一」。手排的三份文档**必然**字体字号漂移、
+图表编号对不上、改一处忘一处。所以内容用 markdown 写(可 diff、进版本库),排版由本器**一套样式表**统一执行:
+
+| 元素 | 字体 | 字号 | 对齐 / 缩进 |
+|---|---|---|---|
+| 封面标题 | 黑体 | 小一(26pt) | 居中 |
+| 章标题(一级 `##`) | 黑体 | 三号(16pt) | 左对齐,**章前分页** |
+| 节标题(二级 `###`) | 黑体 | 四号(14pt) | 左对齐 |
+| 小节标题(三级 `####`) | 黑体 | 小四(12pt) 加粗 | 左对齐 |
+| 正文 | 宋体 | 小四(12pt) | 首行缩进 2 字符、1.5 倍行距、两端对齐 |
+| 列表 | 宋体 | 小四(12pt) | 悬挂缩进 |
+| 表题(表上方) | 黑体 | 五号(10.5pt) | 居中 |
+| 表头 | 黑体 | 五号(10.5pt) 加粗 | 居中 + 浅灰底纹 + 跨页重复 |
+| 表格正文 | 宋体 | 五号(10.5pt)(≥6 列降 9pt) | 左对齐 |
+| 图(居中)与图题(图下方) | 黑体 | 五号(10.5pt) | 居中 |
+| 页眉 / 页脚页码 | 宋体 | 小五(9pt) | 居中 |
+
+英文与数字用 Times New Roman(中式公文惯例:中文宋体/黑体 + 西文 Times New Roman)。
+
+## markdown 约定(渲染器支持的子集,写手必须照此写)
+    # 文档标题                       (只此一处,进封面)
+    ## 1 章 / ### 1.1 节 / #### 1.1.1 小节
+    普通段落(一行一段,段间空行)
+    - 无序项           1. 有序项
+    | 表头 | 表头 |   +  |---| 分隔行           → 表格
+    **表 3-1 标题**(紧挨表格上方一行)            → 表题
+    ![图 3-1 标题](figures/xxx.png)                → 图片 + 图题
+    > 引用段
+    ---                                            → 忽略
+
+## 用法
+    python scripts/delivery_docs_build.py                 # 三份全渲染
+    python scripts/delivery_docs_build.py --only req      # 只渲染一份 (req/des/dat)
+    python scripts/delivery_docs_build.py --check         # 只体检源件(章/节/表/图/字数、图是否在位)
+    python scripts/delivery_docs_build.py --spec          # 打印本器的排版规范表
+
+退出码: 0 全部成功 · 5 有源件缺件/图缺件(逐条打印,仍会尽力渲染其余内容)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import pathlib
+import re
+import sys
+
+ROOT = _install_root(__file__)
+
+from docx import Document                                                    # noqa: E402
+from docx.enum.section import WD_SECTION                                     # noqa: E402
+from docx.enum.table import WD_TABLE_ALIGNMENT                               # noqa: E402
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING               # noqa: E402
+from docx.oxml import OxmlElement                                            # noqa: E402
+from docx.oxml.ns import qn                                                  # noqa: E402
+from docx.shared import Cm, Pt, RGBColor                                     # noqa: E402
+
+VERSION = __import__('importlib').import_module('src.version').VERSION
+
+HEI, SONG, WEST = '黑体', '宋体', 'Times New Roman'
+SZ = dict(cover=26, h1=16, h2=14, h3=12, body=12, table=10.5, table_small=9, caption=10.5, header=9)
+
+DOCS = {
+    'req': dict(src='docs/src/需求分析_观澜_{v}.md', out='docs/需求分析_观澜_{v}.docx',
+                title='需求分析', subtitle='观澜 v2 · 风电场智能分析系统'),
+    'des': dict(src='docs/src/系统设计说明_观澜_{v}.md', out='docs/系统设计说明_观澜_{v}.docx',
+                title='系统设计说明', subtitle='观澜 v2 · 风电场智能分析系统'),
+    'dat': dict(src='docs/src/数据要求说明_观澜_{v}.md', out='docs/数据要求说明_观澜_{v}.docx',
+                title='数据要求说明',
+                subtitle='观澜 v2 · 风电场智能分析系统(结合现场《数据分析收资要求-v3》)',
+                # ★2026-09-22 用户令: 这份是**纯数据需求规格** —— 不体现样本场接入情况, 也不体现实现细节。
+                #   下面这组模式由 `--check`/`--verify` 机器扫描, 命中即 FAIL(不靠人自觉)。
+                strict_terms=True),
+}
+
+# 数据要求说明的"不得出现"模式(用户令 2026-09-22):
+#   英文测点名称 / 文件名称 / 落盘位置 / 机组名称编号 / 样本场现状字样。
+#   中文业务含义名称与「中文(英文简写)」(如 平均无故障间隔(MTBF))不在禁止之列。
+STRICT_PATTERNS = (
+    ('英文测点标识(snake_case)', r'\b[a-z][a-z0-9]*_[a-z0-9_]+\b'),
+    ('文件扩展名', r'\.(csv|parquet|json|yaml|yml|xlsx|xls|pdf|mdb|xml|docx|doc)\b'),
+    ('目录/落盘位置', r'(data/raw|outputs/|configs/|scripts/|reference/|docs/|data\\)'),
+    ('机组名称/编号', r'(WTG\d+|\b\d{2}[A-Z]\b)'),
+    ('样本场现状字样', r'(已在位|未到位|现状|实测|样本场|本场|催缴)'),
+)
+
+# ★去标识化词表(用户令 2026-09-22「内容参考如东风电场,但不体现如东风电场」):
+#   三份文档是**对外**交付件, 不得出现样本场的中文名、罗马化形态、业主、地域、OEM 与第三方机构名。
+#   词表放在代码里 ⇒ `--check`/`--verify` 与 `guanlan.py check` 都会机器扫描, 不靠人自觉。
+#   ★2026-09-22 补(数据要求说明起草时实逮): 样本场 CMS 导出包名是 `CMS_RuDong_CGN_202603-04`
+#   —— **大小写混合**的罗马化场名/业主缩写能绕过精确匹配,所以匹配一律**大小写不敏感**(见 forbidden_hits)。
+FORBIDDEN = ('如东', '如海', '广核', '江苏', 'rudong', 'cgn',
+             '西门子', '上海电气', 'swt', '大生', '4.0-130')
+
+SPEC_ROWS = [
+    ('封面标题', '黑体', '小一(26pt)', '居中'),
+    ('章标题(一级)', '黑体', '三号(16pt)', '左对齐,章前分页'),
+    ('节标题(二级)', '黑体', '四号(14pt)', '左对齐'),
+    ('小节标题(三级)', '黑体', '小四(12pt)加粗', '左对齐'),
+    ('正文', '宋体(西文 Times New Roman)', '小四(12pt)', '首行缩进 2 字符,1.5 倍行距,两端对齐'),
+    ('列表', '宋体', '小四(12pt)', '悬挂缩进 0.74 cm'),
+    ('表题', '黑体', '五号(10.5pt)', '居中,置于表格上方'),
+    ('表头', '黑体', '五号(10.5pt)加粗', '居中,浅灰底纹,跨页重复'),
+    ('表格正文', '宋体', '五号(10.5pt);≥6 列降 9pt', '左对齐,垂直居中'),
+    ('图', '—', '宽度 ≤ 15 cm', '居中'),
+    ('图题', '黑体', '五号(10.5pt)', '居中,置于图下方'),
+    ('页眉', '宋体', '小五(9pt)', '居中'),
+    ('页脚页码', '宋体', '小五(9pt)', '居中(第 X 页 / 共 Y 页)'),
+]
+
+WARN: list[str] = []
+
+
+# ── 低层 docx 工具 ─────────────────────────────────────────────────────────────
+def _rfonts(el, ascii_font: str, ea_font: str):
+    rpr = el.get_or_add_rPr()
+    rf = rpr.find(qn('w:rFonts'))
+    if rf is None:
+        rf = OxmlElement('w:rFonts')
+        rpr.append(rf)
+    rf.set(qn('w:ascii'), ascii_font)
+    rf.set(qn('w:hAnsi'), ascii_font)
+    rf.set(qn('w:eastAsia'), ea_font)
+
+
+def set_run(run, size: float, *, ea: str = SONG, ascii_font: str = WEST, bold: bool = False):
+    run.font.size = Pt(size)
+    run.font.bold = bold
+    run.font.name = ascii_font
+    run.font.color.rgb = RGBColor(0, 0, 0)
+    _rfonts(run._element, ascii_font, ea)
+
+
+def style_setup(doc: Document):
+    """把内置样式(Heading 1-4 / Normal / 表格)改成文档的统一口径。"""
+    normal = doc.styles['Normal']
+    normal.font.name = WEST
+    normal.font.size = Pt(SZ['body'])
+    _rfonts(normal.element, WEST, SONG)
+    normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    normal.paragraph_format.line_spacing = 1.5
+    normal.paragraph_format.space_after = Pt(0)
+
+    spec = {'Heading 1': (SZ['h1'], True), 'Heading 2': (SZ['h2'], True),
+            'Heading 3': (SZ['h3'], True), 'Heading 4': (SZ['h3'], True)}
+    for name, (size, bold) in spec.items():
+        st = doc.styles[name]
+        st.font.name = WEST
+        st.font.size = Pt(size)
+        st.font.bold = bold
+        st.font.italic = False
+        st.font.color.rgb = RGBColor(0, 0, 0)
+        _rfonts(st.element, WEST, HEI)
+        pf = st.paragraph_format
+        pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+        pf.line_spacing = 1.5
+        pf.space_before = Pt(12 if name != 'Heading 1' else 0)
+        pf.space_after = Pt(6)
+        pf.first_line_indent = Pt(0)
+        if name == 'Heading 1':
+            pf.page_break_before = True
+            pf.space_after = Pt(12)
+    sec = doc.sections[0]
+    sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
+    sec.top_margin = sec.bottom_margin = Cm(2.54)
+    sec.left_margin = sec.right_margin = Cm(3.17)
+
+
+def add_field(paragraph, instr: str, placeholder: str = ''):
+    r = paragraph.add_run()
+    b = OxmlElement('w:fldChar'); b.set(qn('w:fldCharType'), 'begin')
+    i = OxmlElement('w:instrText'); i.set(qn('xml:space'), 'preserve'); i.text = instr
+    s = OxmlElement('w:fldChar'); s.set(qn('w:fldCharType'), 'separate')
+    t = OxmlElement('w:t'); t.text = placeholder
+    e = OxmlElement('w:fldChar'); e.set(qn('w:fldCharType'), 'end')
+    for el in (b, i, s, t, e):
+        r._r.append(el)
+    return r
+
+
+def para(doc, text: str = '', *, size=SZ['body'], ea=SONG, bold=False, align='left',
+         indent_chars=0, space_before=0, space_after=6, hanging=False):
+    p = doc.add_paragraph()
+    pf = p.paragraph_format
+    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+    pf.line_spacing = 1.5
+    pf.space_before = Pt(space_before)
+    pf.space_after = Pt(space_after)
+    pf.first_line_indent = Pt(size * indent_chars)
+    if hanging:
+        pf.left_indent = Cm(0.74)
+        pf.first_line_indent = Cm(-0.0)
+    pf.alignment = {'left': WD_ALIGN_PARAGRAPH.LEFT, 'center': WD_ALIGN_PARAGRAPH.CENTER,
+                    'justify': WD_ALIGN_PARAGRAPH.JUSTIFY}[align]
+    if text:
+        for chunk, is_bold, mono in split_bold(text):
+            r = p.add_run(chunk)
+            set_run(r, size, ea=SONG, ascii_font='Consolas' if mono else WEST, bold=bold or is_bold)
+    return p
+
+
+INLINE_RE = re.compile(r'\*\*(.+?)\*\*|`([^`]+)`')
+CODE_RE = re.compile(r'`([^`]+)`')
+BOLD_RE = re.compile(r'\*\*(.+?)\*\*')
+
+
+def _bold_parts(s: str):
+    parts, pos = [], 0
+    for m in BOLD_RE.finditer(s):
+        if m.start() > pos:
+            parts.append((s[pos:m.start()], False, False))
+        parts.append((m.group(1), True, False))
+        pos = m.end()
+    tail = s[pos:]
+    if '**' in tail:
+        # 落单的 `**`(例如通配路径 `pitch/**` 没包在行内代码里)—— 交付文档里不能留字面量,
+        # 去掉并记一条告警,让写手回去补行内代码。
+        WARN.append('落单的 ** 已剔除: ' + tail.strip()[:60])
+        tail = tail.replace('**', '')
+    if tail:
+        parts.append((tail, False, False))
+    return parts
+
+
+def split_bold(text: str):
+    """行内标记 → [(片段, 是否粗体, 是否等宽)]。
+
+    ★单遍分词,支持两种嵌套(2026-09-22 两次实逮后的定稿):
+      ① 代码里含 `**`(如通配路径 `` `pitch/**` ``)—— 不能当成粗体起始;
+      ② 粗体里含代码(如 ``**体量以 `data/raw` 为准**``)—— 不能按代码先切、把粗体配对切断。
+    先按 `**…**` 找配对,配对内部再递归扫代码;代码段内部一律**字面**取,不再解析粗体。
+    落单的 `**` 剔除并记告警(交付文档里不能留字面星号)。
+    """
+    out: list[tuple[str, bool, bool]] = []
+
+    def emit(t: str, bold: bool, mono: bool):
+        if t:
+            out.append((t, bold, mono))
+
+    def scan(s: str, bold: bool):
+        i = 0
+        while i < len(s):
+            if s.startswith('**', i):
+                j = s.find('**', i + 2)
+                if j > i + 2:
+                    scan(s[i + 2:j], True)
+                    i = j + 2
+                    continue
+                WARN.append('落单的 ** 已剔除: ' + s[max(0, i - 20):i + 20].strip())
+                i += 2
+                continue
+            if s[i] == '`':
+                j = s.find('`', i + 1)
+                if j > i:
+                    emit(s[i + 1:j], bold, True)
+                    i = j + 1
+                    continue
+            nxt = len(s)
+            for k in range(i, len(s)):
+                if s[k] == '`' or s.startswith('**', k):
+                    nxt = k
+                    break
+            emit(s[i:nxt], bold, False)
+            i = nxt
+
+    scan(text, False)
+    return out or [(text, False, False)]
+
+
+def shade(cell, fill='F2F2F2'):
+    tcPr = cell._tc.get_or_add_tcPr()
+    shd = OxmlElement('w:shd')
+    shd.set(qn('w:val'), 'clear')
+    shd.set(qn('w:color'), 'auto')
+    shd.set(qn('w:fill'), fill)
+    tcPr.append(shd)
+
+
+def repeat_header(row):
+    trPr = row._tr.get_or_add_trPr()
+    th = OxmlElement('w:tblHeader')
+    th.set(qn('w:val'), 'true')
+    trPr.append(th)
+
+
+def add_table(doc, rows: list[list[str]]):
+    ncol = max(len(r) for r in rows)
+    t = doc.add_table(rows=0, cols=ncol)
+    t.style = 'Table Grid'
+    t.alignment = WD_TABLE_ALIGNMENT.CENTER
+    t.autofit = True
+    size = SZ['table_small'] if ncol >= 6 else SZ['table']
+    for i, r in enumerate(rows):
+        cells = t.add_row().cells
+        for j in range(ncol):
+            txt = (r[j] if j < len(r) else '').strip()
+            cell = cells[j]
+            cell.text = ''
+            p = cell.paragraphs[0]
+            p.paragraph_format.line_spacing = 1.15
+            p.paragraph_format.space_after = Pt(0)
+            p.paragraph_format.first_line_indent = Pt(0)
+            p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT
+            for chunk, is_bold, mono in split_bold(txt):
+                run = p.add_run(chunk.replace('\\|', '|'))
+                set_run(run, size, ea=HEI if i == 0 else SONG,
+                        ascii_font='Consolas' if mono else WEST, bold=(i == 0) or is_bold)
+            if i == 0:
+                shade(cell)
+    if t.rows:
+        repeat_header(t.rows[0])
+    return t
+
+
+# ── markdown 解析 ──────────────────────────────────────────────────────────────
+IMG_RE = re.compile(r'^!\[(?P<cap>[^\]]*)\]\((?P<path>[^)]+)\)\s*$')
+CAP_RE = re.compile(r'^\*\*(?P<cap>表\s*[\d\-–.]+.*?)\*\*\s*$')
+
+
+def parse_md(text: str):
+    """→ [('h1'|'h2'|'h3'|'h4'|'p'|'ul'|'ol'|'table'|'img'|'quote', payload)]"""
+    lines = text.splitlines()
+    blocks, i = [], 0
+    if lines and lines[0].startswith('# '):
+        i = 1
+    while i < len(lines):
+        ln = lines[i]
+        s = ln.strip()
+        if not s or re.match(r'^([-*_])(\s*\1){2,}$', s):
+            i += 1                       # 空行与分隔线(`---` / `***` / `___`)
+            continue
+        m = re.match(r'^(#{2,5})\s+(.*)$', s)
+        if m:
+            blocks.append(('h%d' % (len(m.group(1)) - 1), m.group(2).strip()))
+            i += 1
+            continue
+        m = IMG_RE.match(s)
+        if m:
+            blocks.append(('img', (m.group('cap'), m.group('path'))))
+            i += 1
+            continue
+        if s.startswith('|'):
+            rows = []
+            while i < len(lines) and lines[i].strip().startswith('|'):
+                raw = lines[i].strip().strip('|')
+                if not re.match(r'^[\s:\-|]+$', raw):
+                    rows.append([c.strip() for c in raw.split('|')])
+                i += 1
+            if rows:
+                cap = None
+                if blocks and blocks[-1][0] == 'p' and CAP_RE.match(blocks[-1][1]):
+                    cap = blocks.pop()[1]
+                blocks.append(('table', (cap, rows)))
+            continue
+        if s.startswith('- '):
+            items = []
+            while i < len(lines) and lines[i].strip().startswith('- '):
+                items.append(lines[i].strip()[2:].strip())
+                i += 1
+            blocks.append(('ul', items))
+            continue
+        if re.match(r'^\d+[.)]\s+', s):
+            items = []
+            while i < len(lines) and re.match(r'^\d+[.)]\s+', lines[i].strip()):
+                items.append(re.sub(r'^\d+[.)]\s+', '', lines[i].strip()))
+                i += 1
+            blocks.append(('ol', items))
+            continue
+        if s.startswith('> '):
+            q = []
+            while i < len(lines) and lines[i].strip().startswith('> '):
+                q.append(lines[i].strip()[2:].strip())
+                i += 1
+            blocks.append(('quote', ' '.join(q)))
+            continue
+        blocks.append(('p', s))
+        i += 1
+    return blocks
+
+
+def forbidden_hits(text: str) -> dict:
+    """扫去标识化词表 → {禁用词: 命中次数}(只回报非零项;**大小写不敏感**)。"""
+    low = text.lower()
+    return {w: low.count(w.lower()) for w in FORBIDDEN if low.count(w.lower())}
+
+
+def strict_hits(text: str) -> dict:
+    """数据要求说明的"不得出现实现细节/现状"扫描 → {模式名: 命中次数}(只回报非零项)。"""
+    return {name: len(re.findall(rx, text, re.I)) for name, rx in STRICT_PATTERNS
+            if re.search(rx, text, re.I)}
+
+
+def count_md(text: str) -> dict:
+    blocks = parse_md(text)
+    body = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text)
+    cn = len(re.findall(r'[\u4e00-\u9fff]', body))
+    return dict(
+        章=sum(1 for k, _ in blocks if k == 'h1'),
+        节=sum(1 for k, _ in blocks if k == 'h2'),
+        小节=sum(1 for k, _ in blocks if k == 'h3'),
+        表=sum(1 for k, _ in blocks if k == 'table'),
+        图=sum(1 for k, _ in blocks if k == 'img'),
+        段落=sum(1 for k, _ in blocks if k == 'p'),
+        汉字=cn)
+
+
+# ── 渲染 ───────────────────────────────────────────────────────────────────────
+def cover(doc, title: str, subtitle: str):
+    for _ in range(4):
+        para(doc, '', space_after=0)
+    para(doc, f'{title}', size=SZ['cover'], ea=HEI, bold=True, align='center', space_after=12)
+    para(doc, subtitle, size=14, ea=HEI, align='center', space_after=6)
+    for _ in range(3):
+        para(doc, '', space_after=0)
+    para(doc, f'版本 {VERSION}(与系统版本一致)', size=14, align='center', space_after=6)
+    para(doc, f'编制:观澜项目组  日期:2026-09-22', size=12, align='center', space_after=6)
+    # ★落款不写生成器文件名与源件路径:那会把实现细节带进"对外、不体现文件名/落盘位置"的文档里
+    #   (2026-09-22 实逮: `--check` 扫源件是 clean, 但 `--verify` 扫渲染结果命中 1 处 snake_case + 2 处路径)。
+    para(doc, '本文件由统一的交付文档生成器排版(样式表固定,可重跑、可 diff;正文源件随交付包提供)',
+         size=9, align='center', space_after=0)
+    doc.add_page_break()
+
+
+def toc(doc):
+    para(doc, '目录', size=SZ['h1'], ea=HEI, bold=True, align='center', space_after=12)
+    p = doc.add_paragraph()
+    p.paragraph_format.line_spacing = 1.5
+    add_field(p, r'TOC \o "1-3" \h \z \u',
+              '(目录域:在 Word 中按 Ctrl+A 后 F9,或右键“更新域”即可生成带页码的目录)')
+    doc.add_page_break()
+
+
+def render_blocks(doc, blocks, fig_root: pathlib.Path):
+    """渲染块序列。表号一律**按章编号**「表 <章>-<序>」(附录按字母 A/B/C)——
+    与三份稿正文里的引用口径一致(实逮: 原先按出现顺序编成「表 1…表 54」, 正文写「见表 9-2」就指不到)。
+    """
+    tno = 0
+    chap_label = ''
+    per = 0
+    caps: list[str] = []
+    for kind, payload in blocks:
+        if kind == 'h1':
+            head = payload.strip()
+            m = re.match(r'^(\d+)', head)
+            if m:
+                chap_label, per = m.group(1), 0
+            else:
+                m2 = re.match(r'^附录\s*([A-Za-z])', head)
+                chap_label, per = (m2.group(1), 0) if m2 else (chap_label, 0)
+        if kind.startswith('h'):
+            lvl = int(kind[1])
+            style = {1: 'Heading 1', 2: 'Heading 2', 3: 'Heading 3', 4: 'Heading 4'}[lvl]
+            p = doc.add_paragraph(style=style)
+            size = {1: SZ['h1'], 2: SZ['h2'], 3: SZ['h3'], 4: SZ['h3']}[lvl]
+            for chunk, _b, _m in split_bold(payload):
+                set_run(p.add_run(chunk), size, ea=HEI, bold=True)
+            continue
+        if kind == 'p':
+            para(doc, payload, align='justify', indent_chars=2)
+            continue
+        if kind in ('ul', 'ol'):
+            for n, item in enumerate(payload, 1):
+                mark = '· ' if kind == 'ul' else f'({n})'
+                para(doc, mark + item, align='justify', hanging=True, space_after=3)
+            continue
+        if kind == 'quote':
+            p = para(doc, payload, align='justify', space_before=6, space_after=6)
+            p.paragraph_format.left_indent = Cm(0.74)
+            continue
+        if kind == 'table':
+            cap, rows = payload
+            tno += 1
+            per += 1
+            label = f'表 {chap_label}-{per}' if chap_label else f'表 {tno}'
+            title = re.sub(r'^表\s*[\dA-Za-z]+\s*[-–—]\s*\d+\s*', '', cap or '').strip()
+            if cap:
+                author_no = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', cap)
+                if author_no and f'{author_no.group(1)}-{author_no.group(2)}' != f'{chap_label}-{per}':
+                    WARN.append('表号与作者自编不一致: 原「%s」→ 本器按章编号「%s」'
+                                % (cap[:24], label))
+            caps.append(label)
+            para(doc, (label + (' ' + title if title else '')), size=SZ['caption'], ea=HEI, bold=True,
+                 align='center', space_before=8, space_after=4)
+            add_table(doc, rows)
+            para(doc, '', space_after=2)
+            continue
+        if kind == 'img':
+            cap, rel = payload
+            # md 里的图路径是相对 `docs/` 的(约定 `figures/xxx.png`);容错:只给文件名也认。
+            cand = fig_root / rel if (fig_root / rel).exists() else fig_root / 'figures' / pathlib.Path(rel).name
+            f = cand.resolve()
+            if f.exists():
+                p = doc.add_paragraph()
+                p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                p.paragraph_format.space_before = Pt(8)
+                p.paragraph_format.space_after = Pt(4)
+                p.paragraph_format.first_line_indent = Pt(0)
+                p.add_run().add_picture(str(f), width=Cm(15.0) if f.stat().st_size > 90_000 else Cm(13.0))
+            else:
+                WARN.append(f'图缺件: {rel}')
+                para(doc, f'(图缺失:{rel})', size=SZ['caption'], align='center', space_after=4)
+            para(doc, cap or rel, size=SZ['caption'], ea=HEI, bold=True, align='center', space_after=8)
+            continue
+    doc._tab_labels = caps                     # 供调用方自检(本器 --verify 直接从 docx 回读,不依赖它)
+    return tno
+
+
+def header_footer(doc, title: str):
+    sec = doc.sections[0]
+    hp = sec.header.paragraphs[0]
+    hp.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    set_run(hp.add_run(f'{title} · 观澜 v2 风电场智能分析系统 · 版本 {VERSION}'), SZ['header'])
+    fp = sec.footer.paragraphs[0]
+    fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    set_run(fp.add_run('第 '), SZ['header'])
+    add_field(fp, 'PAGE', '1')
+    set_run(fp.add_run(' 页 / 共 '), SZ['header'])
+    add_field(fp, 'NUMPAGES', '1')
+    set_run(fp.add_run(' 页'), SZ['header'])
+
+
+def appendix_spec(doc):
+    para(doc, '附:排版规范(字体字号分类)', size=SZ['h1'], ea=HEI, bold=True, align='left',
+         space_before=12, space_after=8)
+    para(doc, '本文件与同批交付的另外两份文档由同一个渲染器生成,字体字号分类完全一致;'
+              '中文用黑体(标题)/宋体(正文),西文与数字用 Times New Roman。', align='justify',
+         indent_chars=2)
+    add_table(doc, [['元素', '字体', '字号', '对齐 / 缩进']] + [list(r) for r in SPEC_ROWS])
+
+
+def build(key: str) -> int:
+    spec = DOCS[key]
+    src = ROOT / spec['src'].format(v=VERSION)
+    out = ROOT / spec['out'].format(v=VERSION)
+    if not src.exists():
+        WARN.append(f'源件缺件: {src.relative_to(ROOT).as_posix()}')
+        print(f'✗ {key}: 源件不在位 {src}')
+        return 5
+    blocks = parse_md(src.read_text(encoding='utf-8'))
+    doc = Document()
+    style_setup(doc)
+    cover(doc, spec['title'], spec['subtitle'])
+    toc(doc)
+    ntab = render_blocks(doc, blocks, ROOT / 'docs')
+    appendix_spec(doc)
+    header_footer(doc, spec['title'])
+    out.parent.mkdir(parents=True, exist_ok=True)
+    doc.save(out)
+    st = count_md(src.read_text(encoding='utf-8'))
+    print('%s → %s(%d 章 · %d 节 · %d 表 · %d 图 · 约 %d 汉字 · %.0f KB)'
+          % (key, out.relative_to(ROOT).as_posix(), st['章'], st['节'], max(ntab, st['表']), st['图'],
+             st['汉字'], out.stat().st_size / 1024))
+    return 0
+
+
+def verify_docx(key: str) -> int:
+    """回读渲染结果自检(交付前必跑):计数 + 残留 markdown 标记 + 域 + 字体。"""
+    spec = DOCS[key]
+    out = ROOT / spec['out'].format(v=VERSION)
+    if not out.is_file():
+        print('✗ %-4s 不在位 %s' % (key, out.relative_to(ROOT).as_posix()))
+        return 5
+    doc = Document(str(out))
+    texts = [p.text for p in doc.paragraphs]
+    cells = [c.text for t in doc.tables for r in t.rows for c in r.cells]
+    anti = chr(96)
+    # ★行内代码里的字面量不算残留(实逮: `` `pitch/**` `` 是 glob 通配路径,Word 里就该原样显示)。
+    mono, plain = [], []
+    for p in doc.paragraphs:
+        for r in p.runs:
+            (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text)
+    for t in doc.tables:
+        for row in t.rows:
+            for c in row.cells:
+                for p in c.paragraphs:
+                    for r in p.runs:
+                        (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text)
+    allt = '\n'.join(texts + cells)
+    body = '\n'.join(plain)
+    heads = [p.text for p in doc.paragraphs if p.style.name.startswith('Heading')]
+    residual = dict(反引号=body.count(anti), 星号=body.count('**'), 管道=body.count(' | '),
+                    代码内字面星号='\n'.join(mono).count('**'))
+    foot = doc.sections[0].footer.paragraphs[0]._p.xml
+    head = doc.sections[0].header.paragraphs[0].text
+    ok_field = ('TOC' in doc.element.xml) and ('PAGE' in foot) and ('NUMPAGES' in foot)
+    # 只把"正文里残留的 markdown 标记"判为不合格;代码内的字面星号是内容本身(glob 通配),只报数。
+    bad = [k for k, v in residual.items() if v and k != '代码内字面星号']
+    # ★版本表覆盖检查(2026-09-22 实逮): 升版本时若只改版本串而忘插表行, 表尾会停在旧版本,
+    #   而正文"共 N 条/十六个版本/中七条·小九条"是手写的 ⇒ 用 HISTORY 机器核对, 缺一行就 FAIL。
+    if key == 'req':
+        from src import version as _V
+        want_v = {h['version'] for h in _V.HISTORY}
+        got_v = set()
+        for _tb in doc.tables:
+            for _row in _tb.rows:
+                _c = _row.cells[0].text.strip()
+                if re.fullmatch(r'\d+\.\d+\.\d+', _c):
+                    got_v.add(_c)
+        _miss_v = sorted(want_v - got_v)
+        if _miss_v:
+            bad.append('版本表缺行 %s' % _miss_v)
+    leak = forbidden_hits(allt + '\n' + head)          # ★对外件不得出现样本场标识
+    strict = strict_hits(allt) if spec.get('strict_terms') else {}   # ★数据要求说明: 去实现细节/去现状
+    # ★表号断链检查(2026-09-22 实逮): 正文写「见表 9-2」,文档里就必须真有「表 9-2」。
+    cap_labels = set()
+    for p in doc.paragraphs:
+        mm = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', p.text.strip())
+        if mm:
+            cap_labels.add(f'{mm.group(1)}-{mm.group(2)}')
+
+    def _refs(text: str) -> set:
+        """正文里的「表 X-Y」引用。
+
+        ★两处实逮的误报都要挡:① `年-月`('报表 2026-09');② **跨单元格拼接** —— 把表格单元用换行
+        连起来后, "…如实空表" + "FR-18 至 FR-21" 会拼出 '表\\nFR-18',被当成表号。故分隔符只许空格/制表符。
+        """
+        out = set()
+        for a, b in re.findall(r'表[ \t]*([\dA-Za-z]+)[ \t]*[-–—][ \t]*(\d+)', text):
+            if re.fullmatch(r'(19|20)\d{2}', a):
+                continue
+            out.add(f'{a}-{b}')
+        return out
+
+    refs = _refs('\n'.join(
+        [p.text for p in doc.paragraphs
+         if not re.match(r'^(表|图)\s*[\dA-Za-z]+\s*[-–—]\s*\d+', p.text.strip())] + cells))
+    broken = sorted(refs - cap_labels)
+    noref = sorted(cap_labels - refs)
+    print('%-4s %-38s 段落 %3d · 表 %2d · 图 %2d · 标题 %2d · 页眉 %r'
+          % (key, out.name, len(doc.paragraphs), len(doc.tables), len(doc.inline_shapes),
+             len(heads), head[:28]))
+    print('      域(目录/页码): %s · 残留标记: %s · 去标识化: %s%s · 表号: %s'
+          % ('OK' if ok_field else '缺', bad or '无',
+             'clean' if not leak else '泄漏 %s' % leak,
+             '' if not spec.get('strict_terms') else
+             ' · 去实现细节: %s' % ('clean' if not strict else '命中 %s' % strict),
+             '断链 %s' % broken if broken else '引用闭合(表 %d 张,%d 张无引用)' % (len(cap_labels), len(noref))))
+    return 0 if ok_field and not bad and not leak and not broken and not strict else 5
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--only', choices=sorted(DOCS))
+    ap.add_argument('--check', action='store_true', help='只体检源件与图,不渲染')
+    ap.add_argument('--verify', action='store_true', help='渲染后回读自检(计数/残留标记/域)')
+    ap.add_argument('--spec', action='store_true', help='打印排版规范表')
+    a = ap.parse_args()
+    if a.spec:
+        for row in SPEC_ROWS:
+            print(' | '.join(row))
+        return 0
+    rc = 0
+    if a.verify:
+        for k in ([a.only] if a.only else sorted(DOCS)):
+            rc = max(rc, verify_docx(k))
+        return rc
+    if a.check:
+        for k, spec in DOCS.items():
+            src = ROOT / spec['src'].format(v=VERSION)
+            if not src.exists():
+                print('✗ %-4s 源件不在位 %s' % (k, src.relative_to(ROOT).as_posix()))
+                rc = 5
+                continue
+            st = count_md(src.read_text(encoding='utf-8'))
+            _src_txt = src.read_text(encoding='utf-8')
+            miss, leak = [], forbidden_hits(_src_txt)
+            strict = strict_hits(_src_txt) if spec.get('strict_terms') else {}
+            for _cap, rel in re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', _src_txt):
+                f = ROOT / 'docs' / rel.split('figures/')[-1]
+                if not (ROOT / 'docs' / 'figures' / rel.split('figures/')[-1]).exists():
+                    miss.append(rel)
+            print('%-4s 章 %2d · 节 %2d · 小节 %2d · 表 %2d · 图 %2d · 约 %5d 汉字 · 去标识化 %s%s%s'
+                  % (k, st['章'], st['节'], st['小节'], st['表'], st['图'], st['汉字'],
+                     'clean' if not leak else '泄漏 %s' % leak,
+                     '' if not spec.get('strict_terms') else
+                     ' · 去实现细节 %s' % ('clean' if not strict else '命中 %s' % strict),
+                     '' if not miss else '  ✗ 缺图 %d: %s' % (len(miss), ', '.join(miss))))
+            if miss or leak or strict:
+                rc = 5
+        return rc
+    for k in ([a.only] if a.only else sorted(DOCS)):
+        rc = max(rc, build(k))
+    if WARN:
+        print('\n告警:')
+        for w in sorted(set(WARN)):
+            print(' -', w)
+    return rc
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 525 - 0
app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_figures.py

@@ -0,0 +1,525 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""交付文档图表构建器(需求分析 / 系统设计说明 / 数据要求说明 三份 docx 的插图)。
+
+## 为什么单开一个器
+三份交付文档要求"文表图并茂"。图里出现的每个数字都必须能追到仓库里的真件,所以图**不手画**:
+本器从 `src/version.py` 的 HISTORY、`configs/portal_pages.yaml`、`configs/serve.json`、
+`data/raw/<场>/**`、`outputs/<场>/**` 与 `guanlan.py check` / `rebuild_all.py --dry-run` 的**实跑输出**
+里取数,落到 `docs/figures/*.png`。纯示意图(架构/链路/时序)也在图注里写明"依据"。
+
+## 落点与口径
+    docs/figures/fig-req-01..04 / fig-des-01..06 / fig-dat-01..04 .png
+    docs/src/_guanlan_check.txt        (验收门实跑原文,图的取证底稿)
+    docs/src/_rebuild_plan.txt         (重算链 dry-run 原文,图的取证底稿)
+
+## 用法
+    python scripts/delivery_docs_figures.py            # 全画
+    python scripts/delivery_docs_figures.py --only req # 只画某一组 (req/des/dat)
+    python scripts/delivery_docs_figures.py --check    # 只报缺哪些图
+
+退出码: 0 全部生成 · 5 有图没生成(源件缺或绘图失败,逐条打印原因)
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import json
+import pathlib
+import re
+import subprocess
+import sys
+import time
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+
+import matplotlib                                                          # noqa: E402
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt                                            # noqa: E402
+from matplotlib.patches import FancyArrowPatch, FancyBboxPatch             # noqa: E402
+
+from src import paths as P                                                 # noqa: E402
+
+FIG_DIR = ROOT / 'docs' / 'figures'
+SRC_DIR = ROOT / 'docs' / 'src'
+CJK = 'Microsoft YaHei'
+plt.rcParams['font.sans-serif'] = [CJK, 'SimHei']
+plt.rcParams['axes.unicode_minus'] = False
+plt.rcParams['figure.dpi'] = 200
+
+WARN: list[str] = []
+VERSION = __import__('importlib').import_module('src.version').VERSION
+
+
+# ── 通用绘图元件 ────────────────────────────────────────────────────────────────
+def canvas(w: float, h: float):
+    fig, ax = plt.subplots(figsize=(w, h))
+    ax.set_xlim(0, 100)
+    ax.set_ylim(0, 100)
+    ax.axis('off')
+    return fig, ax
+
+
+def box(ax, x, y, w, h, text, fc='#eef3fb', ec='#3b6ea5', fs=10, bold=False, align='center'):
+    ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0.6,rounding_size=1.4',
+                                linewidth=1.2, edgecolor=ec, facecolor=fc))
+    ax.text(x + w / 2, y + h / 2, text, ha='center', va='center', fontsize=fs,
+            wrap=True, fontweight='bold' if bold else 'normal', linespacing=1.5)
+
+
+def arrow(ax, p, q, color='#555555', style='-|>', lw=1.3, rad=0.0):
+    ax.add_patch(FancyArrowPatch(p, q, arrowstyle=style, mutation_scale=12,
+                                 linewidth=lw, color=color,
+                                 connectionstyle=f'arc3,rad={rad}'))
+
+
+def save(fig, name: str):
+    """落盘前统一做一次**字形兜底**:把 YaHei 缺字的符号换成等价可显字符。
+
+    实逮(2026-09-22): 图里引用的原文(如 `configs/portal_pages.yaml` 的说明)含 `⇒`(U+21D2),
+    matplotlib 报 `Glyph 8658 missing from font(s) Microsoft YaHei` 并画成方框 —— 交付文档里不能有方框。
+    """
+    swap = {'⇒': '→', '⇐': '←', '↔': '↔', '⇔': '↔', '✓': '√', '✗': '×', '≥': '≥'}
+    import matplotlib.text as _mt
+    for t in fig.findobj(_mt.Text):
+        s = t.get_text()
+        if any(k in s for k in swap):
+            for k, v in swap.items():
+                s = s.replace(k, v)
+            t.set_text(s)
+    FIG_DIR.mkdir(parents=True, exist_ok=True)
+    out = FIG_DIR / name
+    fig.tight_layout()
+    fig.savefig(out, bbox_inches='tight', facecolor='white')
+    plt.close(fig)
+    print('  ✓', out.relative_to(ROOT).as_posix(), '%.0f KB' % (out.stat().st_size / 1024))
+
+
+def barh(ax, labels, values, color='#3b6ea5', fmt='{:.0f}', xlabel=''):
+    y = range(len(labels))
+    ax.barh(list(y), values, color=color, height=0.62)
+    ax.set_yticks(list(y))
+    ax.set_yticklabels(labels, fontsize=9)
+    ax.invert_yaxis()
+    for i, v in enumerate(values):
+        ax.text(v, i, ' ' + fmt.format(v), va='center', fontsize=8.5)
+    ax.set_xlabel(xlabel, fontsize=9)
+    ax.tick_params(axis='x', labelsize=8.5)
+    for s in ('top', 'right'):
+        ax.spines[s].set_visible(False)
+
+
+# ── 取数(全部真读,不猜)────────────────────────────────────────────────────────
+def version_history():
+    from src import version as V
+    return list(V.HISTORY), V.VERSION
+
+
+def portal_pages():
+    import yaml
+    d = yaml.safe_load((P.config('portal_pages.yaml')).read_text(encoding='utf-8'))
+    pages = d.get('pages') or d.get('entries') or []
+    if isinstance(d, dict) and not pages:
+        for k, v in d.items():
+            if isinstance(v, list) and v and isinstance(v[0], dict):
+                pages = v
+                break
+    return pages
+
+
+def serve_cfg():
+    return json.loads((P.config('serve.json')).read_text(encoding='utf-8'))
+
+
+def raw_station_stats():
+    """→ [(族, 件数, GB)],实测 data/raw/<场站>/ 下一层目录。
+
+    场站目录名以**场配置**为准(`windscada.config.farm()['raw_station']`),`P.station_dir()` 只是兜底。
+    ★对外文档口径(用户令 2026-09-22): 真实场站名只用于**取数**,不进图题 —— 图里一律写「样本场」。
+    """
+    from src.windscada.config import farm as _farm
+    home = P.RAW_ROOT / str(_farm().get('raw_station') or P.farm())
+    out = []
+    if not home.exists():
+        WARN.append(f'data/raw 场站目录不在位: {home}')
+        return out
+    for d in sorted(p for p in home.iterdir() if p.is_dir()):
+        fs = [p for p in d.rglob('*') if p.is_file()]
+        out.append((d.name, len(fs), sum(p.stat().st_size for p in fs) / 1e9))
+    return out
+
+
+def md_tables(path: pathlib.Path):
+    """→ [ [ [cell,...], ... ] ]:把 markdown 里的表逐个解析成二维列表(不含表头分隔行)。"""
+    if not path.exists():
+        WARN.append('源件不在位: %s' % path.relative_to(ROOT).as_posix())
+        return []
+    lines = path.read_text(encoding='utf-8').splitlines()
+    tables, i = [], 0
+    while i < len(lines):
+        if lines[i].startswith('|') and i + 1 < len(lines) and re.match(r'^\|[\s:\-|]+\|$', lines[i + 1].strip()):
+            rows = []
+            j = i + 2
+            while j < len(lines) and lines[j].strip().startswith('|'):
+                rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
+                j += 1
+            tables.append(rows)
+            i = j
+        else:
+            i += 1
+    return tables
+
+
+def md_find_table(path: pathlib.Path, must_cols, first_cell=None):
+    """找**表头含指定列**的表 → (表头单元格, 数据行)。列位置一律**按表头名定位**,不写死列号。"""
+    lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
+    for i, l in enumerate(lines):
+        if not l.startswith('|') or not all(c in l for c in must_cols):
+            continue
+        head = [c.strip() for c in l.strip().strip('|').split('|')]
+        rows, j = [], i + 2
+        while j < len(lines) and lines[j].strip().startswith('|'):
+            rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
+            j += 1
+        if not rows:
+            continue
+        if first_cell is not None and rows[0][0] != first_cell:
+            continue
+        return head, rows
+    return [], []
+
+
+def col_of(head, *names):
+    """按表头名取列号(取第一个命中的;找不到返回 None)。"""
+    for n in names:
+        for i, h in enumerate(head):
+            if n in h:
+                return i
+    return None
+
+
+def ensure_evidence(refresh=False):
+    """把两张取证底稿跑出来(已存在且未 --refresh 时复用)。"""
+    SRC_DIR.mkdir(parents=True, exist_ok=True)
+    py = str(ROOT / '.venv' / 'Scripts' / 'python.exe')
+    chk, plan = SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'
+    if refresh or not chk.exists():
+        t0 = time.time()
+        r = subprocess.run([py, '-X', 'utf8', 'guanlan.py', 'check'], cwd=str(ROOT),
+                           capture_output=True, timeout=1800)
+        chk.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
+        print('  · guanlan.py check 底稿写入 (%.0fs, rc=%d)' % (time.time() - t0, r.returncode))
+    if refresh or not plan.exists():
+        r = subprocess.run([py, '-X', 'utf8', 'scripts/rebuild_all.py', '--dry-run', '--skip-scada'],
+                           cwd=str(ROOT), capture_output=True, timeout=600)
+        plan.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
+        print('  · rebuild_all --dry-run 底稿写入')
+
+
+# ── 需求分析组 ─────────────────────────────────────────────────────────────────
+def fig_req_01():
+    hist, _ver = version_history()
+    fig, ax = plt.subplots(figsize=(9.2, 0.62 * len(hist) + 1.4))
+    ax.axis('off')
+    n = len(hist)
+    ax.set_ylim(-0.6, n - 0.4)
+    ax.set_xlim(0, 10)
+    ax.plot([0.7, 0.7], [0, n - 1], color='#3b6ea5', lw=2.2, zorder=1)
+    lvl_cn = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号'}
+    for i, h in enumerate(hist):
+        y = n - 1 - i
+        ax.scatter([0.7], [y], s=90, color='#3b6ea5', zorder=3)
+        title = h.get('title', '')
+        title = re.sub(r'(.*?)', '', title)
+        title = title[:46] + ('…' if len(title) > 46 else '')
+        ax.text(1.05, y + 0.18, f"v{h.get('version')}  {h.get('date')}  [{lvl_cn.get(h.get('level'), '')}版本]",
+                fontsize=9.5, fontweight='bold', va='center')
+        ax.text(1.05, y - 0.20, title, fontsize=9, color='#333333', va='center')
+    ax.text(0.7, n - 0.15, '需求演进(自下而上)', fontsize=10, fontweight='bold', ha='center')
+    ax.set_title(f'观澜 v2 需求演进时间轴(样本场;依据 src/version.py 的 HISTORY,共 {n} 条)', fontsize=11)
+    save(fig, 'fig-req-01-需求演进时间轴.png')
+
+
+def fig_req_02():
+    fig, ax = canvas(9.4, 6.2)
+    roles = [('值班运行', 88, '今日态势/部件问题/该问题页/报警与故障统计'),
+             ('点检与检修', 65, '机组页/振动与融合面/油样与工单/报告导出'),
+             ('场站管理', 42, '发电性能/可靠性(MTBF·MTBO·MDT)/可用率/限电'),
+             ('研发与交付', 19, '本体与机制链/数据重算/运行体检/版本与打包')]
+    for name, y, pages in roles:
+        box(ax, 3, y, 20, 16, name, fc='#e8f1e4', ec='#5b8c4a', fs=11, bold=True)
+        box(ax, 30, y, 66, 16, pages, fc='#eef3fb', ec='#3b6ea5', fs=10)
+        arrow(ax, (23.5, y + 8), (29.4, y + 8))
+    ax.text(50, 94, '角色 — 场景 — 页面关系(依据 系统使用说明书 与 configs/portal_pages.yaml)',
+            ha='center', fontsize=11, fontweight='bold')
+    save(fig, 'fig-req-02-角色场景页面.png')
+
+
+def fig_req_03():
+    pages = portal_pages()
+    if not pages:
+        WARN.append('portal_pages.yaml 未解析出页面条目')
+        return
+    from collections import Counter
+    kinds = Counter(str(p.get('kind', '未标')) for p in pages)
+    order = ['data-derived', 'source-derived', 'frozen-delivery', 'static', 'shipped']
+    ks = [k for k in order if k in kinds] + [k for k in kinds if k not in order]
+    vals = [kinds[k] for k in ks]
+    fig, ax = plt.subplots(figsize=(8.6, 0.62 * len(ks) + 2.0))
+    cn = {'data-derived': 'data-derived(随数据变,须重算)',
+          'source-derived': 'source-derived(随源代码变)',
+          'frozen-delivery': 'frozen-delivery(冻结交付件)',
+          'static': 'static(静态)', 'shipped': 'shipped(随包件)'}
+    barh(ax, [cn.get(k, k) for k in ks], vals, xlabel='页面/子页数(共 %d 项)' % len(pages))
+    ax.set_title('门户页面归口分布(依据 configs/portal_pages.yaml)', fontsize=11)
+    save(fig, 'fig-req-03-页面归口分布.png')
+
+
+def fig_req_04():
+    """验收门分类 —— **与《需求分析》§9.1 表同源**(直接解析那张表,避免图文数字打架)。"""
+    head, rows = md_find_table(SRC_DIR / f'需求分析_观澜_{VERSION}.md', ('分类', '判定条数'))
+    if not rows:
+        WARN.append('需求分析 §9.1 的分类表未解析到,图 fig-req-04 用实跑输出兜底')
+        ensure_evidence()
+        txt = (SRC_DIR / '_guanlan_check.txt').read_text(encoding='utf-8', errors='replace')
+        marks = re.findall(r'^\s*\[(OK|FAIL|--|!|i|\?)\]\s*([^—\n]+)', txt, re.M)
+        cats = {'通过': sum(1 for m, _ in marks if m == 'OK'),
+                '需处置': sum(1 for m, _ in marks if m == 'FAIL'),
+                '跳过或提示': sum(1 for m, _ in marks if m not in ('OK', 'FAIL'))}
+        ks, vals, note = list(cats), list(cats.values()), '依据 guanlan.py check 实跑输出'
+    else:
+        ks, vals, note = [], [], '依据《需求分析》§9.1 表(同源于 guanlan.py check 实跑输出)'
+        c_name, c_n = col_of(head, '分类') or 0, col_of(head, '判定条数') or 1
+        c_gate = col_of(head, '代表门')
+        for r in rows:
+            if r[c_name] == '合计':
+                break
+            gate = r[c_gate] if c_gate is not None and c_gate < len(r) else ''
+            ks.append('%s(%s)' % (r[c_name], (gate[:22] + '…') if len(gate) > 22 else gate))
+            vals.append(int(re.sub(r'[^0-9]', '', r[c_n]) or 0))
+    print('    §9.1 门分类:', dict(zip(ks, vals)) if ks else '—')
+    fig, ax = plt.subplots(figsize=(9.6, 0.62 * len(ks) + 1.9))
+    barh(ax, ks, vals, xlabel='判定条数(合计 %d 条)' % sum(vals))
+    ax.set_title('验收门分类与数量(%s)' % note, fontsize=11)
+    save(fig, 'fig-req-04-验收门分类.png')
+
+
+# ── 系统设计说明组 ─────────────────────────────────────────────────────────────────
+def fig_des_01():
+    fig, ax = canvas(9.6, 7.4)
+    ax.text(50, 96, '观澜 v2 风电场智能分析系统 总体架构(离线单包,无外网依赖)', ha='center',
+            fontsize=11.5, fontweight='bold')
+    box(ax, 4, 84, 92, 9, '使用侧:浏览器(门户 / 分析总览 /detail/v2 / 本体网 / 运维控制台 /ops)',
+        fc='#f6f2e8', ec='#b08a3e', fs=10)
+    box(ax, 24, 71, 52, 9, '接入层:组件网关 28084(前缀改写 + data-abs 豁免 + 静态件)',
+        fc='#eef3fb', ec='#3b6ea5', fs=10)
+    box(ax, 3, 56, 18, 11, '分析组件\n18033 /detail', fs=9)
+    box(ax, 23, 56, 17, 11, '振动组件\n18020 /cms', fs=9)
+    box(ax, 42, 56, 16, 11, '仿真台\n18791·18792', fs=9)
+    box(ax, 60, 56, 15, 11, '三维 viewer\n64292', fs=9)
+    box(ax, 77, 56, 20, 11, '本机模型档\nOllama 11434\n(可选,未装即降级)', fs=9,
+        fc='#f4eef6', ec='#7d5ba6')
+    for x in (12, 31.5, 50, 67.5, 87):
+        arrow(ax, (50, 71), (x, 67.4), color='#8a8a8a')
+    box(ax, 3, 41, 94, 10, '产物层 outputs/<场>/:windscada 判级与曲线 · slim10min 窄仓 · 振动六层链 · '
+                           '本体对象库 · SOP/findings · guanlan 契约', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
+    arrow(ax, (50, 56), (50, 51.4))
+    box(ax, 3, 27, 94, 9, '摄入与构建层:rebuild_all.py 23 步(place_raw_data → 10min/1min 摄入 → '
+                          '10 个 SCADA 构建器 → 振动摄入 → 本体六步 → 审计)', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
+    arrow(ax, (50, 41), (50, 36.4))
+    box(ax, 3, 13, 46, 9, '输入层 data/raw/<场>/:scada_10min·scada_1min·scada_mdb\n故障报警·风机故障记录·'
+                          'windcms·m5_cms_tcm·油样报告', fc='#f6f2e8', ec='#b08a3e', fs=9)
+    box(ax, 51, 13, 46, 9, '共享技术资料(OEM 资料,本体 kb_ingest 读)\n配置 configs/:serve.json·'
+                           'models.json·portal_pages.yaml', fc='#f6f2e8', ec='#b08a3e', fs=9)
+    arrow(ax, (26, 23), (26, 26.6))
+    arrow(ax, (74, 23), (74, 26.6))
+    box(ax, 3, 2, 94, 7, '贯穿全链的守卫:单一真源(src/paths.py·src/version.py) · 审计门(guanlan.py check / '
+                         '7 个审计器) · 逐件来源台账(_provenance.json)', fc='#ffffff', ec='#555555', fs=9)
+    save(fig, 'fig-des-01-总体架构.png')
+
+
+def fig_des_02():
+    """serve.json 是**扁平**结构(`gateway`/`detail`/… 直接是端口整数),不是嵌套 dict。"""
+    cfg = serve_cfg()
+    host, pub = cfg.get('host', '?'), cfg.get('public_host', '')
+    gw_port = cfg.get('gateway', '?')
+    comps = [(k, cfg.get(k)) for k in ('detail', 'cms', 'sim', 'sim_sys', 'viewer')]
+    fig, ax = canvas(9.6, 5.2)
+    box(ax, 30, 82, 40, 12, '网关 gateway\n%s:%s(对外唯一入口)' % (pub or host, gw_port),
+        fc='#eef3fb', ec='#3b6ea5', fs=10, bold=True)
+    n = max(1, len(comps))
+    for i, (name, port) in enumerate(comps):
+        x = 3 + i * (94 / n)
+        w = 94 / n - 3
+        box(ax, x, 52, w, 14, '%s\n%s:%s' % (name, host, port), fs=9)
+        arrow(ax, (50, 82), (x + w / 2, 66.6), color='#8a8a8a')
+    box(ax, 3, 30, 94, 12, '绑定与自启:guanlan 服务(Windows 服务 / systemd)· start.bat 静默启动 · '
+                           'run/*.pid 运行态 · logs/ 统一落点', fc='#f6f2e8', ec='#b08a3e', fs=9)
+    arrow(ax, (50, 52), (50, 42.6))
+    box(ax, 3, 12, 94, 12, '端口冲突处置:netstat -ano 找占用进程 → 按端口清理 → 重启服务\n'
+                           '(service_ctl stop 不杀子进程,只按端口杀会留下孤儿实例)', fc='#ffffff',
+        ec='#555555', fs=9)
+    arrow(ax, (50, 30), (50, 24.6))
+    ax.text(50, 96, '端口与进程拓扑(依据 configs/serve.json 实读)', ha='center', fontsize=11.5,
+            fontweight='bold')
+    save(fig, 'fig-des-02-端口与进程.png')
+
+
+def fig_des_03():
+    ensure_evidence()
+    txt = (SRC_DIR / '_rebuild_plan.txt').read_text(encoding='utf-8', errors='replace')
+    steps = []
+    for line in txt.splitlines():
+        m = re.match(r'^\s{2}(\S.*?)\s{2,}(\S.*)$', line)
+        if m and not line.strip().startswith('('):
+            steps.append((m.group(1).strip(), m.group(2).strip()))
+    if not steps:
+        WARN.append('rebuild_all --dry-run 未解析出步骤')
+        return
+    fig, ax = plt.subplots(figsize=(9.6, 0.42 * len(steps) + 1.8))
+    ax.axis('off')
+    ax.set_xlim(0, 10)
+    ax.set_ylim(-0.5, len(steps) - 0.2)
+    for i, (name, cmd) in enumerate(steps):
+        y = len(steps) - 1 - i
+        ax.add_patch(plt.Rectangle((0.2, y - 0.34), 1.75, 0.68, facecolor='#eef3fb',
+                                   edgecolor='#3b6ea5', lw=0.9))
+        ax.text(1.07, y, name[:22], fontsize=8.6, ha='center', va='center')
+        ax.text(2.15, y, cmd[:92], fontsize=8.2, va='center', color='#333333', family='monospace')
+        if i < len(steps) - 1:
+            ax.annotate('', xy=(1.07, y - 0.42), xytext=(1.07, y - 0.30),
+                        arrowprops=dict(arrowstyle='-|>', color='#8a8a8a', lw=0.9))
+    ax.set_title('重算链步骤(依据 scripts/rebuild_all.py --dry-run --skip-scada,共 %d 步)' % len(steps),
+                 fontsize=11)
+    save(fig, 'fig-des-03-重算链.png')
+
+
+def fig_des_04():
+    fig, ax = canvas(9.6, 6.4)
+    ax.text(50, 96, '判级矩阵与四源融合数据流(每条判据都能指到输入列)', ha='center',
+            fontsize=11.5, fontweight='bold')
+    srcs = ['SCADA 10min\n(slim10min 48 列)', 'CMS 振动\n(六层链)', '状态·故障·告警\n(台账)', '油样与工单\n(现场记录)']
+    for i, s in enumerate(srcs):
+        box(ax, 1.5 + i * 24.6, 78, 22, 12, s, fc='#f6f2e8', ec='#b08a3e', fs=9)
+    box(ax, 6, 58, 40, 12, '七系统判级矩阵\n变桨·偏航·主轴承·齿轮箱·发电机·变流器·主控与传感网',
+        fc='#eef3fb', ec='#3b6ea5', fs=9.5)
+    box(ax, 52, 58, 42, 12, '融合面四源对齐\n振动 × SCADA × 台账 × 人工\n(逐台判级 + 证据链)',
+        fc='#eef3fb', ec='#3b6ea5', fs=9.5)
+    for x in (12, 36, 62, 86):
+        arrow(ax, (x, 78), (26 if x < 50 else 73, 70.6), color='#8a8a8a')
+    box(ax, 6, 40, 88, 11, '时间窗:判级与曲线按所选时间窗重算(月/年/自定义起止日期,含两端);'
+                           '温度与停机台账按真窗取月', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
+    arrow(ax, (26, 58), (26, 51.6))
+    arrow(ax, (73, 58), (73, 51.6))
+    outs = ['部件问题清单\n(按系统×机组)', '该问题页\n(单问题+完整依据)', '发电性能与可靠性\n(MTBF/MTBO/MDT)',
+            '控制参数一致性\n(P/ω 封顶·K 聚类)', '报告导出\n(docx/md/页面)', '本体机制链\n与决策台']
+    for i, o in enumerate(outs):
+        x = 2 + (i % 3) * 32.3
+        y = 24 if i < 3 else 8
+        box(ax, x, y, 30, 12, o, fc='#f4eef6', ec='#7d5ba6', fs=9)
+    arrow(ax, (50, 40), (50, 36.6), color='#5b8c4a')
+    box(ax, 2, 21.5, 96, 2.6, '', fc='#ffffff', ec='#ffffff')
+    ax.text(50, 36, '产物(outputs/<场>/…,逐件登记生成端与输入)', ha='center', fontsize=9, color='#444444')
+    save(fig, 'fig-des-04-判级与融合.png')
+
+
+def fig_des_05():
+    fig, ax = canvas(9.6, 5.6)
+    ax.text(50, 96, '时间窗按窗重算与 pending 契约(实测:冷窗首查 pending,约 30 s 后落位)',
+            ha='center', fontsize=11.5, fontweight='bold')
+    lanes = [('前端 app.js', 82), ('服务 windscada_serve.py', 60), ('按窗缓存 _WIN_CACHE', 38), ('重算线程(_HEAVY_LOCK 串行)', 16)]
+    for name, y in lanes:
+        box(ax, 2, y, 24, 11, name, fc='#eef3fb', ec='#3b6ea5', fs=9)
+        ax.plot([26.5, 97], [y + 5.5, y + 5.5], color='#d0d0d0', lw=0.9, zorder=0)
+    msgs = [(82, 39, 'GET /api/fleet?win=2026-07'),
+            (60, 82, 'win 未命中 → 起后台线程,先回上一份口径并标 pending=1'),
+            (38, 60, '缺 → 入队 sysmx/curves/m9'),
+            (16, 38, '算(变桨/偏航/蓄能/温度 + 曲线 + M9)'),
+            (38, 16, '算完写入缓存'),
+            (60, 38, '5 s 轮询再取 → 命中'),
+            (82, 60, 'pending=0,页面换成该时间窗口径')]
+    for y1, y2, text in msgs:
+        arrow(ax, (36, y1 + 5.5), (36, y2 + 5.5), color='#3b6ea5')
+        ax.text(38.5, (y1 + y2) / 2 + 5.5, text, fontsize=8.8, va='center')
+    ax.text(50, 5, '为什么这样:算得慢(每面 5~120 s),不能把页面卡住;宁可先给旧口径并**如实标注**,也不静默等待。',
+            ha='center', fontsize=9, color='#444444')
+    save(fig, 'fig-des-05-时间窗与pending.png')
+
+
+def fig_des_06():
+    fig, ax = canvas(9.0, 6.0)
+    cx, cy, r = 50, 50, 30
+    items = [('改代码/配置', '#eef3fb'), ('重算 rebuild_all 23 步', '#e8f1e4'),
+             ('审计器 7 件(反向/页面/配置/链路/版本…)', '#f6f2e8'),
+             ('guanlan.py check 全绿', '#f4eef6'), ('打包 pack_dist + 开箱验证', '#eef3fb'),
+             ('装到目标机 install.ps1', '#e8f1e4')]
+    import math
+    for i, (t, fc) in enumerate(items):
+        a = math.radians(90 - i * 360 / len(items))
+        x, y = cx + r * math.cos(a), cy + r * math.sin(a)
+        box(ax, x - 15, y - 6, 30, 12, t, fc=fc, ec='#3b6ea5', fs=9)
+        a2 = math.radians(90 - (i + 1) * 360 / len(items))
+        arrow(ax, (cx + (r - 6) * math.cos(a), cy + (r - 6) * math.sin(a)),
+              (cx + (r - 6) * math.cos(a2), cy + (r - 6) * math.sin(a2)),
+              color='#8a8a8a', rad=-0.25)
+    box(ax, 38, 44, 24, 12, '版本纪律\n单一真源 src/version.py', fc='#ffffff', ec='#555555', fs=9, bold=True)
+    ax.text(50, 96, '质量门与审计闭环(每环都能独立重跑、逐条给退出码)', ha='center', fontsize=11.5,
+            fontweight='bold')
+    save(fig, 'fig-des-06-质量门闭环.png')
+
+
+
+GROUPS = {
+    'req': [fig_req_01, fig_req_02, fig_req_03, fig_req_04],
+    'des': [fig_des_01, fig_des_02, fig_des_03, fig_des_04, fig_des_05, fig_des_06],
+}
+# ★2026-09-22 用户令: 《数据要求说明》改成纯数据需求规格、不再放插图(原 dat 组四张是样本场现状图,
+#   不再体现)—— 组与期望清单同步去掉, 四个 PNG 也从 docs/figures 删除。
+EXPECTED = ['fig-req-01-需求演进时间轴.png', 'fig-req-02-角色场景页面.png',
+            'fig-req-03-页面归口分布.png', 'fig-req-04-验收门分类.png',
+            'fig-des-01-总体架构.png', 'fig-des-02-端口与进程.png', 'fig-des-03-重算链.png',
+            'fig-des-04-判级与融合.png', 'fig-des-05-时间窗与pending.png', 'fig-des-06-质量门闭环.png']
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--only', default=None, choices=sorted(GROUPS))
+    ap.add_argument('--refresh', action='store_true',
+                    help='重跑取证底稿(guanlan.py check / rebuild_all --dry-run)后再画图')
+    ap.add_argument('--check', action='store_true', help='只报缺哪些图')
+    a = ap.parse_args()
+    if a.refresh:
+        for p in (SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'):
+            p.unlink(missing_ok=True)
+    if a.check:
+        miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
+        print('图表体检: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
+        for f in miss:
+            print('  ✗', f)
+        return 0 if not miss else 5
+    groups = [a.only] if a.only else sorted(GROUPS)
+    for g in groups:
+        print('── %s 组 ──' % g)
+        for fn in GROUPS[g]:
+            try:
+                fn()
+            except Exception as e:  # noqa: BLE001
+                WARN.append('%s 失败: %s' % (fn.__name__, e))
+                print('  ✗', fn.__name__, e)
+    miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
+    if WARN:
+        print('\n告警/缺件:')
+        for w in WARN:
+            print(' -', w)
+    print('\n图表: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
+    return 0 if not miss else 5
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 107 - 0
app_qualityGate/common/app_qualityGate_guanlan/docs/version_log.py

@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""版本记录生成/校验 (2026-09-17 用户令: 源代码开发维护与打包版本管理)。
+
+## 为什么不是"再手写一个 CHANGELOG.md"
+
+手写版本记录的老毛病是**和代码里的版本号对不上**:改了 `src/version.py` 忘了改 md,或者反过来
+(本仓 0.4.0 期间就出现过三处版本号各说各话)。这里把事实只放在 `src/version.py` 的 `HISTORY` 里,
+`docs/版本记录.md` 由本器**生成**:
+
+    python scripts/version_log.py            # 打印(人看)
+    python scripts/version_log.py --write    # 写进 docs/版本记录.md(自动块)
+    python scripts/version_log.py --check    # 校验 md 与 src/version.py 是否一致(rc=6 表示不一致)
+
+`guanlan.py check` 与打包器都会调用 `--check` 这一条:**版本记录与版本号必须一致**,
+否则交付包里会出现"包说 2.5.0、记录表最后一行是 2.4.0"这种事。
+
+退出码: 0 一致/已写 · 6 不一致(需要跑 --write) · 2 文件缺失
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import pathlib
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import version as V                                            # noqa: E402
+
+DOC = ROOT / 'docs' / '版本记录.md'
+BEGIN, END = '<!-- VERSION-LOG:BEGIN -->', '<!-- VERSION-LOG:END -->'
+
+LEVEL_CN = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号', 'same': '无', '?': '?'}
+
+
+def body() -> str:
+    """生成自动块正文(规则 + 当前版本 + 版本表 + 怎么改)。"""
+    L = ['## 版本号规则(用户令 2026-09-17)', '',
+         f'编号 **`{V.RULE}`**,例如 `v{V.VERSION}`。', '',
+         '| 级别 | 含义 |', '|---|---|']
+    for k in ('major', 'minor', 'patch'):
+        # LEVEL_MEANING 里是"大版本号 —— 系统解决方案…"整句(与用户令措辞一致),
+        # 表头已经有"级别"一列,这里只取破折号后的定义,免得读成"大版本号 | 大版本号 —— …"。
+        L.append(f'| {LEVEL_CN[k]}版本号 | {V.LEVEL_MEANING[k].split(" —— ", 1)[-1]} |')
+    L += ['', f'**怎么改**:{V.BUMP_RULE}', '',
+          f'**打包文件名** = `{V.package_name()}`(规则:`{V.PACKAGE_STEM}_v<版本号>.zip`)。', '',
+          f'版本号只写在 `src/version.py` 一行(`VERSION = \'{V.VERSION}\'`);'
+          '打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。', '',
+          '| 版本 | 日期 | 级别 | 主要变更 | 说明 |', '|---|---|---|---|---|']
+    for h in V.HISTORY:
+        L.append(f"| `v{h.get('version', '?')}` | {h.get('date', '?')} | {LEVEL_CN.get(h.get('level', '?'), '?')} | "
+                 f"{h.get('title', '')} | {h.get('note', '')} |")
+    L += ['', '## 本次交付', '',
+          f'* 版本:`v{V.VERSION}`({V.NAME})',
+          f'* 交付包:`{V.package_name()}`',
+          f'* 服务名:`{V.SERVICE_NAME}`(Windows 服务 / systemd 单元)',
+          f'* 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`', '']
+    return '\n'.join(L)
+
+
+def render() -> str:
+    return (f'# 版本记录({V.NAME})\n\n'
+            '> 本文件由 `scripts/version_log.py` 生成,**勿手改**:事实(版本号、级别、变更)写在\n'
+            '> `src/version.py` 的 `HISTORY` 里,改完跑 `python scripts/version_log.py --write`。\n'
+            '> `guanlan.py check` 与打包器都会校验本文件与代码里的版本号是否一致。\n\n'
+            f'{BEGIN}\n{body()}{END}\n')
+
+
+def block_of(text: str) -> str:
+    i, j = text.find(BEGIN), text.find(END)
+    return text[i + len(BEGIN):j].strip() if i >= 0 and j > i else ''
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='版本记录生成/校验')
+    ap.add_argument('--write', action='store_true', help='写进 docs/版本记录.md')
+    ap.add_argument('--check', action='store_true', help='只校验是否一致 (rc=6 不一致)')
+    a = ap.parse_args()
+    want = body().strip()
+    if a.write:
+        DOC.parent.mkdir(parents=True, exist_ok=True)
+        DOC.write_text(render(), encoding='utf-8', newline='\n')
+        print(f'已写入 {DOC.relative_to(ROOT).as_posix()}(版本 v{V.VERSION},{len(V.HISTORY)} 条历史)')
+        return 0
+    if a.check:
+        if not DOC.is_file():
+            print(f'[X] 缺 {DOC.relative_to(ROOT).as_posix()} —— 跑 python scripts/version_log.py --write')
+            return 2
+        got = block_of(DOC.read_text(encoding='utf-8'))
+        if got != want:
+            print(f'[X] 版本记录与 src/version.py 不一致(版本 v{V.VERSION})'
+                  f' —— 跑 python scripts/version_log.py --write')
+            return 6
+        print(f'[OK] 版本记录一致(v{V.VERSION},{len(V.HISTORY)} 条)')
+        return 0
+    print(render())
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 2 - 0
app_qualityGate/common/app_qualityGate_guanlan/pack/__init__.py

@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+r"""打包器、开箱验证入口与卸载器(P7 起)。"""

+ 456 - 0
app_qualityGate/common/app_qualityGate_guanlan/pack/guanlan_uninstall.py

@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""观澜卸载器 (2026-09-17 用户令: 增加卸载脚本 `uninstall.bat` / `uninstall.sh`)。
+
+入口就是这两个壳脚本 + 本文件;三处共用**同一份**实现(与 install.bat/install.sh→install.ps1 同构),
+免得"Windows 一套、Linux 一套"各写一遍逻辑、过两个月行为不一致。
+
+## 卸载拆成哪几步(默认口径:只拆"本程序装上去的东西",绝不动用户数据)
+
+    ① 停服务      先停服务化常驻体(Windows 服务 / systemd 单元),再停手工启动的那套
+    ② 注销服务    `sc.exe delete guanlan` / `systemctl disable --now` + 删单元文件(需管理员/root)
+    ③ 撤开机自启  HKCU\...\Run 的 guanlan 项 / systemd 用户单元(不需要管理员)
+    ④ 删快捷方式  `启动观澜.lnk`、`start_guanlan.lnk`(安装根 + 桌面 + applications)
+    ⑤ 清运行态    `run/pids.json`、残留 `*.pid`
+    ⑥ 删安装记录  `install-info.json`("已装的是哪一版"的唯一凭据 —— 删掉它 = 这台机器回到"没装过")
+
+    **保留**:`data/`(输入数据)· `outputs/`(产物)· `logs/`(日志)· `.venv/`(虚拟环境)· 整棵程序目录
+
+## 为什么默认既不删数据、也不删程序目录
+
+· `data/` 与 `outputs/` 是**现场资产**(原始件十几 GB + 重算产物);"卸载程序"不等于"销毁数据"。
+  要连数据一起清,显式给 `--purge`(并且**再问一次**,默认答否;`--keep-logs` 可留下日志)。
+· 程序目录**不自己删**:Windows 上正在跑的 `python.exe` 就在 `.venv` 里,删自己必然部分失败,
+  留下"删了一半的目录"比留着整个目录更难排查。要清干净给 `--all`:本器尽力删,**删不掉的逐条列出**,
+  并打印一条收尾命令(`rmdir /s /q <根>` 或 `rm -rf <根>`)让你关掉本窗口后执行。
+
+## 用法
+
+    uninstall.bat                    交互:先打印计划 → 问一次 → 执行
+    uninstall.bat --dry-run          只打印计划,一个字节都不动(★自证用)
+    uninstall.bat --yes              不问(脚本/无人值守)
+    uninstall.bat --purge --yes      连 data/ outputs/ logs/ .venv/ 一起删
+    uninstall.bat --all --yes        连程序文件一起删(最后给收尾命令)
+    sh uninstall.sh [同样的开关]      Linux / macOS
+
+    退出码: 0 完成 · 2 需要确认(非交互又没给 --yes) · 3 权限不足(②需管理员/root, 其余照做) · 5 有步骤失败
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import version as V                                            # noqa: E402
+
+WIN32 = os.name == 'nt'
+UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
+USER_UNIT = pathlib.Path.home() / '.config' / 'systemd' / 'user' / f'{V.SERVICE_NAME}.service'
+AUTOSTART = pathlib.Path.home() / '.config' / 'autostart' / f'{V.SERVICE_NAME}.desktop'
+SHORTCUTS = ('启动观澜.lnk', 'start_guanlan.lnk')
+
+# 卸载时要清掉的可重建件 / 用户数据(--purge 才动)
+PURGE_DIRS = ('data', 'outputs', 'logs', 'run', 'runtime')
+# --all 才动的"程序本体"
+PROGRAM_DIRS = ('src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs', 'wheels', 'vendor')
+PROGRAM_FILES = ('guanlan.py', 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
+                 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh',
+                 'check.bat', 'start.bat', 'stop.bat', 'pack.bat', 'pack.sh', 'dist-manifest.json')
+
+RESULTS: list[tuple[str, int, str]] = []        # (步骤, rc, 说明) —— 结尾统一汇总
+
+
+def say(msg: str = '') -> None:
+    print(msg)
+
+
+def py_exe() -> str:
+    """优先用本安装的 .venv 解释器;它没了(比如已经被删)就退回当前解释器。"""
+    cand = ROOT / '.venv' / ('Scripts/python.exe' if WIN32 else 'bin/python')
+    return str(cand) if cand.is_file() else sys.executable
+
+
+def is_admin() -> bool:
+    if not WIN32:
+        try:
+            return os.geteuid() == 0
+        except AttributeError:
+            return False
+    try:
+        import ctypes
+        return bool(ctypes.windll.shell32.IsUserAnAdmin())
+    except Exception:
+        return False
+
+
+def run(cmd: list[str], dry: bool, note: str = '') -> int:
+    printable = ' '.join(f'"{x}"' if ' ' in x else x for x in cmd)
+    say(f'   $ {printable}' + (f'   ({note})' if note else ''))
+    if dry:
+        return 0
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
+    except FileNotFoundError:
+        say(f'   [X] 找不到命令 {cmd[0]}')
+        return 5
+    for ln in (r.stdout or '').strip().splitlines():
+        say('     ' + ln)
+    for ln in (r.stderr or '').strip().splitlines():
+        say('     [stderr] ' + ln)
+    return 0 if r.returncode == 0 else 5
+
+
+def rm(path: pathlib.Path, dry: bool) -> int:
+    """真删(目录递归 / 文件单件);不存在算成功(卸载必须幂等,跑第二遍不该报错)。"""
+    if not path.exists():
+        say(f'   - 本来就没有: {rel(path)}')
+        return 0
+    size, n = size_of(path)
+    say(f'   $ 删除 {rel(path)}' + (f'   ({n} 件, {size / 1e6:.1f} MB)' if path.is_dir() else f'   ({size} B)'))
+    if dry:
+        return 0
+    try:
+        if path.is_dir():
+            shutil.rmtree(path)
+        else:
+            path.unlink()
+        return 0
+    except OSError as e:
+        say(f'   [X] 删不掉: {e}')
+        return 5
+
+
+def rel(p: pathlib.Path) -> str:
+    try:
+        return p.relative_to(ROOT).as_posix() or '.'
+    except ValueError:
+        return str(p)
+
+
+def size_of(p: pathlib.Path) -> tuple[int, int]:
+    """→ (字节数, 件数)。"""
+    if p.is_file():
+        try:
+            return p.stat().st_size, 1
+        except OSError:
+            return 0, 0
+    tot = n = 0
+    for dp, _dn, fns in os.walk(p):
+        for fn in fns:
+            try:
+                tot += (pathlib.Path(dp) / fn).stat().st_size
+                n += 1
+            except OSError:
+                pass
+    return tot, n
+
+
+# ── 各步 ────────────────────────────────────────────────────────────────────────────
+def service_registered() -> bool:
+    if WIN32:
+        try:
+            r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
+            return r.returncode == 0
+        except FileNotFoundError:
+            return False
+    return UNIT_PATH.is_file()
+
+
+def step_stop(dry: bool) -> None:
+    say('① 停服务')
+    rc = 0
+    note = []
+    if service_registered():
+        rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'stop'], dry, '停服务化常驻体')
+        note.append('服务化常驻体已停' if rc == 0 else '常驻体那步没成功(多为权限)')
+    else:
+        say(f'   - 没注册过 {V.SERVICE_NAME} 服务(跳过常驻体)')
+        note.append('无服务化常驻体')
+    r2 = run([py_exe(), str(ROOT / 'guanlan.py'), 'stop'], dry, '停启动器起的组件')
+    rc = rc or r2
+    note.append('启动器组件已停' if r2 == 0 else '启动器组件停止失败')
+    RESULTS.append(('停止服务', rc, ';'.join(note)))
+
+
+def step_unregister(dry: bool) -> None:
+    say('② 注销服务(需管理员 / root)')
+    if not service_registered():
+        say(f'   - 本机没注册过 {V.SERVICE_NAME} 服务(跳过)')
+        RESULTS.append(('注销服务', 0, '本来就没注册'))
+        return
+    if not is_admin() and not dry:
+        say('   [!] 权限不足:注销服务需要管理员/root —— 其余步骤照做,这一步留给你手工执行:')
+        if WIN32:
+            say(f'       sc.exe stop {V.SERVICE_NAME} && sc.exe delete {V.SERVICE_NAME}')
+        else:
+            say(f'       sudo systemctl disable --now {V.SERVICE_NAME} && sudo rm -f {UNIT_PATH}')
+        RESULTS.append(('注销服务', 3, '权限不足(已给出手工命令)'))
+        return
+    rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'uninstall'], dry, '停 + 删注册')
+    RESULTS.append(('注销服务', rc, '服务已注销' if rc == 0 else '注销失败'))
+
+
+def step_autostart(dry: bool) -> None:
+    say('③ 撤开机自启(不需要管理员)')
+    rc = 0
+    if WIN32:
+        key = r'Software\Microsoft\Windows\CurrentVersion\Run'
+        say(f'   $ reg delete HKCU\\{key} /v {V.SERVICE_NAME} /f')
+        if not dry:
+            try:
+                import winreg
+                with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as k:
+                    try:
+                        winreg.DeleteValue(k, V.SERVICE_NAME)
+                        say('   已移除登录自启项')
+                    except FileNotFoundError:
+                        say('   - 本来就没有这项')
+            except Exception as e:                                     # 注册表异常不该中断整场卸载
+                say(f'   [X] {type(e).__name__}: {e}')
+                rc = 5
+    elif AUTOSTART.exists() or USER_UNIT.exists():
+        rc = run(['systemctl', '--user', 'disable', '--now', V.SERVICE_NAME], dry, '用户级自启(可能没装)')
+        rc = rc or rm(AUTOSTART, dry)
+        rc = rc or rm(USER_UNIT, dry)
+    else:
+        say('   - 没有用户级自启项(跳过)')
+    RESULTS.append(('撤开机自启', rc, '登录/开机自启项已清' if rc == 0 else '清理失败'))
+
+
+def step_shortcuts(dry: bool) -> None:
+    say('④ 删快捷方式(安装根 + 桌面 + applications)')
+    targets: list[pathlib.Path] = [ROOT / n for n in SHORTCUTS]
+    if WIN32:
+        up = os.environ.get('USERPROFILE')
+        if up:
+            targets += [pathlib.Path(up) / d / n for d in ('Desktop', '桌面') for n in SHORTCUTS]
+    else:
+        targets += [pathlib.Path.home() / d / f'{n[:-4]}.desktop'
+                    for d in ('Desktop', '.local/share/applications') for n in SHORTCUTS]
+    hit = [p for p in targets if p.exists()]
+    rc = 0
+    for p in hit:
+        rc = rc or rm(p, dry)
+    if not hit:
+        say('   - 没有安装时建的快捷方式(跳过)')
+    RESULTS.append(('删快捷方式', rc, f'删了 {len(hit)} 个' if hit else '本来就没有'))
+
+
+def step_runtime(dry: bool) -> None:
+    say('⑤ 清运行态(run/pids.json、残留 *.pid)')
+    rc = rm(ROOT / 'run', dry)
+    for p in sorted(ROOT.glob('*.pid')):
+        rc = rc or rm(p, dry)
+    RESULTS.append(('清运行态', rc, 'PID 记录已清' if rc == 0 else '清理失败'))
+
+
+def step_install_record(dry: bool) -> None:
+    say('⑥ 删安装记录 install-info.json')
+    ins = V.read_installed(ROOT)
+    if ins:
+        say(f"   - 记录的是: {ins.get('name', '?')} v{ins.get('version', '?')}"
+            f"(装于 {ins.get('installed_at', '?')},运行方式 {ins.get('mode', '?')})")
+    else:
+        say('   - 没有安装记录(本来就没装过 / 已被删)')
+    rc = rm(V.info_path(ROOT), dry)
+    RESULTS.append(('删安装记录', rc, '这台机器回到"没装过"状态' if rc == 0 else '删除失败'))
+
+
+def step_purge(dry: bool, yes: bool, skip: tuple[str, ...] = ()) -> None:
+    say('⑦ --purge: 删数据与可重建件(输入数据 / 产物 / 日志 / 虚拟环境)')
+    dirs = tuple(d for d in PURGE_DIRS if d not in skip)
+    targets = [ROOT / d for d in dirs] + [ROOT / '.venv']
+    present = [p for p in targets if p.exists()]
+    if skip:
+        say(f'   (--keep-logs: 保留 {", ".join(skip)})')
+    if not present:
+        say('   - 没什么可清的(都不在)')
+        RESULTS.append(('--purge 清数据', 0, '无对象'))
+        return
+    tot = sum(size_of(p)[0] for p in present)
+    say(f'   将删除: {", ".join(rel(p) for p in present)}  —— 合计 {tot / 1e9:.2f} GB')
+    if not yes:
+        if not sys.stdin or not sys.stdin.isatty():
+            say('   [!] 非交互环境:--purge 需要显式 --yes 确认 —— 本步跳过(数据原样保留)')
+            RESULTS.append(('--purge 清数据', 2, '非交互未确认,已跳过'))
+            return
+        ans = input('   这会永久删除现场数据与产物,确认? 输入 yes 继续,其它键跳过: ').strip().lower()
+        if ans not in ('yes', 'y'):
+            say('   - 已跳过(数据原样保留)')
+            RESULTS.append(('--purge 清数据', 0, '用户选择跳过'))
+            return
+    rc = 0
+    for p in present:
+        rc = rc or rm(p, dry)
+    RESULTS.append(('--purge 清数据', rc, f'删除 {len(present)} 项' if rc == 0 else '有删不掉的'))
+
+
+def step_all(dry: bool) -> None:
+    say('⑧ --all: 删程序本体(尽力而为,删不掉的逐条列出)')
+    # ★ 先把自己的工作目录移出安装根:Windows 不允许删除"当前目录",也不允许删正在跑的 python.exe
+    if not dry:
+        try:
+            os.chdir(os.environ.get('TEMP') or os.environ.get('TMP') or '/tmp')
+        except OSError:
+            pass
+    failed: list[str] = []
+    for name in PROGRAM_DIRS + PROGRAM_FILES:
+        p = ROOT / name
+        if p.exists() and rm(p, dry) != 0:
+            failed.append(rel(p))
+    if not dry and ROOT.exists():
+        for leftover in sorted(ROOT.iterdir()):                        # 兜底:清单之外的零碎也清
+            if rm(leftover, dry) != 0:
+                failed.append(rel(leftover))
+        try:
+            ROOT.rmdir()
+            say(f'   已删除安装根 {ROOT}')
+        except OSError as e:
+            say(f'   [!] 安装根没删掉({e.__class__.__name__})—— 正常:本程序正在跑, 自己的目录被占用')
+            say('       关掉本窗口后手工执行: '
+                + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
+    if failed:
+        say(f'   删不掉的 {len(failed)} 项: {", ".join(failed[:8])}' + (' …' if len(failed) > 8 else ''))
+    RESULTS.append(('--all 删程序', 0 if not failed else 5,
+                    '程序文件已清' if not failed else f'{len(failed)} 项留待手工删'))
+
+
+# ── 计划与自检 ──────────────────────────────────────────────────────────────────────
+def plan_lines() -> list[str]:
+    """把"将要做什么"列出来(--dry-run 打印它;也是自检的断言对象)。"""
+    ins = V.read_installed(ROOT)
+    return [
+        f'观澜卸载计划 @ {ROOT}',
+        f'  本包版本: {V.NAME} v{V.VERSION}'
+        + (f";安装记录写的: v{ins.get('version', '?')}(装于 {ins.get('installed_at', '?')})"
+           if ins else ';没有安装记录(本来就没装过 / 已被删)'),
+        '  ① 停服务: service_ctl stop → guanlan.py stop',
+        '  ② 注销服务: '
+        + (f'sc.exe stop/delete {V.SERVICE_NAME}' if WIN32 else f'systemctl disable --now {V.SERVICE_NAME} + 删 {UNIT_PATH}')
+        + f'  (已注册={service_registered()}, 有权限={is_admin()})',
+        '  ③ 撤开机自启: ' + (r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run' if WIN32 else str(USER_UNIT)),
+        '  ④ 删快捷方式: ' + ' / '.join(SHORTCUTS) + '(安装根 + 桌面 + applications)',
+        '  ⑤ 清运行态: run/ 与 *.pid',
+        f'  ⑥ 删安装记录: {V.INSTALL_INFO}',
+        f'  ⑦/⑧ 只有给了 --purge / --all 才动数据与程序本体',
+        '  保留(默认): ' + ' '.join(f'{d}/' + ('' if (ROOT / d).exists() else '(没有)')
+                                    for d in ('data', 'outputs', 'logs', '.venv')) + ' 与整棵程序目录',
+    ]
+
+
+def selftest() -> int:
+    r"""自检(--selftest):证明"计划齐全"且"dry-run 一个字节都不动"。
+
+    一个是**内容断言**(六个步骤一个不缺),一个是**副作用断言**(跑完 --dry-run 后关键路径的
+    存在性与大小完全不变)—— 后者才是"不会误删"的证据。
+    """
+    ok = True
+    need = ('① 停服务', '② 注销服务', '③ 撤开机自启', '④ 删快捷方式', '⑤ 清运行态', '⑥ 删安装记录', '保留')
+    txt = '\n'.join(plan_lines())
+    miss = [k for k in need if k not in txt]
+    print(f'  [{"OK" if not miss else "FAIL"}] 计划齐全(六个步骤 + 保留清单)' + ('' if not miss else f' — 缺 {miss}'))
+    ok &= not miss
+    watch = ['install-info.json', 'run', 'data', 'outputs', 'logs', '.venv']
+    before = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
+    step_stop(True)
+    step_unregister(True)
+    step_autostart(True)
+    step_shortcuts(True)
+    step_runtime(True)
+    step_install_record(True)
+    after = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
+    same = before == after
+    print(f'  [{"OK" if same else "FAIL"}] dry-run 无副作用({len(watch)} 个关键路径的存在性与大小都没变)')
+    ok &= same
+    print('  [OK] 六步全部构建成功: ' + '; '.join(f'{n}={rc}' for n, rc, _ in RESULTS))
+    RESULTS.clear()
+    print('  结论: ' + ('自检通过' if ok else '自检未通过'))
+    return 0 if ok else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description=f'{V.NAME} v{V.VERSION} 卸载器')
+    ap.add_argument('--dry-run', action='store_true', help='只打印计划, 一个字节都不动')
+    ap.add_argument('--yes', '-y', action='store_true', help='不问, 直接执行(脚本/无人值守用)')
+    ap.add_argument('--purge', action='store_true', help='连 data/ outputs/ logs/ .venv/ 一起删(会再问一次)')
+    ap.add_argument('--all', action='store_true', help='连程序本体一起删(尽力而为, 最后给收尾命令)')
+    ap.add_argument('--keep-logs', action='store_true', help='--purge 时保留 logs/(排查用)')
+    ap.add_argument('--selftest', action='store_true', help='自检: 计划齐全 + dry-run 无副作用')
+    a = ap.parse_args()
+
+    say(f'== {V.NAME} v{V.VERSION} 卸载 · {ROOT} ==')
+    if a.selftest:
+        return selftest()
+    for ln in plan_lines():
+        say(ln)
+    say('')
+
+    if not a.dry_run:
+        if not a.yes:
+            if not sys.stdin or not sys.stdin.isatty():
+                say('   [!] 非交互环境:卸载需要确认。请显式加 --yes(或先在终端里手工跑一次看计划)')
+                say('       本次未做任何改动。')
+                return 2
+            ans = input('   确认卸载? 输入 y 继续, 其它键退出: ').strip().lower()
+            if ans not in ('y', 'yes'):
+                say('   已取消(未改动任何文件)')
+                return 0
+        say('')
+
+    step_stop(a.dry_run)
+    step_unregister(a.dry_run)
+    step_autostart(a.dry_run)
+    step_shortcuts(a.dry_run)
+    step_runtime(a.dry_run)
+    step_install_record(a.dry_run)
+    if a.purge:
+        step_purge(a.dry_run, a.yes, skip=('logs',) if a.keep_logs else ())
+    if a.all:
+        step_all(a.dry_run)
+
+    say('')
+    say('== 结果 ==')
+    for name, rc, note in RESULTS:
+        say(f'   [{"OK" if rc == 0 else ("!" if rc == 3 else "X")}] {name}: {note}')
+    worst = max((rc for _n, rc, _s in RESULTS), default=0)
+    if a.dry_run:
+        say('   (dry-run: 以上一个字节都没动 — 去掉 --dry-run 才真做)')
+        return 0
+    say('   保留: ' + ('(--all 已连程序本体一起处理)' if a.all else 'data/ outputs/ logs/ .venv/ 与整棵程序目录'))
+    if worst == 0:
+        say('   卸载完成。')
+        if not a.all:
+            say('   想连目录一起清掉: ' + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
+    elif worst == 3:
+        say('   卸载基本完成,但**服务注销那一步缺权限** —— 用管理员/root 重跑一次即可(命令见上)。')
+    else:
+        say('   有步骤失败,见上面的 [X]。')
+    # 机器审计流水(卸载也要留痕;logs/ 被 --purge 删掉时这一步自然就没了,属预期)
+    try:
+        from src import logfile as LF
+        LF.append_jsonl('uninstall', dict(root=str(ROOT), version=V.VERSION, purge=bool(a.purge),
+                                          all=bool(a.all), worst_rc=worst,
+                                          steps=[dict(name=n, rc=rc, note=s) for n, rc, s in RESULTS]))
+    except Exception:
+        pass
+    return 0 if worst == 0 else worst
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 564 - 0
app_qualityGate/common/app_qualityGate_guanlan/pack/pack_dist.py

@@ -0,0 +1,564 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
+
+## 为什么会需要单独一个打包器
+
+直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍:
+`.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID)
+· `data/raw`(31 GB 现场原始件, 按约定不随包分发)。
+本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。
+
+## 包含 / 排除 (规则即文档)
+
+★ 2026-09-17 用户令: **交付包默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件** ——
+即"开箱是一套干净的程序", 数据与产物到目标机自己放、自己算。默认执行
+`python scripts/pack_dist.py` 就是这四类全不含, 不需要任何开关 (要破例才加 `--with-data` / `--with-products`)。
+
+**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
+· 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
+· 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
+`pack.sh`) · `requirements.txt`
+· ★**输入数据目录结构**(空目录, 2026-09-19 用户令「包中增加输入数据目录结构,但不包含文件」): `data/` `data/raw/`
+`data/_incoming/` 与 `data/raw/<场站>/{scada_10min, scada_1min, 故障报警, 风机故障记录, 油样报告, windcms,
+m5_cms_tcm, scada_mdb}` —— 共 12 个空目录, **里面一个文件都没有**。结构取自单一真源
+`src/windscada/config.py::STATION_SUBDIRS`, 场站目录名取解析出来的那一个(现场是「如东」)。
+目的: 目标机解压后开箱就能看见"每个约定位置在哪、该往哪个抽屉放原始件", 不必翻文档猜。
+开箱验证会同时查两件事: 12 个目录都在位 + 目录里没混进任何文件。
+
+**不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
+**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
+`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
+
+**排除**(每条都写了理由; 前四类就是用户令点名的四类):
+    data/ 里的**文件**                  ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data。
+                                        ★目录结构随包(空目录, 见上); 用户令 2026-09-19
+    outputs/                         ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
+                                        `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
+                                        无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
+    logs/ run/                       ③ **运行日志与运行态**: 本机日志(含 logs/build、logs/legacy)、run/pids.json
+                                        (旧 PID 到新机器上是无效引用); 要连产物一起发用 --with-products
+    *.log *.jsonl(任何位置)           ③ 运行日志/审计流水的兜底规则: 不管躺在哪都不进包
+    *_cache/ .*_cache/ __pycache__/  ④ **临时文件与缓存**: 解释器缓存、第三方工具缓存
+    .pytest_cache .mypy_cache .ruff_cache .ipynb_checkpoints .npm-cache .qa-profile
+    *.tmp *.temp *.bak* *.old *.orig *.rej *.swp *.swo *~ ~$*
+                                    ④ 编辑器/工具留下的临时与备份文件
+    .git/ .venv/ .github/            版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
+    _products_off*/                  历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
+    *.pyc *.pyo                      解释器缓存(字节码)
+    *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
+    *.zip(顶层)                      旧的交付压缩包; 避免包中包
+    .DS_Store Thumbs.db desktop.ini  其它系统在目录里留下的临时索引
+
+## 用法
+
+    python scripts/pack_dist.py --dry-run                  # 只报会打什么/多大, 不写文件
+    python scripts/pack_dist.py                            # 打包(默认不含 数据/产物/日志/临时文件)
+    python scripts/pack_dist.py --out <目标 zip>            # 指定输出路径
+    python scripts/pack_dist.py --with-products            # 例外: 连产物一起打 (页面开箱即有数)
+    python scripts/pack_dist.py --with-data                # 例外: 连 data/raw 一起打 (30 GB, 慎用)
+    python scripts/pack_dist.py --no-products              # (旧开关, 现在默认就是这个; 保留兼容不报错)
+    python scripts/pack_dist.py --out D:\out\x.zip         # 指定输出 # portability-allow: 只是用法示例里的 Windows 路径写法, 不是本机路径
+    python scripts/pack_dist.py --verify <zip>             # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
+
+## 排除结果写进包
+
+包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs / no_temp 与**逐条**排除理由;
+`guanlan.py check` 读它 —— 没带产物的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
+
+一键入口(给不敲命令行的人): Windows 双击 `pack.bat`(或 `pack.bat dry` 只看计划)、Linux/macOS `sh pack.sh`。
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import argparse
+import importlib.util
+import json
+import os
+import pathlib
+import shutil
+import sys
+import tempfile
+import time
+import zipfile
+
+ROOT = _install_root(__file__)
+sys.path.insert(0, str(ROOT))
+from src import version as V       # noqa: E402  ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
+from src import paths as P         # noqa: E402  配置取用口(模块登记表也从这里读)
+
+
+def module_dirs() -> list[str]:
+    """模块目录清单 = `configs/modules.yaml` 的 modules(用户令 2026-09-22 模块化重构)。
+
+    ★为什么不写死: 2.11.1 打包时实逮 —— 收录清单是白名单, 模块目录 `app_*` 不在其中 ⇒
+    包内没有公共层, 而 install.ps1 已按新布局找 `app_common/.../version.py` ⇒ 入口引用闭合检查报
+    "引用的文件包里没有", 包直接卡住。改成读登记表后, 以后加/改模块(例如 app_ETL 改名)无需再动打包器。
+    """
+    try:
+        import yaml
+        reg = yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
+        return [m['name'] for m in (reg.get('modules') or [])]
+    except Exception:                                    # 登记表缺失/损坏时保守回退(不猜目录名)
+        return []
+
+
+INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
+                'wheels', 'vendor'] + module_dirs()
+PRODUCTS_DIR = 'outputs'          # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products
+# ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
+#   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
+#   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
+# ★ 版本号不再在这里写死 (2026-09-17 用户令"打包版本管理"): 改版本只改 `src/version.py` 一行,
+#   本器读它写进 dist-manifest.json 并用它拼默认包名 `app_guanlang_v<版本号>.zip`。
+VERSION = V.VERSION               # 包版本 = src/version.py 的 VERSION(单一真源)
+INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
+                 # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报
+                 #   "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript
+                 #   整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`,
+                 #   安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
+                 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
+                 # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用)
+                 'pack.bat', 'pack.sh',
+                 # 卸载入口 (用户令 2026-09-17: 增加卸载脚本; 它引用的 scripts\guanlan_uninstall.py
+                 # 由 INCLUDE_DIRS 的 scripts/ 覆盖, 入口本身必须在这里点名)
+                 'uninstall.bat', 'uninstall.sh',
+                 # ★2026-09-19: 输入数据目录里的**说明文件**随包 —— 它是仓库里的说明(git 已跟踪), 不是现场数据。
+                 #   作用: 目标机解压后每个抽屉放什么一目了然(含"机理层资料在 data/raw/西门子4.0技术资料/")。
+                 #   注意 data/ 的**数据文件**照旧不进包。
+                 'data/raw/README_把原始数据放这里.txt']
+# 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
+EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__',
+                '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
+                '.cache', '.npm-cache', '.qa-profile', '.Trash', '$RECYCLE.BIN', '.venv.bak'}
+# ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
+#   前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
+#   (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
+#   为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
+EXCLUDE_GLOBS = ['_products_off*', 'data', 'outputs']
+# 进包**只**看目录/名的顶部前缀, 这些则按后缀/文件名排除 —— 都是"本机/装机时/编辑时"才产生的东西
+#   (用户令 2026-09-17 的"临时文件"与"运行日志"两条就落在这里):
+#   .pyc/.pyo  解释器缓存;  .lnk 安装时建的快捷方式(绝对路径, 换机失效, 由 install.ps1 重建);
+#   .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**;  .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份
+EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl',
+                  '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'}
+# 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件 + **本机安装记录**
+# ★ `install-info.json` 必须排除 (2026-09-17 实逮: 它被原样打进了交付包): 那是**本机装没装、装的哪一版**
+#   的凭据, 里面写着安装时间与本机绝对路径; 随包发出去, 目标机第一次装就会读到它, 于是安装前检查
+#   报"已安装 v2.5.0(装于 <打包机时间>)", 明明是新机器却提示"版本相同, 是否重装" —— 用户令 1
+#   要的版本检查当场失灵。它是装机产物, 不是交付件。
+EXCLUDE_NAMES = ('install-info.json', '*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini',
+                 '*.crdownload', '*.partial')
+
+
+def is_machine_artifact(p: pathlib.Path) -> bool:
+    """装机产物(本机装没装 / 本机快捷方式)—— 只在本机有效, 不随包、不入库 (2026-09-17)。"""
+    import fnmatch
+    return (p.name == 'install-info.json' or p.suffix.lower() in EXCLUDE_SUFFIX
+            or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
+
+
+def data_skeleton() -> list[str]:
+    r"""`data/` 下要随包发的**空目录骨架**(用户令 2026-09-19: 「包中增加输入数据目录结构,但不包含文件」)。
+
+    为什么要有它: 交付包默认不含输入数据(现场原始件 30 GB 级),可目标机解压后是一套**没有"放哪儿"的**
+    程序 —— 人得去翻文档才知道 `data/raw/<场站>/{scada_10min,故障报警,…}` 这个约定目录结构。
+    把**目录结构**随包发出去(**不带任何文件**),开箱就能看见每个约定位置在哪、该往哪个抽屉里放。
+
+    结构来源是**单一真源** `src/windscada/config.py::STATION_SUBDIRS`(摄入侧的约定子目录名 —— 改名等于
+    换接口),场站目录名取 `raw_station_dir()` 解析出来的那一个(现场是「如东」,不是 farm key「rudong」)。
+    另外补两个入口目录: `data/` 与 `data/raw/` 本身,以及 `data/_incoming/`(`place_raw_data.py` 的默认
+    投放口)。**不含任何文件**,也不含 `scada_mdb/<年>年/<月>月/` 那种数据生成出来的层级
+    (月目录是收资之后才有的,不该由包来规定)。
+
+    ★2026-09-19 用户逮「`data\raw\西门子4.0技术资料` 目录没有打包进去」后补: 机理层厂商资料按 A2 约定
+    **不在场站目录下、而在 `data/raw/` 根下**(`scripts/raw_data_check.py::TOP_OK_DIRS`),并且它是本体链
+    的输入(`src/ontology/kb_ingest.py`: `TECH = RAW_ROOT/'西门子4.0技术资料'`)⇒ 现在连它**与其一级子目录**
+    一起发空骨架(本机共 30 个空目录)。另外把仓库里那份放置说明 `data/raw/README_把原始数据放这里.txt`
+    作为**说明文件**随包(它 git 已跟踪,不是现场数据)。
+    """
+    out = ['data', 'data/raw', 'data/_incoming']
+    try:
+        from src.windscada.config import STATION_SUBDIRS, raw_station_dir
+        # ★只发**解析出来的那一个场站目录名**(现场是「如东」) —— 不把 farm key(`rudong`) 也发一份:
+        #   两个空场站目录会让 `station_scan` 报"2 个场站目录", 目标机上看起来像"多了一个不知哪来的场"。
+        station = pathlib.Path(str(raw_station_dir()))
+        out.append(f'data/raw/{station.name}')
+        for sub in STATION_SUBDIRS:
+            out.append(f'data/raw/{station.name}/{sub}')
+    except Exception as e:                          # 配不出骨架时如实说, 不静默少发
+        print(f'   [!] 输入数据目录骨架没配出来 ({type(e).__name__}: {e}) —— 包里不会有 data/ 结构')
+    # ★2026-09-19 补 (用户逮: 「data\raw\西门子4.0技术资料 目录没有打包进去」):
+    #   机理层厂商资料按 A2 约定**不在场站目录下**, 而在 `data/raw/` 根下
+    #   (`src/windscada/config.py` 的说明 + `scripts/raw_data_check.py::TOP_OK_DIRS`),
+    #   它是本体链的输入 (`src/ontology/kb_ingest.py`: TECH = RAW_ROOT/'西门子4.0技术资料')。
+    #   骨架只发**空目录**(文件仍不随包, 由现场收资包经 place_raw_data.py 落位);
+    #   本机若有该目录, 连它的一级子目录一起镜像出来(让人看见该往哪一层放哪类资料)。
+    tech = ROOT / 'data' / 'raw' / '西门子4.0技术资料'
+    out.append('data/raw/西门子4.0技术资料')
+    if tech.is_dir():
+        for sub in sorted(p for p in tech.iterdir() if p.is_dir()):
+            out.append(f'data/raw/西门子4.0技术资料/{sub.name}')
+    return out
+
+
+def _xfer():
+    """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
+    spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
+    m = importlib.util.module_from_spec(spec)
+    sys.modules['check_transferable'] = m
+    spec.loader.exec_module(m)
+    return m
+
+
+def plan(with_data=False, with_products=False):
+    """→ (要打包的顶层条目 list, 排除说明 list)
+
+    ★ 2026-09-17 用户令: 交付包**默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件**。
+      · 输入数据 `data/`    —— 默认排除 (--with-data 才带);
+      · 产物     `outputs/` —— 默认排除 (--with-products 才带);
+      · 运行日志 `logs/ run/` 与任何位置的 `*.log`/`*.jsonl` —— 默认排除;
+      · 临时文件 `*_cache/ __pycache__/ *.tmp *.bak* *~ ~$* .DS_Store …` —— 默认排除。
+    于是"开箱是一套干净程序": 页面显示"无产物", 目标机自己放数据、自己重算 (docs §9 / §12)。
+    包内 `dist-manifest.json` 记 `no_data/no_products/no_logs/no_temp`, 目标机 `guanlan.py check`
+    据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
+    """
+    inc = []
+    for d in INCLUDE_DIRS:
+        p = ROOT / d
+        if p.is_dir():
+            inc.append(p)
+    if with_products and (ROOT / PRODUCTS_DIR).is_dir():
+        inc.append(ROOT / PRODUCTS_DIR)
+    if with_data:
+        inc.append(ROOT / 'data')
+    for f in INCLUDE_FILES:
+        p = ROOT / f
+        if p.is_file():
+            inc.append(p)
+        else:
+            print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
+    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
+    #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
+    #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
+    from src.entry_refs import auto_include, referenced_tree_files
+    got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
+    extra = auto_include(ROOT, got)
+    # ★ 装机产物不许被"引用闭合"兜底补进包 (2026-09-17 实逮): `install.ps1`/`install.sh` 的版本检查那步
+    #   会读 `install-info.json`, 于是自动补入把它当"入口引用的文件"补了进来 —— 而它是**本机装没装**的
+    #   凭据(含安装时间与本机路径), 目标机第一次装就会误报"已安装、版本相同"。引用闭合管的是"入口点名的
+    #   文件必须在位", 不管"这个文件该不该随包", 所以这里按排除规则再滤一遍并如实说一句。
+    keepable, dropped = [], []
+    for p in extra:
+        (dropped if is_machine_artifact(p) else keepable).append(p)
+    if keepable:
+        for p in keepable:
+            print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
+        inc.extend(keepable)
+    for p in dropped:
+        print(f'   [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
+    skipped = [('data/ 里的文件', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data。'
+                                  '★目录结构随包 (空目录, 见"输入数据目录骨架"); 用户令 2026-09-19')]
+    if (ROOT / PRODUCTS_DIR).is_dir():
+        skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
+                                 'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
+                                 + ('' if not with_products else ' ※本次给了 --with-products, 例外打进包'))]
+    skipped += [('logs/, run/', '③ 运行日志与运行态 (logs/build、logs/legacy、run/pids.json 旧 PID)')]
+    skipped += [('*.log, *.jsonl (任何位置)', '③ 运行日志/审计流水兜底: 不管躺在哪都不进包')]
+    _glob_why = {
+        '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
+        'data': '① 输入数据: 现场原始件, 用户令不进包 (--with-data 才带)',
+        'outputs': '② 产物/输出数据: 用户令不进包 (--with-products 才带)',
+    }
+    skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
+    skipped += [('*_cache/ __pycache__/ 等', '④ 临时文件与缓存 (解释器/工具缓存: .pytest_cache .npm-cache .qa-profile …)')]
+    skipped += [('*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*', '④ 编辑器/工具留下的临时与备份文件')]
+    skipped += [('.DS_Store Thumbs.db desktop.ini', '④ 系统临时索引文件')]
+    skipped += [('.git/ .venv/ .github/', '版本库与虚拟环境 (venv 换机必失效, 目标机安装时重建)')]
+    skipped += [('__pycache__/ *.pyc *.pyo', '字节码缓存')]
+    skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
+    skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
+    return inc, skipped
+
+
+def keep(f: pathlib.Path, with_data: bool = False, with_products: bool = False) -> bool:
+    """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
+
+    ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
+    而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
+    现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
+    ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
+    ★ 2026-09-17 修: `--with-products` / `--with-data` 原先**是个摆设** —— `keep()` 无条件套用
+      `EXCLUDE_GLOBS`(里面有 `data`/`outputs`), 于是 `plan()` 把 outputs/ 加进要打清单、打印
+      "本次给了 --with-products, 例外打进包", 而写盘循环又把它们逐件滤掉: 实测 `--with-products`
+      打出来的包与默认包**同样 3441 件 / 879 MB**, 里面一件产物都没有。后果不是"少带东西",
+      而是"以为有存档、其实没有" —— 要补随包件时才发现在包里找不到。现在两个开关**真的**生效:
+      例外目录一旦显式要带, 就不再被 EXCLUDE_GLOBS 拦下。
+    """
+    import fnmatch
+    parts = f.relative_to(ROOT).parts
+    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX:
+        return False
+    if any(fnmatch.fnmatch(f.name, pat) for pat in EXCLUDE_NAMES):
+        return False
+    if len(parts) == 1 and f.suffix.lower() == '.zip':      # 顶层旧交付包, 避免包中包
+        return False
+    allow = set()
+    if with_data:
+        allow.add('data')
+    if with_products:
+        allow.add(PRODUCTS_DIR)
+    globs = tuple(g.rstrip('*') for g in EXCLUDE_GLOBS if g.rstrip('*') not in allow)
+    return not any(x.startswith(globs) for x in parts[:1])
+
+
+def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = False):
+    if p.is_file():
+        return 1, p.stat().st_size
+    n = s = 0
+    for f in p.rglob('*'):
+        if f.is_file() and keep(f, with_data, with_products):
+            n += 1; s = s + f.stat().st_size
+    return n, s
+
+
+def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
+    inc, skipped = plan(with_data, with_products)
+    skel = [] if with_data else data_skeleton()      # --with-data 时 data/ 整个进包, 不需要空骨架
+    tot_n = tot_s = 0
+    print('== 要打进包的内容 ==')
+    print('   (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
+    for p in inc:
+        n, s = size_of(p, with_data, with_products)
+        tot_n += n; tot_s += s
+        print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
+    print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
+    if skel:
+        print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录  (用户令 2026-09-19: 带结构不带文件)')
+    print('== 排除 ==')
+    for name, why in skipped:
+        print(f'   {name:26s} {why}')
+
+    out.parent.mkdir(parents=True, exist_ok=True)
+    man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
+               includes=[p.relative_to(ROOT).as_posix() for p in inc],
+               excluded={n: w for n, w in skipped}, files=tot_n + 1,   # +1 = dist-manifest.json 自身
+               bytes=tot_s, data_skeleton=skel or None,
+               data_skeleton_note=('用户令 2026-09-19: 包里带**输入数据目录结构**(空目录), 不带任何文件; '
+                                   '目标机解压后照这个结构往 data/raw/<场站>/ 各抽屉里放原始件'),
+               no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True,
+               note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/ 里的文件) · '
+                    '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**; '
+                    '**含**输入数据目录结构(空目录, 用户令 2026-09-19)。'
+                    '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
+                    '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
+                    '+ `scripts/rebuild_all.py` (无生成端的那批用 '
+                    '没有生成端的族由研发补生成端(运行期不从交付包补齐, 用户令 2026-09-17 #1))。'
+                    '.venv 不在包内(换机必失效, 安装时重建); 打包方法见包内 README_先读我.MD 的「打包」一节。')
+    t0 = time.time()
+    with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
+        z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
+        done = 0
+        for p in inc:
+            if p.is_file():
+                z.write(p, p.relative_to(ROOT).as_posix())
+                done += 1
+            else:
+                for f in p.rglob('*'):
+                    if not f.is_file() or not keep(f, with_data, with_products):
+                        continue
+                    z.write(f, f.relative_to(ROOT).as_posix())
+                    done += 1
+            if progress and done % 500 < 1:
+                print(f'   … 已写入 {done} 件', flush=True)
+        # 输入数据目录骨架: 写成**目录条目**(名以 `/` 结尾 + MS-DOS 目录位), 解压即建出空目录。
+        # ★必须显式写条目: 空目录在 zip 里不会因为"里面没文件"而自动存在; 只写父目录也不够
+        #   (解压工具只按条目建目录)。
+        for rel in skel:
+            zi = zipfile.ZipInfo(rel.rstrip('/') + '/')
+            zi.external_attr = (0o40775 << 16) | 0x10
+            zi.compress_type = zipfile.ZIP_STORED
+            z.writestr(zi, b'')
+        if skel:
+            print(f'   … 目录骨架 {len(skel)} 个空目录已写入', flush=True)
+    print(f'\n完成: {out}  {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件'
+          + (f' + 目录 {len(skel)} 个' if skel else '') + f', 耗时 {time.time()-t0:.0f}s')
+
+    # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
+    # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
+    from src.entry_refs import encoding_problems, missing_refs
+    with zipfile.ZipFile(out) as z:
+        names = z.namelist()
+    enc = encoding_problems(ROOT)
+    miss = missing_refs(ROOT, names)
+    if miss:
+        for e, rel in miss:
+            print(f'   [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
+    for m in enc:
+        print(f'   [X] 入口编码守则: {m}')
+    # 装机产物不得进包 (2026-09-17 实逮: 本机的 install-info.json 被原样打进包 ⇒ 目标机安装前检查
+    # 会误报"已安装 vx.y.z, 版本相同" —— 版本检查当场失灵)。这类"看着无害、后果是静默误报"的东西
+    # 必须由机器守: 包里出现一件就删包。
+    machine = [n for n in names if n.split('/')[-1] in ('install-info.json',) or n.endswith('.lnk')]
+    for n in machine[:5]:
+        print(f'   [X] 包里有"装机产物" {n} —— 它只在本机有效 (安装记录/快捷方式), 不该随包分发')
+    if miss or enc or machine:
+        out.unlink()
+        print(f'   [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
+        return 1
+    print(f'   [OK] 入口引用闭合 + 编码守则 + 无装机产物: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
+    print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
+    print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
+    return 0
+
+
+def verify(zip_path: pathlib.Path, keep=False) -> int:
+    """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
+    tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
+    if tmp.exists():
+        shutil.rmtree(tmp, ignore_errors=True)
+    print(f'① 解压到 {tmp}')
+    t0 = time.time()
+    with zipfile.ZipFile(zip_path) as z:
+        z.extractall(tmp)
+    n = sum(1 for _ in tmp.rglob('*') if _.is_file())
+    print(f'   {n} 件, 耗时 {time.time()-t0:.0f}s')
+    print('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)')
+    from src.entry_refs import encoding_problems, missing_refs
+    miss = missing_refs(tmp)
+    enc = encoding_problems(tmp)
+    if miss:
+        for e, rel in miss:
+            print(f'   [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
+    for m in enc:
+        print(f'   [X] 入口编码守则: {m}')
+    if miss or enc:
+        return 1
+    print('   [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
+    # ★2026-09-19 用户令: 包里带"输入数据目录结构"(空目录)。解压后必须真的建出来了, 且**除说明文件外一个
+    #   文件都没有** —— 两边都查: 目录少了 ⇒ 人不知道往哪放; 目录里混进数据文件 ⇒ 包就不是"只带结构"了。
+    #   ★2026-09-19 实逮 (第一次开箱验证就卡在这): ① 原先用 `rglob` 逐目录收集, 同一个文件会被父目录
+    #   重复计入 (`data` 与 `data/raw` 各算一次) ⇒ 报"混进 2 个文件"; ② 按清单随包发的**放置说明**
+    #   (`data/raw/README_把原始数据放这里.txt`, 在 `INCLUDE_FILES` 里) 被当成"混进的文件"。
+    #   现在: 允许清单 = INCLUDE_FILES 里落在骨架目录内的文件; 收集用集合(去重)。
+    skel = data_skeleton()
+    allowed = {f.replace('\\', '/') for f in INCLUDE_FILES if any(f.replace('\\', '/').startswith(r.rstrip('/') + '/')
+                                                                   for r in skel)}
+    miss_d = [r for r in skel if not (tmp / r).is_dir()]
+    extra_f = set()
+    for r in skel:
+        d = tmp / r
+        if d.is_dir():
+            extra_f |= {p.relative_to(tmp).as_posix() for p in d.rglob('*') if p.is_file()}
+    extra_f -= allowed
+    if miss_d:
+        print(f'   [X] 输入数据目录骨架缺 {len(miss_d)} 个: {miss_d[:5]}')
+    if extra_f:
+        print(f'   [X] 骨架目录里混进了 {len(extra_f)} 个文件 (除说明文件外应只有空目录): {sorted(extra_f)[:5]}')
+    if miss_d or extra_f:
+        return 1
+    print(f'   [OK] 输入数据目录骨架: {len(skel)} 个空目录全部在位'
+          f'{f" + 放置说明 {len(allowed)} 件" if allowed else ""}, 无其他文件'
+          f' (用户令 2026-09-19: 带结构不带数据文件)')
+    print('② 离线安装')
+    xt = _xfer()
+    py = xt.install_in(tmp)
+    if py is None:
+        return 1
+    print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)')
+    bad = []
+    if os.name == 'nt':
+        # 2026-09-17: 安装脚本现在建**两份**快捷方式 —— 中文名给人双击, ASCII 名 (start_guanlan.lnk)
+        # 给控制台/脚本引用 (纯 ASCII 的 start.bat 只能引用 ASCII 名)。两份都要在。
+        for nm in ('启动观澜.lnk', 'start_guanlan.lnk'):
+            lnk = tmp / nm
+            if lnk.is_file() and lnk.stat().st_size > 0:
+                print(f'   [OK] 安装时已生成快捷方式: {nm} ({lnk.stat().st_size} B)')
+            else:
+                bad.append(f'安装后没有 {nm} (install.ps1 第 5/6 步没生效)')
+        import json as _json
+        import subprocess as _sp
+        pyw = tmp / '.venv/Scripts/pythonw.exe'
+        r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'],
+                    capture_output=True, text=True, errors='replace', timeout=120)
+        try:
+            info = _json.loads((r.stdout or '').strip().splitlines()[-1])
+        except Exception:
+            info = {}
+        if info.get('windowless') is True:
+            print(f'   [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立')
+        else:
+            bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }')
+        if list(tmp.glob('*.vbs')):
+            bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}')
+        else:
+            print('   [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)')
+    else:
+        print('   (非 Windows: 无窗口/快捷方式不适用)')
+    for b in bad:
+        print(f'   [X] {b}')
+    if bad:
+        return 1
+    print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
+    ok = xt.run_and_probe(tmp, py)
+    print('③b 卸载核验 (用户令 2026-09-17: 装得上, 也要卸得掉)')
+    ubad = xt.uninstall_probe(tmp, py, gw=xt.LAST_GATEWAY_PORT)
+    print('④ 收尾')
+    if keep:
+        print(f'   解压目录保留在 {tmp} (--keep)')
+    else:
+        shutil.rmtree(tmp, ignore_errors=True)
+        print('   已清理')
+    print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}'
+          + (f';卸载核验 {"通过" if ubad == 0 else f"{ubad} 项未过"}'))
+    return 0 if (ok >= 4 and ubad == 0) else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--out', default=None)
+    ap.add_argument('--with-data', action='store_true',
+                    help='例外: 连输入数据 data/raw 一起打 (约 31 GB, 慎用; 默认不含)')
+    ap.add_argument('--with-products', action='store_true',
+                    help='例外: 连产物 outputs/ 一起打 (页面开箱即有数; 默认不含 —— 用户令 2026-09-17)')
+    ap.add_argument('--no-products', action='store_true',
+                    help='(旧开关) 不含产物 —— 现在**默认就是这个**, 保留只为兼容旧命令')
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
+    ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
+    a = ap.parse_args()
+    if a.verify:
+        return verify(pathlib.Path(a.verify), a.keep)
+    out = (pathlib.Path(a.out) if a.out
+           else ROOT.parent / V.package_name())
+    if a.dry_run:
+        inc, skipped = plan(a.with_data, a.with_products)
+        tot_n = tot_s = 0
+        print(f'== 会打进包 (version {VERSION}) ==')
+        for p in inc:
+            n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
+            print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
+        print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
+        skel = [] if a.with_data else data_skeleton()
+        if skel:
+            print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (不带文件; 用户令 2026-09-19):')
+            for r in skel:
+                print(f'      {r}/')
+        print('== 会排除 ==')
+        for name, why in skipped:
+            print(f'   {name:26s} {why}')
+        print('\n(dry-run, 未写文件)')
+        return 0
+    return build(out, a.with_data, with_products=a.with_products)
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 52 - 0
app_qualityGate/common/app_qualityGate_guanlan/pack/pack_verify_entry.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""`pack.bat verify` 的入口:找最新的交付包(或用户给的路径)→ 交给 pack_dist.py --verify。
+
+为什么要单独一个: `pack.bat verify` 不带参数时应该"自动找最新那个 zip",而批处理里做这个
+(dir /b /o-d 之类) 既啰嗦又在中文路径上不稳 —— 放到 Python 里三行搞定,批处理只负责传参。
+
+用法:
+    python scripts/pack_verify_entry.py                 # 找 <安装根父目录>/guanlan-v*_dist_*.zip 里最新的
+    python scripts/pack_verify_entry.py <zip 路径>
+"""
+from __future__ import annotations
+
+try:
+    from app_common.common.app_common_guanlan.api import install_root as _install_root
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+    from pathlib import Path as _P
+    def _install_root(_f): return _P(_f).resolve().parents[4]
+
+import pathlib
+import subprocess
+import sys
+
+ROOT = _install_root(__file__)
+
+
+def newest_zip() -> pathlib.Path | None:
+    """找最新的交付包: 先看安装根的父目录(打包器默认输出处), 再看安装根自己。"""
+    cands: list[pathlib.Path] = []
+    for d in (ROOT.parent, ROOT):
+        if d.is_dir():
+            cands += [p for p in d.glob('guanlan-*.zip') if p.is_file()]
+            cands += [p for p in d.glob('*guanlan*v*.zip') if p.is_file()]
+    cands = sorted(set(cands), key=lambda p: p.stat().st_mtime, reverse=True)
+    return cands[0] if cands else None
+
+
+def main() -> int:
+    arg = sys.argv[1] if len(sys.argv) > 1 else ''
+    zp = pathlib.Path(arg) if arg else newest_zip()
+    if zp is None or not zp.is_file():
+        print('[X] 找不到交付包 zip。给一个路径: python scripts/pack_verify_entry.py <zip>')
+        return 2
+    print(f'[i] 开箱验证: {zp}  ({zp.stat().st_size/1e6:.0f} MB)')
+    if arg == '' and not str(zp).startswith(str(ROOT.parent)):
+        pass
+    return subprocess.call([sys.executable, str(ROOT / 'scripts' / 'pack_dist.py'), '--verify', str(zp)],
+                           cwd=str(ROOT))
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 3 - 2
configs/modules.yaml

@@ -70,6 +70,7 @@ modules:
     cn: 质量门与审计 + 打包安装
     resp: 审计器(反向呼应/页面归口/配置统一/日志/链路/可移植/安全)、交付文档三检、打包器与开箱验证、安装与卸载
     allow: [app_common, app_ETL, app_algorithmModel, app_ontology, app_backEnd, app_frontEnd]
-    impl_now: scripts/{*_audit,check_*,detail_deps,page_fingerprint,pack_dist,pack_verify_entry,guanlan_uninstall,delivery_docs_*}.py、install.*、uninstall.*
-    phase: P7
+    impl_now: app_qualityGate/common/app_qualityGate_guanlan/{audits/*,docs/*,pack/*}(旧路径 scripts/<件>.py 为 CLI 壳;
+      根引导脚本 install.*/uninstall.*/check.bat/pack.* 按"解压即用"约定原地不动)
+    phase: P7 已完成(2026-09-28)
     change_rate: 慢

+ 1 - 1
docs/src/数据要求说明_观澜_2.11.9.md → docs/src/数据要求说明_观澜_2.12.0.md

@@ -1,4 +1,4 @@
-# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.11.9
+# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.12.0
 
 ## 1 文档说明
 

+ 11 - 9
docs/src/系统设计说明_观澜_2.11.9.md → docs/src/系统设计说明_观澜_2.12.0.md

@@ -1,4 +1,4 @@
-# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.11.9
+# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.12.0
 
 ## 1 文档说明
 
@@ -6,19 +6,19 @@
 
 ### 1.1 目的与范围
 
-本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.11.9;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
+本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.12.0;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
 
 本文覆盖十五个设计面:总体架构与分层、目录结构与路径真源、数据接入与重算链、判级与算法、时间窗口径、服务与前端、本体与知识层、本机模型接入、运维控制台与重算编排、安装与服务化与版本管理、质量保证、安全与离线边界、可移植性与资源占用、多场适用性与换场迁移、已知边界与未实现。
 
-本文不重复需求条目本身(那是《需求分析_观澜_2.11.9.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
+本文不重复需求条目本身(那是《需求分析_观澜_2.12.0.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
 
 ### 1.2 读者与用法
 
 现场运维与检修人员可看第 3 章、第 7 章、第 8 章、第 11 章,了解自己能点到的页面背后读的是什么口径;场站管理人员与换场交付人员可看第 6 章、第 7 章、第 16 章、第 17 章,了解判级与可靠性指标的口径与边界,以及换场时要重新标定哪些参数;研发与交付人员应通读全文,重点是第 4 章、第 5 章、第 12 章、第 13 章;验收方可直接按第 13 章的质量门与附录 A 的编写依据逐条复核。
 
-### 1.3 与《需求分析_观澜_2.11.9.docx》的对应关系
+### 1.3 与《需求分析_观澜_2.12.0.docx》的对应关系
 
-需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.11.9.docx》的数据侧口径去向见 16.6 节。
+需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.12.0.docx》的数据侧口径去向见 16.6 节。
 
 | 本文章节 | 需求分析对应章 | 对应关系说明 |
 |---|---|---|
@@ -41,7 +41,7 @@
 
 ### 1.4 口径与依据
 
-本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.11.9;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.11.9.zip。
+本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.12.0;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.12.0.zip。
 
 本文遵循四条写作口径:含"窗"且确实指时间窗口的,一律写全"时间窗"(天气窗、作业窗、预览窗、观测窗属领域词,保持原样);影响的是风电机组时写"影响机组"或"影响机组数";使用英文简写时必须写成"中文(英文简写)"形式,如平均无故障间隔(MTBF)、平均停机间隔(MTBO)、单次停机时长(MDT);全文用简体中文与半角数字与单位。
 
@@ -209,6 +209,8 @@ P1–P7 分阶段实体迁移,每阶段独立提交、随时可回退,方案
 
 **P6 已落地(本体与知识层实体迁移,2026-09-28)**:本体 20 件(对象库、码表、摄入六步、检索、MCP 只读工具、离线问答代理与模型闸)与 SOP 38 件(契约闸、判别器、调查与案例、台账与日期口径、各类校验、编排)共 58 个模块 / 19,775 行迁入 `app_ontology/common/app_ontology_guanlan/{ontology,sop}/`。**旧路径全部留兼容壳**:`src/ontology/<件>.py`、`src/sop/<件>.py`(模块别名壳;`python -m src.ontology.kb_ingest` 这类重算链入口经壳 `runpy` 按 `__main__` 重跑,命令一字不改)。正文里 `app_ontology/.../ontology/<件>.py` 即 `app_ontology/common/app_ontology_guanlan/ontology/<件>.py`(`sop/` 同理)。**验收**:58/58 模块新旧路径均可导入且为同一模块对象;9 处取根抽查全部等于安装根;门户 / `/ops` / `/cms/` / `/api/ont_list` / `/api/ont_chain` / `/api/maint_survey` / `/api/maint_std` / `/api/maint_framework` / `/api/dq_findings` / `/api/facts` 十条路由响应体 sha256 与迁移前**逐字节一致**;`-m src.ontology.mcp_server`、`-m src.sop.cases` 等旧入口冒烟通过;全门禁 rc=0。本轮实逮:① 包内 `from .. import paths`(23 处)迁入子包后必须改绝对路径;② 19 处 `__file__` 取根改公共层 `install_root()`;③ **`-m` 入口必须由壳 `runpy.run_module(..., run_name="__main__")` 兜住**,否则重算链 ⑦ 步会静默不出活;④ 按源码路径读本层文件的审计器与登记表(`audit_chinese_terms`、`check_portability` 的 ALLOW 键、`products_reverse_audit` 的生成端、`configs/{registry,terms/jargon_rules}.yaml`)要同步改,否则门禁会读壳或误报。
 
+**P7 已落地(质量门与打包安装实体迁移,2026-09-28;重构收官)**:23 个工具 / 7,426 行迁入 `app_qualityGate/common/app_qualityGate_guanlan/{audits,docs,pack}/` —— 审计器 17 件(配置统一、页面归口、产物反向呼应、链路缺口、可移植、日志、安全扫描、中文表达、模块边界、页面指纹、产物清点与补缺、依赖台账)、交付文档三检与版本记录 3 件、打包器与开箱验证与卸载器 3 件。旧路径 `scripts/<件>.py` 全部留 CLI 壳(公开名同面转发 + `main()`;`guanlan.py check` 用 importlib 载入这些路径,照旧可用)。**根引导脚本(`install.ps1`/`install.sh`/`uninstall.*`/`check.bat`/`pack.bat|sh`/`start|stop.bat`)原地不动**:它们是"解压即用"的引导层,被 README、入口引用闭合门与 `.gitattributes` 的换行/编码约定钉着;其 Python 实现迁入模块后,引导脚本与既有命令行一字不改。**验收**:8 条质量门 stdout 与迁移前**逐字节一致**(config / pages / chain_gap / products_reverse / detail_deps / version_log / docs_verify / check_transferable),模块边界与可移植性 rc=0;23 个壳经 importlib 载入且公开名同面;新路径 `python -m app_qualityGate....audits.<件>` 可跑;**打包器从新位置出包,开箱验证页面 5/5 + 卸载核验通过**;`guanlan.py check` 全绿。本轮实逮:① 子包内 `__file__` 取根(`parents[1]`)失效(22 处)⇒ 统一 `install_root()`;② 文件间 bare import(4 处)改相对导入;③ `audit_chinese_terms` 的 `here.parent` 是"仓库根"语义 ⇒ 换 `install_root()`;④ 顺手补 P4 遗留:该审计器的被审件 `scripts/windscada_serve.py` 早已是壳 ⇒ 改读 `app_backEnd/common/app_backEnd_guanlan/serve.py`。
+
 **四个系统组件(TiDB community / MinIO / Redis / Nginx)**:本轮只建立**接口契约**(标准仓读写、对象存储、
 按时间窗缓存与作业状态、统一入口与路由表),默认实现仍是本地目录与进程内缓存 —— 所以离线单包交付形态与现有
 验收链保持不变;后续接实现时只换实现、不动上层调用。
@@ -826,7 +828,7 @@ Windows 安装入口是 install.bat 与 install.ps1,步骤号写死在输出
 
 ### 12.3 安装记录与版本三守卫
 
-安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.11.9,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
+安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.12.0,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
 
 | 字段 | 含义 |
 |---|---|
@@ -1187,7 +1189,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 ### 16.6 与同批交付文档的对应关系
 
-本章对应《需求分析_观澜_2.11.9.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.11.9.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
+本章对应《需求分析_观澜_2.12.0.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.12.0.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
 
 ***
 
@@ -1253,7 +1255,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 | 章节 | 来源文件 |
 |---|---|
-| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.11.9 条目)、docs/需求分析_观澜_2.11.9.docx、scripts/delivery_docs_figures.py |
+| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.12.0 条目)、docs/需求分析_观澜_2.12.0.docx、scripts/delivery_docs_figures.py |
 | 2 设计目标与原则 | docs/系统设计说明.md(四条设计铁律)、app_ontology/.../ontology/mcp_server.py 头部、src/windscada/subsys/fusion.py 头部、src/windscada/perf/reliability.py |
 | 3 总体架构 | configs/serve.json、scripts/guanlan_gateway.py、guanlan.py(组件启动表)、run/pids.json、docs/系统设计说明.md(产物全景) |
 | 4 目录结构与路径真源 | src/paths.py、configs/registry.yaml、docs/系统设计说明.md(路径约定与统一记录)、本次目录实测 |

+ 21 - 20
docs/src/需求分析_观澜_2.11.9.md → docs/src/需求分析_观澜_2.12.0.md

@@ -1,4 +1,4 @@
-# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.11.9
+# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.12.0
 
 ## 1 文档说明
 
@@ -6,7 +6,7 @@
 
 ### 1.1 目的
 
-本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.11.9.docx》《数据要求说明_观澜_2.11.9.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
+本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.12.0.docx》《数据要求说明_观澜_2.12.0.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
 
 本文的写作口径是**只写能取证的事实**:每个数字、每条结论都能指到仓库里的某个文件、某次实跑输出或某条版本记录;查不到、取不到的一律写明「未取证」或「未到位」,不做推测性补全。因此文中会出现少量「未取证」的说明句,那是刻意留下的诚实边界,不是遗漏。
 
@@ -18,19 +18,19 @@
 
 ### 1.3 版本对应关系
 
-系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.11.9,版本记录(HISTORY)共 26 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
+系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.12.0,版本记录(HISTORY)共 27 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
 
 表 1-1 版本与文档的对应关系
 
 | 项 | 值 | 取证方式 |
 |---|---|---|
-| 系统版本 | 2.11.9 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.11.9」 |
-| 交付包名 | app_guanlang_v2.11.9.zip | src/version.py 的 package_name();同一条 check 输出 |
-| 本文版本 | 2.11.9(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
+| 系统版本 | 2.12.0 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.12.0」 |
+| 交付包名 | app_guanlang_v2.12.0.zip | src/version.py 的 package_name();同一条 check 输出 |
+| 本文版本 | 2.12.0(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
 | 版本史条目数 | 13 条(最新一条即本次文档交付) | src/version.py 的 HISTORY;docs/版本记录.md |
 | 版本号规则 | v大版本号.中版本号.小版本号;大改方案或架构、中改非核心功能、小为消缺 | src/version.py 的 BUMP_RULE 与 LEVEL_MEANING;docs/系统设计说明.md §15 |
 | 版本级别机器判据 | level_of(旧, 新) 返回 major 或 minor 或 patch 或 same | src/version.py 的 level_of() |
-| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.11.9 条目的 level 字段 |
+| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.12.0 条目的 level 字段 |
 
 本版相对 2.10.0 的实质变化是**交付文档对外化**:三份交付文档(需求分析、系统设计说明、数据要求说明)全文不体现具体风电场(去标识化),并新增多风电场适用性一章;系统功能未变,属交付物修订。依据用户令原文:「修改三份文档,内容参考样本风电场,但不体现样本风电场,且具有不同风电场适用性」(引用时把样本场名按去标识化口径写成「样本风电场」)。
 
@@ -119,10 +119,11 @@
 
 ### 3.2 需求演进时间轴
 
-表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 26 条)
+表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 27 条)
 
 | 版本 | 日期 | 用户令要点 | 变化级别 |
 |---|---|---|---|
+| 2.12.0 | 2026-09-28 | 模块化重构收官 P7:质量门与打包安装迁移——七个模块齐备 | 中(minor) |
 | 2.11.9 | 2026-09-28 | 模块化重构 P6:本体与知识层实体迁移 | 小(patch) |
 | 2.11.8 | 2026-09-28 | 消缺:guanlan.py stop 在 pids.json 读不动/删不掉时抛异常中断 | 小(patch) |
 | 2.11.7 | 2026-09-28 | 模块化重构 P5:前端层实体迁移 | 小(patch) |
@@ -150,7 +151,7 @@
 | 0.4.0 | 2026-09-17 | 打包默认不含输入数据与产物与日志与临时文件;无窗口启动;统一配置与日志;页面归口;服务化 | 旧编号(legacy) |
 | 0.2.0 | 2026-09-01 | 含产物的旧交付基线,历史上用作「包内无生成端」那批产物的补救源 | 旧编号(legacy) |
 
-需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 26 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的二十四个版本)。按级别统计,这二十四个版本里「中」级变化7条(2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化17条(2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
+需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 27 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的二十五个版本)。按级别统计,这二十五个版本里「中」级变化8条(2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化17条(2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
 
 ![图 1-1 观澜需求演进时间轴(依据 src/version.py 的 HISTORY)](figures/fig-req-01-需求演进时间轴.png)
 
@@ -169,7 +170,7 @@
 | 所有的计算均要形成观澜的源代码 | 逐族补生成端:变桨面、在升闭环、三层基线、融合面 handoff、总览页、由台账生成 claim | 反向呼应审计成立 3,548 件、不成立 0 件、未归类 0 件 |
 | 运行期一律不从交付包补齐 | 第 ⑤ 步由「补齐随包件」改为「反向呼应审计」,只报账不搬运 | rebuild_all --dry-run 计划中 ⑤ 步命令为 products_reverse_audit.py --check |
 | 清除产物不留备份 | products_state.py --off --yes 改真删除;--on 与门户恢复按钮移除 | 控制台按钮语义与 409 前置校验;docs/系统设计说明.md §7 |
-| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.11.9」 |
+| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.12.0」 |
 | 观澜改为监听所有 IP | serve.json 增 public_host 且出厂为 0.0.0.0,仅门户网关用它,组件仍绑本机 | check 实跑显示 6 个端口全部运行中;README 第二节写明无鉴权风险与回退口径 |
 | 描述口径:时间窗、影响机组、中文(英文简写) | 界面文案与报告构建器统一改写;i18n_en 键同步 | docs/系统设计说明.md §17.1 与 §17.2;语言包 845 条前后端成对 |
 | 整理需求与设计与数据接入各写一份 Word 文档 | docs/src 下 markdown 源件;渲染器输出带域目录的 docx;插图生成器生成 14 张图,数字全部从真件取 | HISTORY 2.10.0 条目;本版三份文档 |
@@ -385,7 +386,7 @@
 | NFR-09 | 安全只读:不写回现场系统、不改原始件、不产生控制指令;对外暴露时须自行在网侧限来源 | 原始件按只读输入对待,落位冲突默认拒绝;对外只有一个网关入口;启动日志每次提醒页面无鉴权 | docs/输入数据放置指导_v0.1.md §3;README_先读我.MD 第二节 |
 | NFR-10 | 可维护性:单一真源,改一处不必改多处;关键口径有机器守卫 | 版本号只有一行真源并由三处守卫盯住;路径、配置、日志、语言各有唯一取用口与审计器 | docs/系统设计说明.md §15.2 与 §11.1;docs/系统设计说明.md §3.1 |
 | NFR-11 | 无人值守与自愈:长跑的服务要能被托管并自动拉起 | 服务体每 15 秒巡检并拉起掉线组件;崩溃重启策略;远程部署一律用服务,不用会话前台进程 | docs/系统设计说明.md §14.1 与 §14.4;README_先读我.MD 第二节 |
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.11.9.docx》第 8 章与第 12 章 |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.12.0.docx》第 8 章与第 12 章 |
 
 ### 6.2 性能与耗时实测
 
@@ -421,7 +422,7 @@
 
 | 真源 | 管什么 | 守卫与实跑结果 |
 |---|---|---|
-| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.11.9 且记录表与代码一致 |
+| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.12.0 且记录表与代码一致 |
 | src/paths.py | 一切路径解析的基准与助手 | 配置审计器检查不手拼路径;实跑无不一致 |
 | configs 目录与登记表 | 端口、模型档、场配置、页面归口、配置登记 | 配置审计实跑已知缺口与白名单 14 条、提示 7 条 |
 | src/logfile.py | 日志目录、命名、行格式、保留策略 | 日志审计实跑无不一致、提示 87 条 |
@@ -432,7 +433,7 @@
 
 ### 7.1 数据族与功能映射
 
-系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.11.9.docx》。
+系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.12.0.docx》。
 
 表 7-1 数据族到功能的映射(样本场实测(2026-09-22))
 
@@ -465,7 +466,7 @@
 
 ### 7.3 与数据要求说明的分工
 
-本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.11.9.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
+本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.12.0.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
 
 ## 8 页面与信息架构需求
 
@@ -547,7 +548,7 @@
 | 运行环境与依赖 | 10 | Python 版本不低于 3.11(实测 3.12.10);九个第三方依赖逐个导入 | 全 OK |
 | 静态质量门 | 4 | 源码可编译 229 个文件;语言包 845 条成对;入口引用闭合 48 条;入口脚本编码守则 | 全 OK |
 | 子进程口径 | 3 | 捕获输出可用;无窗口启动输出进日志;无窗口位已设,标志位 0x8000200 | 全 OK |
-| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.11.9,包名 app_guanlang_v2.11.9.zip);卸载入口两个都在位 | 全 OK |
+| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.12.0,包名 app_guanlang_v2.12.0.zip);卸载入口两个都在位 | 全 OK |
 | 制品与台账审计 | 5 | 反向呼应 3,548 件成立;页面归口检查 21 项;配置统一;日志统一;输入数据放置体检 26,503 件结构合规 | 全 OK |
 | 产物与发布件在位 | 10 | 标准仓、本体对象库、findings、事实契约、门户、仿真合页服务与资料包、三维资产、仿真回放资产、治理清单交付件 | 全 OK |
 | 原始件目录 | 1 | 原始件目录存在(无数据时此项不影响页面) | 全 OK |
@@ -673,7 +674,7 @@
 
 本章有两个词要先定义。「本场」指**当前被选中的场**,由配置选定,不必然等于本文取数的样本风电场;「换场」指从一场切到另一场运行的整套动作(改配置、放数据、重跑、按检查表复验),不是把两场的数据混在一棵树里。本章沿用的占位符与附录 A 一致:`<场>`、`<场站>`、`<机型>` 按实际风电场替换。
 
-本章需求与《数据要求说明_观澜_2.11.9.docx》《系统设计说明_观澜_2.11.9.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
+本章需求与《数据要求说明_观澜_2.12.0.docx》《系统设计说明_观澜_2.12.0.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
 
 取证边界要如实写一句:FR-44 至 FR-51 与 NFR-12 有既有的场配置层、摄入层与场级字段语义登记作依据;FR-52 的跨场横向对比是本次新增的口径要求,其实跑记录与机器守卫随后续版本补齐——本文不谎称已经通过。
 
@@ -752,7 +753,7 @@
 
 | 编号 | 需求 | 判据 | 依据文件 |
 |---|---|---|---|
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.11.9.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.12.0.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
 
 ### 11.9 换场验收需求
 
@@ -895,8 +896,8 @@
 | 场定义与机型与物理约束参数的写法样例 | configs/farms/<场>.yaml |
 | 机型—场站数据契约与字段单位真源 | configs/contracts/<机型>_<场>.yaml 与 configs/canonical/dictionary.yaml |
 | 场级字段语义登记与其它厂商导出形态的对齐 | configs/farms/<场>/field_semantic_registry.yaml |
-| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.11.9.md |
-| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.11.9.md |
+| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.12.0.md |
+| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.12.0.md |
 
 表中 `<场>`、`<场站>`、`<机型>` 为占位符,按实际风电场替换。
 
@@ -939,4 +940,4 @@
 | 换场 | — | 从一场切到另一场运行的整套动作:改配置、放数据、重跑、按检查表复验 | 本文 §11.9 |
 | 缺族降级 | — | 某族数据缺失时按缺件如实标注并降低该族功能,不造数、不用他场数字顶替 | 本文 FR-49 |
 | 按场标定 | — | 把阈值、时间窗锚点、限电窗、温度档带宽等场相关参数按本场取值 | 本文 FR-50 |
-| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.11.9.md |
+| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.12.0.md |

BIN
docs/数据要求说明_观澜_2.11.9.docx


BIN
docs/数据要求说明_观澜_2.12.0.docx


+ 6 - 5
docs/版本记录.md

@@ -7,7 +7,7 @@
 <!-- VERSION-LOG:BEGIN -->
 ## 版本号规则(用户令 2026-09-17)
 
-编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.11.9`。
+编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.12.0`。
 
 | 级别 | 含义 |
 |---|---|
@@ -17,12 +17,13 @@
 
 **怎么改**:改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级
 
-**打包文件名** = `app_guanlang_v2.11.9.zip`(规则:`app_guanlang_v<版本号>.zip`)。
+**打包文件名** = `app_guanlang_v2.12.0.zip`(规则:`app_guanlang_v<版本号>.zip`)。
 
-版本号只写在 `src/version.py` 一行(`VERSION = '2.11.9'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
+版本号只写在 `src/version.py` 一行(`VERSION = '2.12.0'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
 
 | 版本 | 日期 | 级别 | 主要变更 | 说明 |
 |---|---|---|---|---|
+| `v2.12.0` | 2026-09-28 | 中 | 模块化重构收官 P7:质量门与打包安装迁移(23 件 / 7,426 行)——七个模块齐备 | 重构收官的架构里程碑(七个模块目录 + 公开面 + 兼容壳齐备)⇒ 中版本 +1。P7 把质量门与打包安装 23 件 / 7,426 行迁入 app_qualityGate/common/app_qualityGate_guanlan/{audits,docs,pack}/;旧路径 scripts/<件>.py 留 CLI 壳(公开名同面转发 + main(),guanlan.py check 的 importlib 载入照旧)。**根引导脚本 install.ps1/install.sh/uninstall.*/check.bat/pack.*/start|stop.bat 原地不动**("解压即用"引导层,其 Python 实现已迁入模块)。实逮: ① 子包内 __file__ 取根(parents[1])失效 22 处 ⇒ 统一 install_root();② 文件间 bare import 4 处改相对导入;③ audit_chinese_terms 的 here.parent 是仓库根语义 ⇒ 换 install_root();④ 补 P4 遗留:该审计器被审件 scripts/windscada_serve.py 早已是壳 ⇒ 改读实现位置。验收: 8 条质量门 stdout 逐字节等于迁移前 + 23 个 CLI 壳 importlib 公开名同面 + 新路径 -m 可跑 + 打包器从新位置出包并开箱验证 5/5 + 卸载核验通过 + guanlan.py check 全绿。至此 P0–P7 全部完成:公共/数据接入/算法/本体/前端/后端/质量门各一个目录与 api 公开面,旧路径全部留兼容壳。 |
 | `v2.11.9` | 2026-09-28 | 小 | 模块化重构 P6:本体与知识层实体迁移(58 模块 / 19,775 行) | 重构推进(源码组织层)⇒ 小版本 +1。本体 20 件 + SOP 38 件迁入 app_ontology/common/app_ontology_guanlan/{ontology,sop}/;旧路径 src/ontology/<件>.py、src/sop/<件>.py 全部留兼容壳 (模块别名 + `python -m` 经 runpy 重跑实现,重算链 ⑦ 步命令不变)。实逮: ① 包内 `from .. import paths`(23 处)改绝对路径;② 19 处 __file__ 取根改 install_root();③ `-m` 入口必须由壳 runpy.run_module(run_name="__main__") 兜住;④ 按源码路径读本层的审计器与登记表(audit_chinese_terms、check_portability 的 ALLOW 键、products_reverse_audit 的生成端、configs/{registry,terms/jargon_rules}.yaml)同步改位置。验收: 58/58 模块新旧路径同体导入 + 取根抽查正确 + 十条本体/SOP 路由响应体逐字节等于迁移前 + `-m` 旧入口冒烟通过 + 全门禁 rc=0 + 开箱验证 5/5。 |
 | `v2.11.8` | 2026-09-28 | 小 | 消缺:guanlan.py stop 在 pids.json 读不动/删不掉时抛异常中断 | 消缺(不改行为口径,只修坏掉的路径)⇒ 小版本 +1。根因: `cli.py::cmd_stop()` 直接 `PIDS.unlink()`,而**服务宿主与启动器并存**时 run/pids.json 可能已被清掉、被句柄占用或读到一半;2026-09-28 远端同步 2.11.7 时实逮:`guanlan.py stop` 以 FileNotFoundError 中断,组件其实已停掉。修法: 读记录失败按空记录处理(如实报一句);删记录失败只报一句,不影响停机结果与退出码;`v.get("pid")` 容忍记录缺字段。回归: pids.json 不存在 / 是目录(读+删都失败)/ 内容损坏 三种情形均不抛异常且 rc=0。验收: 本机 `guanlan.py stop` 正常停六个组件并删记录、再跑一次给友好提示 rc=0;三情形回归 3/3 通过;全门禁 rc=0。 |
 | `v2.11.7` | 2026-09-28 | 小 | 模块化重构 P5:前端层实体迁移(13 件;经典页模板从 serve.py 抽出) | 重构推进(源码组织层)⇒ 小版本 +1。前端层迁入 app_frontEnd/common/app_frontEnd_guanlan/:assets/{app.js,app.css,charts.js,favicon.svg,classic_chart.js}、pages/{shell.html,build.py,snapshot.py,classic.py,classic_*.html}、design.py、lang.py、terms.py(经用户裁:语言包与显示层术语归前端)、builders/{portal_build,portal_fix_anchors,portal_inject_claims}.py。serve.py 的经典页模板(CHART_JS/PAGE_FLEET/PAGE_PROBLEM/PAGE_TURBINE,约 246 KB)抽成真资源件 + 只读装载器,serve.py 5549 → 2707 行,模板值逐字节一致。旧路径全部留壳(5 个模块别名壳 + 3 个 CLI 壳,python -m 照旧)。实逮:① 资源件无壳可留 ⇒ 六个按路径读它们的审计器/登记表同步改;② 子目录取根层数变 ⇒ 统一 install_root();③ 模板装载必须 newline=""(否则换行翻译改掉字节);④ 搬迁中被工具改写的换行风格要按 HEAD blob 对齐,避免整文件伪 diff。验收:build.render 四组合与 design 两段 CSS 逐字节等于迁移前;四个模板常量与三张经典页渲染逐字节一致;portal_build --verify 与 20.19 MB 门户逐字节一致;服务重启后门户//detail/v2//ops//cms/ 页面体 sha256 一致;全门禁 rc=0;开箱验证 5/5。 |
@@ -52,8 +53,8 @@
 
 ## 本次交付
 
-* 版本:`v2.11.9`(观澜·如东样板 v2)
-* 交付包:`app_guanlang_v2.11.9.zip`
+* 版本:`v2.12.0`(观澜·如东样板 v2)
+* 交付包:`app_guanlang_v2.12.0.zip`
 * 服务名:`guanlan`(Windows 服务 / systemd 单元)
 * 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`
 <!-- VERSION-LOG:END -->

BIN
docs/系统设计说明_观澜_2.11.9.docx → docs/系统设计说明_观澜_2.12.0.docx


+ 4 - 3
docs/重构方案_模块化_v0.1.md

@@ -80,7 +80,7 @@ app_qualityGate/
 | `app_ontology` | `src/ontology/*`、`src/sop/*` → `ontology/`、`sop/` 两个子包(旧路径全部留兼容壳) | 58 模块 / 19,775 行 |
 | `app_backEnd` | `guanlan.py`、`scripts/{windscada_serve,guanlan_gateway,guanlan_ops,_ops_launch,_ops_run,_ops_stop_keep_gateway,service_ctl,guanlan_start_hidden}.py`、`src/windscada/{config,terms,i18n,lang,report_export}.py`、`src/windcms/{serve,orchestrator,llm,agent}.py` | 约 20 文件 |
 | `app_frontEnd` | `src/windscada/ui/{app.js,app.css,charts.js,favicon.svg,shell.html,build.py,snapshot.py}`、`src/windscada/{design,lang,terms}.py`、`windscada_serve.py` 内的页面模板(CHART_JS/PAGE_*,约 246 KB)、`scripts/{portal_build,guanlan_portal_fix_anchors,guanlan_portal_inject_claims}.py` | 13 件 / 约 3,300 行 + 246 KB 模板 |
-| `app_qualityGate` | `scripts/{*_audit,check_*,detail_deps,page_fingerprint,pack_dist,pack_verify_entry,guanlan_uninstall,delivery_docs_build,delivery_docs_figures}.py`、`install.ps1`、`install.sh`、`uninstall.*`、`check.bat` | 约 25 文件 |
+| `app_qualityGate` | `scripts/{*_audit,check_*,detail_deps,page_fingerprint,pack_dist,pack_verify_entry,guanlan_uninstall,delivery_docs_build,delivery_docs_figures}.py` → `audits/`、`docs/`、`pack/` 三个子包(旧路径留 CLI 壳;**根引导脚本 install.*/uninstall.*/check.bat/pack.* 按"解压即用"约定原地不动**) | 23 件 / 7,426 行 |
 
 ## 4 分阶段迁移(每阶段一个提交,随时可回退)
 
@@ -93,7 +93,7 @@ app_qualityGate/
 | **P4(已完成 2026-09-22)** | `app_backEnd` 迁移(21 文件 / 10,482 行;服务、网关、编排、CLI、配置与语言件) | 服务起停 + 六条路由全 200 + 全门禁 + 开箱验证 | revert 该提交 |
 | **P5(已完成 2026-09-28)** | `app_frontEnd` 迁移(资源件 / 页面装配 / 经典页模板 / 设计系统 / 文案语言包 / 门户装配器,13 件) | 模板与出页逐字节对拍 + 服务重启后逐页 sha256 一致 + 全门禁 | revert 该提交 |
 | **P6(已完成 2026-09-28)** | `app_ontology` 迁移(本体 20 件 + SOP 38 件,58 模块 / 19,775 行) | 58/58 新旧路径同体导入 + 10 条本体/SOP 路由响应体逐字节对拍 + `-m` 旧入口冒烟 + 全门禁 | revert 该提交 |
-| P7 | `app_qualityGate` 迁移(审计器、打包、安装) | 审计全绿 + 打包开箱验证 5/5 + 卸载核验 | 同上 |
+| **P7(已完成 2026-09-28)** | `app_qualityGate` 迁移(审计 17 + 文档 3 + 打包/卸载 3 = 23 件 / 7,426 行) | 8 条门 stdout 逐字节对拍 + 23 个 CLI 壳 importlib 同面 + 打包开箱 5/5 + 卸载核验 + 全门禁 | revert 该提交 |
 
 **旧路径转发壳约定**(保证兼容性):迁移后旧路径保留一个薄文件,例如
 `src/paths.py` → `from app_common.common.app_common_guanlan.paths import *  # 兼容转发,勿新增逻辑`。
@@ -126,5 +126,6 @@ app_qualityGate/
 - **P4 已完成(2026-09-22)**:后端层迁入 `app_backEnd/common/app_backEnd_guanlan/`;入口(根 `guanlan.py`、`scripts/<9 件>`)与旧库路径留壳;服务起停与六条路由实测全通;全门禁绿。
 - **P5 已完成(2026-09-28)**:前端层迁入 `app_frontEnd/common/app_frontEnd_guanlan/`(`assets/`、`pages/`、`design.py`、`lang.py`、`terms.py`、`builders/`);旧路径留壳;`serve.py` 的经典页模板抽出(值逐字节一致,行数 5549 → 2707);逐页渲染与重启后页面 sha256 对拍一致;全门禁绿。
 - **P6 已完成(2026-09-28)**:本体与 SOP 层 58 个模块迁入 `app_ontology/common/app_ontology_guanlan/{ontology,sop}/`;旧路径全部留壳(含 `python -m src.ontology.<件>` 入口);10 条本体/SOP 路由响应体与迁移前逐字节一致;全门禁绿。
-- P7 未开始;质量门与打包安装仍在既有路径,行为与产物零变化。
+- **P7 已完成(2026-09-28)**:23 个质量门/打包/卸载工具迁入 `app_qualityGate/common/app_qualityGate_guanlan/{audits,docs,pack}/`;旧路径留 CLI 壳,根引导脚本不动;8 条门 stdout 与迁移前逐字节一致;打包器从新位置出包并开箱验证 5/5 + 卸载核验;全门禁绿。
+- **P0–P7 全部完成**:七个模块(公共/数据接入/算法/本体/前端/后端/质量门)各一个目录、各一个 `api.py` 公开面、旧路径全部留兼容壳;逐阶段独立提交、随时可 revert。
 - **待裁**:英文投影层(`ui_en/i18n_en/data_tpl_en/en_text`,后端数据层英文投影在用)的模块归口。

BIN
docs/需求分析_观澜_2.11.9.docx → docs/需求分析_观澜_2.12.0.docx


+ 20 - 115
scripts/audit_chinese_terms.py

@@ -1,124 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-"""中文表达审核 (基准 = OEM 中文文档术语库 configs/terms/terms_baseline.yaml + 规则 configs/terms/jargon_rules.yaml).
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/audit_chinese_terms.py`。**本文件不得新增逻辑**。
 
-机器给候选, 人裁 —— 不自动改文案。两类输出:
-  ① 规则命中: 内部实现词/自造黑话/有更常用说法 (jargon_rules 逐条带建议写法与状态);
-  ② 无根词: 界面文本里的领域词在四家 OEM 中文文档中**一次都没出现过** → 可能是自造词 (按出现次数排, 供人裁)。
-用法: python scripts/audit_chinese_terms.py [--root .] [--out logs/terms_audit.json] [--md] [--strict]
-  --strict: 规则状态=已裁改 的条目仍命中 → exit 2 (CI 闸); 默认只报不拦。"""
+入口路径(`scripts/audit_chinese_terms.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
 from __future__ import annotations
-import argparse, collections, json, re, sys, time
-from pathlib import Path
-import yaml
-CJK_RUN = re.compile(r"[一-鿿][一-鿿0-9A-Za-z·%°/\-]{1,60}")
-TERM = re.compile(r"[一-鿿]{2,8}")
-DEFAULT_TARGETS = ["app_frontEnd/common/app_frontEnd_guanlan/lang.py",
-                       "app_frontEnd/common/app_frontEnd_guanlan/assets/app.js", "scripts/windscada_serve.py", "src/windcms/ui.py", "app_ontology/common/app_ontology_guanlan/ontology/fast_agent.py"]
 
+import pathlib
+import runpy
+import sys
 
-def strings_of(p: Path):
-    """逐行取中文串 (含行号); 跳过注释行 —— 注释不进界面."""
-    out = []
-    for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
-        s = line.strip()
-        if s.startswith("#") or s.startswith("//") or s.startswith("*"): continue
-        for m in CJK_RUN.finditer(line):
-            t = m.group(0).strip()
-            if len(t) >= 2: out.append((i, t))
-    return out
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/audit_chinese_terms.py'
 
-def main():
-    ap = argparse.ArgumentParser(); ap.add_argument("--root", default="."); ap.add_argument("--baseline"); ap.add_argument("--rules")
-    ap.add_argument("--targets", nargs="*"); ap.add_argument("--out", default="logs/audit/terms_audit.json")   # 统一: 审计流水进 logs/audit/ (用户令 2)
-    ap.add_argument("--md", action="store_true"); ap.add_argument("--strict", action="store_true"); ap.add_argument("--top", type=int, default=40); a = ap.parse_args()
-    root = Path(a.root).resolve(); here = Path(__file__).resolve().parent
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import audit_chinese_terms as _impl                                # noqa: E402
 
-    def pick(arg, name):
-        """基准/规则解析序: 显式参数 > 被审仓 configs/terms/ > 脚本同目录 (skill 自带正本)。
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
-        configs/terms/ 那一项走 `src/paths.py` 的配置取用口 (P.config_dir('terms')),
-        不再手拼配置目录字符串 —— 用户令 2「统一配置」口径见 docs §11。
-        """
-        import sys as _sys
-        _sys.path.insert(0, str(here.parent))
-        from src import paths as _P
-        cands = ([Path(arg)] if arg else []) + [_P.config_dir('terms') / name, here / name, here.parent / "configs" / "terms" / name]  # config-path-allow: 末项是 skill 自带正本
-        for c in cands:
-            if c.exists(): return c
-        raise SystemExit(f"找不到 {name} (给 --baseline/--rules 或放到 configs/terms/)")
-    bp, rp = pick(a.baseline, "terms_baseline.yaml"), pick(a.rules, "jargon_rules.yaml")
-    # 显示层映射: 会在渲染时被换掉的词, **用户看不到** → 只记为 fixed_by_display, 不算违规 (strict 不拦)。
-    try:
-        dm = yaml.safe_load(pick(None, "display_map.yaml").read_text(encoding="utf-8"))
-        DISP = sorted([(str(i["from"]), str(i["to"])) for i in (dm.get("items") or [])], key=lambda kv: -len(kv[0]))
-    except SystemExit: DISP = []
-
-    def humanized(x):
-        for u, v in DISP:
-            if u in x: x = x.replace(u, v)
-        return x
-    base = yaml.safe_load(bp.read_text(encoding="utf-8")); known = {x["term"] for x in base["items"]}
-    known_sub = set()
-    for t in known:                       # 允许基准词作为子串命中 (如「主轴承温度」含「主轴承」「温度」)
-        known_sub.add(t)
-    _rdoc = yaml.safe_load(rp.read_text(encoding="utf-8")); rules = _rdoc["items"]
-    # 豁免面: 模型提示词/内部注释/日志 —— 规则只约束**用户看得见的文本**
-    EXEMPT_PATH = [e for e in (_rdoc.get("exempt") or []) if e.get("path")]
-    EXEMPT_LINE = [(re.compile(e["line_re"]), e["why"]) for e in (_rdoc.get("exempt") or []) if e.get("line_re")]
-    for r in rules: r["_re"] = re.compile(r["pattern"])
-    targets = [root / t for t in (a.targets or DEFAULT_TARGETS)]
-    hits, unknown = [], collections.Counter(); unknown_where = collections.defaultdict(list); n_strings = 0
-    for p in targets:
-        if not p.exists(): continue
-        rel = p.relative_to(root).as_posix()
-        ex_path = next((e["why"] for e in EXEMPT_PATH if e["path"] in rel), None)
-        raw_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
-        for ln, s in strings_of(p):
-            n_strings += 1
-            for r in rules:
-                m = r["_re"].search(s)
-                if not m: continue
-                cur = raw_lines[ln - 1] if ln <= len(raw_lines) else ""
-                ctx = "\n".join(raw_lines[max(0, ln - 3):ln + 2])   # 日志调用常跨行 (print(... 换行 file=sys.stderr))
-                cpos = min([i for i in (cur.find("//"), cur.find("#")) if i >= 0], default=-1)
-                in_comment = cpos >= 0 and cur.find(m.group(0)) > cpos   # 行尾注释里的词不算界面文本
-                ex = ex_path or ("行内注释" if in_comment else None) or next((w for rx, w in EXEMPT_LINE if rx.search(ctx)), None)
-                hits.append(dict(file=rel, line=ln, text=s[:80], matched=m.group(0), exempt=ex,
-                                 fixed_by_display=(humanized(s) != s), **{k: r[k] for k in ("class", "why", "suggest", "status", "pattern")}))
-            for w in TERM.findall(s):
-                if w in known_sub: continue
-                if any(k in w for k in known_sub if len(k) >= 3): continue     # 含已知术语的复合词不算无根
-                unknown[w] += 1
-                if len(unknown_where[w]) < 3: unknown_where[w].append(f"{p.name}:{ln}")
-    ruled = [h for h in hits if h["status"] == "已裁改" and not h["fixed_by_display"] and not h["exempt"]]   # 显示层能换掉的不算违规
-    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), root=str(root), baseline=str(bp), rules=str(rp), baseline_terms=len(known), n_strings=n_strings,
-               n_rule_hits=len(hits), n_fixed_by_display=sum(1 for h in hits if h["fixed_by_display"]), n_exempt=sum(1 for h in hits if h["exempt"]), n_ruled_violations=len(ruled),
-               by_class=dict(collections.Counter(h["class"] for h in hits)),
-               rule_hits=hits, unrooted_terms=[dict(term=w, n=n, where=unknown_where[w]) for w, n in unknown.most_common(a.top)])
-    o = root / a.out; o.parent.mkdir(parents=True, exist_ok=True); o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
-    print(f"扫 {len(targets)} 文件 / {n_strings} 条中文串; 规则命中 {len(hits)} ({rep['by_class']}); 其中显示层已覆盖 {rep['n_fixed_by_display']}, 内部面豁免 {rep['n_exempt']}, 仍待处理 {sum(1 for h in hits if not h['fixed_by_display'] and not h['exempt'])}; 无根词 top{a.top} (共 {len(unknown)})")
-    for h in [x for x in hits if not x["fixed_by_display"] and not x["exempt"]][:12]: print(f"  [{h['class']}·{h['status']}] {h['file']}:{h['line']} 「{h['matched']}」 → 建议: {h['suggest'][:40]}")
-    print("  无根词:", [f"{w}×{n}" for w, n in unknown.most_common(15)])
-    if a.md:
-        L = [f"# 中文表达审核 · {rep['ts']}\n", f"基准 {len(known)} 词 (四家 OEM 中文文档实证) · 扫 {n_strings} 条界面中文串\n",
-             "## 一 规则命中 (逐条裁: 改 / 豁免)\n", "| # | 类 | 文件:行 | 命中 | 现文 | 为什么难懂 | 建议写法 | 状态 |", "|---|---|---|---|---|---|---|---|"]
-        for i, h in enumerate(hits, 1): L.append(f"| {i} | {h['class']} | {h['file']}:{h['line']} | {h['matched']} | {h['text'][:28]} | {h['why'][:34]} | {h['suggest'][:34]} | {h['status']} |")
-        L += ["\n## 二 无根词 (OEM 中文文档零出现; 可能自造, 逐条裁)\n", "| # | 词 | 次数 | 出处 | 裁决 |", "|---|---|---|---|---|"]
-        for i, u in enumerate(rep["unrooted_terms"], 1): L.append(f"| {i} | {u['term']} | {u['n']} | {' '.join(u['where'])} |  |")
-        m = o.with_suffix(".md"); m.write_text("\n".join(L) + "\n", encoding="utf-8"); print("  屏:", m)
-    if a.strict and ruled:
-        print(f"✗ {len(ruled)} 条已裁定必改、且显示层也换不掉的表达仍在界面里:"); [print("   ", h["file"], h["line"], h["matched"]) for h in ruled[:10]]; return 2
-    return 0
-
-
-if __name__ == "__main__":
-    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
-    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
-    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
-    import sys as _sys
-    for _s in (_sys.stdout, _sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 139
scripts/chain_gap_check.py

@@ -1,139 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""六层链"缺料"检查器(2026-09-17 用户令 #2 的收口件)。
-
-作用只有一个:**把"还缺什么"变成一条命令**,而不是靠人记着哪四个脚本没随包。
-
-    python scripts/chain_gap_check.py            # 人看
-    python scripts/chain_gap_check.py --check    # 只出结论(缺料 → 退出码 5)
-
-判据(全部是"文件在不在"这种可核事实,不含猜测):
-
-  A. 四步脚本是否在位 —— 壳 `src/windcms/pipeline.py::analyze` 会调它们;
-  B. 两件"标准答案"是否在位 —— `model_run_l6.parquet` / `fusion_38.csv`(逐值对拍的前提);
-  C. 台账里这一族还剩多少"✗ 无生成端"(接料后应显著下降)。
-
-取料单:`docs/向振动线取料单_v0.1.md`;接口规格:`docs/振动六层链_接口规格与缺口_v0.1.md`。
-"""
-from __future__ import annotations
-
-import argparse
-import pathlib
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-sys.path.insert(0, str(ROOT / 'scripts'))
-from src import paths as P                                              # noqa: E402
-
-# 壳里写死的四步脚本(改壳才会变;这里只是把"缺什么"列清楚)
-STEPS = [
-    ('oem_scan', 'scripts/rudong_tcm_oem_scan.py', '特征频率扫描(轴承/齿轮/保持架/叶片 1P)'),
-    ('energy_share', 'scripts/rudong_line_energy_share.py', '线能量占比'),
-    ('model_run', 'scripts/rudong_model_run.py', '六层模型出件(model_run_l6.parquet + 摘要)'),
-    ('fusion', 'scripts/rudong_fusion_run.py', '融合面出件(fusion_38.csv 等)'),
-]
-# 逐值对拍用的"标准答案"(随包件里有,但两件关键件从未随过包)
-#   ★2026-09-19: 这两件现在**本机能自算**(scripts/rudong_model_run.py / rudong_fusion_run.py, 按口径重建)。
-#     于是"文件在不在"再也不能代表"有没有标准答案" —— 本机算出来的就躺在同一个路径上。
-#     判据改成看**台账里的来路**: builder 文本含"按口径重建" ⇒ 那是自算产物, 记 [自算] 不记 [OK]。
-SAMPLES = [
-    ('model_run_l6.parquet', 'model_run 步的标准答案'),
-    ('fusion_38.csv', 'fusion 步的标准答案'),
-]
-
-
-def _selfmade(farm: str) -> set:
-    """台账里标了"按口径重建"的件 —— 它们是**本机自算**, 不是随包标准答案。"""
-    import json as _json
-    out = set()
-    f = P.out_root(farm) / '_provenance.json'
-    if not f.is_file():
-        return out
-    try:
-        files = (_json.loads(f.read_text(encoding='utf-8')) or {}).get('files') or {}
-    except Exception:
-        return out
-    for rel, v in files.items():
-        if '按口径重建' in str((v or {}).get('builder', '')) + str((v or {}).get('why', '')):
-            out.add(pathlib.Path(rel).name)
-    return out
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser(description='六层链缺料检查')
-    ap.add_argument('--check', action='store_true', help='只出结论(缺料 → rc=5)')
-    ap.add_argument('--farm', default=None)
-    a = ap.parse_args()
-    farm = a.farm or P.farm()
-    m5 = P.m5(farm)
-
-    missing_scripts = [s for s in STEPS if not (ROOT / s[1]).is_file()]
-    partial = []          # 脚本在位、但自报 complete=false ⇒ 只是"部分实现"(分母就位/分子待口径)
-    have_fusion_impl = (ROOT / 'scripts/rudong_fusion_run.py').is_file()
-    missing_samples = [s for s in SAMPLES if not (m5 / s[0]).is_file()]
-
-    print(f'== 六层链缺料检查 · 场站 {farm} ==')
-    print(f'   壳: src/windcms/pipeline.py::analyze(它按固定路径调这四步)')
-    print(f'   规格: docs/振动六层链_接口规格与缺口_v0.1.md | 取料单: docs/向振动线取料单_v0.1.md')
-    print()
-    print('A. 四步脚本:')
-    for name, rel, what in STEPS:
-        p = ROOT / rel
-        ok = p.is_file()
-        tag, extra = ('OK ', '') if ok else ('缺 ', '')
-        if ok:
-            # ★ 脚本在位 ≠ 这一步能出产物: 逆向实现可能是"分母就位、分子待口径"(自报 --status)。
-            #   这里调一次 --status 把真实状态问出来, 免得自查器给出"看起来齐了"的假结论。
-            import json as _json
-            import subprocess as _sp
-            try:
-                r = _sp.run([sys.executable, str(p), '--status'], capture_output=True, text=True,
-                            errors='replace', timeout=120)
-                st = _json.loads((r.stdout or '').strip().splitlines()[-1]) if (r.stdout or '').strip() else {}
-                if st and st.get('complete') is False:
-                    tag = '部分'
-                    extra = '(' + st.get('denominator', '') + ';缺: ' + ';'.join(st.get('missing', []) or []) + ')'
-            except Exception:
-                pass
-        if ok and tag == '部分':
-            partial.append(name)
-        if not ok and name == 'fusion' and have_fusion_impl:
-            extra = '(本包已逆向实现其中一件产物: fleet_scalar_z;源码仍缺)'
-        print(f'   [{tag}] {name:12s} {rel:44s} {what}{extra}')
-    print()
-    print('B. 逐值对拍的标准答案:')
-    selfmade = _selfmade(farm)
-    for nm, why in SAMPLES:
-        ok = (m5 / nm).is_file()
-        if ok and nm in selfmade:
-            print(f'   [自算] {nm:28s} 本机**按口径重建**的产物在位(不是标准答案: 无样件可对拍)  ({P.rel(m5 / nm)})')
-        else:
-            print(f'   [{"OK " if ok else "缺 "}] {nm:28s} {why}  ({P.rel(m5 / nm)})')
-    # 已完成的那件: 用本器自带 verify 报一句现状(不写盘)
-    fr = ROOT / 'scripts/rudong_fusion_run.py'
-    if fr.is_file():
-        print()
-        print('C. 已自研部分(fleet_scalar_z): 跑 python scripts/rudong_fusion_run.py --verify 看逐值对拍')
-    print()
-    rc = 5 if (missing_scripts or partial) else 0
-    selfmade_txt = (';两件关键产物已**按口径重建**(无标准答案可对拍: model_run_l6 / fusion_38)'
-                    if selfmade else '')
-    tail = ('四步脚本齐全且都已落地 ⇒ 六层链可跑' if not (missing_scripts or partial)
-            else (f'{len(missing_scripts)} 个步骤脚本未随包' if missing_scripts else '')
-            + (';' if (missing_scripts and partial) else '')
-            + (f'{len(partial)} 个为"部分实现"(分母就位 / 分子待口径)' if partial else '')
-            + ' ⇒ 六层链跑不动(取料单见上)') + selfmade_txt
-    print(f'   结论: {tail}')
-    if a.check:
-        print(f'[{"OK" if rc == 0 else "X"}] chain_gap_check rc={rc}')
-    return rc
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try:
-            _s.reconfigure(errors='replace')
-        except Exception:
-            pass
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/chain_gap_check.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/chain_gap_check.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/chain_gap_check.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import chain_gap_check as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 16 - 182
scripts/check_portability.py

@@ -1,195 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""跨平台/相对路径 门禁 (2026-09-11 用户令) —— 换电脑、换盘、Windows↔Linux 之前先跑它。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/check_portability.py`。**本文件不得新增逻辑**。
 
-## 三条硬规则 (ERROR = 必须修)
-
-1. **不许出现机器相关绝对路径**: `/Users/…`、`/Volumes/…`、`/home/<某人>/…`、`C:\…`/`D:\…`/`F:\…`。
-   部署到另一台电脑/另一个盘时它们必然失配 —— 这类 bug 是**静默**的 (某块页面取不到数, 不报错)。
-2. **不许出现 cwd 相对路径字面量**: `Path('outputs/…')`、`open('data/…')` 这类。
-   相对路径必须相对**安装根**, 由 `src/paths.py` 解析 (`P.store()` / `P.rel()`); 相对 cwd 会随调用位置漂移。
-3. **不许用 `os.sep` 拼库存字符串**: 写进产物/清单/页面的要用 POSIX 形式 (`P.rel()`);
-   `os.sep` 只允许出现在"给人看"的显示函数里。
-
-## 其余是 WARN (不阻塞, 但列出来慢慢收敛)
-
-4. 运行期仍写死 `ROOT / 'outputs/<场名>/…'` → 建议改 `src/paths.py` 的对应函数;
-5. 交付侧离线工具 (`scripts/guanlan_cloud_*` 等) 里的同类写法;
-6. **文本读写没写 `encoding=`** → 这是同一类"换机就坏"的坑: Python 文本 I/O 默认用**系统 locale 编码**,
-   中文 Windows 上是 cp936(GBK), 而本库的 JSON/HTML/索引全是 UTF-8。后果有两种, 都很隐蔽:
-     · 读: UTF-8 文件按 GBK 解 → 中文乱码, 或直接 UnicodeDecodeError;
-     · 写: 内容里出现 GBK 编不出的字符 (零宽空格 `\u200b`、emoji、罕见汉字) → UnicodeEncodeError 崩溃。
-   2026-09-11 从零重算时实逮: `src/ontology/store.py` 写 objects.json 崩在 `\u200b` 上 —— 也就是说
-   **本体层在中文 Windows 上根本不可用**(开发机是 UTF-8 locale, 所以一直没暴露)。
-   本项扫三种形态: `.read_text(...)` / `.write_text(...)`、`open(..., 'w')`(文本模式)、`open(p)`(无 mode = 文本读);
-   二进制模式 (`'rb'`/`'wb'`) 不算。2026-09-11 已把当时存量 18 处全部清零, 新写的代码请一律 `encoding='utf-8'`。
-
-## 例外必须**具名**登记
-
-`ALLOW` 里每一项都要写清"为什么可以例外"; 行内特例用注释 `# portability-allow: 原因`。
-
-用法:
-    python scripts/check_portability.py            # 全量扫描 (默认扫运行期 + 交付侧)
-    python scripts/check_portability.py --runtime  # 只扫运行期 (交付门禁用这个)
+入口路径(`scripts/check_portability.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import collections
 import pathlib
-import re
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-
-# 运行期代码 (服务、门禁、启动器、安装器、配置)
-RUNTIME = ['guanlan.py', 'install.ps1', 'install.sh']
-RUNTIME_GLOBS = ['src/**/*.py', 'scripts/*.py']
-# 交付侧 (离线工具; 不进服务路径, 但仍应相对、无机器路径)
-DELIVERY_GLOBS = ['scripts/sim_hub/*.py']
-
-# 机器相关绝对路径 (盘符形式要求前面是空白/引号/括号 —— 否则 'explanation:\n' 这种转义会被误判成 "n:\")
-RE_ABS = re.compile(r"""(/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*|(?<=[\s'"(\[=])[A-Za-z]:[\\/](?![\\/]))""")
-# cwd 相对路径字面量 (相对当前工作目录 —— 必须改成经 paths 解析)
-RE_CWD = re.compile(r"""(?:open|Path|pathlib\.Path)\(\s*['"](outputs|data|release|configs|src|scripts|reference|resources|logs|run|wheels|vendor)/""")
-# os.sep 出现在"拼字符串"里 (显示函数除外)
-RE_OSSEP = re.compile(r"""\+\s*os\.sep""")
-# 文本读写调用 (用于 WARN 项 6: 缺 encoding= 时按系统 locale 编解码, 中文 Windows 上是 GBK)
-# 三种形态都要逮: read_text/write_text · open(文本模式) · open(没给 mode = 文本读)
-RE_TXTIO = re.compile(r"""\.(read_text|write_text)\(|(?<![\w.])open\(""")   # portability-allow: 本门禁的规则源里必须写出这些形态
-# 二进制模式 ('rb'/'wb'/'ab'/'r+b'…) 不需要 encoding
-RE_BINMODE = re.compile(r"""['"][rwa]\+?b['"]|['"][rwa]b\+?['"]""")
-
-
-def scan_encoding(globs, extra_files=()) -> list:
-    """WARN: 找出没给 encoding= 的文本 I/O 调用。   # portability-allow: 说明文字即规则本身
-
-    覆盖 `.read_text(...)` / `.write_text(...)` / `open(..., 'w')` / `open(p)`(无 mode = 文本读);
-    二进制模式跳过。跨行调用 (如 retrieval.py 里分三行写的 write_text) 要把括号配对后再找 encoding,
-    所以不能只看一行。返回 [(相对路径, 行号, 片段)]。
-    """
-    out = []
-    files = []
-    for g in globs:
-        files += sorted(ROOT.glob(g))
-    files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
-    for f in files:
-        rel = f.relative_to(ROOT).as_posix()
-        # 与 ERROR 扫描同一份例外表: 具名登记过的文件整份跳过 (如本门禁自己 —— 它的规则/文档里
-        # 必须写出 `open('data/…')`、`.read_text(...)` 这些形态, 否则规则没法表达)
-        if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
-            continue
-        try:
-            text = f.read_text(encoding='utf-8')
-        except Exception:
-            continue
-        for m in RE_TXTIO.finditer(text):
-            i = m.end()
-            depth, j = 1, i
-            while j < len(text) and depth:
-                if text[j] == '(':
-                    depth += 1
-                elif text[j] == ')':
-                    depth -= 1
-                j += 1
-            call = text[m.start():j]
-            if 'encoding' in call or RE_BINMODE.search(call):
-                continue
-            if 'portability-allow' in text[text.rfind('\n', 0, m.start()) + 1:text.find('\n', m.start())]:
-                continue
-            out.append((rel, text.count('\n', 0, m.start()) + 1, ' '.join(call.split())[:70]))
-    return out
-
-ALLOW = {
-    'app_ontology/common/app_ontology_guanlan/sop/farm_paths.py': '它本身就是"win 盘符 → POSIX 盘"的映射表 (自带用例), 路径即测试数据',
-    'app_ontology/common/app_ontology_guanlan/sop/validate_pair.py': '其它场站 (华电/肥城) 的配置样例值, 非本场运行期路径',
-    'scripts/build_oem_lexicon.py': 'OEM 资料盘路径样例 (离线词库构建), 非运行期',
-    'scripts/ingest_ops_2025.py': '注释/文档里保留历史 mac 路径; 代码已改为 env + 安装目录相对默认',
-    'src/windscada/data_tpl_en.py': '英文映射表的**键**是页面显示形 (<安装目录>\\data\\raw\\…), 不是路径用法',
-    'src/windscada/deid_public.py': '文档字符串举例说明"产物里带着源文件路径"这一问题本身',
-    'src/windscada/maint/framework.py': '注释说明判据来源 (厂商培训分册), 非路径用法',
-    'app_ontology/common/app_ontology_guanlan/sop/discriminators.py': '证据文本里引用复跑脚本位置 (留痕), 非路径用法',
-    'app_ontology/common/app_ontology_guanlan/sop/vib_confidence.py': '证据文本 (与 discriminators 同源)',
-    'app_ontology/common/app_ontology_guanlan/ontology/retrieval.py': '文档字符串说明索引落盘位置',
-    'app_ontology/common/app_ontology_guanlan/ontology/seed_rudong29.py': '文档字符串说明产物位置',
-    'app_frontEnd/common/app_frontEnd_guanlan/pages/snapshot.py': '对外快照的**归一化改写规则** (outputs/rudong → outputs/<site>), 相对模式',
-    'src/windscada/fast_agent.py': '注释里的实测记录出处',
-    'src/paths.py': '本模块的文档字符串在讲这条约定本身',
-    'scripts/check_portability.py': '本门禁自己 (规则里必须写出这些模式)',
-    'scripts/check_transferable.py': '移植性大扫描自己 (规则里必须写出这些模式, 否则没法检出它们)',
-}
-DELIVERY_ALLOW = {
-    'scripts/sim_hub/': '开发期仿真合页生成器 (mac 检出路径), 不进交付/运行路径',
-    'outputs/rudong/sop/': '历史装配脚本随包留档 (含 mac 死路径), 非运行期; 已在文档标注',
-}
-
-
-def scan(globs, extra_files=()):
-    hits = []
-    files = []
-    for g in globs:
-        files += sorted(ROOT.glob(g))
-    files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
-    for f in files:
-        rel = f.relative_to(ROOT).as_posix()
-        if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
-            continue
-        try:
-            lines = f.read_text(encoding='utf-8').splitlines()
-        except Exception:
-            continue
-        for i, line in enumerate(lines, 1):
-            if 'portability-allow' in line or line.lstrip().startswith('#'):
-                continue                      # 行内已具名登记 / 纯注释行 (说明性文字不算路径用法)
-            for kind, rx in (('机器绝对路径', RE_ABS), ('cwd 相对路径', RE_CWD), ('os.sep 拼串', RE_OSSEP)):
-                m = rx.search(line)
-                if m:
-                    hits.append((kind, rel, i, m.group(0)[:60], line.strip()[:90]))
-    return hits
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--runtime', action='store_true', help='只扫运行期 (交付门禁)')
-    a = ap.parse_args()
-
-    print(f'根目录: {ROOT}')
-    print(f'扫描: {"运行期" if a.runtime else "运行期 + 交付侧"}\n')
-    errs = scan(RUNTIME_GLOBS, RUNTIME)
-    if not a.runtime:
-        errs += scan(DELIVERY_GLOBS)
-
-    if not errs:
-        print('  ✔ 无机器相关绝对路径、无 cwd 相对路径、未用 os.sep 拼库存字符串')
-    else:
-        for kind, rel, i, found, line in errs:
-            print(f'  [ERROR] {kind:12s} {rel}:{i}  ← {found}')
-            print(f'          {line}')
-    print(f'\n例外登记 (ALLOW, 共 {len(ALLOW)} 项 + {len(DELIVERY_ALLOW)} 类): 见本文件注释, 每项都写了理由')
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
 
-    # ── WARN: 文本读写缺 encoding= (项 6; 不阻塞, 但中文 Windows 上会乱码/崩溃) ──
-    warns = scan_encoding(RUNTIME_GLOBS, RUNTIME)
-    if not a.runtime:
-        warns += scan_encoding(DELIVERY_GLOBS)
-    if warns:
-        by_file = collections.Counter(w[0] for w in warns)
-        print(f'\n[WARN] 文本读写缺 encoding= : {len(warns)} 处 / {len(by_file)} 个文件 '
-              f'—— 默认按系统 locale 编解码 (中文 Windows = GBK), 换机/换内容就可能乱码或崩溃;')
-        for rel, n in by_file.most_common(12):
-            print(f'         {n:3d} 处  {rel}')
-        print('       修法: read_text(encoding="utf-8") / write_text(..., encoding="utf-8")')
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/check_portability.py'
 
-    if errs:
-        print(f'\n结论: {len(errs)} 处 ERROR —— 换机部署前必须清零')
-        return 1
-    print('\n结论: 通过 ✔ (路径可移植: 只有相对路径 + 经 src/paths.py 解析)')
-    return 0
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import check_portability as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"通过 ✔"这行 print 会抛
-    # UnicodeEncodeError、退出码变 1 —— 门禁报成失败而实际是通过的 (2026-09-11 踩过)。
-    sys.path.insert(0, str(ROOT))
-    from src import console
-    console.soft()
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 429
scripts/check_transferable.py

@@ -1,443 +1,29 @@
 # -*- coding: utf-8 -*-
-r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/check_transferable.py`。**本文件不得新增逻辑**。
 
-比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
-
-  ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
-     —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
-  ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
-     机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
-  ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
-  ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
-  ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
-
-用法:
-    python scripts/check_transferable.py            # 全量检查 + 结论
-    python scripts/check_transferable.py --brief    # 只报问题
-    python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
-                                                    # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
-                                                    # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
+入口路径(`scripts/check_transferable.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import json
-import os
 import pathlib
-import re
-import shutil
-import socket
-import subprocess
+import runpy
 import sys
-import tempfile
-import time
-import urllib.request
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-from src import paths as P                                            # 配置/路径唯一取用口 (2026-09-17 用户令 2)
-
-# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
-RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
-# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
-TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
-SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
-SKIP_PATH_HINT = ('_products_off_prev',)
-# 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
-# 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
-# EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
-# 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
-SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
-# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
-ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
-              'D:/guanlan', '示例', '例:', '例如')
-
-
-def walk_files():
-    for p in ROOT.rglob('*'):
-        if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
-            continue
-        rel = p.relative_to(ROOT).as_posix()
-        parts = set(p.relative_to(ROOT).parts)
-        if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
-            continue
-        if rel.startswith(SKIP_RAW_PREFIX):
-            continue
-        if rel.startswith(SKIP_RAW_PREFIX):
-            continue
-        if p.stat().st_size > 8 * 1024 * 1024:      # 超大文本(如 20MB 门户)另算, 见下
-            continue
-        yield p
-
-
-def scan_paths(verbose=True):
-    hits = []
-    for p in walk_files():
-        try:
-            text = p.read_text(encoding='utf-8', errors='replace')
-        except Exception:
-            continue
-        for i, line in enumerate(text.splitlines(), 1):
-            if any(a in line for a in ALLOW_LINE):
-                continue
-            if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
-                continue
-            m = RE_ABS.search(line)
-            if m:
-                hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
-    print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
-    if hits and verbose:
-        for rel, i, found, line in hits[:25]:
-            print(f'     {rel}:{i}  ← {found}\n        {line}')
-        if len(hits) > 25:
-            print(f'     … 另有 {len(hits) - 25} 处')
-    return hits
-
-
-def scan_big_text():
-    """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
-    out = []
-    for p in ROOT.rglob('*.html'):
-        rel = p.relative_to(ROOT).as_posix()
-        if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
-            continue
-        try:
-            t = p.read_text(encoding='utf-8', errors='replace')
-        except Exception:
-            continue
-        n = len(RE_ABS.findall(t))
-        if n:
-            out.append((rel, n, p.stat().st_size))
-    if out:
-        print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
-        for rel, n, sz in out[:10]:
-            print(f'     {rel}  {n} 处 ({sz/1e6:.1f} MB)')
-    else:
-        print('② HTML 里没有烘进的绝对路径')
-    return out
-
-
-def check_venv():
-    cfg = ROOT / '.venv' / 'pyvenv.cfg'
-    print('③ 虚拟环境')
-    if not cfg.is_file():
-        print('     [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
-        return 'absent'
-    kv = {}
-    for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
-        if '=' in l:
-            k, v = l.split('=', 1)
-            kv[k.strip()] = v.strip()          # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
-    home = (kv.get('home') or '').strip()
-    print(f'     home(基础解释器) = {home}')
-    print(f'     版本 {kv.get("version", "?").strip()}')
-    here = pathlib.Path(home).is_dir() if home else False
-    print(f'     本机存在该目录: {here}')
-    print('     ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
-          '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
-    return kv
-
-
-def check_wheels():
-    w = ROOT / 'wheels'
-    print('④ 离线依赖轮子')
-    if not w.is_dir():
-        print('     [X] 没有 wheels/ 目录 —— 装依赖需要联网')
-        return []
-    dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
-    print(f'     平台目录: {dirs or "(无)"}')
-    req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
-    req = [r for r in req if r]
-    names = {r.split('==')[0].lower().replace('-', '_') for r in req}
-    for d in dirs:
-        have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
-        miss = sorted(n for n in names if n not in have)
-        tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
-        print(f'     {d}: {len(list((w / d).glob("*.whl")))} 个轮子  {tag}')
-    plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
-    if plat not in dirs:
-        print(f'     [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
-    return dirs
-
-
-def check_vendor():
-    v = ROOT / 'vendor'
-    print('⑤ 便携运行时 / 离线件')
-    if not v.is_dir():
-        print('     [--] 没有 vendor/')
-        return
-    for p in sorted(v.rglob('*')):
-        if p.is_file():
-            print(f'     {p.relative_to(ROOT).as_posix()}  {p.stat().st_size/1e6:.1f} MB')
-    man = v / 'MANIFEST.json'
-    if man.is_file():
-        m = json.loads(man.read_text(encoding='utf-8'))
-        files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
-        undoc = sorted(files - set(m))
-        if undoc:
-            print(f'     [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
-
-
-def check_configs():
-    print('⑥ 配置与端口')
-    p = P.SERVE_JSON                      # 配置唯一取用口
-    if p.is_file():
-        c = json.loads(p.read_text(encoding='utf-8-sig'))
-        print(f'     serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
-              f'raw_dir={c.get("raw_dir")!r}')
-        for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
-            v = str(c.get(k) or '')
-            if re.match(r'^[A-Za-z]:[\\/]|^/', v):
-                print(f'     [!] {k} 是绝对路径: {v}')
-    # 写死的本机端口/入口(允许出现, 但要知道)
-    n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
-    print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
-
-
-def _free_port(start=28700, tries=40):
-    for p in range(start, start + tries):
-        with socket.socket() as s:
-            try:
-                s.bind(('127.0.0.1', p)); return p
-            except OSError:
-                continue
-    return None
-
-
-def _busy(port, host='127.0.0.1'):
-    with socket.socket() as s:
-        s.settimeout(0.3)
-        return s.connect_ex((host, port)) == 0
-
-
-IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
-                                '_products_off', '_products_off_prev_*', '*.pyc')
-
-
-def install_in(dst: pathlib.Path) -> pathlib.Path | None:
-    """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
-    print(f'   在 {dst} 里跑安装 (离线) …')
-    # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
-    # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
-    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
-           if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
-    t0 = time.time()
-    r = subprocess.run(cmd, cwd=str(dst))
-    print(f'   install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
-    py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
-    if not py.is_file():
-        print(f'   [X] 副本里没有 {py} —— 安装没成')
-        return None
-    v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
-                       capture_output=True, text=True, errors='replace')
-    print(f'   副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
-    return py
-
-
-LAST_GATEWAY_PORT: int | None = None      # run_and_probe 实际用的网关端口(卸载核验要拿它查"释放了没")
-
-
-def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
-    """在副本里起服务并核验页面 —— **完全不动正在跑的实例**: 副本整套换端口跑。
-
-    ★ 2026-09-17 改法 (现场事故驱动): 以前网关 ROUTES 的上游端口是写死的, 所以副本**必须**占用
-    18033/18020/18791/18792/64292 —— 于是"开箱验证"就得先把用户正在跑的服务停掉; 实测后果:
-    用户在验证窗口里点门户「登录」, `/detail/` 报"模块未启动 (127.0.0.1:18033)"。
-    现在 `guanlan_gateway.ROUTES` 的上游端口可被 `configs/serve.json` 覆盖, 于是副本可以**整套**
-    换到空闲端口 (网关 + 5 个组件), 主实例照常跑, 谁也不打扰谁。
-    端口占用仍会兜底: 找不到空闲端口就跳过启动核验 (不计失败)。
-    """
-    global LAST_GATEWAY_PORT
-    comp_keys = ('detail', 'cms', 'sim', 'sim_sys', 'viewer')
-    cfg_p = dst / 'configs' / P.SERVE_JSON.name          # config-path-allow: 改的是**副本**里的那份配置
-    try:
-        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
-    except Exception as e:
-        print(f'   [!] 副本配置读不出来 ({type(e).__name__}) —— 跳过启动核验'); return 0
-
-    # ① 主实例在不在: 决定要不要给副本换端口, 以及能否做"门户同字节"比对
-    main_up = not _busy(base_port)
-    gw = base_port if not _busy(base_port) else _free_port(28700)
-    if gw is None:
-        print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
-    LAST_GATEWAY_PORT = gw
-    changed = {}
-    if gw != base_port:
-        changed['gateway'] = gw
-    for k in comp_keys:
-        want = int(cfg.get(k) or 0)
-        if want and _busy(want):
-            free = _free_port(28800 + 100 * (len(changed) % 5))
-            if free is None:
-                print(f'   [!] 找不到空闲端口给组件 {k} —— 跳过启动核验'); return 0
-            changed[k] = free
-    if changed:
-        cfg.update(changed)
-        cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
-        print('   副本改用独立端口 (不打扰正在跑的实例): ' +
-              ', '.join(f'{k}={v}' for k, v in changed.items()))
-    else:
-        print('   主实例未在跑 → 副本直接用默认端口')
-
-    t0 = time.time()
-    rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
-    print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
-    ok = 0
-    try:
-        # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
-        #   门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
-        #   "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
-        exp_portal = None
-        try:
-            with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
-                exp_portal = len(rr.read())
-                print(f'   参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
-        except Exception as e:
-            print(f'   参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
-        urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
-        for path, exp_size in urls:
-            try:
-                with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
-                    body = rr.read()
-                    tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
-                    if exp_size is not None and len(body) != exp_size:
-                        # 副本换了网关端口时, 门户里的绝对地址(127.0.0.1:<端口>)会被网关改写成副本自己的
-                        # 端口 ⇒ 字节数**必然**不同。这是预期, 不是移植问题 (2026-09-17 加这句, 免得看错)。
-                        tag += f' ← 副本网关端口 {gw} ≠ 主实例 {base_port}, 门户内的绝对地址被改写, 字节不同属预期'
-                    print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
-                    ok += 1
-            except Exception as e:
-                print(f'   {path:10s} 失败: {type(e).__name__} {e}')
-        print(f'   页面可用 {ok}/{len(urls)}')
-    finally:
-        # 只停**副本**自己起的那些 (用副本里的 pids.json), 绝不碰主实例
-        subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
-    return ok
-
-
-def _files_of(p: pathlib.Path) -> int:
-    """目录/文件的件数(卸载核验里"数据原样在位"的比对面)。"""
-    if not p.exists():
-        return -1
-    return 1 if p.is_file() else sum(1 for _ in p.rglob('*') if _.is_file())
-
-
-def uninstall_probe(dst: pathlib.Path, py: pathlib.Path, gw: int | None = None) -> int:
-    """卸载核验(用户令 2026-09-17:增加卸载脚本)——在**副本**上真跑一遍卸载器。
-
-    为什么放在开箱验证里:卸载脚本这类东西"在开发机上跑一次看着没事"不算数,它要证明的是
-    **在刚装好的那份副本上**:① 计划齐全且 --dry-run 一个字节都不动;② 真卸载会把服务/运行态/
-    安装记录/快捷方式拆掉;③ **数据与产物一件不少**(默认口径不碰用户数据)。
-
-    → 返回失败条数(0 = 全过)
-    """
-    bad = 0
-    say = lambda s: print('   ' + s)                                        # noqa: E731
-    # ★ 子进程输出的编码必须点名 utf-8:卸载器写中文, Windows 上默认按 cp936 解会得到
-    #   "�ƻ���ȫ" 这种乱码(2026-09-17 实测: 核验打印成了天书, 证据就没法看了)。
-    sub_env = dict(os.environ, PYTHONUTF8='1')
-    r = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--selftest'], cwd=str(dst),
-                       capture_output=True, text=True, encoding='utf-8', errors='replace',
-                       env=sub_env, timeout=600)
-    for ln in (r.stdout or '').strip().splitlines():
-        if '[FAIL]' in ln or '结论' in ln or '[OK]' in ln:
-            say(ln.strip())
-    if r.returncode != 0:
-        bad += 1
-        say('[X] 卸载器 --selftest 未通过')
-    keep = [d for d in ('data', 'outputs') if (dst / d).exists()]
-    before = {d: _files_of(dst / d) for d in keep}
-    r2 = subprocess.run([str(py), 'scripts/guanlan_uninstall.py', '--yes'], cwd=str(dst),
-                        capture_output=True, text=True, encoding='utf-8', errors='replace',
-                        env=sub_env, timeout=900)
-    tail = (r2.stdout or '').strip().splitlines()
-    for ln in tail[-8:]:
-        say(ln.rstrip())
-    if r2.returncode != 0:
-        bad += 1
-        say(f'[X] 卸载器退出码 {r2.returncode}')
-    checks = [('安装记录 install-info.json 已删', not (dst / 'install-info.json').exists()),
-              ('运行态 run/ 已清', not (dst / 'run').exists()),
-              ('快捷方式已撤', not list(dst.glob('*.lnk')))]
-    for d in keep:
-        checks.append((f'数据 {d}/ 原样在位({before[d]} 件)', _files_of(dst / d) == before[d]))
-    if gw:
-        checks.append((f'卸载后端口 {gw} 已释放', not _busy(gw)))
-    for name, good in checks:
-        say(('[OK] ' if good else '[X] ') + name)
-        bad += 0 if good else 1
-    return bad
-
-
-def simulate(into=None, keep=False) -> int:
-    """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
-    dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
-    if dst.exists():
-        print(f'  清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
-    print(f'① 拷副本 → {dst}   (排除 data/.git/.venv/暂存区/logs/run)')
-    t0 = time.time()
-    shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
-    n = sum(1 for _ in dst.rglob('*') if _.is_file())
-    mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
-    print(f'   完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
-
-    # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
-    prod_dst = dst / 'outputs' / 'rudong'
-    if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
-        for src in sorted(ROOT.glob('_products_off*/rudong')):
-            if (src / 'windscada' / 'alarms.parquet').is_file():
-                print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
-                shutil.copytree(src, prod_dst, dirs_exist_ok=True)
-                break
-        else:
-            print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
-    else:
-        print('② 副本已带产物 (从主包拷来)')
-
-    print('③ 在副本里跑安装 (离线)')
-    py = install_in(dst)
-    if py is None:
-        return 1
-
-    print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
-    ok = run_and_probe(dst, py)
-
-    print('⑤ 收尾')
-    if keep:
-        print(f'   副本保留在 {dst} (--keep); 用完手工删掉即可')
-    else:
-        shutil.rmtree(dst, ignore_errors=True)
-        print('   副本已删除')
-    print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
-    return 0
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/check_transferable.py'
 
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--brief', action='store_true')
-    ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
-    ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
-    ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
-    a = ap.parse_args()
-    print(f'移植性大扫描 @ {ROOT}\n')
-    if a.simulate:
-        rc = simulate(a.into, a.keep)
-        print()
-    hits = scan_paths(verbose=not a.brief)
-    big = scan_big_text()
-    kv = check_venv()
-    dirs = check_wheels()
-    check_vendor()
-    check_configs()
-    bad = len(hits) + len(big)
-    print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
-          f' 虚拟环境/轮子/便携运行时见上面各节 —— '
-          f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
-    return 0 if not bad else 1
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import check_transferable as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 262
scripts/config_audit.py

@@ -1,262 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""配置审计 —— 把"配置目录/文件统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
-
-## 查什么 (每条都能指到具体文件/代码行)
-
-    R1 顶层约定    configs/ 顶层只许放 `src/paths.py::TOP_LEVEL_CONFIGS` 里的运行期单件配置 +
-                   已登记的域目录 + README.md/registry.yaml。散件一律报出来。
-    R2 备份垃圾    任何 `*.bak*` `*~` `*.old` `*.orig` `*.tmp` `*.rej` 都是垃圾 (实测 configs/ 里
-                   躺过一个 `serve.json.bak-bomfix`)。
-    R3 命名        配置名不得含空格/中文; 全大写视为"命名不统一"(提示级, 因为场名/机型名有历史约定)。
-    R4 内容绝对路径 配置**内容**里不得出现本机绝对路径 (`C:\` `D:\` `F:\` `/Users/…` `/Volumes/…` `/home/<人名>`)。
-    R5 引用闭合    代码里 `P.config('x')` / `P.config_dir('x')` 指向的文件/目录必须存在; 不存在且**未登记**
-                   在 `configs/registry.yaml::known_missing` 的, 按"悬空引用"报错 (登记过的记 `i`)。
-    R6 手拼路径    代码里不许再拼 `"configs" / …` 字符串 (白名单: src/paths.py 自身、审计脚本、注释/文档串)。
-    R7 无读者      configs/ 下每个文件要么被登记表的 consumers 覆盖, 要么所在域声明了 `no_reader_ok` + 理由。
-    R8 场配置      `configs/farms/` 下每个文件要么是**能加载的场定义**(必需键齐), 要么在登记表里
-                   声明为"机型/物理约束 profile"或"CSV 数据表" —— 否则就是"配了看不见"的东西。
-
-## 退出码 (给 check / rebuild_all 用)
-    0 全部通过 · 5 结构问题(散件/垃圾/未登记域/未登记文件) · 6 悬空引用 · 7 内容含本机绝对路径 · 8 手拼路径 · 9 场配置不合约定
-
-## 用法
-    python scripts/config_audit.py            # 表 + 检查
-    python scripts/config_audit.py --list     # 只打配置目录表
-    python scripts/config_audit.py --json
-"""
-from __future__ import annotations
-
-import argparse
-import fnmatch
-import json
-import os
-import pathlib
-import re
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import paths as P                                          # noqa: E402
-
-REGISTRY = P.config('registry.yaml')
-CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv', '.example', '.md')
-JUNK = ('*.bak', '*.bak-*', '*~', '*.old', '*.orig', '*.tmp', '*.rej', '*.swp')
-RE_JUNK = re.compile(r'\.(?:bak|old|orig|tmp|rej|swp)(?:$|[-.])|~$')
-RE_BADNAME = re.compile(r'[\s]')                       # 空格会切断脚本参数, 一律不许
-RE_CN_NAME = re.compile(r'[\u4e00-\u9fff]')            # 中文文件名: 项目里大量存在, 只提示不判错
-RE_ABS = re.compile(r'(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*)')
-# 配置调用的参数可能不止一个: P.config('value_assumptions', f'{farm}.yaml') —— 要把字面量参数全抓下来拼成相对路径
-RE_CONFIG_CALL = re.compile(r'P\.config(?:_dir)?\(([^)]*)\)')
-RE_STR_ARG = re.compile(r'[\'"]([^\'"]+)[\'"]')
-RE_HANDMADE = re.compile(r'''(?:ROOT|root|REPO|here|dst)\s*/\s*['"]configs['"]|['"]configs/[\w.-]+['"]''')
-# 说明性字段 (记录"当时数据在哪台机器上") 里的本机路径算**溯源信息**, 不算 bug —— 但只限注释行与这些键
-RE_DESCRIPTIVE_KEY = re.compile(r'^\s*#|^\s*(?:data_location|data_source|source_root|source_note|note|comment|说明|来源)\s*:')
-
-SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
-             'outputs', 'data', 'logs', 'run', 'release', 'resources', 'reference'}
-
-
-def load_registry():
-    import yaml
-    if not REGISTRY.is_file():
-        raise SystemExit(f'缺配置登记表 {P.rel(REGISTRY)}')
-    return yaml.safe_load(REGISTRY.read_text(encoding='utf-8')) or {}
-
-
-def cfg_files():
-    out = []
-    for dp, dn, fns in os.walk(P.CONFIGS):
-        dn[:] = [d for d in dn if d not in {'__pycache__'}]
-        for f in fns:
-            out.append(pathlib.Path(dp) / f)
-    return sorted(out)
-
-
-def py_files():
-    out = []
-    for dp, dn, fns in os.walk(ROOT):
-        dn[:] = [d for d in dn if d not in SKIP_WALK]
-        out += [pathlib.Path(dp) / f for f in fns if f.endswith('.py')]
-    return out
-
-
-def audit():
-    """→ (rc, results, info)。results = [(level, item, note, rc)]。"""
-    reg = load_registry()
-    res: list[tuple[str, str, str, int]] = []
-    top_ok = {t['file'] for t in reg.get('top_level') or []}
-    domain_dirs = {d['dir'] for d in reg.get('domains') or []}
-    known_missing = set()
-    known_why = {}
-    for m in reg.get('known_missing') or []:
-        for name in [m['file']] + list(m.get('also_missing') or []):
-            known_missing.add(name)
-            known_why[name] = m.get('impact', '')
-
-    # ── R1/R2/R3/R4: 文件层
-    for f in cfg_files():
-        rel = f.relative_to(P.CONFIGS).as_posix()
-        if f.name == 'registry.yaml' or f.suffix == '.md':
-            continue
-        if '/' not in rel:
-            if f.name not in top_ok:
-                res.append(('X', rel, 'configs/ 顶层只许放已登记的运行期单件配置 (见登记表 top_level)', 5))
-        else:
-            d = rel.split('/')[0]
-            if d not in domain_dirs:
-                res.append(('X', rel, f'所在域 {d}/ 未在登记表登记 (新域要写明 kind + consumers)', 5))
-        if RE_JUNK.search(f.name) or any(fnmatch.fnmatch(f.name, j) for j in JUNK):
-            res.append(('X', rel, '备份/垃圾文件 (配置文件只留正本; 改历史靠 git)', 5))
-        if RE_BADNAME.search(f.name):
-            res.append(('X', rel, '文件名含空格 —— 空格会切断脚本参数, 配置名一律用 [a-z0-9_.-]', 5))
-        elif RE_CN_NAME.search(f.name):
-            res.append(('?', rel, '文件名含中文 (项目里常见, 不是错; 注意某些工具/编码环境会踩)', 0))
-        elif re.search(r'[A-Z]', f.stem) and '/' in rel:
-            res.append(('?', rel, '文件名含大写 (历史约定, 不是错; 新文件建议小写)', 0))
-        if f.suffix.lower() in ('.yaml', '.yml', '.json', '.csv'):
-            try:
-                body = f.read_text(encoding='utf-8-sig', errors='replace')
-            except OSError:
-                body = ''
-            hit_desc = hit_code = None
-            for ln in body.splitlines():
-                m = RE_ABS.search(ln)
-                if not m:
-                    continue
-                if RE_DESCRIPTIVE_KEY.match(ln):
-                    hit_desc = hit_desc or m.group(0)
-                else:
-                    hit_code = hit_code or m.group(0)
-            if hit_code:
-                res.append(('X', rel, f'内容含本机绝对路径 {hit_code[:24]!r} (在**被读取的字段**里 ⇒ 换机必失配)', 7))
-            elif hit_desc:
-                res.append(('?', rel, f'说明性字段/注释里记了本机路径 {hit_desc[:24]!r} —— 属溯源信息(当时数据在哪台机器), 不算失配', 0))
-
-    # ── R5/R6: 代码层
-    seen_conf = set()
-    for p in py_files():
-        t = p.read_text(encoding='utf-8', errors='replace')
-        if p.name != 'config_audit.py':            # 本脚本的说明文字里就有 P.config('x') 之类的例子, 别自证其罪
-            for args in RE_CONFIG_CALL.findall(t):
-                parts = RE_STR_ARG.findall(args)
-                if not parts:
-                    continue
-                rel_call = '/'.join(parts).replace('\\', '/')
-                # f-string 参数 (P.config(f'analysis_lock_{farm}.yaml')) 在源码里是字面量带花括号:
-                # 把 {…} 当通配符, 只要有任一匹配文件就算"在位", 否则报缺。
-                if '{' in rel_call:
-                    import glob as _glob
-                    pat = str(P.CONFIGS / re.sub(r'\{[^}]*\}', '*', rel_call))
-                    if _glob.glob(pat):
-                        continue
-                    wildcard = rel_call
-                    known = known_missing | {k.replace('<场>', '*') for k in known_missing}
-                    if any(fnmatch.fnmatch(wildcard, k) for k in known):
-                        res.append(('i', f'{P.rel(p)} → configs/{wildcard}',
-                                    '悬空引用(已知): per-场配置未随包 (见登记表 known_missing)', 0))
-                    else:
-                        res.append(('X', f'{P.rel(p)} → configs/{wildcard}',
-                                    '代码引用的 per-场配置一个都不存在, 且未登记 ⇒ 补文件或删引用', 6))
-                    continue
-                seen_conf.add(rel_call)
-                target = P.CONFIGS / rel_call
-                if target.exists():
-                    continue
-                if rel_call in known_missing or any(rel_call.startswith(k.replace('<场>', '')) for k in known_missing):
-                    res.append(('i', f'{P.rel(p)} → configs/{rel_call}',
-                                f'悬空引用(已知): {known_why.get(rel_call, "见登记表 known_missing")[:80]}', 0))
-                else:
-                    res.append(('X', f'{P.rel(p)} → configs/{rel_call}',
-                                '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6))
-        for i, ln in enumerate(t.splitlines(), 1):
-            s = ln.strip()
-            # 豁免"配置取用口自己的定义文件": 原先按旧路径 src/paths.py 判, P1 迁移后它搬到
-            # app_common/common/app_common_guanlan/paths.py —— 改成**按取用口所在文件**判, 位置无关。
-            import sys as _s
-            _accessor = _s.modules.get(P.config.__module__)
-            _accessor_file = pathlib.Path(_accessor.__file__).resolve() if getattr(_accessor, '__file__', None) else None
-            if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py' \
-                    or (_accessor_file is not None and p.resolve() == _accessor_file):
-                continue
-            if 'config-path-allow' in ln or 'portability-allow' in ln:
-                continue
-            if RE_HANDMADE.search(ln):
-                res.append(('!', f'{P.rel(p)}:{i}', f'手拼 configs 路径: {s[:70]} ⇒ 改用 P.config()/P.config_dir()', 8))
-
-    # ── R7: 无读者
-    declared = json.dumps(reg, ensure_ascii=False)
-    for d in reg.get('domains') or []:
-        if (d.get('consumers') or []) or d.get('no_reader_ok'):
-            continue
-        res.append(('X', f'{d["dir"]}/', '未声明 consumers, 也没写 no_reader_ok + 理由', 5))
-    for f in cfg_files():
-        rel = f.relative_to(P.CONFIGS).as_posix()
-        if '/' not in rel or f.suffix == '.md' or f.name in top_ok:
-            continue
-        # 逐件"有没有人提过它"太吵 (170 件里绝大多数靠域级 consumers 覆盖) —— 只查**域级声明**。
-        # 域级没声明 consumers 又没写 no_reader_ok 的, 由上面的域检查报出来。
-        _ = rel
-
-    # ── R8: 场配置
-    from src.windscada import config as WC
-    for f, keys in WC.foreign_farm_files():
-        res.append(('i', f'configs/farms/{f.name}', f'非场定义 (顶层键 {keys}) ⇒ 不参与场加载, 已登记为机型/物理约束 profile', 0))
-    farm_dom = next((d for d in reg.get('domains') or [] if d['dir'] == 'farms'), {})
-    n_prof = sum(1 for f, _ in WC.foreign_farm_files())
-    declared_prof = next((s for s in (farm_dom.get('subkinds') or []) if 'profile' in s.get('name', '')), {})
-    if declared_prof and int(declared_prof.get('present', -1)) not in (-1, n_prof):
-        res.append(('X', 'configs/farms/', f'登记表写 profile {declared_prof.get("present")} 个, 实际 {n_prof} 个', 9))
-    for name, src in WC.available().items():
-        if src != '内置' and not (P.ROOT / src).is_file():
-            res.append(('X', f'configs/farms/{name}', f'available() 列出了 {src}, 但文件不在', 9))
-
-    info = dict(config_files=len(cfg_files()),
-                domains=len(domain_dirs),
-                top_level=sorted(top_ok),
-                known_missing=sorted(known_missing),
-                farm_defs=list(WC.available()),
-                farm_profiles=n_prof)
-    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
-    return (max(rc_map) if rc_map else 0), res, info
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--list', action='store_true', help='只打配置目录表')
-    ap.add_argument('--json', action='store_true')
-    a = ap.parse_args()
-    rc, res, info = audit()
-    if a.json:
-        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
-                              info=info), ensure_ascii=False, indent=1))
-        return rc
-    if a.list or True:
-        print('== 配置目录约定 (configs/registry.yaml) ==')
-        print(f'   运行期单件: {", ".join(info["top_level"])}')
-        for d in load_registry().get('domains') or []:
-            print(f'   {d["dir"]:20s} {d.get("kind", "")[:52]}')
-        print(f'   配置文件合计 {info["config_files"]} 件 · 域 {info["domains"]} 个 · '
-              f'场定义 {len(info["farm_defs"])} 个 · 机型 profile {info["farm_profiles"]} 件')
-    if not a.list:
-        lvl = {}
-        for level, item, note, r in res:
-            lvl[level] = lvl.get(level, 0) + 1
-        print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · '
-              f'已知缺口 {lvl.get("i", 0)} ==')
-        for level, item, note, r in res:
-            if level in ('X', '!'):
-                print(f'   [{level}] {item}: {note}')
-        seen = set()
-        for level, item, note, r in res:
-            if level == 'i' and note not in seen:
-                seen.add(note)
-                print(f'   [i] {item}: {note}')
-        print(f'结论: {"全部符合约定" if rc == 0 else "见上"}; 退出码 {rc}')
-    return rc
-
-
-if __name__ == '__main__':
-    from src import console
-    console.soft()
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/config_audit.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/config_audit.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/config_audit.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import config_audit as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 694
scripts/delivery_docs_build.py

@@ -1,694 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""交付文档构建器:`docs/src/*.md` → `docs/*.docx`(用户令 2026-09-22)。
-
-## 为什么要有它(而不是拿 Word 手排)
-用户令要求三份文档「区分章节目录、文表图并茂、字体字号分类统一」。手排的三份文档**必然**字体字号漂移、
-图表编号对不上、改一处忘一处。所以内容用 markdown 写(可 diff、进版本库),排版由本器**一套样式表**统一执行:
-
-| 元素 | 字体 | 字号 | 对齐 / 缩进 |
-|---|---|---|---|
-| 封面标题 | 黑体 | 小一(26pt) | 居中 |
-| 章标题(一级 `##`) | 黑体 | 三号(16pt) | 左对齐,**章前分页** |
-| 节标题(二级 `###`) | 黑体 | 四号(14pt) | 左对齐 |
-| 小节标题(三级 `####`) | 黑体 | 小四(12pt) 加粗 | 左对齐 |
-| 正文 | 宋体 | 小四(12pt) | 首行缩进 2 字符、1.5 倍行距、两端对齐 |
-| 列表 | 宋体 | 小四(12pt) | 悬挂缩进 |
-| 表题(表上方) | 黑体 | 五号(10.5pt) | 居中 |
-| 表头 | 黑体 | 五号(10.5pt) 加粗 | 居中 + 浅灰底纹 + 跨页重复 |
-| 表格正文 | 宋体 | 五号(10.5pt)(≥6 列降 9pt) | 左对齐 |
-| 图(居中)与图题(图下方) | 黑体 | 五号(10.5pt) | 居中 |
-| 页眉 / 页脚页码 | 宋体 | 小五(9pt) | 居中 |
-
-英文与数字用 Times New Roman(中式公文惯例:中文宋体/黑体 + 西文 Times New Roman)。
-
-## markdown 约定(渲染器支持的子集,写手必须照此写)
-    # 文档标题                       (只此一处,进封面)
-    ## 1 章 / ### 1.1 节 / #### 1.1.1 小节
-    普通段落(一行一段,段间空行)
-    - 无序项           1. 有序项
-    | 表头 | 表头 |   +  |---| 分隔行           → 表格
-    **表 3-1 标题**(紧挨表格上方一行)            → 表题
-    ![图 3-1 标题](figures/xxx.png)                → 图片 + 图题
-    > 引用段
-    ---                                            → 忽略
-
-## 用法
-    python scripts/delivery_docs_build.py                 # 三份全渲染
-    python scripts/delivery_docs_build.py --only req      # 只渲染一份 (req/des/dat)
-    python scripts/delivery_docs_build.py --check         # 只体检源件(章/节/表/图/字数、图是否在位)
-    python scripts/delivery_docs_build.py --spec          # 打印本器的排版规范表
-
-退出码: 0 全部成功 · 5 有源件缺件/图缺件(逐条打印,仍会尽力渲染其余内容)
-"""
-from __future__ import annotations
-
-import argparse
-import pathlib
-import re
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-
-from docx import Document                                                    # noqa: E402
-from docx.enum.section import WD_SECTION                                     # noqa: E402
-from docx.enum.table import WD_TABLE_ALIGNMENT                               # noqa: E402
-from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING               # noqa: E402
-from docx.oxml import OxmlElement                                            # noqa: E402
-from docx.oxml.ns import qn                                                  # noqa: E402
-from docx.shared import Cm, Pt, RGBColor                                     # noqa: E402
-
-VERSION = __import__('importlib').import_module('src.version').VERSION
-
-HEI, SONG, WEST = '黑体', '宋体', 'Times New Roman'
-SZ = dict(cover=26, h1=16, h2=14, h3=12, body=12, table=10.5, table_small=9, caption=10.5, header=9)
-
-DOCS = {
-    'req': dict(src='docs/src/需求分析_观澜_{v}.md', out='docs/需求分析_观澜_{v}.docx',
-                title='需求分析', subtitle='观澜 v2 · 风电场智能分析系统'),
-    'des': dict(src='docs/src/系统设计说明_观澜_{v}.md', out='docs/系统设计说明_观澜_{v}.docx',
-                title='系统设计说明', subtitle='观澜 v2 · 风电场智能分析系统'),
-    'dat': dict(src='docs/src/数据要求说明_观澜_{v}.md', out='docs/数据要求说明_观澜_{v}.docx',
-                title='数据要求说明',
-                subtitle='观澜 v2 · 风电场智能分析系统(结合现场《数据分析收资要求-v3》)',
-                # ★2026-09-22 用户令: 这份是**纯数据需求规格** —— 不体现样本场接入情况, 也不体现实现细节。
-                #   下面这组模式由 `--check`/`--verify` 机器扫描, 命中即 FAIL(不靠人自觉)。
-                strict_terms=True),
-}
-
-# 数据要求说明的"不得出现"模式(用户令 2026-09-22):
-#   英文测点名称 / 文件名称 / 落盘位置 / 机组名称编号 / 样本场现状字样。
-#   中文业务含义名称与「中文(英文简写)」(如 平均无故障间隔(MTBF))不在禁止之列。
-STRICT_PATTERNS = (
-    ('英文测点标识(snake_case)', r'\b[a-z][a-z0-9]*_[a-z0-9_]+\b'),
-    ('文件扩展名', r'\.(csv|parquet|json|yaml|yml|xlsx|xls|pdf|mdb|xml|docx|doc)\b'),
-    ('目录/落盘位置', r'(data/raw|outputs/|configs/|scripts/|reference/|docs/|data\\)'),
-    ('机组名称/编号', r'(WTG\d+|\b\d{2}[A-Z]\b)'),
-    ('样本场现状字样', r'(已在位|未到位|现状|实测|样本场|本场|催缴)'),
-)
-
-# ★去标识化词表(用户令 2026-09-22「内容参考如东风电场,但不体现如东风电场」):
-#   三份文档是**对外**交付件, 不得出现样本场的中文名、罗马化形态、业主、地域、OEM 与第三方机构名。
-#   词表放在代码里 ⇒ `--check`/`--verify` 与 `guanlan.py check` 都会机器扫描, 不靠人自觉。
-#   ★2026-09-22 补(数据要求说明起草时实逮): 样本场 CMS 导出包名是 `CMS_RuDong_CGN_202603-04`
-#   —— **大小写混合**的罗马化场名/业主缩写能绕过精确匹配,所以匹配一律**大小写不敏感**(见 forbidden_hits)。
-FORBIDDEN = ('如东', '如海', '广核', '江苏', 'rudong', 'cgn',
-             '西门子', '上海电气', 'swt', '大生', '4.0-130')
-
-SPEC_ROWS = [
-    ('封面标题', '黑体', '小一(26pt)', '居中'),
-    ('章标题(一级)', '黑体', '三号(16pt)', '左对齐,章前分页'),
-    ('节标题(二级)', '黑体', '四号(14pt)', '左对齐'),
-    ('小节标题(三级)', '黑体', '小四(12pt)加粗', '左对齐'),
-    ('正文', '宋体(西文 Times New Roman)', '小四(12pt)', '首行缩进 2 字符,1.5 倍行距,两端对齐'),
-    ('列表', '宋体', '小四(12pt)', '悬挂缩进 0.74 cm'),
-    ('表题', '黑体', '五号(10.5pt)', '居中,置于表格上方'),
-    ('表头', '黑体', '五号(10.5pt)加粗', '居中,浅灰底纹,跨页重复'),
-    ('表格正文', '宋体', '五号(10.5pt);≥6 列降 9pt', '左对齐,垂直居中'),
-    ('图', '—', '宽度 ≤ 15 cm', '居中'),
-    ('图题', '黑体', '五号(10.5pt)', '居中,置于图下方'),
-    ('页眉', '宋体', '小五(9pt)', '居中'),
-    ('页脚页码', '宋体', '小五(9pt)', '居中(第 X 页 / 共 Y 页)'),
-]
-
-WARN: list[str] = []
-
-
-# ── 低层 docx 工具 ─────────────────────────────────────────────────────────────
-def _rfonts(el, ascii_font: str, ea_font: str):
-    rpr = el.get_or_add_rPr()
-    rf = rpr.find(qn('w:rFonts'))
-    if rf is None:
-        rf = OxmlElement('w:rFonts')
-        rpr.append(rf)
-    rf.set(qn('w:ascii'), ascii_font)
-    rf.set(qn('w:hAnsi'), ascii_font)
-    rf.set(qn('w:eastAsia'), ea_font)
-
-
-def set_run(run, size: float, *, ea: str = SONG, ascii_font: str = WEST, bold: bool = False):
-    run.font.size = Pt(size)
-    run.font.bold = bold
-    run.font.name = ascii_font
-    run.font.color.rgb = RGBColor(0, 0, 0)
-    _rfonts(run._element, ascii_font, ea)
-
-
-def style_setup(doc: Document):
-    """把内置样式(Heading 1-4 / Normal / 表格)改成文档的统一口径。"""
-    normal = doc.styles['Normal']
-    normal.font.name = WEST
-    normal.font.size = Pt(SZ['body'])
-    _rfonts(normal.element, WEST, SONG)
-    normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
-    normal.paragraph_format.line_spacing = 1.5
-    normal.paragraph_format.space_after = Pt(0)
-
-    spec = {'Heading 1': (SZ['h1'], True), 'Heading 2': (SZ['h2'], True),
-            'Heading 3': (SZ['h3'], True), 'Heading 4': (SZ['h3'], True)}
-    for name, (size, bold) in spec.items():
-        st = doc.styles[name]
-        st.font.name = WEST
-        st.font.size = Pt(size)
-        st.font.bold = bold
-        st.font.italic = False
-        st.font.color.rgb = RGBColor(0, 0, 0)
-        _rfonts(st.element, WEST, HEI)
-        pf = st.paragraph_format
-        pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
-        pf.line_spacing = 1.5
-        pf.space_before = Pt(12 if name != 'Heading 1' else 0)
-        pf.space_after = Pt(6)
-        pf.first_line_indent = Pt(0)
-        if name == 'Heading 1':
-            pf.page_break_before = True
-            pf.space_after = Pt(12)
-    sec = doc.sections[0]
-    sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
-    sec.top_margin = sec.bottom_margin = Cm(2.54)
-    sec.left_margin = sec.right_margin = Cm(3.17)
-
-
-def add_field(paragraph, instr: str, placeholder: str = ''):
-    r = paragraph.add_run()
-    b = OxmlElement('w:fldChar'); b.set(qn('w:fldCharType'), 'begin')
-    i = OxmlElement('w:instrText'); i.set(qn('xml:space'), 'preserve'); i.text = instr
-    s = OxmlElement('w:fldChar'); s.set(qn('w:fldCharType'), 'separate')
-    t = OxmlElement('w:t'); t.text = placeholder
-    e = OxmlElement('w:fldChar'); e.set(qn('w:fldCharType'), 'end')
-    for el in (b, i, s, t, e):
-        r._r.append(el)
-    return r
-
-
-def para(doc, text: str = '', *, size=SZ['body'], ea=SONG, bold=False, align='left',
-         indent_chars=0, space_before=0, space_after=6, hanging=False):
-    p = doc.add_paragraph()
-    pf = p.paragraph_format
-    pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
-    pf.line_spacing = 1.5
-    pf.space_before = Pt(space_before)
-    pf.space_after = Pt(space_after)
-    pf.first_line_indent = Pt(size * indent_chars)
-    if hanging:
-        pf.left_indent = Cm(0.74)
-        pf.first_line_indent = Cm(-0.0)
-    pf.alignment = {'left': WD_ALIGN_PARAGRAPH.LEFT, 'center': WD_ALIGN_PARAGRAPH.CENTER,
-                    'justify': WD_ALIGN_PARAGRAPH.JUSTIFY}[align]
-    if text:
-        for chunk, is_bold, mono in split_bold(text):
-            r = p.add_run(chunk)
-            set_run(r, size, ea=SONG, ascii_font='Consolas' if mono else WEST, bold=bold or is_bold)
-    return p
-
-
-INLINE_RE = re.compile(r'\*\*(.+?)\*\*|`([^`]+)`')
-CODE_RE = re.compile(r'`([^`]+)`')
-BOLD_RE = re.compile(r'\*\*(.+?)\*\*')
-
-
-def _bold_parts(s: str):
-    parts, pos = [], 0
-    for m in BOLD_RE.finditer(s):
-        if m.start() > pos:
-            parts.append((s[pos:m.start()], False, False))
-        parts.append((m.group(1), True, False))
-        pos = m.end()
-    tail = s[pos:]
-    if '**' in tail:
-        # 落单的 `**`(例如通配路径 `pitch/**` 没包在行内代码里)—— 交付文档里不能留字面量,
-        # 去掉并记一条告警,让写手回去补行内代码。
-        WARN.append('落单的 ** 已剔除: ' + tail.strip()[:60])
-        tail = tail.replace('**', '')
-    if tail:
-        parts.append((tail, False, False))
-    return parts
-
-
-def split_bold(text: str):
-    """行内标记 → [(片段, 是否粗体, 是否等宽)]。
-
-    ★单遍分词,支持两种嵌套(2026-09-22 两次实逮后的定稿):
-      ① 代码里含 `**`(如通配路径 `` `pitch/**` ``)—— 不能当成粗体起始;
-      ② 粗体里含代码(如 ``**体量以 `data/raw` 为准**``)—— 不能按代码先切、把粗体配对切断。
-    先按 `**…**` 找配对,配对内部再递归扫代码;代码段内部一律**字面**取,不再解析粗体。
-    落单的 `**` 剔除并记告警(交付文档里不能留字面星号)。
-    """
-    out: list[tuple[str, bool, bool]] = []
-
-    def emit(t: str, bold: bool, mono: bool):
-        if t:
-            out.append((t, bold, mono))
-
-    def scan(s: str, bold: bool):
-        i = 0
-        while i < len(s):
-            if s.startswith('**', i):
-                j = s.find('**', i + 2)
-                if j > i + 2:
-                    scan(s[i + 2:j], True)
-                    i = j + 2
-                    continue
-                WARN.append('落单的 ** 已剔除: ' + s[max(0, i - 20):i + 20].strip())
-                i += 2
-                continue
-            if s[i] == '`':
-                j = s.find('`', i + 1)
-                if j > i:
-                    emit(s[i + 1:j], bold, True)
-                    i = j + 1
-                    continue
-            nxt = len(s)
-            for k in range(i, len(s)):
-                if s[k] == '`' or s.startswith('**', k):
-                    nxt = k
-                    break
-            emit(s[i:nxt], bold, False)
-            i = nxt
-
-    scan(text, False)
-    return out or [(text, False, False)]
-
-
-def shade(cell, fill='F2F2F2'):
-    tcPr = cell._tc.get_or_add_tcPr()
-    shd = OxmlElement('w:shd')
-    shd.set(qn('w:val'), 'clear')
-    shd.set(qn('w:color'), 'auto')
-    shd.set(qn('w:fill'), fill)
-    tcPr.append(shd)
-
-
-def repeat_header(row):
-    trPr = row._tr.get_or_add_trPr()
-    th = OxmlElement('w:tblHeader')
-    th.set(qn('w:val'), 'true')
-    trPr.append(th)
-
-
-def add_table(doc, rows: list[list[str]]):
-    ncol = max(len(r) for r in rows)
-    t = doc.add_table(rows=0, cols=ncol)
-    t.style = 'Table Grid'
-    t.alignment = WD_TABLE_ALIGNMENT.CENTER
-    t.autofit = True
-    size = SZ['table_small'] if ncol >= 6 else SZ['table']
-    for i, r in enumerate(rows):
-        cells = t.add_row().cells
-        for j in range(ncol):
-            txt = (r[j] if j < len(r) else '').strip()
-            cell = cells[j]
-            cell.text = ''
-            p = cell.paragraphs[0]
-            p.paragraph_format.line_spacing = 1.15
-            p.paragraph_format.space_after = Pt(0)
-            p.paragraph_format.first_line_indent = Pt(0)
-            p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT
-            for chunk, is_bold, mono in split_bold(txt):
-                run = p.add_run(chunk.replace('\\|', '|'))
-                set_run(run, size, ea=HEI if i == 0 else SONG,
-                        ascii_font='Consolas' if mono else WEST, bold=(i == 0) or is_bold)
-            if i == 0:
-                shade(cell)
-    if t.rows:
-        repeat_header(t.rows[0])
-    return t
-
-
-# ── markdown 解析 ──────────────────────────────────────────────────────────────
-IMG_RE = re.compile(r'^!\[(?P<cap>[^\]]*)\]\((?P<path>[^)]+)\)\s*$')
-CAP_RE = re.compile(r'^\*\*(?P<cap>表\s*[\d\-–.]+.*?)\*\*\s*$')
-
-
-def parse_md(text: str):
-    """→ [('h1'|'h2'|'h3'|'h4'|'p'|'ul'|'ol'|'table'|'img'|'quote', payload)]"""
-    lines = text.splitlines()
-    blocks, i = [], 0
-    if lines and lines[0].startswith('# '):
-        i = 1
-    while i < len(lines):
-        ln = lines[i]
-        s = ln.strip()
-        if not s or re.match(r'^([-*_])(\s*\1){2,}$', s):
-            i += 1                       # 空行与分隔线(`---` / `***` / `___`)
-            continue
-        m = re.match(r'^(#{2,5})\s+(.*)$', s)
-        if m:
-            blocks.append(('h%d' % (len(m.group(1)) - 1), m.group(2).strip()))
-            i += 1
-            continue
-        m = IMG_RE.match(s)
-        if m:
-            blocks.append(('img', (m.group('cap'), m.group('path'))))
-            i += 1
-            continue
-        if s.startswith('|'):
-            rows = []
-            while i < len(lines) and lines[i].strip().startswith('|'):
-                raw = lines[i].strip().strip('|')
-                if not re.match(r'^[\s:\-|]+$', raw):
-                    rows.append([c.strip() for c in raw.split('|')])
-                i += 1
-            if rows:
-                cap = None
-                if blocks and blocks[-1][0] == 'p' and CAP_RE.match(blocks[-1][1]):
-                    cap = blocks.pop()[1]
-                blocks.append(('table', (cap, rows)))
-            continue
-        if s.startswith('- '):
-            items = []
-            while i < len(lines) and lines[i].strip().startswith('- '):
-                items.append(lines[i].strip()[2:].strip())
-                i += 1
-            blocks.append(('ul', items))
-            continue
-        if re.match(r'^\d+[.)]\s+', s):
-            items = []
-            while i < len(lines) and re.match(r'^\d+[.)]\s+', lines[i].strip()):
-                items.append(re.sub(r'^\d+[.)]\s+', '', lines[i].strip()))
-                i += 1
-            blocks.append(('ol', items))
-            continue
-        if s.startswith('> '):
-            q = []
-            while i < len(lines) and lines[i].strip().startswith('> '):
-                q.append(lines[i].strip()[2:].strip())
-                i += 1
-            blocks.append(('quote', ' '.join(q)))
-            continue
-        blocks.append(('p', s))
-        i += 1
-    return blocks
-
-
-def forbidden_hits(text: str) -> dict:
-    """扫去标识化词表 → {禁用词: 命中次数}(只回报非零项;**大小写不敏感**)。"""
-    low = text.lower()
-    return {w: low.count(w.lower()) for w in FORBIDDEN if low.count(w.lower())}
-
-
-def strict_hits(text: str) -> dict:
-    """数据要求说明的"不得出现实现细节/现状"扫描 → {模式名: 命中次数}(只回报非零项)。"""
-    return {name: len(re.findall(rx, text, re.I)) for name, rx in STRICT_PATTERNS
-            if re.search(rx, text, re.I)}
-
-
-def count_md(text: str) -> dict:
-    blocks = parse_md(text)
-    body = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text)
-    cn = len(re.findall(r'[\u4e00-\u9fff]', body))
-    return dict(
-        章=sum(1 for k, _ in blocks if k == 'h1'),
-        节=sum(1 for k, _ in blocks if k == 'h2'),
-        小节=sum(1 for k, _ in blocks if k == 'h3'),
-        表=sum(1 for k, _ in blocks if k == 'table'),
-        图=sum(1 for k, _ in blocks if k == 'img'),
-        段落=sum(1 for k, _ in blocks if k == 'p'),
-        汉字=cn)
-
-
-# ── 渲染 ───────────────────────────────────────────────────────────────────────
-def cover(doc, title: str, subtitle: str):
-    for _ in range(4):
-        para(doc, '', space_after=0)
-    para(doc, f'{title}', size=SZ['cover'], ea=HEI, bold=True, align='center', space_after=12)
-    para(doc, subtitle, size=14, ea=HEI, align='center', space_after=6)
-    for _ in range(3):
-        para(doc, '', space_after=0)
-    para(doc, f'版本 {VERSION}(与系统版本一致)', size=14, align='center', space_after=6)
-    para(doc, f'编制:观澜项目组  日期:2026-09-22', size=12, align='center', space_after=6)
-    # ★落款不写生成器文件名与源件路径:那会把实现细节带进"对外、不体现文件名/落盘位置"的文档里
-    #   (2026-09-22 实逮: `--check` 扫源件是 clean, 但 `--verify` 扫渲染结果命中 1 处 snake_case + 2 处路径)。
-    para(doc, '本文件由统一的交付文档生成器排版(样式表固定,可重跑、可 diff;正文源件随交付包提供)',
-         size=9, align='center', space_after=0)
-    doc.add_page_break()
-
-
-def toc(doc):
-    para(doc, '目录', size=SZ['h1'], ea=HEI, bold=True, align='center', space_after=12)
-    p = doc.add_paragraph()
-    p.paragraph_format.line_spacing = 1.5
-    add_field(p, r'TOC \o "1-3" \h \z \u',
-              '(目录域:在 Word 中按 Ctrl+A 后 F9,或右键“更新域”即可生成带页码的目录)')
-    doc.add_page_break()
-
-
-def render_blocks(doc, blocks, fig_root: pathlib.Path):
-    """渲染块序列。表号一律**按章编号**「表 <章>-<序>」(附录按字母 A/B/C)——
-    与三份稿正文里的引用口径一致(实逮: 原先按出现顺序编成「表 1…表 54」, 正文写「见表 9-2」就指不到)。
-    """
-    tno = 0
-    chap_label = ''
-    per = 0
-    caps: list[str] = []
-    for kind, payload in blocks:
-        if kind == 'h1':
-            head = payload.strip()
-            m = re.match(r'^(\d+)', head)
-            if m:
-                chap_label, per = m.group(1), 0
-            else:
-                m2 = re.match(r'^附录\s*([A-Za-z])', head)
-                chap_label, per = (m2.group(1), 0) if m2 else (chap_label, 0)
-        if kind.startswith('h'):
-            lvl = int(kind[1])
-            style = {1: 'Heading 1', 2: 'Heading 2', 3: 'Heading 3', 4: 'Heading 4'}[lvl]
-            p = doc.add_paragraph(style=style)
-            size = {1: SZ['h1'], 2: SZ['h2'], 3: SZ['h3'], 4: SZ['h3']}[lvl]
-            for chunk, _b, _m in split_bold(payload):
-                set_run(p.add_run(chunk), size, ea=HEI, bold=True)
-            continue
-        if kind == 'p':
-            para(doc, payload, align='justify', indent_chars=2)
-            continue
-        if kind in ('ul', 'ol'):
-            for n, item in enumerate(payload, 1):
-                mark = '· ' if kind == 'ul' else f'({n})'
-                para(doc, mark + item, align='justify', hanging=True, space_after=3)
-            continue
-        if kind == 'quote':
-            p = para(doc, payload, align='justify', space_before=6, space_after=6)
-            p.paragraph_format.left_indent = Cm(0.74)
-            continue
-        if kind == 'table':
-            cap, rows = payload
-            tno += 1
-            per += 1
-            label = f'表 {chap_label}-{per}' if chap_label else f'表 {tno}'
-            title = re.sub(r'^表\s*[\dA-Za-z]+\s*[-–—]\s*\d+\s*', '', cap or '').strip()
-            if cap:
-                author_no = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', cap)
-                if author_no and f'{author_no.group(1)}-{author_no.group(2)}' != f'{chap_label}-{per}':
-                    WARN.append('表号与作者自编不一致: 原「%s」→ 本器按章编号「%s」'
-                                % (cap[:24], label))
-            caps.append(label)
-            para(doc, (label + (' ' + title if title else '')), size=SZ['caption'], ea=HEI, bold=True,
-                 align='center', space_before=8, space_after=4)
-            add_table(doc, rows)
-            para(doc, '', space_after=2)
-            continue
-        if kind == 'img':
-            cap, rel = payload
-            # md 里的图路径是相对 `docs/` 的(约定 `figures/xxx.png`);容错:只给文件名也认。
-            cand = fig_root / rel if (fig_root / rel).exists() else fig_root / 'figures' / pathlib.Path(rel).name
-            f = cand.resolve()
-            if f.exists():
-                p = doc.add_paragraph()
-                p.alignment = WD_ALIGN_PARAGRAPH.CENTER
-                p.paragraph_format.space_before = Pt(8)
-                p.paragraph_format.space_after = Pt(4)
-                p.paragraph_format.first_line_indent = Pt(0)
-                p.add_run().add_picture(str(f), width=Cm(15.0) if f.stat().st_size > 90_000 else Cm(13.0))
-            else:
-                WARN.append(f'图缺件: {rel}')
-                para(doc, f'(图缺失:{rel})', size=SZ['caption'], align='center', space_after=4)
-            para(doc, cap or rel, size=SZ['caption'], ea=HEI, bold=True, align='center', space_after=8)
-            continue
-    doc._tab_labels = caps                     # 供调用方自检(本器 --verify 直接从 docx 回读,不依赖它)
-    return tno
-
-
-def header_footer(doc, title: str):
-    sec = doc.sections[0]
-    hp = sec.header.paragraphs[0]
-    hp.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    set_run(hp.add_run(f'{title} · 观澜 v2 风电场智能分析系统 · 版本 {VERSION}'), SZ['header'])
-    fp = sec.footer.paragraphs[0]
-    fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    set_run(fp.add_run('第 '), SZ['header'])
-    add_field(fp, 'PAGE', '1')
-    set_run(fp.add_run(' 页 / 共 '), SZ['header'])
-    add_field(fp, 'NUMPAGES', '1')
-    set_run(fp.add_run(' 页'), SZ['header'])
-
-
-def appendix_spec(doc):
-    para(doc, '附:排版规范(字体字号分类)', size=SZ['h1'], ea=HEI, bold=True, align='left',
-         space_before=12, space_after=8)
-    para(doc, '本文件与同批交付的另外两份文档由同一个渲染器生成,字体字号分类完全一致;'
-              '中文用黑体(标题)/宋体(正文),西文与数字用 Times New Roman。', align='justify',
-         indent_chars=2)
-    add_table(doc, [['元素', '字体', '字号', '对齐 / 缩进']] + [list(r) for r in SPEC_ROWS])
-
-
-def build(key: str) -> int:
-    spec = DOCS[key]
-    src = ROOT / spec['src'].format(v=VERSION)
-    out = ROOT / spec['out'].format(v=VERSION)
-    if not src.exists():
-        WARN.append(f'源件缺件: {src.relative_to(ROOT).as_posix()}')
-        print(f'✗ {key}: 源件不在位 {src}')
-        return 5
-    blocks = parse_md(src.read_text(encoding='utf-8'))
-    doc = Document()
-    style_setup(doc)
-    cover(doc, spec['title'], spec['subtitle'])
-    toc(doc)
-    ntab = render_blocks(doc, blocks, ROOT / 'docs')
-    appendix_spec(doc)
-    header_footer(doc, spec['title'])
-    out.parent.mkdir(parents=True, exist_ok=True)
-    doc.save(out)
-    st = count_md(src.read_text(encoding='utf-8'))
-    print('%s → %s(%d 章 · %d 节 · %d 表 · %d 图 · 约 %d 汉字 · %.0f KB)'
-          % (key, out.relative_to(ROOT).as_posix(), st['章'], st['节'], max(ntab, st['表']), st['图'],
-             st['汉字'], out.stat().st_size / 1024))
-    return 0
-
-
-def verify_docx(key: str) -> int:
-    """回读渲染结果自检(交付前必跑):计数 + 残留 markdown 标记 + 域 + 字体。"""
-    spec = DOCS[key]
-    out = ROOT / spec['out'].format(v=VERSION)
-    if not out.is_file():
-        print('✗ %-4s 不在位 %s' % (key, out.relative_to(ROOT).as_posix()))
-        return 5
-    doc = Document(str(out))
-    texts = [p.text for p in doc.paragraphs]
-    cells = [c.text for t in doc.tables for r in t.rows for c in r.cells]
-    anti = chr(96)
-    # ★行内代码里的字面量不算残留(实逮: `` `pitch/**` `` 是 glob 通配路径,Word 里就该原样显示)。
-    mono, plain = [], []
-    for p in doc.paragraphs:
-        for r in p.runs:
-            (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text)
-    for t in doc.tables:
-        for row in t.rows:
-            for c in row.cells:
-                for p in c.paragraphs:
-                    for r in p.runs:
-                        (mono if (r.font.name or '').startswith('Consolas') else plain).append(r.text)
-    allt = '\n'.join(texts + cells)
-    body = '\n'.join(plain)
-    heads = [p.text for p in doc.paragraphs if p.style.name.startswith('Heading')]
-    residual = dict(反引号=body.count(anti), 星号=body.count('**'), 管道=body.count(' | '),
-                    代码内字面星号='\n'.join(mono).count('**'))
-    foot = doc.sections[0].footer.paragraphs[0]._p.xml
-    head = doc.sections[0].header.paragraphs[0].text
-    ok_field = ('TOC' in doc.element.xml) and ('PAGE' in foot) and ('NUMPAGES' in foot)
-    # 只把"正文里残留的 markdown 标记"判为不合格;代码内的字面星号是内容本身(glob 通配),只报数。
-    bad = [k for k, v in residual.items() if v and k != '代码内字面星号']
-    # ★版本表覆盖检查(2026-09-22 实逮): 升版本时若只改版本串而忘插表行, 表尾会停在旧版本,
-    #   而正文"共 N 条/十六个版本/中七条·小九条"是手写的 ⇒ 用 HISTORY 机器核对, 缺一行就 FAIL。
-    if key == 'req':
-        from src import version as _V
-        want_v = {h['version'] for h in _V.HISTORY}
-        got_v = set()
-        for _tb in doc.tables:
-            for _row in _tb.rows:
-                _c = _row.cells[0].text.strip()
-                if re.fullmatch(r'\d+\.\d+\.\d+', _c):
-                    got_v.add(_c)
-        _miss_v = sorted(want_v - got_v)
-        if _miss_v:
-            bad.append('版本表缺行 %s' % _miss_v)
-    leak = forbidden_hits(allt + '\n' + head)          # ★对外件不得出现样本场标识
-    strict = strict_hits(allt) if spec.get('strict_terms') else {}   # ★数据要求说明: 去实现细节/去现状
-    # ★表号断链检查(2026-09-22 实逮): 正文写「见表 9-2」,文档里就必须真有「表 9-2」。
-    cap_labels = set()
-    for p in doc.paragraphs:
-        mm = re.match(r'^表\s*([\dA-Za-z]+)\s*[-–—]\s*(\d+)', p.text.strip())
-        if mm:
-            cap_labels.add(f'{mm.group(1)}-{mm.group(2)}')
-
-    def _refs(text: str) -> set:
-        """正文里的「表 X-Y」引用。
-
-        ★两处实逮的误报都要挡:① `年-月`('报表 2026-09');② **跨单元格拼接** —— 把表格单元用换行
-        连起来后, "…如实空表" + "FR-18 至 FR-21" 会拼出 '表\\nFR-18',被当成表号。故分隔符只许空格/制表符。
-        """
-        out = set()
-        for a, b in re.findall(r'表[ \t]*([\dA-Za-z]+)[ \t]*[-–—][ \t]*(\d+)', text):
-            if re.fullmatch(r'(19|20)\d{2}', a):
-                continue
-            out.add(f'{a}-{b}')
-        return out
-
-    refs = _refs('\n'.join(
-        [p.text for p in doc.paragraphs
-         if not re.match(r'^(表|图)\s*[\dA-Za-z]+\s*[-–—]\s*\d+', p.text.strip())] + cells))
-    broken = sorted(refs - cap_labels)
-    noref = sorted(cap_labels - refs)
-    print('%-4s %-38s 段落 %3d · 表 %2d · 图 %2d · 标题 %2d · 页眉 %r'
-          % (key, out.name, len(doc.paragraphs), len(doc.tables), len(doc.inline_shapes),
-             len(heads), head[:28]))
-    print('      域(目录/页码): %s · 残留标记: %s · 去标识化: %s%s · 表号: %s'
-          % ('OK' if ok_field else '缺', bad or '无',
-             'clean' if not leak else '泄漏 %s' % leak,
-             '' if not spec.get('strict_terms') else
-             ' · 去实现细节: %s' % ('clean' if not strict else '命中 %s' % strict),
-             '断链 %s' % broken if broken else '引用闭合(表 %d 张,%d 张无引用)' % (len(cap_labels), len(noref))))
-    return 0 if ok_field and not bad and not leak and not broken and not strict else 5
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--only', choices=sorted(DOCS))
-    ap.add_argument('--check', action='store_true', help='只体检源件与图,不渲染')
-    ap.add_argument('--verify', action='store_true', help='渲染后回读自检(计数/残留标记/域)')
-    ap.add_argument('--spec', action='store_true', help='打印排版规范表')
-    a = ap.parse_args()
-    if a.spec:
-        for row in SPEC_ROWS:
-            print(' | '.join(row))
-        return 0
-    rc = 0
-    if a.verify:
-        for k in ([a.only] if a.only else sorted(DOCS)):
-            rc = max(rc, verify_docx(k))
-        return rc
-    if a.check:
-        for k, spec in DOCS.items():
-            src = ROOT / spec['src'].format(v=VERSION)
-            if not src.exists():
-                print('✗ %-4s 源件不在位 %s' % (k, src.relative_to(ROOT).as_posix()))
-                rc = 5
-                continue
-            st = count_md(src.read_text(encoding='utf-8'))
-            _src_txt = src.read_text(encoding='utf-8')
-            miss, leak = [], forbidden_hits(_src_txt)
-            strict = strict_hits(_src_txt) if spec.get('strict_terms') else {}
-            for _cap, rel in re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', _src_txt):
-                f = ROOT / 'docs' / rel.split('figures/')[-1]
-                if not (ROOT / 'docs' / 'figures' / rel.split('figures/')[-1]).exists():
-                    miss.append(rel)
-            print('%-4s 章 %2d · 节 %2d · 小节 %2d · 表 %2d · 图 %2d · 约 %5d 汉字 · 去标识化 %s%s%s'
-                  % (k, st['章'], st['节'], st['小节'], st['表'], st['图'], st['汉字'],
-                     'clean' if not leak else '泄漏 %s' % leak,
-                     '' if not spec.get('strict_terms') else
-                     ' · 去实现细节 %s' % ('clean' if not strict else '命中 %s' % strict),
-                     '' if not miss else '  ✗ 缺图 %d: %s' % (len(miss), ', '.join(miss))))
-            if miss or leak or strict:
-                rc = 5
-        return rc
-    for k in ([a.only] if a.only else sorted(DOCS)):
-        rc = max(rc, build(k))
-    if WARN:
-        print('\n告警:')
-        for w in sorted(set(WARN)):
-            print(' -', w)
-    return rc
-
-
-if __name__ == '__main__':
-    raise SystemExit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/docs/delivery_docs_build.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/delivery_docs_build.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_build.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import docs as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.docs import delivery_docs_build as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 505
scripts/delivery_docs_figures.py

@@ -1,519 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""交付文档图表构建器(需求分析 / 系统设计说明 / 数据要求说明 三份 docx 的插图)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/docs/delivery_docs_figures.py`。**本文件不得新增逻辑**。
 
-## 为什么单开一个器
-三份交付文档要求"文表图并茂"。图里出现的每个数字都必须能追到仓库里的真件,所以图**不手画**:
-本器从 `src/version.py` 的 HISTORY、`configs/portal_pages.yaml`、`configs/serve.json`、
-`data/raw/<场>/**`、`outputs/<场>/**` 与 `guanlan.py check` / `rebuild_all.py --dry-run` 的**实跑输出**
-里取数,落到 `docs/figures/*.png`。纯示意图(架构/链路/时序)也在图注里写明"依据"。
-
-## 落点与口径
-    docs/figures/fig-req-01..04 / fig-des-01..06 / fig-dat-01..04 .png
-    docs/src/_guanlan_check.txt        (验收门实跑原文,图的取证底稿)
-    docs/src/_rebuild_plan.txt         (重算链 dry-run 原文,图的取证底稿)
-
-## 用法
-    python scripts/delivery_docs_figures.py            # 全画
-    python scripts/delivery_docs_figures.py --only req # 只画某一组 (req/des/dat)
-    python scripts/delivery_docs_figures.py --check    # 只报缺哪些图
-
-退出码: 0 全部生成 · 5 有图没生成(源件缺或绘图失败,逐条打印原因)
+入口路径(`scripts/delivery_docs_figures.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import json
 import pathlib
-import re
-import subprocess
+import runpy
 import sys
-import time
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
 
-import matplotlib                                                          # noqa: E402
-matplotlib.use('Agg')
-import matplotlib.pyplot as plt                                            # noqa: E402
-from matplotlib.patches import FancyArrowPatch, FancyBboxPatch             # noqa: E402
-
-from src import paths as P                                                 # noqa: E402
-
-FIG_DIR = ROOT / 'docs' / 'figures'
-SRC_DIR = ROOT / 'docs' / 'src'
-CJK = 'Microsoft YaHei'
-plt.rcParams['font.sans-serif'] = [CJK, 'SimHei']
-plt.rcParams['axes.unicode_minus'] = False
-plt.rcParams['figure.dpi'] = 200
-
-WARN: list[str] = []
-VERSION = __import__('importlib').import_module('src.version').VERSION
-
-
-# ── 通用绘图元件 ────────────────────────────────────────────────────────────────
-def canvas(w: float, h: float):
-    fig, ax = plt.subplots(figsize=(w, h))
-    ax.set_xlim(0, 100)
-    ax.set_ylim(0, 100)
-    ax.axis('off')
-    return fig, ax
-
-
-def box(ax, x, y, w, h, text, fc='#eef3fb', ec='#3b6ea5', fs=10, bold=False, align='center'):
-    ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0.6,rounding_size=1.4',
-                                linewidth=1.2, edgecolor=ec, facecolor=fc))
-    ax.text(x + w / 2, y + h / 2, text, ha='center', va='center', fontsize=fs,
-            wrap=True, fontweight='bold' if bold else 'normal', linespacing=1.5)
-
-
-def arrow(ax, p, q, color='#555555', style='-|>', lw=1.3, rad=0.0):
-    ax.add_patch(FancyArrowPatch(p, q, arrowstyle=style, mutation_scale=12,
-                                 linewidth=lw, color=color,
-                                 connectionstyle=f'arc3,rad={rad}'))
-
-
-def save(fig, name: str):
-    """落盘前统一做一次**字形兜底**:把 YaHei 缺字的符号换成等价可显字符。
-
-    实逮(2026-09-22): 图里引用的原文(如 `configs/portal_pages.yaml` 的说明)含 `⇒`(U+21D2),
-    matplotlib 报 `Glyph 8658 missing from font(s) Microsoft YaHei` 并画成方框 —— 交付文档里不能有方框。
-    """
-    swap = {'⇒': '→', '⇐': '←', '↔': '↔', '⇔': '↔', '✓': '√', '✗': '×', '≥': '≥'}
-    import matplotlib.text as _mt
-    for t in fig.findobj(_mt.Text):
-        s = t.get_text()
-        if any(k in s for k in swap):
-            for k, v in swap.items():
-                s = s.replace(k, v)
-            t.set_text(s)
-    FIG_DIR.mkdir(parents=True, exist_ok=True)
-    out = FIG_DIR / name
-    fig.tight_layout()
-    fig.savefig(out, bbox_inches='tight', facecolor='white')
-    plt.close(fig)
-    print('  ✓', out.relative_to(ROOT).as_posix(), '%.0f KB' % (out.stat().st_size / 1024))
-
-
-def barh(ax, labels, values, color='#3b6ea5', fmt='{:.0f}', xlabel=''):
-    y = range(len(labels))
-    ax.barh(list(y), values, color=color, height=0.62)
-    ax.set_yticks(list(y))
-    ax.set_yticklabels(labels, fontsize=9)
-    ax.invert_yaxis()
-    for i, v in enumerate(values):
-        ax.text(v, i, ' ' + fmt.format(v), va='center', fontsize=8.5)
-    ax.set_xlabel(xlabel, fontsize=9)
-    ax.tick_params(axis='x', labelsize=8.5)
-    for s in ('top', 'right'):
-        ax.spines[s].set_visible(False)
-
-
-# ── 取数(全部真读,不猜)────────────────────────────────────────────────────────
-def version_history():
-    from src import version as V
-    return list(V.HISTORY), V.VERSION
-
-
-def portal_pages():
-    import yaml
-    d = yaml.safe_load((P.config('portal_pages.yaml')).read_text(encoding='utf-8'))
-    pages = d.get('pages') or d.get('entries') or []
-    if isinstance(d, dict) and not pages:
-        for k, v in d.items():
-            if isinstance(v, list) and v and isinstance(v[0], dict):
-                pages = v
-                break
-    return pages
-
-
-def serve_cfg():
-    return json.loads((P.config('serve.json')).read_text(encoding='utf-8'))
-
-
-def raw_station_stats():
-    """→ [(族, 件数, GB)],实测 data/raw/<场站>/ 下一层目录。
-
-    场站目录名以**场配置**为准(`windscada.config.farm()['raw_station']`),`P.station_dir()` 只是兜底。
-    ★对外文档口径(用户令 2026-09-22): 真实场站名只用于**取数**,不进图题 —— 图里一律写「样本场」。
-    """
-    from src.windscada.config import farm as _farm
-    home = P.RAW_ROOT / str(_farm().get('raw_station') or P.farm())
-    out = []
-    if not home.exists():
-        WARN.append(f'data/raw 场站目录不在位: {home}')
-        return out
-    for d in sorted(p for p in home.iterdir() if p.is_dir()):
-        fs = [p for p in d.rglob('*') if p.is_file()]
-        out.append((d.name, len(fs), sum(p.stat().st_size for p in fs) / 1e9))
-    return out
-
-
-def md_tables(path: pathlib.Path):
-    """→ [ [ [cell,...], ... ] ]:把 markdown 里的表逐个解析成二维列表(不含表头分隔行)。"""
-    if not path.exists():
-        WARN.append('源件不在位: %s' % path.relative_to(ROOT).as_posix())
-        return []
-    lines = path.read_text(encoding='utf-8').splitlines()
-    tables, i = [], 0
-    while i < len(lines):
-        if lines[i].startswith('|') and i + 1 < len(lines) and re.match(r'^\|[\s:\-|]+\|$', lines[i + 1].strip()):
-            rows = []
-            j = i + 2
-            while j < len(lines) and lines[j].strip().startswith('|'):
-                rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
-                j += 1
-            tables.append(rows)
-            i = j
-        else:
-            i += 1
-    return tables
-
-
-def md_find_table(path: pathlib.Path, must_cols, first_cell=None):
-    """找**表头含指定列**的表 → (表头单元格, 数据行)。列位置一律**按表头名定位**,不写死列号。"""
-    lines = path.read_text(encoding='utf-8').splitlines() if path.exists() else []
-    for i, l in enumerate(lines):
-        if not l.startswith('|') or not all(c in l for c in must_cols):
-            continue
-        head = [c.strip() for c in l.strip().strip('|').split('|')]
-        rows, j = [], i + 2
-        while j < len(lines) and lines[j].strip().startswith('|'):
-            rows.append([c.strip() for c in lines[j].strip().strip('|').split('|')])
-            j += 1
-        if not rows:
-            continue
-        if first_cell is not None and rows[0][0] != first_cell:
-            continue
-        return head, rows
-    return [], []
-
-
-def col_of(head, *names):
-    """按表头名取列号(取第一个命中的;找不到返回 None)。"""
-    for n in names:
-        for i, h in enumerate(head):
-            if n in h:
-                return i
-    return None
-
-
-def ensure_evidence(refresh=False):
-    """把两张取证底稿跑出来(已存在且未 --refresh 时复用)。"""
-    SRC_DIR.mkdir(parents=True, exist_ok=True)
-    py = str(ROOT / '.venv' / 'Scripts' / 'python.exe')
-    chk, plan = SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'
-    if refresh or not chk.exists():
-        t0 = time.time()
-        r = subprocess.run([py, '-X', 'utf8', 'guanlan.py', 'check'], cwd=str(ROOT),
-                           capture_output=True, timeout=1800)
-        chk.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
-        print('  · guanlan.py check 底稿写入 (%.0fs, rc=%d)' % (time.time() - t0, r.returncode))
-    if refresh or not plan.exists():
-        r = subprocess.run([py, '-X', 'utf8', 'scripts/rebuild_all.py', '--dry-run', '--skip-scada'],
-                           cwd=str(ROOT), capture_output=True, timeout=600)
-        plan.write_text((r.stdout or b'').decode('utf-8', 'replace'), encoding='utf-8')
-        print('  · rebuild_all --dry-run 底稿写入')
-
-
-# ── 需求分析组 ─────────────────────────────────────────────────────────────────
-def fig_req_01():
-    hist, _ver = version_history()
-    fig, ax = plt.subplots(figsize=(9.2, 0.62 * len(hist) + 1.4))
-    ax.axis('off')
-    n = len(hist)
-    ax.set_ylim(-0.6, n - 0.4)
-    ax.set_xlim(0, 10)
-    ax.plot([0.7, 0.7], [0, n - 1], color='#3b6ea5', lw=2.2, zorder=1)
-    lvl_cn = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号'}
-    for i, h in enumerate(hist):
-        y = n - 1 - i
-        ax.scatter([0.7], [y], s=90, color='#3b6ea5', zorder=3)
-        title = h.get('title', '')
-        title = re.sub(r'(.*?)', '', title)
-        title = title[:46] + ('…' if len(title) > 46 else '')
-        ax.text(1.05, y + 0.18, f"v{h.get('version')}  {h.get('date')}  [{lvl_cn.get(h.get('level'), '')}版本]",
-                fontsize=9.5, fontweight='bold', va='center')
-        ax.text(1.05, y - 0.20, title, fontsize=9, color='#333333', va='center')
-    ax.text(0.7, n - 0.15, '需求演进(自下而上)', fontsize=10, fontweight='bold', ha='center')
-    ax.set_title(f'观澜 v2 需求演进时间轴(样本场;依据 src/version.py 的 HISTORY,共 {n} 条)', fontsize=11)
-    save(fig, 'fig-req-01-需求演进时间轴.png')
-
-
-def fig_req_02():
-    fig, ax = canvas(9.4, 6.2)
-    roles = [('值班运行', 88, '今日态势/部件问题/该问题页/报警与故障统计'),
-             ('点检与检修', 65, '机组页/振动与融合面/油样与工单/报告导出'),
-             ('场站管理', 42, '发电性能/可靠性(MTBF·MTBO·MDT)/可用率/限电'),
-             ('研发与交付', 19, '本体与机制链/数据重算/运行体检/版本与打包')]
-    for name, y, pages in roles:
-        box(ax, 3, y, 20, 16, name, fc='#e8f1e4', ec='#5b8c4a', fs=11, bold=True)
-        box(ax, 30, y, 66, 16, pages, fc='#eef3fb', ec='#3b6ea5', fs=10)
-        arrow(ax, (23.5, y + 8), (29.4, y + 8))
-    ax.text(50, 94, '角色 — 场景 — 页面关系(依据 系统使用说明书 与 configs/portal_pages.yaml)',
-            ha='center', fontsize=11, fontweight='bold')
-    save(fig, 'fig-req-02-角色场景页面.png')
-
-
-def fig_req_03():
-    pages = portal_pages()
-    if not pages:
-        WARN.append('portal_pages.yaml 未解析出页面条目')
-        return
-    from collections import Counter
-    kinds = Counter(str(p.get('kind', '未标')) for p in pages)
-    order = ['data-derived', 'source-derived', 'frozen-delivery', 'static', 'shipped']
-    ks = [k for k in order if k in kinds] + [k for k in kinds if k not in order]
-    vals = [kinds[k] for k in ks]
-    fig, ax = plt.subplots(figsize=(8.6, 0.62 * len(ks) + 2.0))
-    cn = {'data-derived': 'data-derived(随数据变,须重算)',
-          'source-derived': 'source-derived(随源代码变)',
-          'frozen-delivery': 'frozen-delivery(冻结交付件)',
-          'static': 'static(静态)', 'shipped': 'shipped(随包件)'}
-    barh(ax, [cn.get(k, k) for k in ks], vals, xlabel='页面/子页数(共 %d 项)' % len(pages))
-    ax.set_title('门户页面归口分布(依据 configs/portal_pages.yaml)', fontsize=11)
-    save(fig, 'fig-req-03-页面归口分布.png')
-
-
-def fig_req_04():
-    """验收门分类 —— **与《需求分析》§9.1 表同源**(直接解析那张表,避免图文数字打架)。"""
-    head, rows = md_find_table(SRC_DIR / f'需求分析_观澜_{VERSION}.md', ('分类', '判定条数'))
-    if not rows:
-        WARN.append('需求分析 §9.1 的分类表未解析到,图 fig-req-04 用实跑输出兜底')
-        ensure_evidence()
-        txt = (SRC_DIR / '_guanlan_check.txt').read_text(encoding='utf-8', errors='replace')
-        marks = re.findall(r'^\s*\[(OK|FAIL|--|!|i|\?)\]\s*([^—\n]+)', txt, re.M)
-        cats = {'通过': sum(1 for m, _ in marks if m == 'OK'),
-                '需处置': sum(1 for m, _ in marks if m == 'FAIL'),
-                '跳过或提示': sum(1 for m, _ in marks if m not in ('OK', 'FAIL'))}
-        ks, vals, note = list(cats), list(cats.values()), '依据 guanlan.py check 实跑输出'
-    else:
-        ks, vals, note = [], [], '依据《需求分析》§9.1 表(同源于 guanlan.py check 实跑输出)'
-        c_name, c_n = col_of(head, '分类') or 0, col_of(head, '判定条数') or 1
-        c_gate = col_of(head, '代表门')
-        for r in rows:
-            if r[c_name] == '合计':
-                break
-            gate = r[c_gate] if c_gate is not None and c_gate < len(r) else ''
-            ks.append('%s(%s)' % (r[c_name], (gate[:22] + '…') if len(gate) > 22 else gate))
-            vals.append(int(re.sub(r'[^0-9]', '', r[c_n]) or 0))
-    print('    §9.1 门分类:', dict(zip(ks, vals)) if ks else '—')
-    fig, ax = plt.subplots(figsize=(9.6, 0.62 * len(ks) + 1.9))
-    barh(ax, ks, vals, xlabel='判定条数(合计 %d 条)' % sum(vals))
-    ax.set_title('验收门分类与数量(%s)' % note, fontsize=11)
-    save(fig, 'fig-req-04-验收门分类.png')
-
-
-# ── 系统设计说明组 ─────────────────────────────────────────────────────────────────
-def fig_des_01():
-    fig, ax = canvas(9.6, 7.4)
-    ax.text(50, 96, '观澜 v2 风电场智能分析系统 总体架构(离线单包,无外网依赖)', ha='center',
-            fontsize=11.5, fontweight='bold')
-    box(ax, 4, 84, 92, 9, '使用侧:浏览器(门户 / 分析总览 /detail/v2 / 本体网 / 运维控制台 /ops)',
-        fc='#f6f2e8', ec='#b08a3e', fs=10)
-    box(ax, 24, 71, 52, 9, '接入层:组件网关 28084(前缀改写 + data-abs 豁免 + 静态件)',
-        fc='#eef3fb', ec='#3b6ea5', fs=10)
-    box(ax, 3, 56, 18, 11, '分析组件\n18033 /detail', fs=9)
-    box(ax, 23, 56, 17, 11, '振动组件\n18020 /cms', fs=9)
-    box(ax, 42, 56, 16, 11, '仿真台\n18791·18792', fs=9)
-    box(ax, 60, 56, 15, 11, '三维 viewer\n64292', fs=9)
-    box(ax, 77, 56, 20, 11, '本机模型档\nOllama 11434\n(可选,未装即降级)', fs=9,
-        fc='#f4eef6', ec='#7d5ba6')
-    for x in (12, 31.5, 50, 67.5, 87):
-        arrow(ax, (50, 71), (x, 67.4), color='#8a8a8a')
-    box(ax, 3, 41, 94, 10, '产物层 outputs/<场>/:windscada 判级与曲线 · slim10min 窄仓 · 振动六层链 · '
-                           '本体对象库 · SOP/findings · guanlan 契约', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
-    arrow(ax, (50, 56), (50, 51.4))
-    box(ax, 3, 27, 94, 9, '摄入与构建层:rebuild_all.py 23 步(place_raw_data → 10min/1min 摄入 → '
-                          '10 个 SCADA 构建器 → 振动摄入 → 本体六步 → 审计)', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
-    arrow(ax, (50, 41), (50, 36.4))
-    box(ax, 3, 13, 46, 9, '输入层 data/raw/<场>/:scada_10min·scada_1min·scada_mdb\n故障报警·风机故障记录·'
-                          'windcms·m5_cms_tcm·油样报告', fc='#f6f2e8', ec='#b08a3e', fs=9)
-    box(ax, 51, 13, 46, 9, '共享技术资料(OEM 资料,本体 kb_ingest 读)\n配置 configs/:serve.json·'
-                           'models.json·portal_pages.yaml', fc='#f6f2e8', ec='#b08a3e', fs=9)
-    arrow(ax, (26, 23), (26, 26.6))
-    arrow(ax, (74, 23), (74, 26.6))
-    box(ax, 3, 2, 94, 7, '贯穿全链的守卫:单一真源(src/paths.py·src/version.py) · 审计门(guanlan.py check / '
-                         '7 个审计器) · 逐件来源台账(_provenance.json)', fc='#ffffff', ec='#555555', fs=9)
-    save(fig, 'fig-des-01-总体架构.png')
-
-
-def fig_des_02():
-    """serve.json 是**扁平**结构(`gateway`/`detail`/… 直接是端口整数),不是嵌套 dict。"""
-    cfg = serve_cfg()
-    host, pub = cfg.get('host', '?'), cfg.get('public_host', '')
-    gw_port = cfg.get('gateway', '?')
-    comps = [(k, cfg.get(k)) for k in ('detail', 'cms', 'sim', 'sim_sys', 'viewer')]
-    fig, ax = canvas(9.6, 5.2)
-    box(ax, 30, 82, 40, 12, '网关 gateway\n%s:%s(对外唯一入口)' % (pub or host, gw_port),
-        fc='#eef3fb', ec='#3b6ea5', fs=10, bold=True)
-    n = max(1, len(comps))
-    for i, (name, port) in enumerate(comps):
-        x = 3 + i * (94 / n)
-        w = 94 / n - 3
-        box(ax, x, 52, w, 14, '%s\n%s:%s' % (name, host, port), fs=9)
-        arrow(ax, (50, 82), (x + w / 2, 66.6), color='#8a8a8a')
-    box(ax, 3, 30, 94, 12, '绑定与自启:guanlan 服务(Windows 服务 / systemd)· start.bat 静默启动 · '
-                           'run/*.pid 运行态 · logs/ 统一落点', fc='#f6f2e8', ec='#b08a3e', fs=9)
-    arrow(ax, (50, 52), (50, 42.6))
-    box(ax, 3, 12, 94, 12, '端口冲突处置:netstat -ano 找占用进程 → 按端口清理 → 重启服务\n'
-                           '(service_ctl stop 不杀子进程,只按端口杀会留下孤儿实例)', fc='#ffffff',
-        ec='#555555', fs=9)
-    arrow(ax, (50, 30), (50, 24.6))
-    ax.text(50, 96, '端口与进程拓扑(依据 configs/serve.json 实读)', ha='center', fontsize=11.5,
-            fontweight='bold')
-    save(fig, 'fig-des-02-端口与进程.png')
-
-
-def fig_des_03():
-    ensure_evidence()
-    txt = (SRC_DIR / '_rebuild_plan.txt').read_text(encoding='utf-8', errors='replace')
-    steps = []
-    for line in txt.splitlines():
-        m = re.match(r'^\s{2}(\S.*?)\s{2,}(\S.*)$', line)
-        if m and not line.strip().startswith('('):
-            steps.append((m.group(1).strip(), m.group(2).strip()))
-    if not steps:
-        WARN.append('rebuild_all --dry-run 未解析出步骤')
-        return
-    fig, ax = plt.subplots(figsize=(9.6, 0.42 * len(steps) + 1.8))
-    ax.axis('off')
-    ax.set_xlim(0, 10)
-    ax.set_ylim(-0.5, len(steps) - 0.2)
-    for i, (name, cmd) in enumerate(steps):
-        y = len(steps) - 1 - i
-        ax.add_patch(plt.Rectangle((0.2, y - 0.34), 1.75, 0.68, facecolor='#eef3fb',
-                                   edgecolor='#3b6ea5', lw=0.9))
-        ax.text(1.07, y, name[:22], fontsize=8.6, ha='center', va='center')
-        ax.text(2.15, y, cmd[:92], fontsize=8.2, va='center', color='#333333', family='monospace')
-        if i < len(steps) - 1:
-            ax.annotate('', xy=(1.07, y - 0.42), xytext=(1.07, y - 0.30),
-                        arrowprops=dict(arrowstyle='-|>', color='#8a8a8a', lw=0.9))
-    ax.set_title('重算链步骤(依据 scripts/rebuild_all.py --dry-run --skip-scada,共 %d 步)' % len(steps),
-                 fontsize=11)
-    save(fig, 'fig-des-03-重算链.png')
-
-
-def fig_des_04():
-    fig, ax = canvas(9.6, 6.4)
-    ax.text(50, 96, '判级矩阵与四源融合数据流(每条判据都能指到输入列)', ha='center',
-            fontsize=11.5, fontweight='bold')
-    srcs = ['SCADA 10min\n(slim10min 48 列)', 'CMS 振动\n(六层链)', '状态·故障·告警\n(台账)', '油样与工单\n(现场记录)']
-    for i, s in enumerate(srcs):
-        box(ax, 1.5 + i * 24.6, 78, 22, 12, s, fc='#f6f2e8', ec='#b08a3e', fs=9)
-    box(ax, 6, 58, 40, 12, '七系统判级矩阵\n变桨·偏航·主轴承·齿轮箱·发电机·变流器·主控与传感网',
-        fc='#eef3fb', ec='#3b6ea5', fs=9.5)
-    box(ax, 52, 58, 42, 12, '融合面四源对齐\n振动 × SCADA × 台账 × 人工\n(逐台判级 + 证据链)',
-        fc='#eef3fb', ec='#3b6ea5', fs=9.5)
-    for x in (12, 36, 62, 86):
-        arrow(ax, (x, 78), (26 if x < 50 else 73, 70.6), color='#8a8a8a')
-    box(ax, 6, 40, 88, 11, '时间窗:判级与曲线按所选时间窗重算(月/年/自定义起止日期,含两端);'
-                           '温度与停机台账按真窗取月', fc='#e8f1e4', ec='#5b8c4a', fs=9.5)
-    arrow(ax, (26, 58), (26, 51.6))
-    arrow(ax, (73, 58), (73, 51.6))
-    outs = ['部件问题清单\n(按系统×机组)', '该问题页\n(单问题+完整依据)', '发电性能与可靠性\n(MTBF/MTBO/MDT)',
-            '控制参数一致性\n(P/ω 封顶·K 聚类)', '报告导出\n(docx/md/页面)', '本体机制链\n与决策台']
-    for i, o in enumerate(outs):
-        x = 2 + (i % 3) * 32.3
-        y = 24 if i < 3 else 8
-        box(ax, x, y, 30, 12, o, fc='#f4eef6', ec='#7d5ba6', fs=9)
-    arrow(ax, (50, 40), (50, 36.6), color='#5b8c4a')
-    box(ax, 2, 21.5, 96, 2.6, '', fc='#ffffff', ec='#ffffff')
-    ax.text(50, 36, '产物(outputs/<场>/…,逐件登记生成端与输入)', ha='center', fontsize=9, color='#444444')
-    save(fig, 'fig-des-04-判级与融合.png')
-
-
-def fig_des_05():
-    fig, ax = canvas(9.6, 5.6)
-    ax.text(50, 96, '时间窗按窗重算与 pending 契约(实测:冷窗首查 pending,约 30 s 后落位)',
-            ha='center', fontsize=11.5, fontweight='bold')
-    lanes = [('前端 app.js', 82), ('服务 windscada_serve.py', 60), ('按窗缓存 _WIN_CACHE', 38), ('重算线程(_HEAVY_LOCK 串行)', 16)]
-    for name, y in lanes:
-        box(ax, 2, y, 24, 11, name, fc='#eef3fb', ec='#3b6ea5', fs=9)
-        ax.plot([26.5, 97], [y + 5.5, y + 5.5], color='#d0d0d0', lw=0.9, zorder=0)
-    msgs = [(82, 39, 'GET /api/fleet?win=2026-07'),
-            (60, 82, 'win 未命中 → 起后台线程,先回上一份口径并标 pending=1'),
-            (38, 60, '缺 → 入队 sysmx/curves/m9'),
-            (16, 38, '算(变桨/偏航/蓄能/温度 + 曲线 + M9)'),
-            (38, 16, '算完写入缓存'),
-            (60, 38, '5 s 轮询再取 → 命中'),
-            (82, 60, 'pending=0,页面换成该时间窗口径')]
-    for y1, y2, text in msgs:
-        arrow(ax, (36, y1 + 5.5), (36, y2 + 5.5), color='#3b6ea5')
-        ax.text(38.5, (y1 + y2) / 2 + 5.5, text, fontsize=8.8, va='center')
-    ax.text(50, 5, '为什么这样:算得慢(每面 5~120 s),不能把页面卡住;宁可先给旧口径并**如实标注**,也不静默等待。',
-            ha='center', fontsize=9, color='#444444')
-    save(fig, 'fig-des-05-时间窗与pending.png')
-
-
-def fig_des_06():
-    fig, ax = canvas(9.0, 6.0)
-    cx, cy, r = 50, 50, 30
-    items = [('改代码/配置', '#eef3fb'), ('重算 rebuild_all 23 步', '#e8f1e4'),
-             ('审计器 7 件(反向/页面/配置/链路/版本…)', '#f6f2e8'),
-             ('guanlan.py check 全绿', '#f4eef6'), ('打包 pack_dist + 开箱验证', '#eef3fb'),
-             ('装到目标机 install.ps1', '#e8f1e4')]
-    import math
-    for i, (t, fc) in enumerate(items):
-        a = math.radians(90 - i * 360 / len(items))
-        x, y = cx + r * math.cos(a), cy + r * math.sin(a)
-        box(ax, x - 15, y - 6, 30, 12, t, fc=fc, ec='#3b6ea5', fs=9)
-        a2 = math.radians(90 - (i + 1) * 360 / len(items))
-        arrow(ax, (cx + (r - 6) * math.cos(a), cy + (r - 6) * math.sin(a)),
-              (cx + (r - 6) * math.cos(a2), cy + (r - 6) * math.sin(a2)),
-              color='#8a8a8a', rad=-0.25)
-    box(ax, 38, 44, 24, 12, '版本纪律\n单一真源 src/version.py', fc='#ffffff', ec='#555555', fs=9, bold=True)
-    ax.text(50, 96, '质量门与审计闭环(每环都能独立重跑、逐条给退出码)', ha='center', fontsize=11.5,
-            fontweight='bold')
-    save(fig, 'fig-des-06-质量门闭环.png')
-
-
-
-GROUPS = {
-    'req': [fig_req_01, fig_req_02, fig_req_03, fig_req_04],
-    'des': [fig_des_01, fig_des_02, fig_des_03, fig_des_04, fig_des_05, fig_des_06],
-}
-# ★2026-09-22 用户令: 《数据要求说明》改成纯数据需求规格、不再放插图(原 dat 组四张是样本场现状图,
-#   不再体现)—— 组与期望清单同步去掉, 四个 PNG 也从 docs/figures 删除。
-EXPECTED = ['fig-req-01-需求演进时间轴.png', 'fig-req-02-角色场景页面.png',
-            'fig-req-03-页面归口分布.png', 'fig-req-04-验收门分类.png',
-            'fig-des-01-总体架构.png', 'fig-des-02-端口与进程.png', 'fig-des-03-重算链.png',
-            'fig-des-04-判级与融合.png', 'fig-des-05-时间窗与pending.png', 'fig-des-06-质量门闭环.png']
-
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/docs/delivery_docs_figures.py'
 
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--only', default=None, choices=sorted(GROUPS))
-    ap.add_argument('--refresh', action='store_true',
-                    help='重跑取证底稿(guanlan.py check / rebuild_all --dry-run)后再画图')
-    ap.add_argument('--check', action='store_true', help='只报缺哪些图')
-    a = ap.parse_args()
-    if a.refresh:
-        for p in (SRC_DIR / '_guanlan_check.txt', SRC_DIR / '_rebuild_plan.txt'):
-            p.unlink(missing_ok=True)
-    if a.check:
-        miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
-        print('图表体检: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
-        for f in miss:
-            print('  ✗', f)
-        return 0 if not miss else 5
-    groups = [a.only] if a.only else sorted(GROUPS)
-    for g in groups:
-        print('── %s 组 ──' % g)
-        for fn in GROUPS[g]:
-            try:
-                fn()
-            except Exception as e:  # noqa: BLE001
-                WARN.append('%s 失败: %s' % (fn.__name__, e))
-                print('  ✗', fn.__name__, e)
-    miss = [f for f in EXPECTED if not (FIG_DIR / f).exists()]
-    if WARN:
-        print('\n告警/缺件:')
-        for w in WARN:
-            print(' -', w)
-    print('\n图表: %d/%d 在位' % (len(EXPECTED) - len(miss), len(EXPECTED)))
-    return 0 if not miss else 5
+from app_qualityGate.common.app_qualityGate_guanlan import docs as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.docs import delivery_docs_figures as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    raise SystemExit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 379
scripts/detail_deps.py

@@ -1,393 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""`/detail` 详细分析工作台 · 页面 → 取数接口 → 产物 → 来源 的**可复算**台账 (2026-09-18 用户令)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/detail_deps.py`。**本文件不得新增逻辑**。
 
-用户令: 「http://127.0.0.1:28084/detail 下的各页面, 不能用旧版的产出补, 只能参照旧版产物的呈现
-样式内容, 必须基于输入数据重算」。本器把这条令变成一份**每次都能重跑出来的清单**:
-
-    页面(页签)  ←  取数接口(在线 /api/*)  ←  产物件  →  来源(台账) + 生成端(族表) + 缺口处置
-
-证据不是印象, 两头都对着源码核:
-  · 页面 → 接口: 在 `app_frontEnd/.../assets/app.js` 里逐个搜到该接口 (页签表 TABS 与各 fetch 调用);
-  · 接口 → 产物: 声明时给出 `scripts/windscada_serve.py` 里的函数名, 本器逐一核对该符号存在;
-  · 产物 → 来源: 读 `outputs/<场>/_provenance.json`(逐件来源台账, 由 products_restore_missing
-    --refresh 维护) 与 `scripts/products_reverse_audit.py` 的族表(生成端)。
-任一条核不上 → 本器报 `[核不上]` 并 rc=5 —— 页面改了接口而清单没跟, 或产物换了生成端, 当场露出来。
-
-`/detail` 是**网关**(scripts/guanlan_gateway.py, 默认 28084)把请求转到**组件服务**
-(scripts/windscada_serve.py, 默认 18033)的 `/v2` 前端; 两边都是同一套 `/api/*`, 所以本清单对
-在线页与单文件快照 (app_frontEnd/.../pages/snapshot.py) 同时成立。
-
-用法:
-    python scripts/detail_deps.py                # 打印清单 (人看)
-    python scripts/detail_deps.py --probe        # 额外实测在线接口 (需服务在跑; 只打印, 不写盘)
-    python scripts/detail_deps.py --write        # 写 docs/detail_页面依赖与重算台账_v0.1.md
-    python scripts/detail_deps.py --check        # 文档是否与现场一致 (rc=5 = 该重写)
+入口路径(`scripts/detail_deps.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import fnmatch
-import json
 import pathlib
-import re
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-sys.path.insert(0, str(ROOT / 'scripts'))
-from src import paths as P                                              # noqa: E402
-
-APPJS = ROOT / 'app_frontEnd' / 'common' / 'app_frontEnd_guanlan' / 'assets' / 'app.js'
-# ★P4(2026-09-22): 分析组件实现已迁入 app_backEnd 模块目录(scripts/ 下只剩兼容壳)——
-#   校验路由符号要读**真实现**,故新位置优先、旧路径兜底。
-SERVE = ROOT / 'app_backEnd' / 'common' / 'app_backEnd_guanlan' / 'serve.py'
-if not SERVE.is_file():
-    SERVE = ROOT / 'scripts' / 'windscada_serve.py'
-DOC = ROOT / 'docs' / 'detail_页面依赖与重算台账_v0.1.md'
-BEGIN, END = '<!-- DETAIL-DEPS:BEGIN -->', '<!-- DETAIL-DEPS:END -->'
-PBEGIN, PEND = '<!-- DETAIL-PROBE:BEGIN -->', '<!-- DETAIL-PROBE:END -->'
-
-# ── 页签 → 取数接口 (证据: app.js 里能搜到该接口) ────────────────────────────────
-#   ★实测校准 (2026-09-18): 所有页签的**底数**都是 `/api/fleet` (app.js:1029 `load()`), 其余按页签
-#     各自取: decision→ont_chain(403) · report→rpt_compose/facts(586) · system→maint_survey(728)
-#     · generation→curves(752) · vibration/cms→vibcms(931) · assistant→ask*/facts(425/413)。
-#     下钻浮层 (`/api/problem` `/api/turbine`, app.js:995) 从任意页签都能触发, 挂在部件页。
-PAGES: list[tuple[str, str, list[str]]] = [
-    ('overview', '总览', ['/api/fleet']),
-    ('energy', '发电与损失', ['/api/fleet']),
-    ('component', '部件与系统', ['/api/fleet', '/api/problem', '/api/turbine']),
-    ('vibration', '振动评估(CMS)', ['/api/fleet', '/api/vibcms']),
-    ('generation', '特性曲线', ['/api/curves']),
-    ('fault', '故障与可用率', ['/api/fleet']),
-    ('decision', '决策链', ['/api/fleet', '/api/ont_chain']),
-    ('assistant', '问答', ['/api/fleet', '/api/ask', '/api/ask_status', '/api/ask_models',
-                           '/api/facts', '/api/facts/claim']),
-    ('report', '报告', ['/api/fleet', '/api/facts', '/api/rpt_compose']),
-    ('system', '系统自查', ['/api/fleet', '/api/maint_survey']),
-]
-
-# ── 接口 → 产物 → 生成端 (证据: windscada_serve.py 里的符号名, 本器核对存在) ──────
-#   req=True  = 缺了这页就出不来 (接口回 no_products / err);
-#   req=False = 接口内有 .exists() 兜底, 缺了只是少一块 (页面仍出, 但内容不全)。
-APIS: list[dict] = [
-    dict(path='/api/fleet', fn='fleet_view', req=[
-        'windscada/temp_monthly.parquet', 'windscada/alarms.parquet', 'windscada/loss_monthly.parquet',
-        'windscada/powercurve_bins.parquet', 'windscada/powercurve_dev.parquet'],
-        opt=['windscada/*.parquet', 'ontology/turbine_params.parquet',
-             'm5_cms_tcm/handoff_vibration_v2.json', 'm5_cms_tcm/fleet_scalar_z.parquet',
-             'pitch/pitch_daily.parquet', 'pitch/pitch_zero_monthly.parquet'],
-        note='硬缺件在 `_load()` 里直接抛 ProductsMissing ⇒ 接口回 err=no_products'),
-    dict(path='/api/curves', fn='curve_view',
-        req=['windscada/powercurve_bins.parquet'],
-        opt=['windscada/curve_lenses.parquet', 'windscada/curve_liveness.parquet',
-             'windscada/control_profile.parquet', 'windscada/hydraulic_accum.parquet'],
-        note='曲线镜头读 10min 原始档 + 已重算的派生件'),
-    dict(path='/api/problem', fn='single_problem', req=['windscada/alarms.parquet'],
-        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='部件下钻浮层'),
-    dict(path='/api/turbine', fn='turbine_problems', req=['windscada/alarms.parquet'],
-        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='逐台下钻浮层'),
-    dict(path='/api/vibcms', fn='vibcms_results',
-        req=['windcms/报告_CMS振动状态评估报告_*.md'],
-        opt=['m5_cms_tcm/handoff_vibration_v2.json', 'windcms/cache/*'],
-        note='报告是振动六层链 report 步的出件; 缺它本接口如实回 no_report(不再抛 IndexError)'),
-    dict(path='/api/maint_survey', fn='maint_framework_view', req=['ontology/objects.json'], opt=[],
-        note='维护面盘点读本体对象库 (/v2 的「系统自查」页签取它)'),
-    dict(path='/api/maint_std', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
-        opt=[], note='四判据+成熟度 (经典页 `/?…` 用, /v2 不取)'),
-    dict(path='/api/maint_framework', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
-        opt=[], note='运维体系三维 (经典页用, /v2 不取)'),
-    dict(path='/api/dq_findings', fn='dq_findings_view', ui='classic', req=['ontology/objects.json'], opt=[],
-        note='只认 finding/data_quality|caliber|system_coverage|tier_trend 四族对象; 这四族**不在重算链上** '
-             '(唯一写方 scripts/ingest_ops_2025.py 读 data/raw/工作库, 该目录不在现场数据里)'),
-    dict(path='/api/ont_chain', fn='ontology_view', req=['ontology/objects.json'], opt=[], note='故障链/预防链/故障树'),
-    dict(path='/api/ontology', fn='ontology_view', ui='classic', req=['ontology/objects.json'], opt=[],
-        note='本体总表 (经典页用, /v2 用 fleet 里的本体投影)'),
-    dict(path='/api/facts', fn=None,
-        req=['guanlan/facts_contract_v0.json', 'guanlan/derived/detail_cards.json'],
-        opt=['guanlan/derived/qa_refs.json', 'guanlan/derived/portal_claims.json'],
-        note='契约链: 输入是 sop/findings.json + paradigm_r1 三份**人裁底稿** ⇒ 与 data/raw 隔着不止一跳'),
-    dict(path='/api/ask', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
-    dict(path='/api/ask_status', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
-    dict(path='/api/ask_models', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
-    dict(path='/api/rpt_compose', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
-]
-
-
-def _expand_braces(glob) -> list[str]:
-    """族 glob (str 或 list) → 展开 `{a,b}` 后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
-    out = []
-    for g in ([glob] if isinstance(glob, str) else list(glob)):
-        if '{' in g:
-            head, tail = g.split('{', 1)
-            opts, rest = tail.split('}', 1)
-            out += [head + o + rest for o in opts.split(',')]
-        else:
-            out.append(g)
-    return out
-
-
-def _pat_match(rel: str, glob) -> bool:
-    """rel 是否落在族 glob 里 (深度对齐: 模式无 `**` 时 `/` 个数必须相等 —— fnmatch 的 `*` 跨 `/`)。"""
-    for pt in _expand_braces(glob):
-        if '**' not in pt and rel.count('/') != pt.count('/'):
-            continue
-        if rel == pt or fnmatch.fnmatch(rel, pt):
-            return True
-    return False
-
-
-# ── 环境读取 ────────────────────────────────────────────────────────────────────
-def load_env(farm: str | None = None):
-    root = P.out_root(farm)
-    ledger = {}
-    lf = root / '_provenance.json'
-    if lf.is_file():
-        try:
-            ledger = (json.loads(lf.read_text(encoding='utf-8')) or {}).get('files') or {}
-        except Exception:
-            ledger = {}
-    disk = {p.relative_to(root).as_posix(): p.stat().st_size
-            for p in root.rglob('*') if p.is_file()}
-    try:
-        from products_reverse_audit import FAMILIES
-    except Exception:
-        FAMILIES = []
-
-    def family_of(rel: str):
-        for fam in FAMILIES:
-            if _pat_match(rel, fam['glob']):
-                return fam
-        return None
-
-    return dict(farm=farm or P.farm(), root=root, ledger=ledger, disk=disk, family_of=family_of,
-                fams=FAMILIES)
-
-
-def expand(pat: str, env) -> list[str]:
-    got = [r for r in env['disk'] if fnmatch.fnmatch(r, pat)]
-    return sorted(got)
-
-
-def row_of(pat: str, env) -> dict:
-    """一条产物声明 → 在位情况 + 来源 + 生成端。"""
-    hits = expand(pat, env)
-    if hits:
-        srcs = sorted({(env['ledger'].get(r) or {}).get('source', '(未登记)') for r in hits})
-        fams = []
-        for r in hits[:40]:
-            f = env['family_of'](r)
-            if f and f['id'] not in fams:
-                fams.append(f['id'])
-        gen = next((env['family_of'](r) for r in hits if env['family_of'](r)), None)
-        return dict(pat=pat, n=len(hits), bytes=sum(env['disk'][r] for r in hits),
-                    src='·'.join(srcs), builder=(gen or {}).get('gen') or '—',
-                    family='·'.join(fams) or '—', ok=True)
-    f = env['family_of'](pat) if '*' not in pat else None
-    return dict(pat=pat, n=0, bytes=0, src='—', builder='—',
-                family=(f or {}).get('id') or '—', ok=False)
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--probe', action='store_true', help='实测在线接口 (需服务在跑; 只打印)')
-    ap.add_argument('--write', action='store_true', help='写回文档')
-    ap.add_argument('--check', action='store_true', help='核对文档与现场一致 (rc=5 = 该重写)')
-    ap.add_argument('--farm', default=None)
-    a = ap.parse_args()
-    env = load_env(a.farm)
-    appjs = APPJS.read_text(encoding='utf-8')
-    serve = SERVE.read_text(encoding='utf-8')
-
-    # ① 证据核对: 页面端接口在 app.js 里找得到吗; 接口函数在 serve 里找得到吗
-    bad = []
-    for pid, name, eps in PAGES:
-        if f"'{pid}'" not in appjs:
-            bad.append(f'页签 {pid} 不在 app.js 的 TABS 里')
-        for ep in eps:
-            if f"'{ep}'" not in appjs and f"`{ep}" not in appjs and ep not in appjs:
-                bad.append(f'页签 {pid} 声明的 {ep} 在 app.js 里搜不到')
-    for it in APIS:
-        if it.get('ui') == 'classic':
-            # 经典页 (/ 老前端) 的接口: 出处是 serve 里的路由分支, 不是 app.js
-            if f"'{it['path']}'" not in serve:
-                bad.append(f"经典页接口 {it['path']} 在 windscada_serve.py 的路由里搜不到")
-        elif f"'{it['path']}'" not in appjs and it['path'] not in appjs:
-            bad.append(f"{it['path']} 声明给 /v2 用, 但 app.js 里搜不到它")
-        if it['fn'] and f"def {it['fn']}(" not in serve:
-            bad.append(f"{it['path']} 声明的实现函数 {it['fn']}() 在 windscada_serve.py 里不存在")
-
-    probe = {}
-    if a.probe:
-        probe = {it['path']: _probe(it['path']) for it in APIS}
-
-    L = []
-    L.append(f'# /detail 页面依赖与重算台账 (自动生成, 禁手改)')
-    L.append('')
-    L.append(f'> 生成端: `python scripts/detail_deps.py --write`|场站 `{env["farm"]}`|'
-             f'产物仓 `{P.rel(env["root"])}`|盘上 {len(env["disk"]):,} 件')
-    L.append('>')
-    L.append('> 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, '
-             '数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。')
-    L.append('')
-    L.append('## 1 页面 → 取数接口')
-    L.append('')
-    L.append('| 页签 | 页面 | 取数接口 |')
-    L.append('|---|---|---|')
-    for pid, name, eps in PAGES:
-        for i, ep in enumerate(eps):
-            L.append(f'| {pid if i == 0 else ""} | {name if i == 0 else ""} | `{ep}` |')
-    L.append('')
-    L.append('注: `/api/maint_std` `/api/maint_framework` `/api/dq_findings` `/api/ontology` 属**经典页** '
-             '(`/?…`, 组件根), `/v2` 工作台不取它们 —— 但同一批本体产物 (ontology/objects.json) 仍然决定着'
-             '它们能不能出数, 故一并列在 §2。')
-    L.append('')
-    L.append('## 2 接口 → 产物 → 来源 / 生成端')
-    L.append('')
-    L.append('| 接口 | 产物 | 件 | 大小 | 台账来源 | 生成端(族) | 判定 |')
-    L.append('|---|---|---:|---:|---|---|---|')
-    gaps = []
-    for it in APIS:
-        for kind, label in (('req', '✅必需'), ('opt', '○可选')):
-            for pat in it[kind]:
-                r = row_of(pat, env)
-                verdict = ('在位' if r['ok'] else ('**缺**' if kind == 'req' else '缺(页面降级)'))
-                if not r['ok']:
-                    gaps.append((it['path'], pat, kind, r['family']))
-                L.append(f"| {it['path'] if pat == it[kind][0] else ''} | `{pat}` {label} | {r['n']} | "
-                         f"{r['bytes'] / 1e6:.1f} MB | {r['src']} | {r['builder']} ({r['family']}) | {verdict} |")
-    L.append('')
-    L.append('## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)')
-    L.append('')
-    if not gaps:
-        L.append('无缺口: 表内每件产物都在位且来源已登记。')
-    else:
-        L.append('| 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |')
-        L.append('|---|---|---|---|---|')
-        for ep, pat, kind, fam in gaps:
-            g = _gen_of(env, pat)
-            L.append(f"| `{ep}` | `{pat}` | {'是' if kind == 'req' else '否'} | {g['gen']} | {g['how']} |")
-    L.append('')
-    L.append('## 4 与输入数据的对应 (重算口径)')
-    L.append('')
-    L.append('| 产物族 | 输入 (data/raw/<场站>/…) | 生成端 |')
-    L.append('|---|---|---|')
-    seen = set()
-    for it in APIS:
-        for pat in it['req'] + it['opt']:
-            f = env['family_of'](pat) if '*' not in pat else None
-            if not f or f['id'] in seen:
-                continue
-            seen.add(f['id'])
-            L.append(f"| {f['id']} | {f.get('input') or '—'} | `{f.get('gen') or '—'}` |")
-    L.append('')
-    L.append('## 5 复现')
-    L.append('')
-    L.append('```')
-    L.append('python scripts/detail_deps.py --probe        # 实测在线接口 (页面同路径: 网关 → 组件 /v2)')
-    L.append('python scripts/products_restore_missing.py --refresh   # 重算后维护逐件来源台账')
-    L.append('python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)')
-    L.append('```')
-    body = '\n'.join(L)
-    text = f'{BEGIN}\n{body}\n{END}\n'
-
-    # ── 实测段: 与"清单"分开标记 —— 它的数随服务在场与否变, 不该让 --check 抖动 ─────────
-    ptext = ''
-    if probe:
-        P_ = ['## P 实测 (本次运行, 走网关 28084 → 组件 /v2)', '',
-              '| 页签 | 接口 | 判定 | 实测 |', '|---|---|---|---|']
-        for pid, name, eps in PAGES:
-            for i, ep in enumerate(eps):
-                r = probe.get(ep) or {}
-                st = (f"HTTP {r['http']}" if r.get('http') else '—') + \
-                     (f" · {r['err'][:70]}" if r.get('err') else (f" · {r['n']:,} B" if r.get('n') else ''))
-                P_.append(f"| {pid if i == 0 else ''} | `{ep}` | {r.get('verdict', '—')} | {st} |")
-        P_ += ['', '判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; '
-                   '`/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。']
-        ptext = f'{PBEGIN}\n' + '\n'.join(P_) + f'\n{PEND}\n'
-
-    if bad:
-        print('证据核对不通过 —— 清单与源码/现场脱节:')
-        for b in bad:
-            print('   [核不上] ' + b)
-    print(f'清单: {len(PAGES)} 页签 · {len(APIS)} 接口 · 缺口 {len(gaps)} 件 · '
-          f'产物仓 {len(env["disk"]):,} 件')
-    if a.write:
-        old = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
-        if PBEGIN in old and PEND in old:                 # 旧实测段先整段摘掉, 再按需重写
-            old = old[:old.index(PBEGIN)].rstrip() + '\n' + old[old.index(PEND) + len(PEND):].lstrip('\n')
-        if BEGIN in old and END in old:
-            new = old[:old.index(BEGIN)] + text + old[old.index(END) + len(END):].lstrip('\n')
-        else:
-            new = (old.rstrip() + '\n\n' if old.strip() else
-                   '# /detail 页面依赖与重算台账 v0.1\n\n'
-                   '用户令 (2026-09-18): `/detail` 各页面不许用旧版产出补, 必须由 `data/raw` 重算;\n'
-                   '旧版产物只可参照**呈现样式与内容**。逐页依赖见下表 (自动生成段)。\n\n') + text
-        new = new.rstrip() + '\n\n' + ptext if ptext else new
-        DOC.write_text(new, encoding='utf-8')
-        print(f'已写 {P.rel(DOC)}' + ('' if ptext else ' (未带实测段; 加 --probe 可一并写入)'))
-    if a.check:
-        cur = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
-        seg = cur[cur.index(BEGIN):cur.index(END) + len(END)] if (BEGIN in cur and END in cur) else ''
-        if seg.strip() != text.strip():
-            print('[X] 文档与现场不一致 ⇒ 跑 --write 重写')
-            return 5
-        print('[OK] 文档与现场一致 (清单段; 实测段随服务状态变, 不参与比对)')
-    return 5 if bad else 0
-
-
-def _gen_of(env, pat: str) -> dict:
-    """缺口件 → 生成端与"能否从 data/raw 重算"的如实判断 (依据: 族表的 kind/algo/why)。"""
-    f = next((fam for fam in env['fams'] if _pat_match(pat, fam['glob'])), None)
-    if not f:
-        return dict(gen='—', how='族表里没有这一族 ⇒ 需人工归口')
-    if f.get('gen'):
-        return dict(gen=f"`{f['gen']}`", how='能: 放原始件到 data/raw 后 `rebuild_all.py`')
-    return dict(gen='(无生成端)', how=f"**不能**: {str(f.get('why') or '')[:60]} ⇒ 需研发补生成端")
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/detail_deps.py'
 
-def _probe(ep: str) -> dict:
-    """实测一个接口 (走网关, 与页面同路径)。判读只认**顶层**的 err/error/no_products/no_report ——
-    ★2026-09-18 首版按正则全文搜 `"err":` 判读, 结果把 fleet 响应里某个以 err 结尾的字段的**值**
-    (per_turbine) 当成了报错, 页面明明好着却显示"有缺件痕迹"。判读不许靠猜文本。"""
-    import urllib.error
-    import urllib.request
-    base = 'http://127.0.0.1:28084/detail'
-    q = {'/api/fleet': '?win=2026%E5%B9%B4',
-         '/api/turbine': '?t=WTG01&win=2026%E5%B9%B4',
-         '/api/problem': '?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
-         '/api/ont_chain': '?kind=prev&system=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F',
-         '/api/facts/claim': '?id=RD-000'}.get(ep, '')
-    try:
-        with urllib.request.urlopen(base + ep + q, timeout=60) as r:
-            raw, code = r.read(), r.status
-    except urllib.error.HTTPError as e:
-        raw, code = e.read(), e.code
-    except Exception as e:
-        return dict(http=None, n=0, err=f'{type(e).__name__}: {e}', verdict='连不上')
-    if ep == '/api/ask':      # 只收 POST (页面是表单提交), GET 探测必 404 —— 不是缺件
-        return dict(http=code, n=len(raw), err='', verdict='不适用(POST 接口)')
-    try:
-        obj = json.loads(raw.decode('utf-8', 'replace'))
-    except Exception:
-        return dict(http=code, n=len(raw), err='(非 JSON)', verdict='?')
-    if not isinstance(obj, dict):
-        return dict(http=code, n=len(raw), err='', verdict='有数')
-    msg = next((str(obj[k]) for k in ('err', 'error') if obj.get(k)), '')
-    # 本机模型 (Ollama) 没起 / 未接: 这不是"重算缺件", 标成不适用 —— 与 `guanlan.py check` 的口径一致
-    if ep in ('/api/ask_status', '/api/ask_models', '/api/rpt_compose'):
-        return dict(http=code, n=len(raw), err=msg,
-                    verdict='不适用(本机模型未启动)' if msg or code >= 400 else '有数')
-    # 503 + '缺失/No such file' = 产物不在位(如 /api/facts 的契约派生件) ⇒ 记无产物, 不记报错
-    _missing = (obj.get('no_products') or obj.get('no_report')
-                or (ep == '/api/facts/claim' and code >= 400)
-                or (code == 503 and ('缺失' in msg or 'No such file' in msg)))
-    if _missing:
-        return dict(http=code, n=len(raw), err=msg or 'no_products', verdict='无产物(如实)')
-    if msg:
-        return dict(http=code, n=len(raw), err=msg, verdict='报错')
-    return dict(http=code, n=len(raw), err='', verdict='有数')
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import detail_deps as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 79
scripts/guanlan_baseline_manifest.py

@@ -1,79 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""观澜 详细分析工作台 视觉基线 (detail_style_baseline) 清单构建/校验 (用户裁决 2026-09-06: 确认 C1 = /v2, 删末块与门户一致, 补返回门户后冻结).
-build: 从 src/windscada/ui 源重建 zh 页 → 摘要 {css_tokens, nav, dom, sha256, source blobs} → outputs/rudong/windscada/release-manifest.json (detail_style_baseline 段)
-check: 重建后摘要 vs 清单; 任一差异 = 样式/框架变化 = FAIL (除非用户批准新版本并重写清单).
-用法: python scripts/guanlan_baseline_manifest.py build|check [--html <path>]"""
-import hashlib, datetime as dt, hashlib, json, re, subprocess, sys
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parents[1]; MAN = ROOT / "outputs/rudong/windscada/release-manifest.json"
-UI = ROOT / "app_frontEnd/common/app_frontEnd_guanlan" / "assets"; SRC_FILES = ["app_frontEnd/common/app_frontEnd_guanlan/pages/shell.html",
-                 "app_frontEnd/common/app_frontEnd_guanlan/assets/app.css",
-                 "app_frontEnd/common/app_frontEnd_guanlan/assets/app.js", "src/windscada/ui/charts.js", "src/windscada/ui/build.py", "src/windscada/lang.py"]
-TOKENS = ("--ground", "--panel", "--ink", "--muted", "--line", "--hair", "--accent", "--accent-deep", "--accent-soft", "--wash", "--critical", "--warning", "--good", "--excellent", "--sans", "--mono")
-sha = lambda b: hashlib.sha256(b).hexdigest()
-
-
-def render_zh() -> str:
-    sys.path.insert(0, str(ROOT)); from src.windscada.ui import build; return build.render("zh")
-
-
-def summarize(html: str) -> dict:
-    css = "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", html, flags=re.S))   # 全部 style 块按出现顺序 (服务端注入在后 → 覆盖)
-    roots = [b for sel, b in re.findall(r"(?:^|[}\s;])(:root[^{]*)\{([^}]*)\}", css) if ":root" in sel]; eff = {}   # 任何含 :root 的选择器块 (含 ':root,:root[data-theme]' 这种注入写法)
-    for blk in roots:                                  # 后块覆盖前块 = 浏览器生效值 (末块已删, 中段已删; 若有人再加覆盖, 摘要就变)
-        for k, v in re.findall(r"(--[a-z-]+)\s*:\s*([^;]+)", blk): eff[k] = v.strip()
-    tokens = {k: eff.get(k) for k in TOKENS}
-    js = html[html.rfind("<script>") + 8: html.rfind("</script>")]
-    tabs = re.search(r"const TABS = \[([^\]]*)\]", js).group(1); tabs = re.findall(r"'([a-z_]+)'", tabs)
-    pack = json.loads(re.search(r"window\.__L=(\{.*?\});</script>", html, flags=re.S).group(1))
-    nav = [pack.get("tab." + t) for t in tabs]; actions = {k: pack.get(k) for k in ("ui.back_portal", "ui.reselect", "ui.back_to", "win.label")}
-    dom = {"shell_ids": re.findall(r'id="(top|tabs|root|foot)"', html), "header_controls": ["backPortal" if 'id="backPortal"' in html else None, "reselect" if 'id="reselect"' in html else None, "win" if 'id="win"' in html else None],
-           "theme_attr": bool(re.search(r'<html[^>]*data-theme', html)), "lang": re.search(r'<html lang="([a-z]+)"', html).group(1)}
-    return {"css_tokens": tokens, "n_root_blocks": len(roots), "tabs": tabs, "nav_zh": nav, "actions": actions, "dom": dom, "sha256": sha(html.encode("utf-8")), "size": len(html.encode("utf-8")),
-            "fingerprint": sha(json.dumps({"t": tokens, "n": nav, "d": dom, "a": actions}, sort_keys=True, ensure_ascii=False).encode("utf-8"))[:16]}
-
-
-def live_check(url: str, man: dict) -> list:
-    """线上 /v2 实际响应 (含服务端注入的样式) 的摘要必须等于基线 —— 服务端注入 (如 guanlan-unified-review) 改令牌 = 红."""
-    import urllib.request
-    html = urllib.request.urlopen(url, timeout=10).read().decode("utf-8"); s = summarize(html); diffs = []
-    for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom"):
-        if man[k] != s[k]: diffs.append(f"live {k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:100]} ≠ 线上 {json.dumps(s[k], ensure_ascii=False)[:100]}")
-    inj = re.findall(r'<style id="([^"]+)"', html)
-    if inj: diffs.append(f"live 服务端注入 <style id=…>: {inj} (基线构建物无)")
-    return diffs
-
-
-def main():
-    mode = sys.argv[1] if len(sys.argv) > 1 else "check"; html = render_zh(); s = summarize(html)
-    if mode == "check" and "--live" in sys.argv:
-        url = sys.argv[sys.argv.index("--live") + 1]; man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; d = live_check(url, man)
-        print(("PASS" if not d else "FAIL") + f" live {url}"); [print("  -", x) for x in d]; return 0 if not d else 2
-    git = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
-    blobs = {f: subprocess.run(["git", "-C", str(ROOT), "ls-tree", "HEAD", "--", f], capture_output=True, text=True).stdout.split()[2][:16] for f in SRC_FILES}
-    if mode == "build":
-        def portal_sha(prev):  # 门户参考 = 桌面固定单文件 (用户裁定; 2026-09-06 契约段就地注入后变更), 不在库内 → 缺失时沿用清单旧值, 禁写死
-            rel = (ROOT / "release/portal.html"); pp = rel if rel.exists() else Path.home() / "Desktop/观澜_如东样板_门户_单文件.html"   # 发布目录优先 (用户裁 2026-09-06), 回退桌面
-            return hashlib.sha256(pp.read_bytes()).hexdigest() if pp.exists() else (prev.get("detail_style_baseline") or {}).get("portal_reference_sha256")
-        man = json.loads(MAN.read_text(encoding="utf-8")) if MAN.exists() else {"schema": "release-manifest/v0"}
-        man["detail_style_baseline"] = {"confirmed_by": "用户裁决 2026-09-06 (确认 C1; 删末块与门户一致; 补返回门户/重新选择后冻结)", "route": "/v2 (8033) → 目标架构 /detail/", "source": "src/windscada/ui + src/windscada/lang.py", "git_commit": git, "source_blobs_sha16": blobs,
-                                        "build_cmd": "python -m src.windscada.ui.build zh", "built": dt.datetime.now().isoformat(timespec="minutes"), "artifact_sha256": s["sha256"], "artifact_size": s["size"],
-                                        "portal_reference_sha256": portal_sha(man), "portal_tokens": {"--ground": "#F5F7F8", "--ink": "#1B2430", "--accent": "#1F6F8B", "--line": "#DDE3E8"},
-                                        "css_tokens": s["css_tokens"], "n_root_blocks": s["n_root_blocks"], "tabs": s["tabs"], "nav_zh": s["nav_zh"], "actions": s["actions"], "dom": s["dom"], "fingerprint": s["fingerprint"],
-                                        "screenshot_note": "2026-09-06 浏览器实测 computed style: --ground #F5F7F8 / --ink #1B2430 / --accent #1F6F8B / --line #DDE3E8, 10 tab, 返回门户 href=/ (?portal= 可覆盖), 重新选择 存在; 像素级截图指纹未建 (无基线截图机制), fingerprint = 令牌+导航+DOM 摘要 sha",
-                                        "frozen": ["色彩令牌", "字体", "字号层级/留白/卡片/边框 (app.css)", "顶部导航与十模块顺序", "返回门户/重新选择 位置与语义", "板块框架 (总览…系统维护)"],
-                                        "allowed_changes": "仅结构化事实契约驱动的数据/时间窗/结论/图表数据/报告文本; 不得改 DOM 布局/CSS 令牌/导航顺序/按钮层级"}
-        MAN.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding="utf-8"); print("wrote", MAN.relative_to(ROOT), "commit", git, "sha", s["sha256"][:16], "fp", s["fingerprint"]); return 0
-    man = json.loads(MAN.read_text(encoding="utf-8"))["detail_style_baseline"]; diffs = []
-    for k in ("css_tokens", "tabs", "nav_zh", "actions", "dom", "n_root_blocks"):
-        if man[k] != s[k]: diffs.append(f"{k}: 基线 {json.dumps(man[k], ensure_ascii=False)[:120]} ≠ 现 {json.dumps(s[k], ensure_ascii=False)[:120]}")
-    for k, v in man["css_tokens"].items():
-        if k in man["portal_tokens"] and s["css_tokens"].get(k) != man["portal_tokens"][k]: diffs.append(f"{k} ≠ 门户 {man['portal_tokens'][k]}")
-    print(("PASS" if not diffs else "FAIL") + f" detail_style_baseline fp={s['fingerprint']} (基线 {man['fingerprint']}) sha={s['sha256'][:16]} (基线 {man['artifact_sha256'][:16]})"); [print("  -", d) for d in diffs]
-    return 0 if not diffs else 2
-
-
-if __name__ == "__main__":
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/guanlan_baseline_manifest.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/guanlan_baseline_manifest.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/guanlan_baseline_manifest.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import guanlan_baseline_manifest as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 450
scripts/guanlan_uninstall.py

@@ -1,450 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""观澜卸载器 (2026-09-17 用户令: 增加卸载脚本 `uninstall.bat` / `uninstall.sh`)。
-
-入口就是这两个壳脚本 + 本文件;三处共用**同一份**实现(与 install.bat/install.sh→install.ps1 同构),
-免得"Windows 一套、Linux 一套"各写一遍逻辑、过两个月行为不一致。
-
-## 卸载拆成哪几步(默认口径:只拆"本程序装上去的东西",绝不动用户数据)
-
-    ① 停服务      先停服务化常驻体(Windows 服务 / systemd 单元),再停手工启动的那套
-    ② 注销服务    `sc.exe delete guanlan` / `systemctl disable --now` + 删单元文件(需管理员/root)
-    ③ 撤开机自启  HKCU\...\Run 的 guanlan 项 / systemd 用户单元(不需要管理员)
-    ④ 删快捷方式  `启动观澜.lnk`、`start_guanlan.lnk`(安装根 + 桌面 + applications)
-    ⑤ 清运行态    `run/pids.json`、残留 `*.pid`
-    ⑥ 删安装记录  `install-info.json`("已装的是哪一版"的唯一凭据 —— 删掉它 = 这台机器回到"没装过")
-
-    **保留**:`data/`(输入数据)· `outputs/`(产物)· `logs/`(日志)· `.venv/`(虚拟环境)· 整棵程序目录
-
-## 为什么默认既不删数据、也不删程序目录
-
-· `data/` 与 `outputs/` 是**现场资产**(原始件十几 GB + 重算产物);"卸载程序"不等于"销毁数据"。
-  要连数据一起清,显式给 `--purge`(并且**再问一次**,默认答否;`--keep-logs` 可留下日志)。
-· 程序目录**不自己删**:Windows 上正在跑的 `python.exe` 就在 `.venv` 里,删自己必然部分失败,
-  留下"删了一半的目录"比留着整个目录更难排查。要清干净给 `--all`:本器尽力删,**删不掉的逐条列出**,
-  并打印一条收尾命令(`rmdir /s /q <根>` 或 `rm -rf <根>`)让你关掉本窗口后执行。
-
-## 用法
-
-    uninstall.bat                    交互:先打印计划 → 问一次 → 执行
-    uninstall.bat --dry-run          只打印计划,一个字节都不动(★自证用)
-    uninstall.bat --yes              不问(脚本/无人值守)
-    uninstall.bat --purge --yes      连 data/ outputs/ logs/ .venv/ 一起删
-    uninstall.bat --all --yes        连程序文件一起删(最后给收尾命令)
-    sh uninstall.sh [同样的开关]      Linux / macOS
-
-    退出码: 0 完成 · 2 需要确认(非交互又没给 --yes) · 3 权限不足(②需管理员/root, 其余照做) · 5 有步骤失败
-"""
-from __future__ import annotations
-
-import argparse
-import os
-import pathlib
-import shutil
-import subprocess
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import version as V                                            # noqa: E402
-
-WIN32 = os.name == 'nt'
-UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
-USER_UNIT = pathlib.Path.home() / '.config' / 'systemd' / 'user' / f'{V.SERVICE_NAME}.service'
-AUTOSTART = pathlib.Path.home() / '.config' / 'autostart' / f'{V.SERVICE_NAME}.desktop'
-SHORTCUTS = ('启动观澜.lnk', 'start_guanlan.lnk')
-
-# 卸载时要清掉的可重建件 / 用户数据(--purge 才动)
-PURGE_DIRS = ('data', 'outputs', 'logs', 'run', 'runtime')
-# --all 才动的"程序本体"
-PROGRAM_DIRS = ('src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs', 'wheels', 'vendor')
-PROGRAM_FILES = ('guanlan.py', 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
-                 'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh',
-                 'check.bat', 'start.bat', 'stop.bat', 'pack.bat', 'pack.sh', 'dist-manifest.json')
-
-RESULTS: list[tuple[str, int, str]] = []        # (步骤, rc, 说明) —— 结尾统一汇总
-
-
-def say(msg: str = '') -> None:
-    print(msg)
-
-
-def py_exe() -> str:
-    """优先用本安装的 .venv 解释器;它没了(比如已经被删)就退回当前解释器。"""
-    cand = ROOT / '.venv' / ('Scripts/python.exe' if WIN32 else 'bin/python')
-    return str(cand) if cand.is_file() else sys.executable
-
-
-def is_admin() -> bool:
-    if not WIN32:
-        try:
-            return os.geteuid() == 0
-        except AttributeError:
-            return False
-    try:
-        import ctypes
-        return bool(ctypes.windll.shell32.IsUserAnAdmin())
-    except Exception:
-        return False
-
-
-def run(cmd: list[str], dry: bool, note: str = '') -> int:
-    printable = ' '.join(f'"{x}"' if ' ' in x else x for x in cmd)
-    say(f'   $ {printable}' + (f'   ({note})' if note else ''))
-    if dry:
-        return 0
-    try:
-        r = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
-    except FileNotFoundError:
-        say(f'   [X] 找不到命令 {cmd[0]}')
-        return 5
-    for ln in (r.stdout or '').strip().splitlines():
-        say('     ' + ln)
-    for ln in (r.stderr or '').strip().splitlines():
-        say('     [stderr] ' + ln)
-    return 0 if r.returncode == 0 else 5
-
-
-def rm(path: pathlib.Path, dry: bool) -> int:
-    """真删(目录递归 / 文件单件);不存在算成功(卸载必须幂等,跑第二遍不该报错)。"""
-    if not path.exists():
-        say(f'   - 本来就没有: {rel(path)}')
-        return 0
-    size, n = size_of(path)
-    say(f'   $ 删除 {rel(path)}' + (f'   ({n} 件, {size / 1e6:.1f} MB)' if path.is_dir() else f'   ({size} B)'))
-    if dry:
-        return 0
-    try:
-        if path.is_dir():
-            shutil.rmtree(path)
-        else:
-            path.unlink()
-        return 0
-    except OSError as e:
-        say(f'   [X] 删不掉: {e}')
-        return 5
-
-
-def rel(p: pathlib.Path) -> str:
-    try:
-        return p.relative_to(ROOT).as_posix() or '.'
-    except ValueError:
-        return str(p)
-
-
-def size_of(p: pathlib.Path) -> tuple[int, int]:
-    """→ (字节数, 件数)。"""
-    if p.is_file():
-        try:
-            return p.stat().st_size, 1
-        except OSError:
-            return 0, 0
-    tot = n = 0
-    for dp, _dn, fns in os.walk(p):
-        for fn in fns:
-            try:
-                tot += (pathlib.Path(dp) / fn).stat().st_size
-                n += 1
-            except OSError:
-                pass
-    return tot, n
-
-
-# ── 各步 ────────────────────────────────────────────────────────────────────────────
-def service_registered() -> bool:
-    if WIN32:
-        try:
-            r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
-            return r.returncode == 0
-        except FileNotFoundError:
-            return False
-    return UNIT_PATH.is_file()
-
-
-def step_stop(dry: bool) -> None:
-    say('① 停服务')
-    rc = 0
-    note = []
-    if service_registered():
-        rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'stop'], dry, '停服务化常驻体')
-        note.append('服务化常驻体已停' if rc == 0 else '常驻体那步没成功(多为权限)')
-    else:
-        say(f'   - 没注册过 {V.SERVICE_NAME} 服务(跳过常驻体)')
-        note.append('无服务化常驻体')
-    r2 = run([py_exe(), str(ROOT / 'guanlan.py'), 'stop'], dry, '停启动器起的组件')
-    rc = rc or r2
-    note.append('启动器组件已停' if r2 == 0 else '启动器组件停止失败')
-    RESULTS.append(('停止服务', rc, ';'.join(note)))
-
-
-def step_unregister(dry: bool) -> None:
-    say('② 注销服务(需管理员 / root)')
-    if not service_registered():
-        say(f'   - 本机没注册过 {V.SERVICE_NAME} 服务(跳过)')
-        RESULTS.append(('注销服务', 0, '本来就没注册'))
-        return
-    if not is_admin() and not dry:
-        say('   [!] 权限不足:注销服务需要管理员/root —— 其余步骤照做,这一步留给你手工执行:')
-        if WIN32:
-            say(f'       sc.exe stop {V.SERVICE_NAME} && sc.exe delete {V.SERVICE_NAME}')
-        else:
-            say(f'       sudo systemctl disable --now {V.SERVICE_NAME} && sudo rm -f {UNIT_PATH}')
-        RESULTS.append(('注销服务', 3, '权限不足(已给出手工命令)'))
-        return
-    rc = run([py_exe(), str(ROOT / 'scripts' / 'service_ctl.py'), 'uninstall'], dry, '停 + 删注册')
-    RESULTS.append(('注销服务', rc, '服务已注销' if rc == 0 else '注销失败'))
-
-
-def step_autostart(dry: bool) -> None:
-    say('③ 撤开机自启(不需要管理员)')
-    rc = 0
-    if WIN32:
-        key = r'Software\Microsoft\Windows\CurrentVersion\Run'
-        say(f'   $ reg delete HKCU\\{key} /v {V.SERVICE_NAME} /f')
-        if not dry:
-            try:
-                import winreg
-                with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as k:
-                    try:
-                        winreg.DeleteValue(k, V.SERVICE_NAME)
-                        say('   已移除登录自启项')
-                    except FileNotFoundError:
-                        say('   - 本来就没有这项')
-            except Exception as e:                                     # 注册表异常不该中断整场卸载
-                say(f'   [X] {type(e).__name__}: {e}')
-                rc = 5
-    elif AUTOSTART.exists() or USER_UNIT.exists():
-        rc = run(['systemctl', '--user', 'disable', '--now', V.SERVICE_NAME], dry, '用户级自启(可能没装)')
-        rc = rc or rm(AUTOSTART, dry)
-        rc = rc or rm(USER_UNIT, dry)
-    else:
-        say('   - 没有用户级自启项(跳过)')
-    RESULTS.append(('撤开机自启', rc, '登录/开机自启项已清' if rc == 0 else '清理失败'))
-
-
-def step_shortcuts(dry: bool) -> None:
-    say('④ 删快捷方式(安装根 + 桌面 + applications)')
-    targets: list[pathlib.Path] = [ROOT / n for n in SHORTCUTS]
-    if WIN32:
-        up = os.environ.get('USERPROFILE')
-        if up:
-            targets += [pathlib.Path(up) / d / n for d in ('Desktop', '桌面') for n in SHORTCUTS]
-    else:
-        targets += [pathlib.Path.home() / d / f'{n[:-4]}.desktop'
-                    for d in ('Desktop', '.local/share/applications') for n in SHORTCUTS]
-    hit = [p for p in targets if p.exists()]
-    rc = 0
-    for p in hit:
-        rc = rc or rm(p, dry)
-    if not hit:
-        say('   - 没有安装时建的快捷方式(跳过)')
-    RESULTS.append(('删快捷方式', rc, f'删了 {len(hit)} 个' if hit else '本来就没有'))
-
-
-def step_runtime(dry: bool) -> None:
-    say('⑤ 清运行态(run/pids.json、残留 *.pid)')
-    rc = rm(ROOT / 'run', dry)
-    for p in sorted(ROOT.glob('*.pid')):
-        rc = rc or rm(p, dry)
-    RESULTS.append(('清运行态', rc, 'PID 记录已清' if rc == 0 else '清理失败'))
-
-
-def step_install_record(dry: bool) -> None:
-    say('⑥ 删安装记录 install-info.json')
-    ins = V.read_installed(ROOT)
-    if ins:
-        say(f"   - 记录的是: {ins.get('name', '?')} v{ins.get('version', '?')}"
-            f"(装于 {ins.get('installed_at', '?')},运行方式 {ins.get('mode', '?')})")
-    else:
-        say('   - 没有安装记录(本来就没装过 / 已被删)')
-    rc = rm(V.info_path(ROOT), dry)
-    RESULTS.append(('删安装记录', rc, '这台机器回到"没装过"状态' if rc == 0 else '删除失败'))
-
-
-def step_purge(dry: bool, yes: bool, skip: tuple[str, ...] = ()) -> None:
-    say('⑦ --purge: 删数据与可重建件(输入数据 / 产物 / 日志 / 虚拟环境)')
-    dirs = tuple(d for d in PURGE_DIRS if d not in skip)
-    targets = [ROOT / d for d in dirs] + [ROOT / '.venv']
-    present = [p for p in targets if p.exists()]
-    if skip:
-        say(f'   (--keep-logs: 保留 {", ".join(skip)})')
-    if not present:
-        say('   - 没什么可清的(都不在)')
-        RESULTS.append(('--purge 清数据', 0, '无对象'))
-        return
-    tot = sum(size_of(p)[0] for p in present)
-    say(f'   将删除: {", ".join(rel(p) for p in present)}  —— 合计 {tot / 1e9:.2f} GB')
-    if not yes:
-        if not sys.stdin or not sys.stdin.isatty():
-            say('   [!] 非交互环境:--purge 需要显式 --yes 确认 —— 本步跳过(数据原样保留)')
-            RESULTS.append(('--purge 清数据', 2, '非交互未确认,已跳过'))
-            return
-        ans = input('   这会永久删除现场数据与产物,确认? 输入 yes 继续,其它键跳过: ').strip().lower()
-        if ans not in ('yes', 'y'):
-            say('   - 已跳过(数据原样保留)')
-            RESULTS.append(('--purge 清数据', 0, '用户选择跳过'))
-            return
-    rc = 0
-    for p in present:
-        rc = rc or rm(p, dry)
-    RESULTS.append(('--purge 清数据', rc, f'删除 {len(present)} 项' if rc == 0 else '有删不掉的'))
-
-
-def step_all(dry: bool) -> None:
-    say('⑧ --all: 删程序本体(尽力而为,删不掉的逐条列出)')
-    # ★ 先把自己的工作目录移出安装根:Windows 不允许删除"当前目录",也不允许删正在跑的 python.exe
-    if not dry:
-        try:
-            os.chdir(os.environ.get('TEMP') or os.environ.get('TMP') or '/tmp')
-        except OSError:
-            pass
-    failed: list[str] = []
-    for name in PROGRAM_DIRS + PROGRAM_FILES:
-        p = ROOT / name
-        if p.exists() and rm(p, dry) != 0:
-            failed.append(rel(p))
-    if not dry and ROOT.exists():
-        for leftover in sorted(ROOT.iterdir()):                        # 兜底:清单之外的零碎也清
-            if rm(leftover, dry) != 0:
-                failed.append(rel(leftover))
-        try:
-            ROOT.rmdir()
-            say(f'   已删除安装根 {ROOT}')
-        except OSError as e:
-            say(f'   [!] 安装根没删掉({e.__class__.__name__})—— 正常:本程序正在跑, 自己的目录被占用')
-            say('       关掉本窗口后手工执行: '
-                + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
-    if failed:
-        say(f'   删不掉的 {len(failed)} 项: {", ".join(failed[:8])}' + (' …' if len(failed) > 8 else ''))
-    RESULTS.append(('--all 删程序', 0 if not failed else 5,
-                    '程序文件已清' if not failed else f'{len(failed)} 项留待手工删'))
-
-
-# ── 计划与自检 ──────────────────────────────────────────────────────────────────────
-def plan_lines() -> list[str]:
-    """把"将要做什么"列出来(--dry-run 打印它;也是自检的断言对象)。"""
-    ins = V.read_installed(ROOT)
-    return [
-        f'观澜卸载计划 @ {ROOT}',
-        f'  本包版本: {V.NAME} v{V.VERSION}'
-        + (f";安装记录写的: v{ins.get('version', '?')}(装于 {ins.get('installed_at', '?')})"
-           if ins else ';没有安装记录(本来就没装过 / 已被删)'),
-        '  ① 停服务: service_ctl stop → guanlan.py stop',
-        '  ② 注销服务: '
-        + (f'sc.exe stop/delete {V.SERVICE_NAME}' if WIN32 else f'systemctl disable --now {V.SERVICE_NAME} + 删 {UNIT_PATH}')
-        + f'  (已注册={service_registered()}, 有权限={is_admin()})',
-        '  ③ 撤开机自启: ' + (r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run' if WIN32 else str(USER_UNIT)),
-        '  ④ 删快捷方式: ' + ' / '.join(SHORTCUTS) + '(安装根 + 桌面 + applications)',
-        '  ⑤ 清运行态: run/ 与 *.pid',
-        f'  ⑥ 删安装记录: {V.INSTALL_INFO}',
-        f'  ⑦/⑧ 只有给了 --purge / --all 才动数据与程序本体',
-        '  保留(默认): ' + ' '.join(f'{d}/' + ('' if (ROOT / d).exists() else '(没有)')
-                                    for d in ('data', 'outputs', 'logs', '.venv')) + ' 与整棵程序目录',
-    ]
-
-
-def selftest() -> int:
-    r"""自检(--selftest):证明"计划齐全"且"dry-run 一个字节都不动"。
-
-    一个是**内容断言**(六个步骤一个不缺),一个是**副作用断言**(跑完 --dry-run 后关键路径的
-    存在性与大小完全不变)—— 后者才是"不会误删"的证据。
-    """
-    ok = True
-    need = ('① 停服务', '② 注销服务', '③ 撤开机自启', '④ 删快捷方式', '⑤ 清运行态', '⑥ 删安装记录', '保留')
-    txt = '\n'.join(plan_lines())
-    miss = [k for k in need if k not in txt]
-    print(f'  [{"OK" if not miss else "FAIL"}] 计划齐全(六个步骤 + 保留清单)' + ('' if not miss else f' — 缺 {miss}'))
-    ok &= not miss
-    watch = ['install-info.json', 'run', 'data', 'outputs', 'logs', '.venv']
-    before = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
-    step_stop(True)
-    step_unregister(True)
-    step_autostart(True)
-    step_shortcuts(True)
-    step_runtime(True)
-    step_install_record(True)
-    after = {w: (size_of(ROOT / w) if (ROOT / w).exists() else None) for w in watch}
-    same = before == after
-    print(f'  [{"OK" if same else "FAIL"}] dry-run 无副作用({len(watch)} 个关键路径的存在性与大小都没变)')
-    ok &= same
-    print('  [OK] 六步全部构建成功: ' + '; '.join(f'{n}={rc}' for n, rc, _ in RESULTS))
-    RESULTS.clear()
-    print('  结论: ' + ('自检通过' if ok else '自检未通过'))
-    return 0 if ok else 1
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser(description=f'{V.NAME} v{V.VERSION} 卸载器')
-    ap.add_argument('--dry-run', action='store_true', help='只打印计划, 一个字节都不动')
-    ap.add_argument('--yes', '-y', action='store_true', help='不问, 直接执行(脚本/无人值守用)')
-    ap.add_argument('--purge', action='store_true', help='连 data/ outputs/ logs/ .venv/ 一起删(会再问一次)')
-    ap.add_argument('--all', action='store_true', help='连程序本体一起删(尽力而为, 最后给收尾命令)')
-    ap.add_argument('--keep-logs', action='store_true', help='--purge 时保留 logs/(排查用)')
-    ap.add_argument('--selftest', action='store_true', help='自检: 计划齐全 + dry-run 无副作用')
-    a = ap.parse_args()
-
-    say(f'== {V.NAME} v{V.VERSION} 卸载 · {ROOT} ==')
-    if a.selftest:
-        return selftest()
-    for ln in plan_lines():
-        say(ln)
-    say('')
-
-    if not a.dry_run:
-        if not a.yes:
-            if not sys.stdin or not sys.stdin.isatty():
-                say('   [!] 非交互环境:卸载需要确认。请显式加 --yes(或先在终端里手工跑一次看计划)')
-                say('       本次未做任何改动。')
-                return 2
-            ans = input('   确认卸载? 输入 y 继续, 其它键退出: ').strip().lower()
-            if ans not in ('y', 'yes'):
-                say('   已取消(未改动任何文件)')
-                return 0
-        say('')
-
-    step_stop(a.dry_run)
-    step_unregister(a.dry_run)
-    step_autostart(a.dry_run)
-    step_shortcuts(a.dry_run)
-    step_runtime(a.dry_run)
-    step_install_record(a.dry_run)
-    if a.purge:
-        step_purge(a.dry_run, a.yes, skip=('logs',) if a.keep_logs else ())
-    if a.all:
-        step_all(a.dry_run)
-
-    say('')
-    say('== 结果 ==')
-    for name, rc, note in RESULTS:
-        say(f'   [{"OK" if rc == 0 else ("!" if rc == 3 else "X")}] {name}: {note}')
-    worst = max((rc for _n, rc, _s in RESULTS), default=0)
-    if a.dry_run:
-        say('   (dry-run: 以上一个字节都没动 — 去掉 --dry-run 才真做)')
-        return 0
-    say('   保留: ' + ('(--all 已连程序本体一起处理)' if a.all else 'data/ outputs/ logs/ .venv/ 与整棵程序目录'))
-    if worst == 0:
-        say('   卸载完成。')
-        if not a.all:
-            say('   想连目录一起清掉: ' + (f'rmdir /s /q "{ROOT}"' if WIN32 else f'rm -rf "{ROOT}"'))
-    elif worst == 3:
-        say('   卸载基本完成,但**服务注销那一步缺权限** —— 用管理员/root 重跑一次即可(命令见上)。')
-    else:
-        say('   有步骤失败,见上面的 [X]。')
-    # 机器审计流水(卸载也要留痕;logs/ 被 --purge 删掉时这一步自然就没了,属预期)
-    try:
-        from src import logfile as LF
-        LF.append_jsonl('uninstall', dict(root=str(ROOT), version=V.VERSION, purge=bool(a.purge),
-                                          all=bool(a.all), worst_rc=worst,
-                                          steps=[dict(name=n, rc=rc, note=s) for n, rc, s in RESULTS]))
-    except Exception:
-        pass
-    return 0 if worst == 0 else worst
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try:
-            _s.reconfigure(errors='replace')
-        except Exception:
-            pass
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/pack/guanlan_uninstall.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/guanlan_uninstall.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/pack/guanlan_uninstall.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import pack as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.pack import guanlan_uninstall as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 353
scripts/inventory_products.py

@@ -1,353 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""产物清点与「输入 ↔ 产物」呼应校验 (2026-09-16 用户令 1、2)。
-
-用户令:
-  1. 基于观澜源码, 认真检查、梳理**所有产物**(产物类型/功用/输出路径)确保无遗漏; 输出路径不统一的改源码统一;
-     完善到 `<安装目录>\docs\系统设计说明.md`。
-  2. 确保 `data/raw` 下的输入数据**可用于重算**, 且**产物与输入数据呼应**。
-
-本脚本是三件事的**单一实现** (别在文档里手抄第二份, 会飘):
-  · 清点: 每个产物仓的件数/大小/类型, 以及**逐件来源**(raw-derived / shipped, 读 `_provenance.json`
-    与 `_derived_manifest.json`);
-  · 呼应: 对每类输入算它的**数据跨度/条数**, 与它喂出来的产物**逐项对拍** (产物跨度必须落在输入跨度内,
-    且输入更新时产物不该明显落后);
-  · 写档: `--write-doc` 把清单块写进 `docs\系统设计说明.md` 的
-    `<!-- INVENTORY:BEGIN --> … <!-- INVENTORY:END -->` 之间 (文档其余部分手工维护)。
-
-用法:
-    python scripts/inventory_products.py                # 打清单 + 呼应结论 (人看)
-    python scripts/inventory_products.py --check        # 只做呼应校验 (有问题 → 退出码 5)
-    python scripts/inventory_products.py --write-doc    # 把清单块写进 docs/系统设计说明.md
-    python scripts/inventory_products.py --json         # 机器可读
-"""
-from __future__ import annotations
-
-import argparse
-import datetime as dt
-import json
-import pathlib
-import re
-import sys
-from collections import OrderedDict
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src.console import soft  # noqa: E402
-from src import paths as P    # noqa: E402
-
-soft()
-DOC = P.DOCS / '系统设计说明.md'
-MARK_B, MARK_E = '<!-- INVENTORY:BEGIN', '<!-- INVENTORY:END -->'
-
-# ── 产物仓: 路径助手 → (仓名, 功用, 谁生成, 谁消费) ───────────────────────────────
-# ★路径一律用 src/paths.py 的助手 (唯一真源); 这里写的是**相对安装根**的形式, 便于跨平台显示。
-STORES = OrderedDict([
-    ('windscada',  dict(helper='P.store()',            purpose='L0 标准仓: SCADA/台账/派生分析的全部 parquet (页面主取数处)',
-                        producer='rebuild_from_raw.py (三门台账) + --scada (10 个构建器) + windscada_monthly_build.py',
-                        consumer='scripts/windscada_serve.py 各视图 · src/windscada/taxonomy · subsys/fusion')),
-    ('ontology',   dict(helper='P.ont()',              purpose='本体对象库: 码表/手册/工单展开/失效树 + 检索索引 + 实机参数',
-                        producer='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest → retrieval.build',
-                        consumer='脚本 windscada_serve.py 本体页/问答 · scripts/guanlan_facts_contract.py')),
-    ('windcms',    dict(helper='P.cms()',              purpose='CMS 振动诊断产物: 状态评估报告/逐台页/工作台页/知识库 + 厂家报告转录',
-                        producer='scripts/windcms.py report/kb · scripts/vib_reports_build.py',
-                        consumer='自服务 :18020 (每请求现读) · taxonomy.system_matrix (转录设备状态)')),
-    ('m5_cms_tcm', dict(helper='P.m5()',               purpose='振动线出件与窗级分析: handoff 接口 + 窗索引/谱库 + TCM 兼容件',
-                        producer='scripts/vib_raw_build.py (窗索引/谱) · 振动线出件 (handoff, 随包快照)',
-                        consumer='src/windscada/subsys/fusion.py · src/windcms/data.py · scripts/windcms.py report')),
-    ('tcm_compatible_replay', dict(helper='P.tcm_replay()', purpose='TCM 兼容链回放资产 (模型表/掩码阈值/裁决记录)',
-                        producer='随包快照 (无生成端)', consumer='src/windcms/report*.py · config.mask_thresholds')),
-    ('sop',        dict(helper='P.sop()',              purpose='SOP 中间件/评审/台账与事实契约底稿',
-                        producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py · 门户结论段')),
-    ('guanlan',    dict(helper='P.guanlan()',          purpose='事实契约与对外派生 (可上云面孔)',
-                        producer='scripts/guanlan_facts_contract.py', consumer='门户 #findings · /api/facts')),
-    ('pitch',      dict(helper='P.pitch()',            purpose='变桨侧派生件 (日粒度液压/润滑/柱塞 + 零位三口径月表)',
-                        producer='scripts/pitch_face_build.py (重算链 ④c; scada_10min + scada_1min)',
-                        consumer='脚本 windscada_serve.py 变桨面 · src/windscada/subsys/pitch.py')),
-    ('paradigm_r1', dict(helper='(无助手: P.out_root()/paradigm_r1)', purpose='范式实验件 (E3/E5/E8 底稿, 事实契约输入)',
-                        producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py')),
-])
-
-# ── 输入类 → (放什么, 喂哪些产物, 跨度判据) ────────────────────────────────────────
-# span: 'csv_col' = 逐台 CSV 的首/末数据行; 'fname' = 从文件名年份/日期推; 'dir' = 目录名 (年/月)
-INPUTS = OrderedDict([
-    ('scada_10min', dict(what='SCADA 10min 导出 (逐台 WTG01.csv…WTG38.csv)', span='csv_col', time_col='Time',
-                         feeds=['windscada/powercurve*.parquet', 'windscada/loss_monthly.parquet', 'windscada/temp_monthly.parquet',
-                                'windscada/temp_bins.parquet', 'windscada/stop_events.parquet', 'windscada/yaw_daily.parquet',
-                                'windscada/hydraulic_accum.parquet', 'windscada/thermal_chain.parquet',
-                                'windscada/curve_*.parquet', 'windscada/control_*.parquet', 'windscada/system_aux.parquet',
-                                'pitch/pitch_daily.parquet (日粒度液压/润滑/柱塞)'])),
-    ('scada_1min', dict(what='1min 导出 (逐台内部台号 01E.csv…38B.csv)', span='csv_col', time_col='occur_time',
-                        feeds=['windscada/watch_channels_monthly.parquet (watch 月度)',
-                               'pitch/pitch_daily.parquet (越卸荷/跌启泵分钟计数)',
-                               'pitch/pitch_zero_monthly.parquet (停机顺桨段/满发段零位两口径)'])),
-    ('故障报警',   dict(what='报警事件导出 (SpreadsheetML *.xls)', span='fname_year',
-                        feeds=['windscada/alarms.parquet'])),
-    ('风机故障记录', dict(what='检修工单台账 (*.xls/xlsx)', span='fname_year',
-                        feeds=['windscada/workorders.parquet', 'windscada/mblub_monthly.parquet'])),
-    ('油样报告',   dict(what='油液化验报告 (*.pdf)', span='fname_date',
-                        feeds=['windscada/oil_samples_index.parquet'])),
-    ('windcms',    dict(what='CMS 原始测量导出 (Brande TCM *_decode.json) + 厂家评估报告', span='dir',
-                        feeds=['m5_cms_tcm/windows/<窗>/{index.parquet,spectra/*}', 'windcms/厂家报告提取_*.json'])),
-    ('m5_cms_tcm', dict(what='振动线出件与 TCM 侧报告', span='none',
-                        feeds=['m5_cms_tcm/handoff_vibration_v2.json (现场正本优先)', 'windcms/报告_TCM传动链振动分析_*.md'])),
-])
-# 机理层 (不在场站目录下)
-TECH_INPUT = ('西门子4.0技术资料', '厂商技术资料 (故障处理手册/维护 WI/图纸/对译表)',
-              ['ontology/objects.json', 'ontology/turbine_params.parquet', 'ontology/retrieval_index.json'])
-
-
-def _rows_of(path: pathlib.Path, probe: int = 1):
-    """只读首/末若干行, 不整表载入 (scada csv 单文件几百 MB)。"""
-    try:
-        with open(path, 'rb') as f:
-            head = f.readline()
-            f.seek(max(0, path.stat().st_size - 65536))
-            tail = f.read().splitlines()
-        return head, (tail[-1] if tail else b'')
-    except Exception:
-        return b'', b''
-
-
-def input_span(station: pathlib.Path, sub: str, spec: dict):
-    """→ (起, 止, 件数, 说明, 粒度)。粒度 ∈ {'日','月','年'}: 只有'日/月'才参与"产物落后"判定 ——
-    按文件名年份推出来的跨度天然是**年粒度**, 拿它当"输入到 2026-12"会造出假缺口 (本脚本第一版就这么
-    误报过 2 条: 报警/工单"落后 5 个月"), 故显式带粒度、按粒度决定能不能比。"""
-    d = station / sub
-    if not d.is_dir():
-        return None, None, 0, '目录不存在', '年'
-    files = [p for p in d.rglob('*') if p.is_file()]
-    kind = spec.get('span')
-    if kind == 'csv_col':
-        col = spec.get('time_col')
-        starts, ends = [], []
-        for p in files[:60]:
-            try:
-                with open(p, 'rb') as f:
-                    first = f.readline()          # 表头
-                    first = f.readline()          # 第一条数据行
-                    f.seek(max(0, p.stat().st_size - 65536))
-                    last = f.read().splitlines()[-1]
-                hdr = first.decode('utf-8', 'replace').strip().split(',')
-                i = hdr.index(col) if col in hdr else 0
-                starts.append(hdr[i])
-                ends.append(last.decode('utf-8', 'replace').split(',')[0])
-            except Exception:
-                continue
-        if starts and ends:
-            return min(starts), max(ends), len(files), f'{len(files)} 件 (抽样 {len(starts)} 件首/末数据行)', '日'
-        return None, None, len(files), 'CSV 首/末行解析失败', '年'
-    if kind == 'fname_year':
-        ys = sorted({int(m.group()) for p in files for m in [re.search(r'(20\d\d)', p.name)] if m})
-        return (f'{ys[0]}-01' if ys else None), (f'{ys[-1]}-12' if ys else None), len(files), \
-               f'{len(files)} 件 (文件名年份, 年粒度)', '年'
-    if kind == 'fname_date':
-        ds = []
-        for p in files:
-            m = re.search(r'(\d{2})(\d{2})(20\d\d)', p.name)
-            if m:
-                ds.append(f'{m.group(3)}-{m.group(2)}-{m.group(1)}')
-        return (min(ds) if ds else None), (max(ds) if ds else None), len(files), \
-               f'{len(files)} 件 (文件名日期)', '日'
-    if kind == 'dir':
-        months = sorted({f'{m.group(1)}-{m.group(2)}' for p in d.rglob('*')
-                         for m in [re.search(r'[/\\](20\d\d)[/\\](\d{2})[/\\]', str(p))] if m})
-        return (months[0] if months else None), (months[-1] if months else None), len(files), \
-               f'{len(files)} 件 (目录年月 {", ".join(months) or "未识别"})', '月'
-    return None, None, len(files), '按件数清点 (无跨度判据)', '年'
-
-
-def product_stats(farm: str):
-    """→ {仓: dict(n, bytes, files: [(名, 类型, 大小)], kinds)}。"""
-    out = {}
-    for name in STORES:
-        d = P.out_root(farm) / name
-        if not d.is_dir():
-            out[name] = dict(n=0, bytes=0, kinds={}, newest=None)
-            continue
-        files = [p for p in d.rglob('*') if p.is_file()]
-        kinds = {}
-        for p in files:
-            k = p.suffix.lower() or '(无扩展名)'
-            kinds[k] = kinds.get(k, 0) + 1
-        newest = max((p.stat().st_mtime for p in files), default=None)
-        out[name] = dict(n=len(files), bytes=sum(p.stat().st_size for p in files), kinds=kinds,
-                         newest=(dt.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M') if newest else None))
-    return out
-
-
-def provenance(farm: str):
-    root = P.out_root(farm)
-    prov = {}
-    for fn in ('_provenance.json', '_derived_manifest.json'):
-        f = root / fn
-        if not f.exists():
-            continue
-        try:
-            d = json.loads(f.read_text(encoding='utf-8'))
-        except Exception:
-            continue
-        for rel, v in (d.get('files') or {}).items():
-            src = v.get('source') if isinstance(v, dict) else None
-            prov[rel] = dict(source=src or 'raw-derived',
-                             builder=(v.get('builder') if isinstance(v, dict) else str(v)) or '')
-    return prov
-
-
-def span_check(farm: str, station: pathlib.Path, verbose=True):
-    """逐类输入: 算输入跨度 + 对应产物的跨度/条数, 报联动结论。→ (rows, problems)"""
-    import pandas as pd
-    rows, problems = [], []
-    ST = P.store(farm)
-
-    def pspan(p: pathlib.Path, col=None, path_cols=()):
-        if p is None or not p.exists():
-            return None, None, 0
-        try:
-            if p.suffix == '.parquet':
-                d = pd.read_parquet(p)
-                n = len(d)
-                c = col if col in d.columns else next((c for c in path_cols if c in d.columns), None)
-                if c is None:
-                    return None, None, n
-                s = pd.to_datetime(d[c], errors='coerce')
-                return ((str(s.min())[:10] if s.notna().any() else None),
-                        (str(s.max())[:10] if s.notna().any() else None), n)
-        except Exception:
-            return None, None, 0
-        return None, None, 0
-
-    # 输入类 → 它喂出来的产物 (路径, 时间列)
-    PAIRS = {
-        'scada_10min': [('windscada/temp_monthly.parquet', ST / 'temp_monthly.parquet', 'month'),
-                        ('windscada/loss_monthly.parquet', ST / 'loss_monthly.parquet', 'month'),
-                        ('windscada/powercurve_bins.parquet', ST / 'powercurve_bins.parquet', None)],
-        '故障报警': [('windscada/alarms.parquet', ST / 'alarms.parquet', 't_on')],
-        '风机故障记录': [('windscada/workorders.parquet', ST / 'workorders.parquet', 't_report')],
-        '油样报告': [('windscada/oil_samples_index.parquet', ST / 'oil_samples_index.parquet', 'date')],
-    }
-    for sub, spec in INPUTS.items():
-        if sub == 'm5_cms_tcm':
-            continue                                     # 出件不是"测量输入", 无跨度判据
-        i_s, i_e, i_n, note, gran = input_span(station, sub, spec)
-        comparable = gran in ('日', '月')                 # 年粒度不参与"落后"判定 (见 input_span 注释)
-        if sub == 'windcms':
-            # ★glob 必须写 `w[0-9][0-9][0-9][0-9]`: `w[????]` 是"字符类里含 ? 之一", 匹配不到任何窗名
-            #  (本脚本第一版就这么写, windcms 那行因此整行不出现 —— 静默漏项, 不是报错)
-            wins = [w for w in sorted((P.m5(farm) / 'windows').glob('w[0-9][0-9][0-9][0-9]'))
-                    if (w / 'index.parquet').exists()]
-            if wins:
-                w = wins[-1]
-                p_s, p_e, p_n = pspan(w / 'index.parquet', 'trigger_time')
-                rows.append(dict(input=sub, note=note, in_span=f'{i_s} ~ {i_e}', files=i_n,
-                                 product=f'm5_cms_tcm/windows/{w.name}/index.parquet',
-                                 prod_span=f'{p_s} ~ {p_e}', rows_n=p_n))
-                if p_s and i_s and p_s[:7] < i_s[:7]:
-                    problems.append(f'{sub}: 窗 {w.name} 索引起点 {p_s} 早于输入最早月 {i_s} (跨窗混入?)')
-            continue
-        for label, path, col in PAIRS.get(sub, []):
-            p_s, p_e, p_n = pspan(path, col)
-            rows.append(dict(input=sub, note=note, in_span=f'{i_s} ~ {i_e}', files=i_n, gran=gran,
-                             product=label, prod_span=f'{p_s} ~ {p_e}', rows_n=p_n))
-            if comparable and i_e and p_e and _months_between(p_e[:7], i_e[:7]) >= 2:
-                problems.append(f'{sub}: 产物 {label} 只到 {p_e}, 输入到 {i_e} '
-                                f'(落后 {_months_between(p_e[:7], i_e[:7])} 个月) → 未重算 或 该产物无生成端')
-            elif not comparable and p_e:
-                pass                                     # 年粒度: 跨度只作参考, 不判落后 (避免假缺口)
-    if verbose:
-        for r in rows:
-            print(f"  {r['input']:12s} 输入 {r['in_span']:26s} {r['files']:6d} 件 | "
-                  f"{r['product']:52s} {r['rows_n']:8d} 行 {r['prod_span']}")
-    return rows, problems
-
-
-def _months_between(a: str, b: str) -> int:
-    try:
-        ya, ma = int(a[:4]), int(a[5:7])
-        yb, mb = int(b[:4]), int(b[5:7])
-        return (yb - ya) * 12 + (mb - ma)
-    except Exception:
-        return 0
-
-
-def doc_block(farm: str) -> str:
-    """生成写进 docs/系统设计说明.md 的清单块 (Markdown)。"""
-    ps = product_stats(farm)
-    prov = provenance(farm)
-    from collections import Counter
-    by_store = Counter()
-    for rel, v in prov.items():
-        by_store[(rel.split('/')[0], v['source'])] += 1
-    L = [f'<!-- INVENTORY:BEGIN (由 scripts/inventory_products.py --write-doc 生成, 勿手改) -->',
-         f'*自动生成于 {dt.datetime.now():%Y-%m-%d %H:%M};数据源: `outputs/{farm}/_provenance.json` + `_derived_manifest.json` + 实际文件*', '',
-         '| 产物仓 | 输出路径 (相对安装根) | 功用 | 生成端 | 消费端 | 件数 | 大小 | 来源(raw 重算/随包) |',
-         '|---|---|---|---|---|---|---|---|']
-    for name, meta in STORES.items():
-        st = ps[name]
-        rd = by_store.get((name, 'raw-derived'), 0)
-        sh = by_store.get((name, 'shipped'), 0)
-        path = f'outputs/{farm}/{name}/'
-        L.append(f"| `{name}` | `{path}` | {meta['purpose']} | {meta['producer']} | {meta['consumer']} | "
-                 f"{st['n']} | {st['bytes'] / 1048576:.1f} MB | {rd} / {sh} |")
-    L += ['', f'合计 {sum(s["n"] for s in ps.values())} 件 / {sum(s["bytes"] for s in ps.values()) / 1048576:.1f} MB;'
-              f'其中 raw 重算 {sum(v for (_, k), v in by_store.items() if k == "raw-derived")} 件、'
-              f'随包补齐 {sum(v for (_, k), v in by_store.items() if k == "shipped")} 件。', MARK_E]
-    return '\n'.join(L)
-
-
-def write_doc(farm: str) -> int:
-    block = doc_block(farm)
-    txt = DOC.read_text(encoding='utf-8') if DOC.exists() else ''
-    if MARK_B in txt and MARK_E in txt:
-        head = txt.split(MARK_B)[0]
-        tail = txt.split(MARK_E)[1]
-        new = head + block + tail
-    else:
-        new = (txt + '\n\n## 附: 产物清单 (自动生成)\n\n' + block + '\n') if txt else block + '\n'
-    DOC.parent.mkdir(parents=True, exist_ok=True)
-    DOC.write_text(new, encoding='utf-8')
-    print(f'已写产物清单 → {P.rel(DOC)}')
-    return 0
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--farm', default=os_farm())
-    ap.add_argument('--check', action='store_true', help='只做呼应校验 (有问题退出码 5)')
-    ap.add_argument('--write-doc', action='store_true', help='把清单块写进 docs/系统设计说明.md')
-    ap.add_argument('--json', action='store_true')
-    a = ap.parse_args()
-    from src.windscada.config import farm as _farm, raw_station_dir
-    station = pathlib.Path(raw_station_dir(a.farm))
-    ps = product_stats(a.farm)
-
-    if a.write_doc:
-        return write_doc(a.farm)
-
-    print(f'=== 产物仓 ({len(STORES)} 个) @ outputs/{a.farm}/ ===')
-    for name, meta in STORES.items():
-        st = ps[name]
-        kinds = ' '.join(f'{k}×{v}' for k, v in sorted(st['kinds'].items(), key=lambda kv: -kv[1])[:5])
-        print(f"  {name:24s} {st['n']:6d} 件 {st['bytes'] / 1048576:9.1f} MB  {meta['helper']:28s} {kinds}")
-        print(f"     功用: {meta['purpose']}")
-
-    print(f'\n=== 输入 ↔ 产物 呼应 (输入根 {P.rel(station)}) ===')
-    rows, problems = span_check(a.farm, station)
-    if not rows:
-        print('  (无可对拍项)')
-    print(f'\n=== 结论: {"呼应正常" if not problems else str(len(problems)) + " 项要处理"} ===')
-    for p in problems:
-        print(f'  [!] {p}')
-    if a.json:
-        print(json.dumps(dict(products={k: {kk: vv for kk, vv in v.items() if kk != "kinds"} for k, v in ps.items()},
-                              spans=rows, problems=problems), ensure_ascii=False, indent=1))
-    return 5 if (a.check and problems) else 0
-
-
-def os_farm() -> str:
-    import os
-    return os.environ.get('WINDSCADA_FARM') or 'rudong'
-
-
-if __name__ == '__main__':
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/inventory_products.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/inventory_products.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/inventory_products.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import inventory_products as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 252
scripts/log_audit.py

@@ -1,266 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""日志审计 —— 把"日志目录/命名/格式统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/log_audit.py`。**本文件不得新增逻辑**。
 
-## 五条规则
-
-    L1 只在 logs/ 下    全仓的 `*.log` / `*.jsonl` 只许出现在 `logs/` (白名单: 产物里的
-                        `analyze_stdout.log` 属**产物附件**, 与运行日志不是一回事, 见登记理由)。
-    L2 命名              `logs/<组件>.log`(组件 = configs/serve.json 的服务键 + gateway/serve/start_hidden)、
-                        `logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log`、`logs/audit/<名字>.jsonl`。别的名字报出来。
-    L3 行格式            每一行必须匹配 `YYYY-MM-DD HH:MM:SS LEVEL 组件 消息` (src/logfile.py::LINE_RE)。
-                        **按文件末尾 200 行判**: 启用统一格式之前的旧行不算数 (这批已归档到 logs/legacy/)。
-    L4 行尾与颜色        logs/ 下的文本日志不许有 CRLF、不许有 ANSI 颜色码 (重定向到文件后颜色码是垃圾字节)。
-    L5 保留策略          `logs/ops/` 只留最近 20 份 / 30 天; 超出即报 (并可用 --prune 就地清理)。
-                        `logs/audit/*.jsonl` 每行必须是合法 JSON。
-
-## 退出码
-    0 全部通过 · 5 日志跑到 logs/ 外面 · 6 命名不合口径 · 7 行格式不合规 · 8 行尾/颜色问题 · 9 保留策略超限
-
-## 用法
-    python scripts/log_audit.py            # 检查
-    python scripts/log_audit.py --prune    # 检查并就地清理超限的动作日志
-    python scripts/log_audit.py --json
+入口路径(`scripts/log_audit.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import json
-import os
 import pathlib
-import re
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-from src import logfile as L                                          # noqa: E402
-from src import paths as P                                            # noqa: E402
-
-SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor', '.git-*'}
-# logs/ 之外的日志白名单: (正则, 理由)。两类:
-#   ① 产物附件: CMS 插件把"这次分析的 stdout"当产物附件留在产物目录;
-#   ② 第三方工具的缓存/配置库: npm 缓存、Puppeteer(Chromium) 的 QA profile —— 它们的 000003.log
-#      是 LevelDB 内部文件, 不是本系统的运行日志 (删不删是另一件事, 见 docs §11 的清理建议)。
-OUTSIDE_OK = [
-    (re.compile(r'^outputs/.*/windcms/.*analyze_stdout\.log$'),
-     'CMS 插件把"这次分析的 stdout"当作产物附件留在产物目录 (属产物, 不是运行日志)'),
-    (re.compile(r'^(?:.*/)?(?:\.npm-cache|\.qa-profile)/'),
-     '第三方工具 (npm 缓存 / Chromium QA profile) 自带的状态文件, 非本系统日志'),
-    (re.compile(r'^(?:.*/)?node_modules/'),
-     'node_modules 里的第三方包自带日志'),
-]
-TAIL_LINES = 200
-
-
-def scan_outside() -> list[pathlib.Path]:
-    found = []
-    for dp, dn, fns in os.walk(ROOT):
-        dn[:] = [d for d in dn if d not in SKIP_WALK and not d.startswith('.git')]
-        rel_dir = pathlib.Path(dp).relative_to(ROOT).as_posix()
-        if rel_dir == 'logs' or rel_dir.startswith('logs/'):
-            dn[:] = []
-            continue
-        for f in fns:
-            if f.endswith(('.log', '.jsonl')):
-                found.append(pathlib.Path(dp) / f)
-    return found
-
-
-def tail_lines(p: pathlib.Path, n: int = TAIL_LINES) -> list[str]:
-    try:
-        data = p.read_bytes()
-    except OSError:
-        return []
-    txt = data.decode('utf-8', 'replace')
-    lines = txt.replace('\r\n', '\n').split('\n')
-    return lines[-n:]
-
-
-def migrate_product_logs(dry: bool = True) -> list:
-    """把产物目录里的日志搬进 `logs/build/`, 并同步台账 (用户令 2: 日志不许留在产物里)。
-
-    为什么要搬: 交付包里 39 个 `.log` 躺在 `outputs/<场>/…` 下, 还被 `_provenance.json`
-    登记成 `shipped` 随包件 —— 产物台账里混着日志, 排障时也找不到。搬迁后:
-      · 文件去 `logs/build/<场>/<原相对路径>`;
-      · 台账里对应条目**删掉并重算计数**, 并写明"因日志归位而移出" (只搬文件不改台账 = 台账失真)。
-    返回 (moved, ledger_notes)。
-    """
-    moved, notes = [], []
-    out = P.ROOT / 'outputs'
-    if not out.is_dir():
-        return moved, notes
-    for f in sorted(out.rglob('*.log')):
-        rel = f.relative_to(out)                       # 如 rudong/windscada/temp_build.log
-        dst = L.LOGS / 'build' / rel
-        moved.append((f, dst))
-        if dry:
-            continue
-        dst.parent.mkdir(parents=True, exist_ok=True)
-        if dst.exists():
-            dst = dst.with_name(f'{dst.stem}_{int(dst.stat().st_mtime)}{dst.suffix}')
-        f.replace(dst)
-    if dry or not moved:
-        return moved, notes
-    for farm_dir in sorted(x for x in out.iterdir() if x.is_dir()):
-        prov_p = farm_dir / '_provenance.json'
-        if not prov_p.is_file():
-            continue
-        prov = json.loads(prov_p.read_text(encoding='utf-8'))
-        files = prov.get('files') or {}
-        drop = [k for k in files if str(k).endswith('.log')]
-        for k in drop:
-            files.pop(k, None)
-        if drop:
-            counts = {}
-            for v in files.values():
-                s = v.get('source') if isinstance(v, dict) else '?'
-                counts[s] = counts.get(s, 0) + 1
-            prov['counts'] = counts
-            prov['files'] = files
-            prov['note'] = (str(prov.get('note', '')) +
-                            f' | 2026-09-17 因「日志归位」(用户令 2) 移出 {len(drop)} 个 .log 条目: '
-                            f'那批是构建日志, 现位于 logs/build/{farm_dir.name}/ 下')
-            prov_p.write_text(json.dumps(prov, ensure_ascii=False, indent=1), encoding='utf-8')
-            notes.append(f'{farm_dir.name}/_provenance.json: 台账移出 {len(drop)} 个 .log 条目并重算计数')
-    return moved, notes
-
-
-def audit() -> tuple[int, list, dict]:
-    res: list[tuple[str, str, str, int]] = []
-    L.ensure_dirs()
-    n_svc = n_ops = n_audit = n_build = 0
-
-    # L1 只在 logs/ 下
-    for f in scan_outside():
-        rel = f.relative_to(ROOT).as_posix()
-        if any(r.match(rel) for r, _ in OUTSIDE_OK):
-            why = next(w for r, w in OUTSIDE_OK if r.match(rel))
-            res.append(('i', rel, f'白名单: {why}', 0))
-        else:
-            res.append(('X', rel, '日志跑到 logs/ 外面了 (用户令 2: 运行日志只在 logs/)', 5))
-
-    # L2/L3/L4/L5
-    for f in sorted(L.LOGS.rglob('*')):
-        if not f.is_file():
-            continue
-        rel = f.relative_to(ROOT).as_posix()
-        if 'legacy' in f.parts:
-            continue
-        if f.suffix == '.jsonl':
-            n_audit += 1
-            for i, ln in enumerate(tail_lines(f), 1):
-                if not ln.strip():
-                    continue
-                try:
-                    json.loads(ln)
-                except Exception:
-                    res.append(('X', rel, f'审计流水第 {i} 行不是合法 JSON (jsonl 一行一条)', 7))
-                    break
-            if f.parent != L.AUDIT_DIR:
-                res.append(('!', rel, '审计流水应放 logs/audit/ 下', 6))
-            continue
-        if f.suffix != '.log':
-            if f.name.endswith('.json'):
-                n_audit += 1
-                if f.parent != L.AUDIT_DIR:
-                    res.append(('!', rel, '审计/报告类 JSON 应放 logs/audit/ 下', 6))
-            continue
-        if f.parent == L.OPS_DIR:
-            n_ops += 1
-            if not re.match(r'^[\w\-]+_\d{8}-\d{6}\.log$', f.name):
-                res.append(('!', rel, '动作日志命名应为 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log', 6))
-        elif L.BUILD_DIR in f.parents:
-            n_build += 1
-            # 构建日志: 允许按产物子路径归档 (logs/build/<场>/<原相对路径>.log)。
-            # 这些是**从产物目录归位过来的历史文件**, 由未随包的构建器写成, 无法追溯重排格式 ⇒ 只提示。
-            lines = [ln for ln in tail_lines(f) if ln.strip()]
-            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
-            if bad:
-                res.append(('?', rel, f'历史构建日志 {len(bad)}/{len(lines)} 行不是统一格式 '
-                                       f'(写它的人不在本包; 新构建器请用 logfile.build_log() + logfile.line())', 0))
-            continue                      # 历史构建日志不再套"服务日志"那套严格检查 (没法追溯重排)
-        elif f.parent == L.LOGS:
-            n_svc += 1
-            if not re.match(r'^[\w\u4e00-\u9fff.\-]+\.log$', f.name):
-                res.append(('!', rel, '服务日志命名应为 logs/<组件>.log', 6))
-        else:
-            res.append(('!', rel, 'logs/ 下只允许 服务日志(顶层)、ops/、audit/、build/ 四处', 6))
-
-        raw = f.read_bytes()
-        if b'\r\n' in raw:
-            res.append(('?', rel, f'含 CRLF ({raw.count(bytes([13, 10]))} 处) —— 新写日志统一 LF', 0))
-        if L.ANSI_RE.search(raw.decode('utf-8', 'replace')):
-            res.append(('?', rel, '含 ANSI 颜色码 (历史行; 重定向到文件后是垃圾字节)', 0))
-        lines = [ln for ln in tail_lines(f) if ln.strip()]
-        if lines:
-            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
-            if len(bad) == len(lines):
-                res.append(('X', rel, f'末尾 {len(lines)} 行都不符合统一格式 (应为 "时间戳 级别 组件 消息")', 7))
-            elif bad:
-                res.append(('?', rel, f'末尾 {len(lines)} 行里 {len(bad)} 行不符格式 (多为启用前的旧行)', 0))
-
-    # L5 保留策略
-    over = L.prune_actions(dry=True)
-    if over:
-        res.append(('X', f'{P.rel(L.OPS_DIR)}', f'{len(over)} 份动作日志超出保留策略 (最近 {L.ACTION_KEEP} 份 / {L.ACTION_DAYS} 天)', 9))
-
-    info = dict(service_logs=n_svc, ops_logs=n_ops, audit_files=n_audit, build_logs=n_build,
-                ops_over_quota=len(over), logs_dir=str(L.LOGS.relative_to(ROOT)))
-    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
-    return (max(rc_map) if rc_map else 0), res, info
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/log_audit.py'
 
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--prune', action='store_true', help='就地清理超限的动作日志')
-    ap.add_argument('--migrate', action='store_true', help='把产物目录里的日志搬到 logs/build/ 并同步台账')
-    ap.add_argument('--yes', action='store_true', help='--migrate 时真正执行 (默认只预演)')
-    ap.add_argument('--json', action='store_true')
-    a = ap.parse_args()
-    rc, res, info = audit()
-    if a.migrate:
-        moved, notes = migrate_product_logs(dry=not a.yes)
-        print(f'{"预演" if not a.yes else "已执行"}日志归位: {len(moved)} 个文件 '
-              f'(产物目录 → logs/build/)')
-        for src, dst in moved[:5]:
-            print(f'   {src.relative_to(ROOT).as_posix()}  →  {dst.relative_to(ROOT).as_posix()}')
-        if len(moved) > 5:
-            print(f'   … 另有 {len(moved) - 5} 个')
-        for n in notes:
-            print(f'   台账: {n}')
-        if not a.yes:
-            print('   (要真搬请加 --yes)')
-        rc, res, info = audit()
-    if a.prune:
-        gone = L.prune_actions()
-        print(f'已清理动作日志 {len(gone)} 份: {[g.name for g in gone[:5]]}{" …" if len(gone) > 5 else ""}')
-        rc, res, info = audit()
-    if a.json:
-        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
-                              info=info), ensure_ascii=False, indent=1))
-        return rc
-    print('== 日志口径 (src/logfile.py, 用户令 2) ==')
-    print(f'   服务日志 {info["service_logs"]} 份 (logs/<组件>.log) · 动作日志 {info["ops_logs"]} 份 '
-          f'(logs/ops/, 保留 {L.ACTION_KEEP} 份/{L.ACTION_DAYS} 天) · 审计流水 {info["audit_files"]} 份 (logs/audit/) · '
-          f'构建日志 {info["build_logs"]} 份 (logs/build/<场>/…)')
-    print(f'   行格式   {L.line("INFO", "示例", "一行长这样")}')
-    lvl = {}
-    for level, item, note, r in res:
-        lvl[level] = lvl.get(level, 0) + 1
-    print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · 白名单 {lvl.get("i", 0)} ==')
-    for level, item, note, r in res:
-        if level in ('X', '!'):
-            print(f'   [{level}] {item}: {note}')
-    seen = set()
-    for level, item, note, r in res:
-        if level == '?' and note not in seen:
-            seen.add(note)
-            print(f'   [?] {item}: {note}')
-    print(f'结论: {"全部符合口径" if rc == 0 else "见上"}; 退出码 {rc}')
-    return rc
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import log_audit as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    from src import console
-    console.soft()
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 175
scripts/module_boundary_audit.py

@@ -1,189 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""模块边界审计(用户令 2026-09-22:按算法/数据接入管理/前端/后端等模块重构,组件化、高内聚低耦合、可插拔)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/module_boundary_audit.py`。**本文件不得新增逻辑**。
 
-## 它查什么(每条都由 `configs/modules.yaml` 驱动)
-
-| 规则 | 内容 | 退出码 |
-|---|---|---|
-| R1 结构 | 每个登记模块目录含 `common/<pkg>/__init__.py`、`common/<pkg>/api.py`、`README.md` | 5 |
-| R2 边界 | 模块之间**只许经对方 `api.py`**:`app_x/**/*.py` 里 import 另一个 `app_y` 的实现包(非 `api`)即违规 | 6 |
-| R3 依赖方向 | 只许 import `allow` 里列出的模块;反向依赖/成环即违规 | 7 |
-| R4 公共层纯净 | `app_common/**` 不得 import 任何其它业务模块 | 7 |
-| R5 接口可用 | 每个 `api.py` 的 `_TARGETS` 转发目标(既有实现的文件路径)必须真实存在 | 8 |
-
-另外**统计迁移进度**(不算失败):`src/**`、`scripts/**` 里还有多少件尚未迁进模块目录(P1–P7 的度量)。
-
-## 用法
-    python scripts/module_boundary_audit.py            # 审计(rc=0 通过)
-    python scripts/module_boundary_audit.py --brief    # 只打结论与进度
-退出码: 0 通过 · 5 结构缺失 · 6 边界违规 · 7 依赖违规 · 8 接口目标缺失
+入口路径(`scripts/module_boundary_audit.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import ast
 import pathlib
-import re
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
 
-from src import paths as P                                                  # noqa: E402
-
-
-def load_registry() -> dict:
-    import yaml
-    return yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
-
-
-def module_pkg(name: str) -> str:
-    return f'{name}_guanlan'
-
-
-def _imports_of(path: pathlib.Path) -> list[str]:
-    """→ 该文件里所有 import 的模块名(`import a.b` 与 `from a.b import c` 都算 a.b)。"""
-    try:
-        tree = ast.parse(path.read_text(encoding='utf-8'))
-    except SyntaxError:
-        return []
-    out = []
-    for n in ast.walk(tree):
-        if isinstance(n, ast.Import):
-            out += [a.name for a in n.names]
-        elif isinstance(n, ast.ImportFrom) and n.module:
-            out.append(n.module)
-    return out
-
-
-def audit(verbose: bool = True):
-    reg = load_registry()
-    mods = {m['name']: m for m in reg['modules']}
-    problems: list[tuple[str, str]] = []          # (规则, 说明)
-    notes: list[str] = []
-
-    # R1 结构
-    for name, m in mods.items():
-        base = ROOT / name
-        for rel in (f'common/__init__.py', f'common/{module_pkg(name)}/__init__.py',
-                    f'common/{module_pkg(name)}/api.py', 'README.md'):
-            if not (base / rel).is_file():
-                problems.append(('R1', f'{name}/ 缺 {rel}'))
-    # R5 接口目标存在
-    for name in mods:
-        api = ROOT / name / 'common' / module_pkg(name) / 'api.py'
-        if not api.is_file():
-            continue
-        try:
-            tree = ast.parse(api.read_text(encoding='utf-8'))
-        except SyntaxError as e:
-            problems.append(('R1', f'{name}/api.py 语法错误: {e}'))
-            continue
-        tg = {}
-        for n in tree.body:
-            if isinstance(n, ast.Assign) and getattr(n.targets[0], 'id', '') == '_TARGETS':
-                try:
-                    tg = ast.literal_eval(n.value)
-                except Exception:  # noqa: BLE001
-                    tg = {}
-        for pub, target in tg.items():
-            rel = pathlib.Path(*target.split('.'))
-            if not ((ROOT / rel).with_suffix('.py').is_file() or (ROOT / rel / '__init__.py').is_file()):
-                problems.append(('R5', f'{name}.api.{pub} → {target} 的转发目标不存在'))
-
-    # R2/R3/R4 边界与依赖
-    for name, m in mods.items():
-        allow = set(m.get('allow') or [])
-        for f in (ROOT / name).rglob('*.py'):
-            for imp in _imports_of(f):
-                parts = imp.split('.')
-                if parts[0] not in mods or parts[0] == name:
-                    continue
-                other = parts[0]
-                # 只许 app_<other>.common.app_<other>_guanlan.api
-                ok_face = (len(parts) >= 4 and parts[1] == 'common'
-                           and parts[3] == 'api' and parts[2] == module_pkg(other))
-                if not ok_face:
-                    problems.append(('R2', f'{f.relative_to(ROOT).as_posix()} 直接 import {imp}'
-                                           f'(应走 {other}.common.{module_pkg(other)}.api)'))
-                if other not in allow:
-                    problems.append(('R3', f'{f.relative_to(ROOT).as_posix()} import {other}'
-                                           f',但 {name} 的 allow 未包含它'))
-                if name == 'app_common':
-                    problems.append(('R4', f'{f.relative_to(ROOT).as_posix()} 公共层依赖了业务模块 {other}'))
-
-    # R6 入口与布局解耦: 安装器必须能在当前布局下解析出包版本
-    _ver_cands = ['app_common/common/app_common_guanlan/version.py', 'src/version.py']
-    _found = None
-    for _c in _ver_cands:
-        _f = ROOT / _c
-        if _f.is_file() and re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M):
-            _found = (_c, re.search(r"^VERSION\s*=\s*'([^']+)'", _f.read_text(encoding='utf-8'), re.M).group(1))
-            break
-    if not _found:
-        problems.append(('R6', f'当前布局下没有任何候选版本文件含 `VERSION = \'…\'` 字面量(候选: {_ver_cands})'
-                               ' ⇒ install.ps1/install.sh 会读不到包版本'))
-    else:
-        _ps1 = (ROOT / 'install.ps1').read_text(encoding='utf-8') if (ROOT / 'install.ps1').is_file() else ''
-        _sh = (ROOT / 'install.sh').read_text(encoding='utf-8') if (ROOT / 'install.sh').is_file() else ''
-        if _ps1 and 'app_common' not in _ps1:
-            problems.append(('R6', 'install.ps1 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
-        if _sh and 'app_common' not in _sh:
-            problems.append(('R6', 'install.sh 未包含新布局的候选路径(app_common/...)⇒ 平台件搬家后会读不到版本'))
-
-    # R7 入口读 JSON 必须指定 UTF-8(PS 5.1 默认 ANSI)
-    _ps1f = ROOT / 'install.ps1'
-    if _ps1f.is_file():
-        for _i, _ln in enumerate(_ps1f.read_text(encoding='utf-8').splitlines(), 1):
-            if _ln.strip().startswith('#'):
-                continue          # 注释里提到 Get-Content/.json 不算
-            if 'Get-Content' in _ln and '.json' in _ln and '-Encoding' not in _ln:
-                problems.append(('R7', f'install.ps1:{_i} 读 JSON 未指定 -Encoding UTF8 ⇒ PS 5.1 会按 ANSI 解码中文'))
-
-    # R8 CLI 兼容壳必须同面转发实现的公开名(否则 importlib 调用其函数会 AttributeError)
-    for _shim in sorted((ROOT / 'scripts').glob('*.py')):
-        _txt = _shim.read_text(encoding='utf-8')
-        if '兼容转发(P' not in _txt:
-            continue
-        if 'globals().setdefault' not in _txt and 'sys.modules[__name__]' not in _txt:
-            problems.append(('R8', f'scripts/{_shim.name} 是兼容壳但未转发实现公开名 '
-                                   '⇒ 用 importlib 调其函数的调用方会 AttributeError'))
-
-    # 迁移进度(不算失败)
-    legacy = [p for p in list((ROOT / 'src').rglob('*.py')) + list((ROOT / 'scripts').rglob('*.py'))
-              if p.is_file()]
-    notes.append(f'尚未迁入模块目录的既有实现件: src/** {len(list((ROOT / "src").rglob("*.py")))} 件 · '
-                 f'scripts/** {len(list((ROOT / "scripts").rglob("*.py")))} 件(迁移进度按 docs/重构方案_模块化_v0.1.md 的 P1–P7 推进)')
-
-    rc = 0
-    if any(p[0] in ('R1',) for p in problems):
-        rc = 5
-    if any(p[0] == 'R2' for p in problems):
-        rc = rc or 6
-    if any(p[0] in ('R3', 'R4', 'R6', 'R7', 'R8') for p in problems):
-        rc = rc or 7
-    if any(p[0] == 'R5' for p in problems):
-        rc = rc or 8
-    if verbose:
-        print('模块边界审计 · 登记模块 %d 个(%s)' % (len(mods), '、'.join(mods)))
-        for n in notes:
-            print('  [i]', n)
-        if problems:
-            for rule, msg in problems:
-                print('  [X] %s %s' % (rule, msg))
-        else:
-            print('  [OK] 结构齐全 · 模块间只经 api 调用 · 依赖方向合规 · 公共层纯净 · 接口目标全在位')
-    return rc, problems, notes, mods
-
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/module_boundary_audit.py'
 
-def main() -> int:
-    brief = '--brief' in sys.argv
-    rc, problems, notes, mods = audit(verbose=not brief)
-    if brief:
-        print('[OK] module_boundary_audit rc=0(登记 %d 模块)' % len(mods) if rc == 0
-              else '[X] module_boundary_audit rc=%d(%d 条违规)' % (rc, len(problems)))
-    return rc
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import module_boundary_audit as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    raise SystemExit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 544
scripts/pack_dist.py

@@ -1,558 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/pack/pack_dist.py`。**本文件不得新增逻辑**。
 
-## 为什么会需要单独一个打包器
-
-直接 `右键压缩整个目录` 会把**本机痕迹**一起打进去, 目标机上要么装不了、要么体积翻倍:
-`.venv`(688 MB, 换机必失效) · `.git`(55 MB) · `_products_off*`(670 MB 本机产物暂存) · `logs` `run`(本机日志与旧 PID)
-· `data/raw`(31 GB 现场原始件, 按约定不随包分发)。
-本器把这些**显式列出来**(含理由), 照单排除 —— "打了什么/没打什么"可复核, 而不是靠记忆。
-
-## 包含 / 排除 (规则即文档)
-
-★ 2026-09-17 用户令: **交付包默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件** ——
-即"开箱是一套干净的程序", 数据与产物到目标机自己放、自己算。默认执行
-`python scripts/pack_dist.py` 就是这四类全不含, 不需要任何开关 (要破例才加 `--with-data` / `--with-products`)。
-
-**包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
-· 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
-· 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
-`pack.sh`) · `requirements.txt`
-· ★**输入数据目录结构**(空目录, 2026-09-19 用户令「包中增加输入数据目录结构,但不包含文件」): `data/` `data/raw/`
-`data/_incoming/` 与 `data/raw/<场站>/{scada_10min, scada_1min, 故障报警, 风机故障记录, 油样报告, windcms,
-m5_cms_tcm, scada_mdb}` —— 共 12 个空目录, **里面一个文件都没有**。结构取自单一真源
-`src/windscada/config.py::STATION_SUBDIRS`, 场站目录名取解析出来的那一个(现场是「如东」)。
-目的: 目标机解压后开箱就能看见"每个约定位置在哪、该往哪个抽屉放原始件", 不必翻文档猜。
-开箱验证会同时查两件事: 12 个目录都在位 + 目录里没混进任何文件。
-
-**不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
-**不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
-`scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
-
-**排除**(每条都写了理由; 前四类就是用户令点名的四类):
-    data/ 里的**文件**                  ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data。
-                                        ★目录结构随包(空目录, 见上); 用户令 2026-09-19
-    outputs/                         ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
-                                        `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
-                                        无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
-    logs/ run/                       ③ **运行日志与运行态**: 本机日志(含 logs/build、logs/legacy)、run/pids.json
-                                        (旧 PID 到新机器上是无效引用); 要连产物一起发用 --with-products
-    *.log *.jsonl(任何位置)           ③ 运行日志/审计流水的兜底规则: 不管躺在哪都不进包
-    *_cache/ .*_cache/ __pycache__/  ④ **临时文件与缓存**: 解释器缓存、第三方工具缓存
-    .pytest_cache .mypy_cache .ruff_cache .ipynb_checkpoints .npm-cache .qa-profile
-    *.tmp *.temp *.bak* *.old *.orig *.rej *.swp *.swo *~ ~$*
-                                    ④ 编辑器/工具留下的临时与备份文件
-    .git/ .venv/ .github/            版本库与虚拟环境 —— venv 换机必失效, 目标机安装时重建
-    _products_off*/                  历史遗留的"清除产物"暂存档(2026-09-16 起清除=真删, 不再产生此类目录)
-    *.pyc *.pyo                      解释器缓存(字节码)
-    *.lnk                            安装时建的快捷方式(内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)
-    *.zip(顶层)                      旧的交付压缩包; 避免包中包
-    .DS_Store Thumbs.db desktop.ini  其它系统在目录里留下的临时索引
-
-## 用法
-
-    python scripts/pack_dist.py --dry-run                  # 只报会打什么/多大, 不写文件
-    python scripts/pack_dist.py                            # 打包(默认不含 数据/产物/日志/临时文件)
-    python scripts/pack_dist.py --out <目标 zip>            # 指定输出路径
-    python scripts/pack_dist.py --with-products            # 例外: 连产物一起打 (页面开箱即有数)
-    python scripts/pack_dist.py --with-data                # 例外: 连 data/raw 一起打 (30 GB, 慎用)
-    python scripts/pack_dist.py --no-products              # (旧开关, 现在默认就是这个; 保留兼容不报错)
-    python scripts/pack_dist.py --out D:\out\x.zip         # 指定输出 # portability-allow: 只是用法示例里的 Windows 路径写法, 不是本机路径
-    python scripts/pack_dist.py --verify <zip>             # **开箱验证**: 解压 → 离线安装 → 起服务核验页面 → 清理
-
-## 排除结果写进包
-
-包里 `dist-manifest.json` 记下 version / no_data / no_products / no_logs / no_temp 与**逐条**排除理由;
-`guanlan.py check` 读它 —— 没带产物的包, 产物那几行检查按 `[--]` 说明而不判 FAIL。
-
-一键入口(给不敲命令行的人): Windows 双击 `pack.bat`(或 `pack.bat dry` 只看计划)、Linux/macOS `sh pack.sh`。
+入口路径(`scripts/pack_dist.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import importlib.util
-import json
-import os
 import pathlib
-import shutil
+import runpy
 import sys
-import tempfile
-import time
-import zipfile
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-from src import version as V       # noqa: E402  ★ 版本号与包名的**唯一真源** (用户令 2026-09-17)
-from src import paths as P         # noqa: E402  配置取用口(模块登记表也从这里读)
-
-
-def module_dirs() -> list[str]:
-    """模块目录清单 = `configs/modules.yaml` 的 modules(用户令 2026-09-22 模块化重构)。
-
-    ★为什么不写死: 2.11.1 打包时实逮 —— 收录清单是白名单, 模块目录 `app_*` 不在其中 ⇒
-    包内没有公共层, 而 install.ps1 已按新布局找 `app_common/.../version.py` ⇒ 入口引用闭合检查报
-    "引用的文件包里没有", 包直接卡住。改成读登记表后, 以后加/改模块(例如 app_ETL 改名)无需再动打包器。
-    """
-    try:
-        import yaml
-        reg = yaml.safe_load(P.config('modules.yaml').read_text(encoding='utf-8'))
-        return [m['name'] for m in (reg.get('modules') or [])]
-    except Exception:                                    # 登记表缺失/损坏时保守回退(不猜目录名)
-        return []
-
-
-INCLUDE_DIRS = ['src', 'scripts', 'configs', 'release', 'resources', 'reference', 'docs',
-                'wheels', 'vendor'] + module_dirs()
-PRODUCTS_DIR = 'outputs'          # 产物仓: **默认不进包** (用户令 2026-09-17); 要带用 --with-products
-# ★ 不打包"修复记录/临时/脚手架"类目录 (2026-09-12 用户令): 本包已纳入 git 管理, 变更历史由版本库承载,
-#   不必在交付物里再带一份 `_修复记录_<日期>\`。原先这里含 `_修复记录_20260911` (该目录的**内容**已并入
-#   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
-# ★ 版本号不再在这里写死 (2026-09-17 用户令"打包版本管理"): 改版本只改 `src/version.py` 一行,
-#   本器读它写进 dist-manifest.json 并用它拼默认包名 `app_guanlang_v<版本号>.zip`。
-VERSION = V.VERSION               # 包版本 = src/version.py 的 VERSION(单一真源)
-INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
-                 # ★ 无窗口启动: v0.4.0 第一版靠 start_hidden.vbs, 漏打进包 → 目标机 start.bat 报
-                 #   "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。现已按用户令**把 VBScript
-                 #   整个去掉**: 无窗口启动 = `pythonw.exe` + `scripts/guanlan_start_hidden.py`,
-                 #   安装时另建 `启动观澜.lnk`。这条手工清单仍由 src/entry_refs.py 从引用反推兜底(漏不掉)。
-                 'requirements.txt', 'README_先读我.MD', '测试须知.txt',
-                 # 打包入口 (用户令 2026-09-17: 把"打包功能"做成双击可用)
-                 'pack.bat', 'pack.sh',
-                 # 卸载入口 (用户令 2026-09-17: 增加卸载脚本; 它引用的 scripts\guanlan_uninstall.py
-                 # 由 INCLUDE_DIRS 的 scripts/ 覆盖, 入口本身必须在这里点名)
-                 'uninstall.bat', 'uninstall.sh',
-                 # ★2026-09-19: 输入数据目录里的**说明文件**随包 —— 它是仓库里的说明(git 已跟踪), 不是现场数据。
-                 #   作用: 目标机解压后每个抽屉放什么一目了然(含"机理层资料在 data/raw/西门子4.0技术资料/")。
-                 #   注意 data/ 的**数据文件**照旧不进包。
-                 'data/raw/README_把原始数据放这里.txt']
-# 目录排除: 版本库/虚拟环境/日志/运行态/缓存 (名字出现在路径的任何一层都算)
-EXCLUDE_DIRS = {'.git', '.venv', '.github', '.git-*', 'logs', 'run', '__pycache__',
-                '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
-                '.cache', '.npm-cache', '.qa-profile', '.Trash', '$RECYCLE.BIN', '.venv.bak'}
-# ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
-#   前端运行期读的是打包好的 viewer.js(three 已 bundle 进去), 理论上可以不发 —— 但**历史交付包是带着它的**
-#   (实测 guanlan-rudong-v2_0.2.0_test_win64.zip 里就有 1207 条 node_modules 条目),
-#   为省 52 MB 去冒"三维页在别人机器上打不开"的风险不划算。宁可多带。
-EXCLUDE_GLOBS = ['_products_off*', 'data', 'outputs']
-# 进包**只**看目录/名的顶部前缀, 这些则按后缀/文件名排除 —— 都是"本机/装机时/编辑时"才产生的东西
-#   (用户令 2026-09-17 的"临时文件"与"运行日志"两条就落在这里):
-#   .pyc/.pyo  解释器缓存;  .lnk 安装时建的快捷方式(绝对路径, 换机失效, 由 install.ps1 重建);
-#   .log/.jsonl 运行日志与审计流水 —— **不管躺在哪都不进包**;  .tmp/.bak/.old/.orig/.rej/.swp 编辑器临时与备份
-EXCLUDE_SUFFIX = {'.pyc', '.pyo', '.lnk', '.log', '.jsonl',
-                  '.tmp', '.temp', '.bak', '.old', '.orig', '.rej', '.swp', '.swo'}
-# 文件名/尾部模式 (glob): 临时与备份的常见写法 + 系统索引文件 + **本机安装记录**
-# ★ `install-info.json` 必须排除 (2026-09-17 实逮: 它被原样打进了交付包): 那是**本机装没装、装的哪一版**
-#   的凭据, 里面写着安装时间与本机绝对路径; 随包发出去, 目标机第一次装就会读到它, 于是安装前检查
-#   报"已安装 v2.5.0(装于 <打包机时间>)", 明明是新机器却提示"版本相同, 是否重装" —— 用户令 1
-#   要的版本检查当场失灵。它是装机产物, 不是交付件。
-EXCLUDE_NAMES = ('install-info.json', '*~', '~$*', '*.bak-*', '.DS_Store', 'Thumbs.db', 'desktop.ini',
-                 '*.crdownload', '*.partial')
-
-
-def is_machine_artifact(p: pathlib.Path) -> bool:
-    """装机产物(本机装没装 / 本机快捷方式)—— 只在本机有效, 不随包、不入库 (2026-09-17)。"""
-    import fnmatch
-    return (p.name == 'install-info.json' or p.suffix.lower() in EXCLUDE_SUFFIX
-            or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
-
-
-def data_skeleton() -> list[str]:
-    r"""`data/` 下要随包发的**空目录骨架**(用户令 2026-09-19: 「包中增加输入数据目录结构,但不包含文件」)。
-
-    为什么要有它: 交付包默认不含输入数据(现场原始件 30 GB 级),可目标机解压后是一套**没有"放哪儿"的**
-    程序 —— 人得去翻文档才知道 `data/raw/<场站>/{scada_10min,故障报警,…}` 这个约定目录结构。
-    把**目录结构**随包发出去(**不带任何文件**),开箱就能看见每个约定位置在哪、该往哪个抽屉里放。
-
-    结构来源是**单一真源** `src/windscada/config.py::STATION_SUBDIRS`(摄入侧的约定子目录名 —— 改名等于
-    换接口),场站目录名取 `raw_station_dir()` 解析出来的那一个(现场是「如东」,不是 farm key「rudong」)。
-    另外补两个入口目录: `data/` 与 `data/raw/` 本身,以及 `data/_incoming/`(`place_raw_data.py` 的默认
-    投放口)。**不含任何文件**,也不含 `scada_mdb/<年>年/<月>月/` 那种数据生成出来的层级
-    (月目录是收资之后才有的,不该由包来规定)。
-
-    ★2026-09-19 用户逮「`data\raw\西门子4.0技术资料` 目录没有打包进去」后补: 机理层厂商资料按 A2 约定
-    **不在场站目录下、而在 `data/raw/` 根下**(`scripts/raw_data_check.py::TOP_OK_DIRS`),并且它是本体链
-    的输入(`src/ontology/kb_ingest.py`: `TECH = RAW_ROOT/'西门子4.0技术资料'`)⇒ 现在连它**与其一级子目录**
-    一起发空骨架(本机共 30 个空目录)。另外把仓库里那份放置说明 `data/raw/README_把原始数据放这里.txt`
-    作为**说明文件**随包(它 git 已跟踪,不是现场数据)。
-    """
-    out = ['data', 'data/raw', 'data/_incoming']
-    try:
-        from src.windscada.config import STATION_SUBDIRS, raw_station_dir
-        # ★只发**解析出来的那一个场站目录名**(现场是「如东」) —— 不把 farm key(`rudong`) 也发一份:
-        #   两个空场站目录会让 `station_scan` 报"2 个场站目录", 目标机上看起来像"多了一个不知哪来的场"。
-        station = pathlib.Path(str(raw_station_dir()))
-        out.append(f'data/raw/{station.name}')
-        for sub in STATION_SUBDIRS:
-            out.append(f'data/raw/{station.name}/{sub}')
-    except Exception as e:                          # 配不出骨架时如实说, 不静默少发
-        print(f'   [!] 输入数据目录骨架没配出来 ({type(e).__name__}: {e}) —— 包里不会有 data/ 结构')
-    # ★2026-09-19 补 (用户逮: 「data\raw\西门子4.0技术资料 目录没有打包进去」):
-    #   机理层厂商资料按 A2 约定**不在场站目录下**, 而在 `data/raw/` 根下
-    #   (`src/windscada/config.py` 的说明 + `scripts/raw_data_check.py::TOP_OK_DIRS`),
-    #   它是本体链的输入 (`src/ontology/kb_ingest.py`: TECH = RAW_ROOT/'西门子4.0技术资料')。
-    #   骨架只发**空目录**(文件仍不随包, 由现场收资包经 place_raw_data.py 落位);
-    #   本机若有该目录, 连它的一级子目录一起镜像出来(让人看见该往哪一层放哪类资料)。
-    tech = ROOT / 'data' / 'raw' / '西门子4.0技术资料'
-    out.append('data/raw/西门子4.0技术资料')
-    if tech.is_dir():
-        for sub in sorted(p for p in tech.iterdir() if p.is_dir()):
-            out.append(f'data/raw/西门子4.0技术资料/{sub.name}')
-    return out
-
-
-def _xfer():
-    """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
-    spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
-    m = importlib.util.module_from_spec(spec)
-    sys.modules['check_transferable'] = m
-    spec.loader.exec_module(m)
-    return m
-
-
-def plan(with_data=False, with_products=False):
-    """→ (要打包的顶层条目 list, 排除说明 list)
-
-    ★ 2026-09-17 用户令: 交付包**默认不含 输入数据 / 产物(输出数据) / 运行日志 / 临时文件**。
-      · 输入数据 `data/`    —— 默认排除 (--with-data 才带);
-      · 产物     `outputs/` —— 默认排除 (--with-products 才带);
-      · 运行日志 `logs/ run/` 与任何位置的 `*.log`/`*.jsonl` —— 默认排除;
-      · 临时文件 `*_cache/ __pycache__/ *.tmp *.bak* *~ ~$* .DS_Store …` —— 默认排除。
-    于是"开箱是一套干净程序": 页面显示"无产物", 目标机自己放数据、自己重算 (docs §9 / §12)。
-    包内 `dist-manifest.json` 记 `no_data/no_products/no_logs/no_temp`, 目标机 `guanlan.py check`
-    据此把"产物缺失"显示为 `--`(待重算) 而不是 FAIL。
-    """
-    inc = []
-    for d in INCLUDE_DIRS:
-        p = ROOT / d
-        if p.is_dir():
-            inc.append(p)
-    if with_products and (ROOT / PRODUCTS_DIR).is_dir():
-        inc.append(ROOT / PRODUCTS_DIR)
-    if with_data:
-        inc.append(ROOT / 'data')
-    for f in INCLUDE_FILES:
-        p = ROOT / f
-        if p.is_file():
-            inc.append(p)
-        else:
-            print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
-    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.ps1/.sh) 引用到的、本机真实存在的
-    #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
-    #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
-    from src.entry_refs import auto_include, referenced_tree_files
-    got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
-    extra = auto_include(ROOT, got)
-    # ★ 装机产物不许被"引用闭合"兜底补进包 (2026-09-17 实逮): `install.ps1`/`install.sh` 的版本检查那步
-    #   会读 `install-info.json`, 于是自动补入把它当"入口引用的文件"补了进来 —— 而它是**本机装没装**的
-    #   凭据(含安装时间与本机路径), 目标机第一次装就会误报"已安装、版本相同"。引用闭合管的是"入口点名的
-    #   文件必须在位", 不管"这个文件该不该随包", 所以这里按排除规则再滤一遍并如实说一句。
-    keepable, dropped = [], []
-    for p in extra:
-        (dropped if is_machine_artifact(p) else keepable).append(p)
-    if keepable:
-        for p in keepable:
-            print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
-        inc.extend(keepable)
-    for p in dropped:
-        print(f'   [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
-    skipped = [('data/ 里的文件', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data。'
-                                  '★目录结构随包 (空目录, 见"输入数据目录骨架"); 用户令 2026-09-19')]
-    if (ROOT / PRODUCTS_DIR).is_dir():
-        skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
-                                 'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
-                                 + ('' if not with_products else ' ※本次给了 --with-products, 例外打进包'))]
-    skipped += [('logs/, run/', '③ 运行日志与运行态 (logs/build、logs/legacy、run/pids.json 旧 PID)')]
-    skipped += [('*.log, *.jsonl (任何位置)', '③ 运行日志/审计流水兜底: 不管躺在哪都不进包')]
-    _glob_why = {
-        '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
-        'data': '① 输入数据: 现场原始件, 用户令不进包 (--with-data 才带)',
-        'outputs': '② 产物/输出数据: 用户令不进包 (--with-products 才带)',
-    }
-    skipped += [(g + '/', _glob_why.get(g, '本机痕迹/超大体量, 见脚本头部说明')) for g in EXCLUDE_GLOBS]
-    skipped += [('*_cache/ __pycache__/ 等', '④ 临时文件与缓存 (解释器/工具缓存: .pytest_cache .npm-cache .qa-profile …)')]
-    skipped += [('*.tmp *.bak* *.old *.orig *.rej *.swp *~ ~$*', '④ 编辑器/工具留下的临时与备份文件')]
-    skipped += [('.DS_Store Thumbs.db desktop.ini', '④ 系统临时索引文件')]
-    skipped += [('.git/ .venv/ .github/', '版本库与虚拟环境 (venv 换机必失效, 目标机安装时重建)')]
-    skipped += [('__pycache__/ *.pyc *.pyo', '字节码缓存')]
-    skipped += [('*.lnk', '安装时建的快捷方式 (内含绝对路径, 换机必失效; 目标机由 install.ps1 重建)')]
-    skipped += [('*.zip (顶层)', '旧的交付压缩包; 避免包中包')]
-    return inc, skipped
-
-
-def keep(f: pathlib.Path, with_data: bool = False, with_products: bool = False) -> bool:
-    """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
-
-    ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
-    而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
-    现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
-    ★ 2026-09-17: 加"运行日志(*.log/*.jsonl 任何位置)"与"临时/备份文件"两类排除 (用户令)。
-    ★ 2026-09-17 修: `--with-products` / `--with-data` 原先**是个摆设** —— `keep()` 无条件套用
-      `EXCLUDE_GLOBS`(里面有 `data`/`outputs`), 于是 `plan()` 把 outputs/ 加进要打清单、打印
-      "本次给了 --with-products, 例外打进包", 而写盘循环又把它们逐件滤掉: 实测 `--with-products`
-      打出来的包与默认包**同样 3441 件 / 879 MB**, 里面一件产物都没有。后果不是"少带东西",
-      而是"以为有存档、其实没有" —— 要补随包件时才发现在包里找不到。现在两个开关**真的**生效:
-      例外目录一旦显式要带, 就不再被 EXCLUDE_GLOBS 拦下。
-    """
-    import fnmatch
-    parts = f.relative_to(ROOT).parts
-    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix.lower() in EXCLUDE_SUFFIX:
-        return False
-    if any(fnmatch.fnmatch(f.name, pat) for pat in EXCLUDE_NAMES):
-        return False
-    if len(parts) == 1 and f.suffix.lower() == '.zip':      # 顶层旧交付包, 避免包中包
-        return False
-    allow = set()
-    if with_data:
-        allow.add('data')
-    if with_products:
-        allow.add(PRODUCTS_DIR)
-    globs = tuple(g.rstrip('*') for g in EXCLUDE_GLOBS if g.rstrip('*') not in allow)
-    return not any(x.startswith(globs) for x in parts[:1])
-
-
-def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = False):
-    if p.is_file():
-        return 1, p.stat().st_size
-    n = s = 0
-    for f in p.rglob('*'):
-        if f.is_file() and keep(f, with_data, with_products):
-            n += 1; s = s + f.stat().st_size
-    return n, s
-
-
-def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
-    inc, skipped = plan(with_data, with_products)
-    skel = [] if with_data else data_skeleton()      # --with-data 时 data/ 整个进包, 不需要空骨架
-    tot_n = tot_s = 0
-    print('== 要打进包的内容 ==')
-    print('   (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
-    for p in inc:
-        n, s = size_of(p, with_data, with_products)
-        tot_n += n; tot_s += s
-        print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
-    print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
-    if skel:
-        print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录  (用户令 2026-09-19: 带结构不带文件)')
-    print('== 排除 ==')
-    for name, why in skipped:
-        print(f'   {name:26s} {why}')
-
-    out.parent.mkdir(parents=True, exist_ok=True)
-    man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
-               includes=[p.relative_to(ROOT).as_posix() for p in inc],
-               excluded={n: w for n, w in skipped}, files=tot_n + 1,   # +1 = dist-manifest.json 自身
-               bytes=tot_s, data_skeleton=skel or None,
-               data_skeleton_note=('用户令 2026-09-19: 包里带**输入数据目录结构**(空目录), 不带任何文件; '
-                                   '目标机解压后照这个结构往 data/raw/<场站>/ 各抽屉里放原始件'),
-               no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True,
-               note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/ 里的文件) · '
-                    '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**; '
-                    '**含**输入数据目录结构(空目录, 用户令 2026-09-19)。'
-                    '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
-                    '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
-                    '+ `scripts/rebuild_all.py` (无生成端的那批用 '
-                    '没有生成端的族由研发补生成端(运行期不从交付包补齐, 用户令 2026-09-17 #1))。'
-                    '.venv 不在包内(换机必失效, 安装时重建); 打包方法见包内 README_先读我.MD 的「打包」一节。')
-    t0 = time.time()
-    with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
-        z.writestr('dist-manifest.json', json.dumps(man, ensure_ascii=False, indent=1))
-        done = 0
-        for p in inc:
-            if p.is_file():
-                z.write(p, p.relative_to(ROOT).as_posix())
-                done += 1
-            else:
-                for f in p.rglob('*'):
-                    if not f.is_file() or not keep(f, with_data, with_products):
-                        continue
-                    z.write(f, f.relative_to(ROOT).as_posix())
-                    done += 1
-            if progress and done % 500 < 1:
-                print(f'   … 已写入 {done} 件', flush=True)
-        # 输入数据目录骨架: 写成**目录条目**(名以 `/` 结尾 + MS-DOS 目录位), 解压即建出空目录。
-        # ★必须显式写条目: 空目录在 zip 里不会因为"里面没文件"而自动存在; 只写父目录也不够
-        #   (解压工具只按条目建目录)。
-        for rel in skel:
-            zi = zipfile.ZipInfo(rel.rstrip('/') + '/')
-            zi.external_attr = (0o40775 << 16) | 0x10
-            zi.compress_type = zipfile.ZIP_STORED
-            z.writestr(zi, b'')
-        if skel:
-            print(f'   … 目录骨架 {len(skel)} 个空目录已写入', flush=True)
-    print(f'\n完成: {out}  {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件'
-          + (f' + 目录 {len(skel)} 个' if skel else '') + f', 耗时 {time.time()-t0:.0f}s')
-
-    # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
-    # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
-    from src.entry_refs import encoding_problems, missing_refs
-    with zipfile.ZipFile(out) as z:
-        names = z.namelist()
-    enc = encoding_problems(ROOT)
-    miss = missing_refs(ROOT, names)
-    if miss:
-        for e, rel in miss:
-            print(f'   [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
-    for m in enc:
-        print(f'   [X] 入口编码守则: {m}')
-    # 装机产物不得进包 (2026-09-17 实逮: 本机的 install-info.json 被原样打进包 ⇒ 目标机安装前检查
-    # 会误报"已安装 vx.y.z, 版本相同" —— 版本检查当场失灵)。这类"看着无害、后果是静默误报"的东西
-    # 必须由机器守: 包里出现一件就删包。
-    machine = [n for n in names if n.split('/')[-1] in ('install-info.json',) or n.endswith('.lnk')]
-    for n in machine[:5]:
-        print(f'   [X] 包里有"装机产物" {n} —— 它只在本机有效 (安装记录/快捷方式), 不该随包分发')
-    if miss or enc or machine:
-        out.unlink()
-        print(f'   [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
-        return 1
-    print(f'   [OK] 入口引用闭合 + 编码守则 + 无装机产物: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
-    print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
-    print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
-    return 0
-
-
-def verify(zip_path: pathlib.Path, keep=False) -> int:
-    """解压 → 离线安装 → 起服务核验页面 → 清理。这是"能不能装、能不能跑"的直接证据。"""
-    tmp = pathlib.Path(tempfile.gettempdir()) / 'guanlan_distcheck'
-    if tmp.exists():
-        shutil.rmtree(tmp, ignore_errors=True)
-    print(f'① 解压到 {tmp}')
-    t0 = time.time()
-    with zipfile.ZipFile(zip_path) as z:
-        z.extractall(tmp)
-    n = sum(1 for _ in tmp.rglob('*') if _.is_file())
-    print(f'   {n} 件, 耗时 {time.time()-t0:.0f}s')
-    print('①b 入口引用闭合核对 (包内 start.bat → scripts\\guanlan_start_hidden.py 之类)')
-    from src.entry_refs import encoding_problems, missing_refs
-    miss = missing_refs(tmp)
-    enc = encoding_problems(tmp)
-    if miss:
-        for e, rel in miss:
-            print(f'   [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
-    for m in enc:
-        print(f'   [X] 入口编码守则: {m}')
-    if miss or enc:
-        return 1
-    print('   [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
-    # ★2026-09-19 用户令: 包里带"输入数据目录结构"(空目录)。解压后必须真的建出来了, 且**除说明文件外一个
-    #   文件都没有** —— 两边都查: 目录少了 ⇒ 人不知道往哪放; 目录里混进数据文件 ⇒ 包就不是"只带结构"了。
-    #   ★2026-09-19 实逮 (第一次开箱验证就卡在这): ① 原先用 `rglob` 逐目录收集, 同一个文件会被父目录
-    #   重复计入 (`data` 与 `data/raw` 各算一次) ⇒ 报"混进 2 个文件"; ② 按清单随包发的**放置说明**
-    #   (`data/raw/README_把原始数据放这里.txt`, 在 `INCLUDE_FILES` 里) 被当成"混进的文件"。
-    #   现在: 允许清单 = INCLUDE_FILES 里落在骨架目录内的文件; 收集用集合(去重)。
-    skel = data_skeleton()
-    allowed = {f.replace('\\', '/') for f in INCLUDE_FILES if any(f.replace('\\', '/').startswith(r.rstrip('/') + '/')
-                                                                   for r in skel)}
-    miss_d = [r for r in skel if not (tmp / r).is_dir()]
-    extra_f = set()
-    for r in skel:
-        d = tmp / r
-        if d.is_dir():
-            extra_f |= {p.relative_to(tmp).as_posix() for p in d.rglob('*') if p.is_file()}
-    extra_f -= allowed
-    if miss_d:
-        print(f'   [X] 输入数据目录骨架缺 {len(miss_d)} 个: {miss_d[:5]}')
-    if extra_f:
-        print(f'   [X] 骨架目录里混进了 {len(extra_f)} 个文件 (除说明文件外应只有空目录): {sorted(extra_f)[:5]}')
-    if miss_d or extra_f:
-        return 1
-    print(f'   [OK] 输入数据目录骨架: {len(skel)} 个空目录全部在位'
-          f'{f" + 放置说明 {len(allowed)} 件" if allowed else ""}, 无其他文件'
-          f' (用户令 2026-09-19: 带结构不带数据文件)')
-    print('② 离线安装')
-    xt = _xfer()
-    py = xt.install_in(tmp)
-    if py is None:
-        return 1
-    print('②b 无窗口启动核验 (VBScript 已按用户令移除, 现在靠 pythonw.exe + 启动器 + 快捷方式)')
-    bad = []
-    if os.name == 'nt':
-        # 2026-09-17: 安装脚本现在建**两份**快捷方式 —— 中文名给人双击, ASCII 名 (start_guanlan.lnk)
-        # 给控制台/脚本引用 (纯 ASCII 的 start.bat 只能引用 ASCII 名)。两份都要在。
-        for nm in ('启动观澜.lnk', 'start_guanlan.lnk'):
-            lnk = tmp / nm
-            if lnk.is_file() and lnk.stat().st_size > 0:
-                print(f'   [OK] 安装时已生成快捷方式: {nm} ({lnk.stat().st_size} B)')
-            else:
-                bad.append(f'安装后没有 {nm} (install.ps1 第 5/6 步没生效)')
-        import json as _json
-        import subprocess as _sp
-        pyw = tmp / '.venv/Scripts/pythonw.exe'
-        r = _sp.run([str(pyw), str(tmp / 'scripts/guanlan_start_hidden.py'), '--selftest'],
-                    capture_output=True, text=True, errors='replace', timeout=120)
-        try:
-            info = _json.loads((r.stdout or '').strip().splitlines()[-1])
-        except Exception:
-            info = {}
-        if info.get('windowless') is True:
-            print(f'   [OK] pythonw.exe 跑启动器: console_hwnd={info.get("console_hwnd")} → 无窗口成立')
-        else:
-            bad.append(f'pythonw --selftest 没报无窗口: stdout={ (r.stdout or "").strip()[:120] }')
-        if list(tmp.glob('*.vbs')):
-            bad.append(f'包里还有 .vbs: {[p.name for p in tmp.glob("*.vbs")]}')
-        else:
-            print('   [OK] 包里无 .vbs (不依赖 Windows 脚本宿主/WSH)')
-    else:
-        print('   (非 Windows: 无窗口/快捷方式不适用)')
-    for b in bad:
-        print(f'   [X] {b}')
-    if bad:
-        return 1
-    print('③ 起服务核验 (副本用独立端口, 不动正在跑的实例)')
-    ok = xt.run_and_probe(tmp, py)
-    print('③b 卸载核验 (用户令 2026-09-17: 装得上, 也要卸得掉)')
-    ubad = xt.uninstall_probe(tmp, py, gw=xt.LAST_GATEWAY_PORT)
-    print('④ 收尾')
-    if keep:
-        print(f'   解压目录保留在 {tmp} (--keep)')
-    else:
-        shutil.rmtree(tmp, ignore_errors=True)
-        print('   已清理')
-    print(f'\n开箱验证结论: 页面可用 {ok}/5 —— {"可以交付" if ok >= 4 else "有问题, 见上"}'
-          + (f';卸载核验 {"通过" if ubad == 0 else f"{ubad} 项未过"}'))
-    return 0 if (ok >= 4 and ubad == 0) else 1
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/pack/pack_dist.py'
 
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--out', default=None)
-    ap.add_argument('--with-data', action='store_true',
-                    help='例外: 连输入数据 data/raw 一起打 (约 31 GB, 慎用; 默认不含)')
-    ap.add_argument('--with-products', action='store_true',
-                    help='例外: 连产物 outputs/ 一起打 (页面开箱即有数; 默认不含 —— 用户令 2026-09-17)')
-    ap.add_argument('--no-products', action='store_true',
-                    help='(旧开关) 不含产物 —— 现在**默认就是这个**, 保留只为兼容旧命令')
-    ap.add_argument('--dry-run', action='store_true')
-    ap.add_argument('--verify', default=None, help='对已打好的 zip 做开箱验证(解压→安装→起服务核验)')
-    ap.add_argument('--keep', action='store_true', help='--verify 后保留解压目录')
-    a = ap.parse_args()
-    if a.verify:
-        return verify(pathlib.Path(a.verify), a.keep)
-    out = (pathlib.Path(a.out) if a.out
-           else ROOT.parent / V.package_name())
-    if a.dry_run:
-        inc, skipped = plan(a.with_data, a.with_products)
-        tot_n = tot_s = 0
-        print(f'== 会打进包 (version {VERSION}) ==')
-        for p in inc:
-            n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
-            print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
-        print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
-        skel = [] if a.with_data else data_skeleton()
-        if skel:
-            print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (不带文件; 用户令 2026-09-19):')
-            for r in skel:
-                print(f'      {r}/')
-        print('== 会排除 ==')
-        for name, why in skipped:
-            print(f'   {name:26s} {why}')
-        print('\n(dry-run, 未写文件)')
-        return 0
-    return build(out, a.with_data, with_products=a.with_products)
+from app_qualityGate.common.app_qualityGate_guanlan import pack as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.pack import pack_dist as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 46
scripts/pack_verify_entry.py

@@ -1,46 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""`pack.bat verify` 的入口:找最新的交付包(或用户给的路径)→ 交给 pack_dist.py --verify。
-
-为什么要单独一个: `pack.bat verify` 不带参数时应该"自动找最新那个 zip",而批处理里做这个
-(dir /b /o-d 之类) 既啰嗦又在中文路径上不稳 —— 放到 Python 里三行搞定,批处理只负责传参。
-
-用法:
-    python scripts/pack_verify_entry.py                 # 找 <安装根父目录>/guanlan-v*_dist_*.zip 里最新的
-    python scripts/pack_verify_entry.py <zip 路径>
-"""
-from __future__ import annotations
-
-import pathlib
-import subprocess
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-
-
-def newest_zip() -> pathlib.Path | None:
-    """找最新的交付包: 先看安装根的父目录(打包器默认输出处), 再看安装根自己。"""
-    cands: list[pathlib.Path] = []
-    for d in (ROOT.parent, ROOT):
-        if d.is_dir():
-            cands += [p for p in d.glob('guanlan-*.zip') if p.is_file()]
-            cands += [p for p in d.glob('*guanlan*v*.zip') if p.is_file()]
-    cands = sorted(set(cands), key=lambda p: p.stat().st_mtime, reverse=True)
-    return cands[0] if cands else None
-
-
-def main() -> int:
-    arg = sys.argv[1] if len(sys.argv) > 1 else ''
-    zp = pathlib.Path(arg) if arg else newest_zip()
-    if zp is None or not zp.is_file():
-        print('[X] 找不到交付包 zip。给一个路径: python scripts/pack_verify_entry.py <zip>')
-        return 2
-    print(f'[i] 开箱验证: {zp}  ({zp.stat().st_size/1e6:.0f} MB)')
-    if arg == '' and not str(zp).startswith(str(ROOT.parent)):
-        pass
-    return subprocess.call([sys.executable, str(ROOT / 'scripts' / 'pack_dist.py'), '--verify', str(zp)],
-                           cwd=str(ROOT))
-
-
-if __name__ == '__main__':
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/pack/pack_verify_entry.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/pack_verify_entry.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/pack/pack_verify_entry.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import pack as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.pack import pack_verify_entry as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 136
scripts/page_fingerprint.py

@@ -1,150 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""页面/接口指纹 —— 改造前后与换机部署的回归判据 (2026-09-11)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/page_fingerprint.py`。**本文件不得新增逻辑**。
 
-## 为什么要有
-
-路径收敛、换机器部署这类改动,**危险在静默**: 某处路径漏改 → 页面某块空掉/取不到数, 但不报错。
-所以需要一把"改前改后必须一模一样"的尺子。
-
-指纹 = 对一组页面与接口取 `status + 字节数 + sha256`; 另存一份**归一化** sha256:
-把安装根路径 (本机绝对路径, 正/反斜杠两种写法) 统一替换成 `<ROOT>` 后再算 —— 这样
-"换了机器所以绝对路径前缀不同"不会误报成内容变化, 而"内容真的变了"仍然抓得到。
-
-用法:
-    python scripts/page_fingerprint.py --save .fingerprint_before.json    # 取基线 (相对安装根)
-    python scripts/page_fingerprint.py --diff .fingerprint_before.json    # 比对 (不一致则退出码 3)
+入口路径(`scripts/page_fingerprint.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import hashlib
-import json
 import pathlib
+import runpy
 import sys
-import urllib.error
-import urllib.parse
-import urllib.request
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-from src import paths as P          # 配置唯一取用口
-
-# 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
-ENDPOINTS = [
-    ('gateway.healthz', 'http://127.0.0.1:{gateway}/healthz'),
-    ('gateway.version', 'http://127.0.0.1:{gateway}/api/version'),
-    ('portal.home', 'http://127.0.0.1:{gateway}/'),
-    ('detail.v2', 'http://127.0.0.1:{detail}/v2'),
-    ('detail.maint_survey', 'http://127.0.0.1:{detail}/api/maint_survey'),
-    ('detail.query.actpower', 'http://127.0.0.1:{detail}/api/query?t=WTG01&ch=grd_wtc_ActPower_mean&win=2026%E5%B9%B4&agg=med'),
-    ('detail.turbine_problems', 'http://127.0.0.1:{detail}/api/turbine?t=WTG01&win=2026%E5%B9%B4'),
-    ('cms.home', 'http://127.0.0.1:{cms}/?theme=light'),
-    ('sim_sys.page', 'http://127.0.0.1:{sim_sys}/' + urllib.parse.quote('0_四系统合页.html')),
-    ('viewer.index', 'http://127.0.0.1:{viewer}/index.html'),
-]
-
-
-def ports() -> dict:
-    """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
-    default = dict(gateway=28084, detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292, ollama=11434)
-    p = P.SERVE_JSON                      # 配置唯一取用口
-    cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
-    return {**default, **{k: v for k, v in cfg.items() if isinstance(v, int)}}
-
-
-# 设计上就会变的字段 (实测: healthz 的 checked/ms 每次不同; version 的 repo_head 随提交变) ——
-# 归一化时剔除, 否则每次比对都是噪音; 真实数据/页面内容不在其列。
-VOLATILE = {'checked', 'ms', 'repo_head', 'elapsed', 'uptime', 'uptime_s', 'pid', 'generated_at', 'asof'}
-
-
-def _strip(node):
-    if isinstance(node, dict):
-        return {k: _strip(v) for k, v in node.items() if k not in VOLATILE}
-    if isinstance(node, list):
-        return [_strip(v) for v in node]
-    return node
-
-
-def normalize(body: bytes) -> bytes:
-    """→ 用于指纹的字节: JSON 先剔易变字段再按键排序; 非 JSON 原样 (页面类实测稳定)。
-    另外把安装根 (绝对路径, 两种斜杠写法) 归一成 <ROOT>, 消除"换机器"的前缀差异。"""
-    t = body.decode('utf-8', 'replace')
-    try:
-        j = json.loads(t)
-        t = json.dumps(_strip(j), ensure_ascii=False, sort_keys=True)
-    except Exception:
-        pass
-    for form in (str(ROOT), str(ROOT).replace('\\', '/'), str(ROOT).replace('/', '\\')):
-        t = t.replace(form, '<ROOT>')
-    return t.encode('utf-8')
-
-
-def fetch(url: str):
-    try:
-        with urllib.request.urlopen(url, timeout=120) as r:
-            return r.status, r.read()
-    except urllib.error.HTTPError as e:
-        return e.code, e.read()
-    except Exception as e:
-        return None, f'{type(e).__name__}: {e}'.encode()
-
-
-def take() -> dict:
-    P = ports()
-    out = {}
-    for name, tpl in ENDPOINTS:
-        url = tpl.format(**P)
-        status, body = fetch(url)
-        out[name] = dict(url=url, status=status, bytes=len(body),
-                         sha256=hashlib.sha256(body).hexdigest()[:16],
-                         norm_sha256=hashlib.sha256(normalize(body)).hexdigest()[:16],
-                         norm_bytes=len(normalize(body)))
-    return out
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--save', default=None, help='把指纹写到这个 JSON')
-    ap.add_argument('--diff', default=None, help='与这个基线 JSON 比对')
-    a = ap.parse_args()
-    now = take()
-
-    if a.save:
-        p = pathlib.Path(a.save)
-        p.parent.mkdir(parents=True, exist_ok=True)
-        p.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
-        print(f'已保存 {len(now)} 个端点指纹 → {p}')
-        for k, v in now.items():
-            print(f'  {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B  {v["sha256"]}  norm {v["norm_sha256"]}')
-        return 0
 
-    if a.diff:
-        base = json.loads(pathlib.Path(a.diff).read_text(encoding='utf-8'))
-        bad = 0
-        print(f'{"端点":26s} {"状态":>10s} {"归一化字节":>24s}  norm_sha (基线→现在)')
-        for k, v in now.items():
-            b = base.get(k)
-            if b is None:
-                print(f'{k:26s} {"(基线无)":>10s}'); bad += 1; continue
-            same = v['norm_sha256'] == b.get('norm_sha256')
-            flag = '一致' if same else '★变化'
-            if not same:
-                bad += 1
-            print(f'{k:26s} {str(b["status"])+"→"+str(v["status"]):>10s} '
-                  f'{b.get("norm_bytes", b["bytes"]):>11,}→{v["norm_bytes"]:<12,} '
-                  f'{b.get("norm_sha256")}→{v["norm_sha256"]}  {flag}')
-        print(f'\n结论: {"全部一致 ✔ (页面与取数面零变化)" if not bad else f"{bad} 个端点有变化"}')
-        return 0 if not bad else 3
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/page_fingerprint.py'
 
-    for k, v in now.items():
-        print(f'  {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B  {v["sha256"]}  norm {v["norm_sha256"]}')
-    return 0
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import page_fingerprint as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"全部一致"这一行 print 会抛
-    # UnicodeEncodeError、退出码变 1 —— 闸门报成失败而实际是通过的 (2026-09-11 踩过)。
-    from src import console
-    console.soft()
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 622
scripts/pages_audit.py

@@ -1,636 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/pages_audit.py`。**本文件不得新增逻辑**。
 
-## 背景(用户的问题)
-
-    http://127.0.0.1:28084/#findings  #sim  #documents 及它们包含的子页,
-    "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。
-
-答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告),
-还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。
-本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是:
-
-  · static            —— 正文不得引用产物(setting 变了就该改分类);
-  · live              —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务;
-  · data-citing       —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉);
-  · data-derived      —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物
-                         逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来);
-  · frozen-delivery   —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。
-
-## 为什么要有"陈旧检测"
-
-页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。
-数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。
-
-## 退出码 (给 rebuild_all / check 用)
-
-    0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误
-
-## 用法
-
-    python scripts/pages_audit.py                 # 表 + 检查(默认)
-    python scripts/pages_audit.py --list          # 只打表
-    python scripts/pages_audit.py --check         # 只检查(安静模式, 只打结论)
-    python scripts/pages_audit.py --write-doc     # 把表写进 docs/系统设计说明.md (标记区间内)
-    python scripts/pages_audit.py --json          # 机器可读结果
+入口路径(`scripts/pages_audit.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import fnmatch
-import glob as globmod
-import hashlib
-import html as htmllib
-import json
 import pathlib
-import re
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-from src import paths as P                                          # noqa: E402
-
-REG = P.CONFIGS / 'portal_pages.yaml'
-DOC = P.ROOT / 'docs' / '系统设计说明.md'
-
-
-def dt_from(ts: float | None) -> str:
-    """mtime → 'YYYY-mm-dd HH:MM' (报告里给人看的时间)。"""
-    if not ts:
-        return '?'
-    import datetime as _dt
-    return _dt.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
-DOC_BEGIN, DOC_END = '<!-- PAGES:BEGIN -->', '<!-- PAGES:END -->'
-
-RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9
-KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery')
-
-RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+')
-# 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。
-# ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。
-RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版')
-RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}')
-RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)')
-
-
-def load(path=None):
-    import yaml
-    reg_p = pathlib.Path(path) if path else REG
-    if not reg_p.is_file():
-        raise SystemExit(f'缺登记表 {P.rel(reg_p)}')
-    reg = yaml.safe_load(reg_p.read_text(encoding='utf-8'))
-    reg['_file'] = str(reg_p)
-    return reg
-
-
-def sha256(p: pathlib.Path) -> str | None:
-    try:
-        return hashlib.sha256(p.read_bytes()).hexdigest()
-    except OSError:
-        return None
-
-
-def expand(entry) -> list[pathlib.Path]:
-    """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。"""
-    pat = P.ROOT / entry['file']
-    s = str(pat)
-    if any(c in s for c in '*?['):
-        return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True))
-    return [pat] if pat.is_file() else []
-
-
-def texts(files):
-    for f in files:
-        try:
-            yield f, f.read_text(encoding='utf-8', errors='replace')
-        except OSError:
-            continue
-
-
-def read_embedded_json(path: pathlib.Path, var: str):
-    """抓页面里内嵌的 window.<var> = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。"""
-    try:
-        t = path.read_text(encoding='utf-8', errors='replace')
-    except OSError:
-        return None
-    m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t)
-    if not m:
-        return None
-    raw, depth = '', 0
-    for ch in t[m.start(1):]:
-        raw += ch
-        if ch == '{':
-            depth += 1
-        elif ch == '}':
-            depth -= 1
-            if depth == 0:
-                break
-    for cand in (raw, htmllib.unescape(raw)):
-        try:
-            return json.loads(cand)
-        except Exception:
-            continue
-    return None
-
-
-def check_entry(e, results):
-    """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。
-
-    level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。
-    ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life),
-      页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处,
-      所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。
-    """
-    eid = e.get('id') or e.get('name') or '?'
-    kind = e.get('kind')
-    gap = e.get('known_gap') or {}
-    if kind not in KINDS:
-        results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND))
-        return
-    files = expand(e)
-    if not files and not e.get('members_in_zip'):
-        results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS))
-        return
-
-    # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查)
-    for f, t in texts(files):
-        cited = set(e.get('cites') or [])
-        if kind in ('data-derived', 'data-citing'):
-            cited |= set(RE_OUT.findall(t))
-        for c in sorted(cited):
-            if (P.ROOT / c).exists():
-                continue
-            if gap and c in (gap.get('missing') or []):
-                results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK))
-            else:
-                results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS))
-
-    # ② 分类规则
-    if kind == 'static':
-        for f, t in texts(files):
-            if RE_OUT.search(t):
-                results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived',
-                                RC_KIND))
-
-    elif kind == 'live':
-        svc = {}
-        try:
-            svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
-        except Exception:
-            pass
-        ports = {int(v) for k, v in svc.items() if isinstance(v, int)}
-        tgt = e.get('live_target') or ''
-        m = re.search(r':(\d{2,5})', tgt)
-        if m and ports and int(m.group(1)) not in ports:
-            results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS))
-
-    elif kind == 'data-derived':
-        src = e.get('source')
-        var = e.get('embedded_json_var')
-        skey, shkey = e.get('source_key'), e.get('source_sha_key')
-        found_sha, found_src, found_gen = None, src, None
-        if var and skey:
-            for f in files:
-                obj = read_embedded_json(f, var)
-                if isinstance(obj, dict):
-                    found_src = obj.get(skey) or found_src
-                    found_sha = obj.get(shkey) if shkey else None
-                    break
-        for f, t in texts(files):
-            if found_gen is None:
-                g = RE_GEN.search(t)
-                if g:
-                    found_gen = g.group(1)
-        if not found_src:
-            if gap:
-                results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK))
-            else:
-                results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV))
-        if not (found_sha or found_gen or e.get('generated_at_key')):
-            if e.get('generated_at_source') == 'mtime':
-                # 页面件自己就是生成物: 用"它自己的 mtime"当生成时间, 与 source 的 mtime 比 ⇒ 可做陈旧代理判据
-                src = e.get('source')
-                mt = None
-                for f in files:
-                    try:
-                        mt = f.stat().st_mtime
-                        break
-                    except OSError:
-                        continue
-                sp = (P.ROOT / src) if isinstance(src, str) else None
-                if mt and sp is not None and sp.is_file() and (sp.stat().st_mtime - mt) > 86400:
-                    results.append(('X', eid, f'**页面件疑似陈旧**: {P.rel(files[0])} 写于 '
-                                              f'{dt_from(mt)}, 而它的源 {src} 更新于 {dt_from(sp.stat().st_mtime)} '
-                                              f'⇒ 源换了、这份页面件没重生成', RC_STALE))
-                else:
-                    results.append(('OK', eid, f'按 mtime 判: 页面件与源 {src or "(未登记)"} 的新旧关系正常 '
-                                                f'(页面件 {dt_from(mt) if mt else "?"})', RC_OK))
-                if not found_src:
-                    pass
-            elif not gap:
-                results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV))
-        if found_src:
-            sp = P.ROOT / found_src
-            if not sp.exists():
-                if gap and found_src in (gap.get('missing') or []):
-                    results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK))
-                else:
-                    results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS))
-            elif found_sha:
-                cur = sha256(sp)
-                if cur and cur.lower() != str(found_sha).lower():
-                    results.append(('X', eid,
-                                    f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 '
-                                    f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成',
-                                    RC_STALE))
-                else:
-                    results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK))
-            elif e.get('generated_at_source') != 'mtime':
-                results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 '
-                                          f'(记录: {found_gen or "无"})', RC_PROV))
-
-    elif kind == 'frozen-delivery':
-        ok_ver = ok_date = False
-        for f in files:
-            name = f.name
-            ok_ver |= bool(RE_VER.search(name))
-            ok_date |= bool(RE_DATE.search(name))
-            if not (ok_ver and ok_date):
-                t = f.read_text(encoding='utf-8', errors='replace')      # 版本号/日期可能在正文深处
-                ok_ver |= bool(RE_VER.search(t))
-                ok_date |= bool(RE_DATE.search(t))
-        if e.get('version'):
-            ok_ver = True
-        if not ok_ver:
-            results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版',
-                            RC_VER))
-        if not ok_date and not e.get('version'):
-            results.append(('?', eid, '冻结交付件没有日期标记', RC_VER))
-
-    # ③ 子项递归
-    for c in e.get('children') or []:
-        check_entry(c, results)
-
-
-def declared_patterns(reg) -> list[str]:
-    """登记表里所有 file 字段 (含通配) —— 覆盖检查用。"""
-    pats = []
-
-    def walk(e):
-        f = e.get('file')
-        if f:
-            pats.append(str(f))
-        for c in e.get('children') or []:
-            walk(c)
-    for e in reg.get('pages') or []:
-        walk(e)
-    return pats
-
-
-def coverage_gaps(reg) -> list[str]:
-    """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。"""
-    pats = declared_patterns(reg)
-    gaps = []
-    tdir = P.RELEASE / 'portal_src' / 'templates'
-    for f in sorted(tdir.glob('*.html')):
-        rel = f.relative_to(P.ROOT).as_posix()
-        if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats):
-            gaps.append(rel)
-    return gaps
-
-
-def render(reg, results) -> str:
-    lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|']
-    def walk(e, depth=0):
-        eid = e.get('id') or e.get('name') or '?'
-        title = e.get('title') or ''
-        pre = '&nbsp;&nbsp;└ ' * depth
-        lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | "
-                     f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |")
-        for c in e.get('children') or []:
-            walk(c, depth + 1)
-    for e in reg.get('pages') or []:
-        walk(e)
-    return '\n'.join(lines)
-
-
-def inventory() -> list[dict]:
-    """**所有网页**总清点(用户令 2026-09-17 第 2 问):把系统会呈现的每一页/每个子页列出来,
-    并判定"是不是产物(= 由 data/raw 重算出来的输出数据)"。
-
-    来源四路,各有依据(不靠猜):
-      ① 门户锚点   `release/portal_src/shell.html` 的 `<div class="pg" id=… data-title=…>`
-      ② 工作台标签  `app_frontEnd/.../assets/app.js` 的 `TABS` 常量(SPA tab,实时读产物)
-      ③ 组件产物页  `outputs/<场>/**/*.html|*.md` —— **这些本身就是产物**
-      ④ 交付/仿真/三维页 `release/如东/**/*.html`、仿真资料包 zip 内的页面、`release/viewer/*.html`、内嵌件
-    """
-    reg = load()
-    entries = _all_entries(reg)
-    reg_files = {str(e.get('file') or '') for e in entries}
-    reg_ids = {str(e.get('id') or e.get('name') or '') for e in entries}
-    rows: list[dict] = []
-
-    # ① 门户锚点
-    shell = P.RELEASE / 'portal_src' / 'shell.html'
-    if shell.is_file():
-        t = shell.read_text(encoding='utf-8', errors='replace')
-        for m in re.finditer(r'<div class="pg"[^>]*id="([^"]+)"[^>]*data-title="([^"]*)"', t):
-            rows.append(dict(source='门户锚点', name=f'#{m.group(1)}', title=m.group(2), kind='shell',
-                             product=False, registered=m.group(1) in reg_ids,
-                             evidence='外壳页(静态/iframe/链接);逐条判定见 §10 与 §12'))
-    # ② 工作台 SPA 标签
-    appjs = P.ROOT / 'app_frontEnd' / 'common' / 'app_frontEnd_guanlan' / 'assets' / 'app.js'
-    if appjs.is_file():
-        t = appjs.read_text(encoding='utf-8', errors='replace')
-        m = re.search(r"const TABS = \[([^\]]+)\]", t)
-        if m:
-            for tab in re.findall(r"'([a-z_]+)'", m.group(1)):
-                rows.append(dict(source='工作台标签', name=f'tab={tab}', title='', kind='live', product=False,
-                                 registered=_tab_registered(tab, entries),
-                                 evidence='SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照'))
-    # ③ 组件产物页(本身就是产物)
-    out = P.out_root()
-    ledger = _ledger_keys()
-    REPORT_HINT = ('报告', '收敛', '诊断', '评估', '结论', 'report', 'summary', 'findings')
-    if out.is_dir():
-        for f in sorted(list(out.rglob('*.html')) + list(out.rglob('*.md'))):
-            rel = f.relative_to(out).as_posix()
-            if rel.startswith('_'):
-                continue
-            full = f'{out.name}/{rel}'
-            if f.suffix == '.html':
-                src, prod, why = '组件页面件 (.html)', True, '位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件'
-            elif any(k in f.name.lower() for k in REPORT_HINT):
-                src, prod, why = '组件结论件 (.md,页面会渲染)', True, '产物(结论文本);工作台 report tab / CMS report.html 会渲染它'
-            else:
-                src, prod, why = '产物里的过程留痕 (.md,非网页)', True, '产物,但没有任何页面呈现它(给下游线的交接/复跑记录)'
-            rows.append(dict(source=src, name=rel, title='', kind='product-page', product=prod,
-                             registered=(rel in ledger), evidence=why + f';台账: {"在" if rel in ledger else "缺"}'))
-    # ④ 交付/仿真/三维/内嵌
-    # 已登记判定: 精确名 或 **通配模式**(登记表里常按 wildcard 收一族文件, 如 tpl-report-*.html.html)
-    _ = None
-    for pat, src, kind, prod, why in (
-            ('如东/**/*.html', '交付页 (release/如东)', 'frozen-delivery', False, '客户交付件,按版本冻结'),
-            ('viewer/*.html', '三维页 (release/viewer)', 'static', False, '图纸/模型派生的三维资产'),
-            ('portal_src/templates/*.html', '门户内嵌件', 'embed', None, '内嵌交付件(§10 逐条判定;个别为数据派生)')):
-        for f in sorted(P.RELEASE.glob(pat)):
-            if f.name.startswith('.'):
-                continue
-            rel = f.relative_to(P.RELEASE).as_posix()
-            rows.append(dict(source=src, name=rel, title='', kind=kind, product=prod,
-                             registered=_is_registered(rel, f'outputs/{rel}', entries), evidence=why))
-    # 仿真资料包里的页面
-    z = P.RELEASE / '如东SWT40_控制律仿真台_20260906.zip'
-    if z.is_file():
-        import zipfile
-        with zipfile.ZipFile(z) as zf:
-            for n in zf.namelist():
-                if not n.endswith('.html'):
-                    continue
-                try:
-                    nm = n.encode('cp437').decode('utf-8')
-                except Exception:
-                    nm = n
-                rows.append(dict(source='仿真资料包 (zip)', name=nm.split('/')[-1], title='', kind='frozen-delivery',
-                                 product=False, registered=True, evidence='图纸派生的冻结资料包(sim_sys_server 从 zip 现读)'))
-    return rows
-
-
-def _declared_patterns(entries: list[dict]) -> list[str]:
-    """登记表里所有 `file:` 取值(含通配)—— 判定"这一页/这一件有没有被登记"用。"""
-    return [str(e.get('file') or '') for e in entries if e.get('file')]
-
-
-def _is_registered(rel: str, full: str, entries: list[dict]) -> bool:
-    """精确 或 通配 命中页面登记表 → 已登记(通配用全路径与 basename 两种都比一次)。"""
-    import fnmatch
-    names = {rel, full, pathlib.Path(rel).name}
-    pats = _declared_patterns(entries)
-    pats += [pathlib.Path(x).name for x in pats]
-    for n in names:
-        if n in pats:
-            return True
-        if any(fnmatch.fnmatch(n, x) for x in pats):
-            return True
-    return False
-
-
-def _tab_registered(tab: str, entries: list[dict]) -> bool:
-    for e in entries:
-        if e.get('id') == 'detail_tabs' and tab in (e.get('tabs') or []):
-            return True
-    return False
-
-
-def _ledger_keys() -> set:
-    """产物台账里的键(相对 outputs/<场>/),判定"产物有没有被管起来"用。"""
-    prov_p = P.out_root() / '_provenance.json'
-    if not prov_p.is_file():
-        return set()
-    try:
-        return set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
-    except Exception:
-        return set()
-
-
-def _all_entries(reg) -> list[dict]:
-    out = []
-
-    def walk(e):
-        out.append(e)
-        for c in e.get('children') or []:
-            walk(c)
-    for e in reg.get('pages') or []:
-        walk(e)
-    return out
-
-
-def render_inventory(rows: list[dict]) -> str:
-    head = ['| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |', '|---|---|---|---|---|']
-    body = []
-    for r in rows:
-        p = '**是**' if r['product'] else ('—(逐条判定)' if r['product'] is None else '不是')
-        body.append(f"| {r['source']} | `{r['name']}`{' ' + r['title'] if r.get('title') else ''} | {p} | "
-                    f"{r['evidence']} | {'✔' if r['registered'] else '**✘ 未登记**'} |")
-    return '\n'.join(head + body)
-
-
-def audit(registry=None):
-    """→ (rc, results, unregistered)。
-
-    供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程:
-    子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。
-    """
-    reg = load(registry)
-    results: list[tuple[str, str, str, int]] = []
-    for e in reg.get('pages') or []:
-        check_entry(e, results)
-    results += ledger_coverage(reg)
-    rc_map = {r[3] for r in results if r[0] not in ('OK', 'i', '?') and r[3]}
-    return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg)
-
-
-def ledger_coverage(reg) -> list:
-    """页面侧产物的**台账覆盖**检查 (用户令 2026-09-17: 是产物就要纳入产物管理)。
-
-    凡登记表里指向 `outputs/<场>/…` 的路径 (`file` / `source`), 都必须出现在该场的
-    `_provenance.json::files` 里 —— 否则它虽然被页面用了, 却没进产物台账 (来源/类型无从查)。
-    """
-    out: list[tuple[str, str, str, int]] = []
-    prov_p = P.out_root() / '_provenance.json'
-    if not prov_p.is_file():
-        return out
-    try:
-        prov = set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
-    except Exception:
-        return out
-    prefix = P.out_root().relative_to(P.ROOT).as_posix() + '/'      # outputs/<场>/
-
-    def walk(e):
-        eid = e.get('id') or e.get('name') or '?'
-        gap = e.get('known_gap') or {}
-        gap_missing = set(gap.get('missing') or [])
-        for key in ('file', 'source'):
-            v = e.get(key)
-            if not isinstance(v, str) or not v.startswith(prefix) or any(c in v for c in '*?['):
-                continue
-            if v in gap_missing or any(v.endswith(g.split('/')[-1]) for g in gap_missing):
-                out.append(('i', eid, f'页面侧产物 {v} 不在本包(已知缺口): {gap.get("why", "")[:60]}', RC_OK))
-                continue
-            rel = v[len(prefix):]
-            if rel not in prov:
-                out.append(('X', eid, f'页面侧产物 {v} 未进产物台账 (_provenance.json 里没有 {rel}) '
-                                      f'⇒ 来源/类型无从查, 不算"纳入产物管理"', RC_PROV))
-        for c in e.get('children') or []:
-            walk(c)
-    for e in reg.get('pages') or []:
-        walk(e)
-    return out
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--list', action='store_true', help='只打表')
-    ap.add_argument('--check', action='store_true', help='只检查')
-    ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md')
-    ap.add_argument('--inventory', action='store_true',
-                    help='**所有网页**总清点: 门户锚点/工作台标签/组件产物页/交付页/三维页/内嵌件/仿真页, 逐条判定是否产物')
-    ap.add_argument('--json', action='store_true', help='机器可读输出')
-    ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
-    a = ap.parse_args()
-    reg = load(a.registry)
-    rc, results, gaps = audit(a.registry)
-
-    if a.json:
-        print(json.dumps(dict(rc=rc,
-                              results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results],
-                              unregistered=gaps), ensure_ascii=False, indent=1))
-        return rc
-
-    if a.inventory:
-        rows = inventory()
-        prod = [r for r in rows if r['product'] is True]
-        un = [r for r in rows if not r['registered']]
-        print(f'== 所有网页总清点: {len(rows)} 项 ==')
-        print(f'   判定为产物(= 输入数据重算出来的输出数据): {len(prod)} 项; 未被管起来: {len(un)} 项')
-        cur = None
-        for r in rows:
-            if r['source'] != cur:
-                cur = r['source']
-                print(f'\n  ▸ {cur}')
-            flag = '产物' if r['product'] is True else ('逐条判定' if r['product'] is None else '非产物')
-            print(f'     [{"✔" if r["registered"] else "✘"}] {r["name"][:74]:74s} {flag}')
-        if un:
-            print('\n   未被管起来的条目:')
-            for r in un:
-                print(f'     ✘ {r["source"]} | {r["name"]}')
-        print('\n   判定依据: 产物板(组件页面件/结论件/过程留痕) = 位置在 outputs/<场>/ 下; 页面侧 = 登记表 kind 与 §10/§12 的逐条依据')
-        if a.write_doc:
-            write_doc(load())
-        return RC_OK
-
-    if a.list or not a.check:
-        print('== 门户页面归口 (configs/portal_pages.yaml) ==')
-        for e in reg.get('pages') or []:
-            def walk(x, d=0):
-                eid = x.get('id') or x.get('name')
-                print(f'   {"  " * d}{eid:34s} {x.get("kind"):16s} '
-                      f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}')
-                for c in x.get('children') or []:
-                    walk(c, d + 1)
-            walk(e)
-
-    if not a.check or True:
-        lvl = {}
-        for level, item, note, r in results:
-            lvl[level] = lvl.get(level, 0) + 1
-        print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, '
-              f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==')
-        for level, item, note, r in results:
-            if level not in ('OK', 'i'):
-                print(f'   [{level}] {item}: {note}')
-        if lvl.get('i'):
-            print(f'   [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):')
-            seen = set()
-            for level, item, note, r in results:
-                if level == 'i' and item not in seen:
-                    seen.add(item)
-                    print(f'        · {item}')
-        if gaps:
-            print(f'   [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):')
-            for g in gaps[:8]:
-                print(f'        · {g}')
-        print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}')
-        if a.write_doc:
-            write_doc(reg)
-        return rc
-    return rc
-
-
-INV_BEGIN, INV_END = '<!-- PAGES-INV:BEGIN -->', '<!-- PAGES-INV:END -->'
-
-
-def write_inventory_doc() -> None:
-    """把"所有网页总清点"写进 §12.2(自动块, 放在 §13 之前)。"""
-    rows = inventory()
-    prod = sum(1 for r in rows if r['product'] is True)
-    un = [r for r in rows if not r['registered']]
-    head = (f'### 12.2 全部网页总清点(自动生成,勿手改)\n\n'
-            f'共 **{len(rows)}** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / '
-            f'交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 {prod} 项;'
-            f'未被管起来的 **{len(un)}** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。\n')
-    block = f'{INV_BEGIN}\n{head}\n{render_inventory(rows)}\n\n{INV_END}'
-    if not DOC.is_file():
-        return
-    t = DOC.read_text(encoding='utf-8')
-    if INV_BEGIN in t and INV_END in t:
-        t = re.sub(re.escape(INV_BEGIN) + r'.*?' + re.escape(INV_END), lambda m: block, t, flags=re.S)
-    else:
-        anchor = '\n---\n\n## 13.'
-        t = t.replace(anchor, f'\n\n{block}\n{anchor}', 1) if anchor in t else t.rstrip() + '\n\n' + block + '\n'
-    DOC.write_text(t, encoding='utf-8')
-    print('   已写入 §12.2 全部网页总清点 (自动块)')
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/pages_audit.py'
 
-def write_doc(reg) -> None:
-    if not DOC.is_file():
-        print(f'   (缺 {P.rel(DOC)}, 跳过写文档)')
-        return
-    t = DOC.read_text(encoding='utf-8')
-    block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n'
-             f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n'
-             f'{render(reg, None)}\n\n{DOC_END}')
-    if DOC_BEGIN in t and DOC_END in t:
-        t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S)
-    else:
-        t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n'
-    DOC.write_text(t, encoding='utf-8')
-    print(f'   已写入 {P.rel(DOC)} (§10 页面归口)')
-    write_inventory_doc()
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import pages_audit as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    from src import console
-    console.soft()
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 289
scripts/pages_sweep.py

@@ -1,303 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""观澜各页面「有没有输出」体检(用户令 2026-09-19)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/pages_sweep.py`。**本文件不得新增逻辑**。
 
-背景: 清过产物 + 从 `data/raw` 重算之后, 页面出现"没有输出"的现象。本器**逐页面(含嵌套页)**走一遍
-网关入口(默认 http://127.0.0.1:28084), 对每一页给出:
-
-    HTTP 码 · 字节数 · 判定(有输出 / 空 / 缺件 / 降级 / 报错 / 静态资源)· 证据(页面里那句话)· 疑似原因
-
-覆盖面:
-  · 门户 `/`(含内嵌面板是否有内容)· `/healthz` `/api/version` `/ops`
-  · `/release/*` 本体发布层(随包件, 清产物后必缺)
-  · `/detail/*` 综合详细分析工作台(v2 的 10 个页签取数接口 + 经典嵌套页 `/turbine/<台>` `/problem/<台>/<系统>`)
-  · `/cms/*` CMS 振动诊断(总览/逐台页/报告页)
-  · `/sim/` `/sim/sys/` `/viewer/` 三个静态模块的入口
-  · `release/` 下**随包静态页**(取数单等, 按入口页里出现的链接发现)
-
-判定口径(不猜): 先看 HTTP 与字节数; 再看**页面自己认账的那句话**(缺件/无产物/加载失败/…);
-JSON 则解析顶层 `err/error/no_products/no_report` 与空数组。每条判定都带证据片段, 便于人工复核。
-
-用法:
-    python scripts/pages_sweep.py                 # 控制台摘要 + 写 UTF-8 报告
-    python scripts/pages_sweep.py --base http://127.0.0.1:28084
-    python scripts/pages_sweep.py --write docs/页面输出体检_v0.1.md
+入口路径(`scripts/pages_sweep.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import json
 import pathlib
-import re
+import runpy
 import sys
-import urllib.error
-import urllib.parse
-import urllib.request
-from concurrent.futures import ThreadPoolExecutor
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-sys.path.insert(0, str(ROOT / 'scripts'))
-from src import paths as P                                              # noqa: E402
-
-# ── 页面清单 ────────────────────────────────────────────────────────────────────
-#   kind: page=人看的页面 · api=取数接口 · static=静态资源
-#   why : 这一页的产物依赖(用于把"空"归因到"缺哪件")
-PAGES: list[dict] = [
-    dict(path='/', kind='page', why='门户单文件 (release/portal.html), 内嵌结论段由产物渲染',
-         dep='guanlan/derived/portal_claims.json'),
-    dict(path='/healthz', kind='api', why='模块探活'),
-    dict(path='/api/version', kind='api', why='版本/契约指纹'),
-    dict(path='/ops', kind='page', why='运维控制台 (网关自提供)'),
-    dict(path='/release/', kind='api', why='本体发布层 manifest (随包件 ontology/release_*)'),
-    dict(path='/release/r1/manifest.json', kind='api', why='本体发布层 r1', dep='ontology/release_r1/manifest.json'),
-    dict(path='/release/r2/manifest.json', kind='api', why='本体发布层 r2', dep='ontology/release_r2/manifest.json'),
-
-    # /detail 工作台: v2 页签底数 + 各自取数
-    dict(path='/detail/', kind='page', why='v2 工作台外壳 (10 页签)'),
-    dict(path='/detail/api/fleet?win=2026%E5%B9%B4', kind='api', why='全部页签底数: windscada/*.parquet + ontology', dep='windscada/temp_monthly.parquet'),
-    dict(path='/detail/api/curves', kind='api', why='特性曲线页', dep='windscada/curve_lenses.parquet'),
-    dict(path='/detail/api/vibcms', kind='api', why='振动评估页: windcms/报告_CMS*.md', dep='windcms/报告_CMS振动状态评估报告_2026-09-19.md'),
-    dict(path='/detail/api/maint_survey', kind='api', why='系统自查页: ontology/objects.json', dep='ontology/objects.json'),
-    dict(path='/detail/api/ont_chain?kind=tree&system=%E9%BD%BF%E8%BD%AE%E7%AE%B1', kind='api', why='决策链页', dep='ontology/objects.json'),
-    dict(path='/detail/api/facts', kind='api', why='问答/报告页: guanlan/ 事实契约 (人裁底稿派生)', dep='guanlan/derived/detail_cards.json'),
-    dict(path='/detail/api/ask_status', kind='api', why='问答页: 本机模型'),
-    # 经典嵌套页(/detail/<rest> → 组件根路径)
-    dict(path='/detail/turbine/WTG01', kind='page', why='逐台页 (经典)'),
-    dict(path='/detail/problem/WTG01/%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F', kind='page', why='问题页 (经典)'),
-    dict(path='/detail/api/turbine?t=WTG01&win=2026%E5%B9%B4', kind='api', why='逐台下钻浮层'),
-    dict(path='/detail/api/problem?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
-         kind='api', why='问题下钻浮层'),
-
-    # /cms CMS 振动诊断
-    dict(path='/cms/', kind='page', why='CMS 首页 (windcms/index.html)', dep='windcms/index.html'),
-    dict(path='/cms/overview.html', kind='page', why='CMS 总览', dep='windcms/overview.html'),
-    dict(path='/cms/turbines/WTG01.html', kind='page', why='CMS 逐台页', dep='windcms/turbines/WTG01.html'),
-    dict(path='/cms/report.md', kind='page', why='CMS 结构化层报告', dep='windcms/report.md'),
-
-    # 静态模块入口
-    dict(path='/sim/', kind='page', why='仿真与回放 (随包资产)'),
-    dict(path='/sim/sys/', kind='page', why='仿真·四系统合页 (随包资产)'),
-    dict(path='/viewer/', kind='page', why='三维拆装工作台 (随包资产)'),
-]
-
-# ★2026-09-19 收紧: 只认"这一页确实没数据"的**具体话术**。首版把 不可用/缺件/无数据 这类通用词也算命中,
-#   结果门户图例、i18n 词表、运维页说明文字都被算成"缺件" ⇒ 噪声淹掉真问题。
-EMPTY_MARKERS = [
-    ('无产物', '页面自报"无产物"'), ('产物未生成', '页面自报"产物未生成"'),
-    ('还没有生成', '页面自报"还没有生成"'), ('本体库缺失', '本体库缺失'),
-    ('报告解析失败', '报告解析失败'), ('模块不可用: ', '网关的下线页'),
-    ('加载失败', '页面自报加载失败'), ('读取失败', '页面自报读取失败'),
-    ('Traceback', '页面里带 traceback'), ('no_products', '接口回 no_products'),
-    ('not found', '404/未找到'),
-]
-SPA_SHELLS = {'/', '/detail/'}          # 单页应用壳: 正文由 JS 取数渲染, 不能按"可见文本"判空
-
-
-def fetch(base: str, path: str, timeout: int = 60):
-    url = base + path
-    try:
-        with urllib.request.urlopen(url, timeout=timeout) as r:
-            return r.status, r.headers.get('Content-Type', ''), r.read()
-    except urllib.error.HTTPError as e:
-        return e.code, e.headers.get('Content-Type', '') if e.headers else '', e.read()
-    except Exception as e:
-        return None, f'{type(e).__name__}', str(e).encode('utf-8')
-
-
-_TAG = re.compile(r'<[^>]+>')
-_SCRIPT = re.compile(r'<(script|style)\b.*?</\1>', re.S | re.I)
-_COMMENT = re.compile(r'<!--.*?-->', re.S)
-
-
-def visible_text(html: str) -> str:
-    """页面**可见文本**(去掉 script/style/注释/标签)。
-
-    ★为什么必须先去脚本: 首版直接在整页文本里找"不可用/undefined/缺件"这类词, 命中的全是 JS 里的
-    i18n 词表与 CSS 类名(例如逐台页里 `'监测不可用':'var(--unknown)'`)⇒ **好页面被误判成缺件**。
-    判定只许看人能看到的那部分。
-    """
-    t = _SCRIPT.sub(' ', html)
-    t = _COMMENT.sub(' ', t)
-    t = _TAG.sub(' ', t)
-    return re.sub(r'\s+', ' ', t).strip()
-
-
-def judge(path: str, kind: str, code, ctype: str, body: bytes) -> dict:
-    raw = body.decode('utf-8', 'replace')
-    n = len(body)
-    is_html = ('html' in (ctype or '').lower()) or raw.lstrip()[:1] == '<'
-    txt = visible_text(raw) if is_html else raw
-    hits = [why for mk, why in EMPTY_MARKERS if mk in txt]
-    err_msg = ''
-    if 'json' in (ctype or '') and n < 400000:
-        try:
-            o = json.loads(raw)
-            if isinstance(o, dict):
-                err_msg = str(o.get('err') or o.get('error') or '')
-                if o.get('no_products') or o.get('no_report'):
-                    hits.append('接口自报无产物')
-                empties = [k for k, v in o.items() if isinstance(v, list) and not v]
-                if empties and len(empties) >= max(1, len(o) // 2):
-                    hits.append('顶层多数数组为空: ' + ','.join(empties[:4]))
-        except Exception:
-            pass
-    if code is None:
-        verdict = '连不上'
-    elif code >= 500:
-        verdict = '报错'
-    elif code >= 400:
-        verdict = '报错' if code != 404 else '未找到'
-    elif kind == 'static':
-        verdict = '静态资源'
-    elif n < 200:
-        verdict = '空'
-    elif hits:
-        verdict = '缺件/降级'
-    elif path in SPA_SHELLS:
-        verdict = '有输出(单页壳)'      # 壳本身只有标题, 正文由 JS 从 /api/* 取 —— 判空要看它的接口
-    elif is_html and len(txt) < 120 and n < 60000:
-        # ★阈值要带字节数: 200 KB 的逐台/问题页可见文本本来就少(正文全是 JS 渲染的),
-        #   只看"可见文本 <120 字"会把它们误判成空页(2026-09-19 实逮)。
-        verdict = '空(可见文本极少)'
-    else:
-        verdict = '有输出'
-    ev = err_msg[:90] or (hits[0] if hits else txt[:70])
-    return dict(verdict=verdict, code=code, n=n, ctype=(ctype or '')[:40], hits=hits[:3],
-                evidence=ev, visible=len(txt))
-
-
-CAUSE_RUNNING = '重算进行中(跑完自愈)'
-CAUSE_NOGEN = '该族无生成端(重算也补不出)'
-CAUSE_HUMAN = '依赖人裁底稿(非机械可算)'
-CAUSE_EXTERNAL = '外部组件(本机模型)'
-CAUSE_STATIC = '随包静态资产(与重算无关)'
-CAUSE_OK = '有输出'
-
-
-def _families() -> tuple:
-    """→ (族表, 是否正在重算)。族表用来判"缺的这一族有没有生成端"; 在跑与否决定能不能自愈。"""
-    fams = []
-    try:
-        from products_reverse_audit import FAMILIES
-        fams = FAMILIES
-    except Exception:
-        pass
-    running = False
-    try:
-        from src import opsjob
-        running = bool(opsjob.current().get('running'))
-    except Exception:
-        pass
-    return fams, running
-
-
-def family_of(fams, rel: str):
-    import fnmatch
-    for fam in fams:
-        pats = [fam.get('glob')] if isinstance(fam.get('glob'), str) else list(fam.get('glob') or [])
-        for g in pats:
-            if '{' in g:
-                head, tail = g.split('{', 1)
-                opts, rest = tail.split('}', 1)
-                exp = [head + o + rest for o in opts.split(',')]
-            else:
-                exp = [g]
-            for pt in exp:
-                if '**' not in pt and rel.count('/') != pt.count('/'):
-                    continue
-                if rel == pt or fnmatch.fnmatch(rel, pt):
-                    return fam
-    return None
-
-
-def cause_of(path: str, verdict: str, dep: str | None, fams, running: bool) -> str:
-    """把"空/报错"翻成原因: 依赖族有没有生成端 → 会不会自愈。判据只有事实, 不猜。"""
-    if verdict == '有输出':
-        return CAUSE_OK
-    if verdict == '静态资源':
-        return CAUSE_STATIC
-    if path.startswith('/local-ai') or '/ask' in path:
-        return CAUSE_EXTERNAL
-    f = family_of(fams, dep) if dep else None
-    if f is None:
-        return CAUSE_RUNNING if running else '依赖件不在位(放原始件后重算)'
-    if f.get('kind') in ('shipped', 'human') and not f.get('gen'):
-        # 本体发布层/带版本交付件 = 研发出件(不是人裁底稿,别把两类混成一句)
-        if f.get('id') in ('ontology_releases', 'windscada_pages', 'tcm_replay'):
-            return '包内无生成端(研发出件)'
-        return CAUSE_HUMAN if f.get('id') in ('guanlan_contract', 'sop_workspace', 'paradigm_r1',
-                                             'human_deliverables') else CAUSE_NOGEN
-    return CAUSE_RUNNING if running else '依赖件不在位(重算未覆盖到)'
-
-
-def discover_links(base: str, prefix: str, html: bytes, limit: int = 25) -> list[str]:
-    """从页面里发现**同前缀**的嵌套页(href/src),用于"含嵌套页面"的覆盖。"""
-    txt = html.decode('utf-8', 'replace')
-    got, seen = [], set()
-    for m in re.finditer(r'(?:href|src)=["\']([^"\'#?]+)["\']', txt):
-        u = m.group(1)
-        if not u.startswith('/') or not u.startswith(prefix):
-            continue
-        if u.endswith(('.js', '.css', '.png', '.jpg', '.svg', '.woff', '.woff2', '.ico', '.map')):
-            continue
-        if u in seen or '${' in u:          # JS 模板字面量不是页面
-            continue
-        seen.add(u)
-        got.append(u)
-        if len(got) >= limit:
-            break
-    return got
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--base', default='http://127.0.0.1:28084')
-    ap.add_argument('--write', default=None)
-    ap.add_argument('--links', type=int, default=12, help='每个模块再抽几个嵌套页')
-    a = ap.parse_args()
-
-    tasks = list(PAGES)
-    # 嵌套页: 先抓每个模块入口, 从中发现同前缀链接
-    for prefix in ('/detail', '/cms', '/sim', '/sim/sys', '/viewer', '/ops'):
-        code, ct, body = fetch(a.base, prefix + '/')
-        if not body:
-            continue
-        for u in discover_links(a.base, prefix, body, a.links):
-            tasks.append(dict(path=u, kind='page', why=f'{prefix} 内的嵌套页 (从入口页发现)'))
 
-    fams, running = _families()
-    with ThreadPoolExecutor(max_workers=8) as ex:
-        res = list(ex.map(lambda t: (t, fetch(a.base, t['path'])), tasks))
-    rows = []
-    for t, (code, ctype, body) in res:
-        j = judge(t['path'], t['kind'], code, ctype, body)
-        j.update(path=t['path'], kind=t['kind'], why=t['why'], dep=t.get('dep'))
-        j['cause'] = cause_of(t['path'], j['verdict'], t.get('dep'), fams, running)
-        rows.append(j)
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/pages_sweep.py'
 
-    order = {'报错': 0, '未找到': 1, '空': 2, '缺件/降级': 3, '连不上': 4, '有输出': 5, '静态资源': 6}
-    rows.sort(key=lambda r: (order.get(r['verdict'], 9), r['path']))
-    L = [f'# 观澜页面输出体检 · {a.base}', '',
-         f'共 {len(rows)} 页(含从各模块入口发现的嵌套页)· 判定口径: HTTP + 字节数 + 页面自己认账的那句话',
-         f'· 重算是否正在跑: **{"是(产物会随步骤长回来)" if running else "否"}**', '',
-         '| 判定 | 路径 | 码 | 字节 | 原因 | 证据 | 产物依赖 |', '|---|---|---:|---:|---|---|---|']
-    for r in rows:
-        L.append(f"| {r['verdict']} | `{r['path']}` | {r['code']} | {r['n']:,} | {r['cause']} | "
-                 f"{str(r['evidence'])[:60]} | {r['why']} |")
-    rep = '\n'.join(L)
-    if a.write:
-        pathlib.Path(a.write).write_text(rep + '\n', encoding='utf-8')
-    from collections import Counter
-    cnt = Counter(r['verdict'] for r in rows)
-    print('判定分布: ' + ' · '.join(f'{k}={v}' for k, v in cnt.most_common()))
-    for r in rows:
-        if r['verdict'] not in ('有输出', '静态资源'):
-            print(f"  [{r['verdict']}] {r['path']}  HTTP {r['code']}  {r['n']:,}B  {str(r['evidence'])[:70]}")
-    if a.write:
-        print(f'已写 {a.write}')
-    return 0
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import pages_sweep as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 445
scripts/products_restore_missing.py

@@ -1,445 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""补齐"包内没有生成端"的产物 —— 只补缺件, 不动 raw 重算件; 并落一份**来源台账** (2026-09-12)。
-
-## ★ 本器已退出运行链(用户令 2026-09-17 #1)
-
-**"观澜系统运行不要从含产物的交付包补齐"**。所以:
-
-· `rebuild_all.py` 的第 ⑤ 步**不再调用本器**(改成"反向呼应审计",只报账不搬运);
-  `products_state` / 门户 / 各组件页里的提示也不再指向"拿交付包补";
-· 本器现在**只作研发/离线人工补救**:某台机器上确实缺了一批"包内无生成端"的件、且当场无法重算时,
-  由人显式指定来源跑一次。任何自动化的"看旁边有没有 zip"都去掉了 —— 那会把"包"变成隐式依赖。
-· 运行期遇到缺件的正确处置是(按优先级):
-    ① 放原始件到 `data/raw/<场站>/` → `python scripts/rebuild_all.py`(有生成端的那些);
-    ② 没有生成端的那些 → **由研发补生成端**,逐族可逆性见 `docs/系统设计说明.md` §13.6;
-    ③ 仍缺 → 页面/自检会如实列出缺哪件(结构化缺件页 + `guanlan.py check` 的「反向呼应」一行)。
-
-## 为什么它当初存在
-
-`rebuild_from_raw.py` 能从 `data/raw` 重算出一部分产物; 但随包里另有一批产物**全库只有读取方、
-0 处写入方** —— 没有生成端, 从零重算就是拿不到 (工作台主数据 `/api/fleet` 一缺
-`pitch/pitch_daily.parquet` / `temp_monthly.parquet` 就整个回 `err=no_products`, 页面看着像"没数据")。
-2026-09-12 的口径是"证明不了的用随包件补齐、但标明它不是重算件";2026-09-17 用户令把这条
-**从运行期拿掉**:宁可如实显示缺件,也不让系统依赖交付包。
-
-## 规则 (保守优先)
-
-对随包产物来源里的每个文件:
-  · 目标**已存在** → 跳过 (那是 raw 重算出来的, 不许被随包件覆盖);
-  · 目标不存在 → 拷过去, 记为 `shipped`(台账 `_provenance.json` 里一眼看得出它不是重算件);
-  · `.log/.jsonl` → 按用户令 2 落到 `logs/build/<场>/`,不算产物。
-另: 只处理"产物目录"(outputs/<场>/ 下的仓), 不碰 release/ 与 data/。
-
-## 用法(**人工**,不在重算链里)
-
-    python scripts/products_restore_missing.py --dry-run          # 只报要补什么
-    python scripts/products_restore_missing.py --stash <dir>      # 从随包产物目录补
-    python scripts/products_restore_missing.py --stash <包.zip>   # 从含产物的 zip 补(离线补救)
-    python scripts/products_restore_missing.py --find             # 只看旁边哪个 zip 里真有产物
-"""
-from __future__ import annotations
-
-import argparse
-import fnmatch
-import json
-import pathlib
-import shutil
-import sys
-import time
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-
-from src import paths as P                                     # noqa: E402
-
-# 由 data/raw 重算出来的 (重建器 + 验证依据) —— 这些**永不被随包件覆盖**
-RAW_DERIVED = {
-    # 三门台账 (rebuild_from_raw.py; --verify 与随包基线逐值比对过)
-    'windscada/alarms.parquet': 'scripts/windscada_alarms_ingest.py',
-    'windscada/workorders.parquet': 'scripts/windscada_workorder_ingest.py',
-    'windscada/oil_samples_index.parquet': 'scripts/windscada_watch_channels_build.py',
-    # SCADA 侧 10 个构建器 (rebuild_from_raw.py --scada)
-    'windscada/powercurve_dev.parquet': 'src.windscada.perf.powercurve',
-    'windscada/powercurve_bins.parquet': 'src.windscada.perf.powercurve',
-    'windscada/loss_monthly.parquet': 'src.windscada.perf.availability',
-    'windscada/curve_lenses.parquet': 'src.windscada.perf.curves',
-    'windscada/curve_liveness.parquet': 'src.windscada.perf.curves',
-    'windscada/control_profile.parquet': 'src.windscada.perf.control',
-    'windscada/control_schedule.parquet': 'src.windscada.perf.control',
-    'windscada/stop_events.parquet': 'src.windscada.perf.faults',
-    'windscada/temp_bins.parquet': 'src.windscada.subsys.temp_nbm',
-    'windscada/yaw_daily.parquet': 'src.windscada.subsys.yaw',
-    'windscada/hydraulic_accum.parquet': 'src.windscada.subsys.hydraulic',
-    'windscada/thermal_chain.parquet': 'src.windscada.subsys.thermal_chain',
-    'windscada/system_aux.parquet': 'src.windscada.taxonomy',
-    # 月度派生件 (本轮新增; 逐值对齐随包件后才算数)
-    'windscada/temp_monthly.parquet': 'scripts/windscada_monthly_build.py (19494/19494 逐值一致)',
-    # 本体层 (kb_ingest → populate → chain_ingest → trend_ingest; 全链跑完后 audit 0 问题)
-    'ontology/objects.json': 'python -m src.ontology.kb_ingest + populate + chain_ingest + trend_ingest (audit 0 问题)',
-    'ontology/retrieval_index.json': 'src.ontology.retrieval.build',
-    'ontology/turbine_params.parquet': 'src.ontology.maintenance.refresh_params',
-}
-
-
-def zip_candidates() -> list[pathlib.Path]:
-    """可能含产物的包(本仓根 + 上一级)—— **仅供离线人工诊断**,运行链不用它。
-
-    ★ 用户令 2026-09-17 #1「观澜系统运行不要从含产物的交付包补齐」:本器已**退出运行链**
-      (`rebuild_all.py` 第 ⑤ 步改成反向呼应审计,只报账不搬运)。自动找包的逻辑一并去掉 ——
-      留着它等于让"配一个 zip 在旁边"变成隐式依赖,与"目标机放数据自己重算"这条主线冲突。
-      本函数现在只服务于 `--find`(人工看一眼"旁边哪个包里有产物")。
-    """
-    out = []
-    for base in (ROOT, ROOT.parent):
-        try:
-            out += [p for p in sorted(base.glob('*.zip')) if p.is_file()]
-        except OSError:
-            pass
-    return list(dict.fromkeys(out))
-
-
-def find_stash_zip() -> tuple[pathlib.Path, int] | None:
-    """→ (含产物的包, 该场产物件数);找不到就 None。"""
-    import zipfile
-    marker = f'outputs/{P.farm()}/windscada/temp_monthly.parquet'
-    pref = f'outputs/{P.farm()}/'
-    best = None
-    for z in zip_candidates():
-        try:
-            with zipfile.ZipFile(z) as zf:
-                names = set(zf.namelist())
-        except Exception:                                    # 不是 zip / 读不动 / 加密: 跳过, 不中断
-            continue
-        if marker not in names:
-            continue
-        n = sum(1 for x in names if x.startswith(pref))
-        if best is None or n > best[1]:
-            best = (z, n)
-    return best
-
-
-def stash_dir(explicit=None) -> pathlib.Path:
-    """随包产物原件在哪。
-
-    四个地方都可能是它(取决于用过哪些开关), 所以按"像不像随包整份产物"打分挑:
-      · `_products_off/<场>/`                     = products_state --off 挪走的当前产物;
-      · `_products_off_prev_<时间戳>/<场>/`         = --off --archive-old 存档的上一代(= 随包原件);
-      · `--stash` 显式指定 (目录或交付包 zip);
-      · **含产物的交付包 zip, 自动找** (2026-09-17 加: 本仓根与上一级 `.zip` 里挑真含随包件的那个)。
-    判据用**只在随包里有的件**(windscada/temp_monthly.parquet 这类"包内无生成端"的产物):
-    含它的目录才是"标准答案"来源。原先只看第一个候选, 于是 --on 还原后暂存区空了 → 台账被写成 0/0。
-    """
-    if explicit:
-        # 必须 resolve: 传相对路径时下面 `stash.relative_to(ROOT)` 会 ValueError (2026-09-12 实逮)
-        return pathlib.Path(explicit).resolve()
-    marker = 'windscada/temp_monthly.parquet'
-    cands = []
-    for pat in ('_products_off/*', '_products_off_prev_*/*'):
-        cands += [p for p in sorted(ROOT.glob(pat)) if p.is_dir() and p.name != '_baseline_kept']
-    scored = []
-    for c in cands:
-        n = sum(1 for _ in c.rglob('*') if _.is_file())
-        scored.append(((1 if (c / marker).is_file() else 0), n, c))
-    scored.sort(key=lambda x: (-x[0], -x[1]))
-    if scored and scored[0][0]:                       # 只有真含随包件的目录才算数
-        return scored[0][2]
-    raise StashMissing('找不到随包产物原件 (暂存区 _products_off/**、或 --stash 显式指定)')
-
-
-class StashMissing(SystemExit):
-    """暂存区不存在 —— **不是崩溃, 是"这一步没得做"**。
-
-    2026-09-16 用户令"系统运行中/未运行都能重算"时实逮: `rebuild_all.py` 的第⑤步 (补齐"包内没有生成端"
-    的产物) 在**没有暂存区**的检出上必抛 SystemExit → 重建链当场中断, 后面的 ⑦本体链 与 ⑧审计**全不执行**
-    (脚本的中断语义是"后面的步骤依赖它")。而暂存区只有"清除产物"动作会产生 —— 首次全量重算、或
-    清完又还原过的机器上就都没有, 于是"从零重算"在交付包上跑不完。
-    处置: 本类带退出码 6, 由 rebuild_all.py 显式容忍并打印"跳过随包补齐" —— 缺的是"随包件"这一路,
-    不是重算本身; 如实报出来比假装通过好, 也比把整条链打断好。
-    """
-
-    exit_code = 6
-
-
-def _family_patterns(glob) -> list[str]:
-    """族 glob(str 或 list)→ 展开花括号后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
-    out = []
-    for g in ([glob] if isinstance(glob, str) else list(glob)):
-        if '{' in g:
-            head, tail = g.split('{', 1)
-            opts, rest = tail.split('}', 1)
-            out += [head + o + rest for o in opts.split(',')]
-        else:
-            out.append(g)
-    return out
-
-
-def _attribute(rel: str) -> dict:
-    """盘上有、无人登记的件 → 按**族表**归口 (2026-09-18)。
-
-    为什么必须做这一步: 生成端**自登记**(`_derived_manifest.json`) 并非全覆盖 —— 实测
-    `windscada/*.parquet`(17 件, 由 `rebuild_from_raw.py --scada` 的 10 个构建器写)、
-    `ontology/*`(3 件, 由本体的 kb_ingest/populate/retrieval 写) 都不自登记。此前本模式
-    **只**照抄自登记件 ⇒ 台账 1705 条、盘上 1728 件, 差的 23 件既不在账上也不被报出
-    (账实不符的另一个方向, 而且正好是"页面主取数"那一批)。现在按族表补归口, 归不上
-    的**如实记 `unregistered`** —— 不猜来源。
-
-    深度对齐规则同 `products_reverse_audit._files_of`: 模式无 `**` 时, `/` 个数必须相等
-    (fnmatch 的 `*` 跨 `/`, 不显式挡会让 `windscada/*.parquet` 吞掉子目录里的同名件)。
-    """
-    try:
-        from products_reverse_audit import FAMILIES
-    except Exception as e:                                      # 族表读不到: 全部按未归类记
-        return dict(source='unregistered', builder='?',
-                    why=f'盘上有、生成端未自登记, 且族表读不到({type(e).__name__}) ⇒ 需人工归口')
-    for fam in FAMILIES:
-        for pt in _family_patterns(fam.get('glob')):
-            if '**' not in pt and rel.count('/') != pt.count('/'):
-                continue
-            if rel == pt or fnmatch.fnmatch(rel, pt):
-                kind = fam.get('kind') or 'raw-derived'
-                src = {'raw-derived': 'raw-derived', 'shipped': 'shipped', 'human': 'human',
-                       'source-derived': 'source-derived', 'not-product': 'not-product'}.get(kind, kind)
-                return dict(source=src, builder=fam.get('gen') or '(族表登记: 无生成端)',
-                            why=f'盘上在位但生成端未自登记 → 按族表 {fam.get("id")} 归口'
-                                f'({fam.get("func") or ""})')
-    return dict(source='unregistered', builder='?',
-                why='盘上有、生成端未自登记、族表也未登记 ⇒ 需人工归口 '
-                    '(本条是软件兜底记账, 不是真来源; 见到了要么补自登记, 要么补族表)')
-
-
-def _reattr_needed(v: dict) -> bool:
-    """这条账是不是"族表归口的产物"(可随族表改进而重写),而不是真来源(自登记/人工补入)。"""
-    return v.get('source') == 'unregistered' or '按族表' in str(v.get('why') or '')
-
-
-def refresh_ledger(farm: str | None = None) -> int:
-    r"""**只维护台账**(不需要任何随包件/交付包):把盘上已经不存在的条目从 `_provenance.json` 里去掉。
-
-    为什么需要它(用户令 2026-09-17 #3):把"非产物"的杂物从产物仓移走后,台账里还留着它们的条目 ——
-    于是反向呼应审计仍把它们当产物统计("帐上 2292 件、盘上 2275 件"这种不一致正是这套台账最该避免的)。
-    本模式只做"账实相符":**在位且来源已登记的照抄,盘上没了的删掉**,不引入任何新来源。
-    """
-    farm = farm or P.farm()
-    root = P.out_root(farm)
-    old = {}
-    f = root / '_provenance.json'
-    if f.is_file():
-        try:
-            old = (json.loads(f.read_text(encoding='utf-8')) or {}).get('files') or {}
-        except Exception:
-            old = {}
-    kept, dropped = {}, []
-    for rel, v in old.items():
-        if (root / rel).is_file():
-            kept[rel] = v
-        else:
-            dropped.append(rel)
-    # 自登记件 (_derived_manifest.json): 在位且台账里没有的补进来
-    added = 0
-    try:
-        from src.derived_manifest import load as _load
-        for rel, info in ((_load(root).get('files') or {})).items():
-            if (root / rel).is_file() and rel not in kept:
-                kept[rel] = dict(source='raw-derived',
-                                 builder=f"{info.get('builder', '?')} (自登记 by {info.get('by', '?')})")
-                added += 1
-    except Exception:
-        pass
-    # ── 另一个方向: 盘上有、账上没有的件 → 按族表归口 (绝不静默漏掉) ──────────────
-    #    台账两本 (_provenance.json / _derived_manifest.json) 都是**这本台账自己**:
-    #    它们不是产物, 不登记 (否则每次写盘都会让"账实相符"永远差两件)。
-    swept: list[str] = []
-    for p in sorted(root.rglob('*')):
-        if not p.is_file():
-            continue
-        rel = p.relative_to(root).as_posix()
-        if rel in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):   # 链上台账/快照, 不是产物
-            kept.pop(rel, None)          # ★旧账里可能留有这条(早先没排): 顺手出清, 免得"账实相符"永远差一件
-            continue
-        # 只补"没人登记"的; 但**族表归口过的条目要重跑** —— 否则族表改了(例如给 windcms/cache
-        # 单列一族)这条账会一直停在旧归口上, 而它并不是谁自登记/谁补进来的真来源。
-        if rel in kept and not _reattr_needed(kept[rel]):
-            continue
-        kept[rel] = _attribute(rel)
-        swept.append(rel)
-    by = {}
-    for v in kept.values():
-        by[v.get('source', '?')] = by.get(v.get('source', '?'), 0) + 1
-    print(f'台账: {len(old)} 条 → {len(kept)} 条(盘上已删 {len(dropped)} 条, 自登记补入 {added} 条, '
-          f'按族表归口 {len(swept)} 条)')
-    for r in dropped[:12]:
-        print(f'   - 移出/删除: {r}')
-    if len(dropped) > 12:
-        print(f'   … 另有 {len(dropped) - 12} 条')
-    for r in swept[:8]:
-        print(f'   + 归口: {r}  ← {kept[r].get("builder")}')
-    if len(swept) > 8:
-        print(f'   … 另有 {len(swept) - 8} 条归口')
-    unk = [r for r in swept if kept[r].get('source') == 'unregistered']
-    if unk:
-        print(f'   ★ {len(unk)} 件**归不上族**: ' + ', '.join(unk[:6]) + (' …' if len(unk) > 6 else ''))
-    print('   来源分布: ' + ' · '.join(f'{k}={v}' for k, v in sorted(by.items())))
-    f.write_text(json.dumps(dict(
-        at=time.strftime('%Y-%m-%d %H:%M:%S'),
-        note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端(历史随包件); '
-             'source-derived = 由源码重建(如发布清单); human = 人工正本/交证件; '
-             'unregistered = 盘上有但无人登记(需人工归口); '
-             'log-relocated = 包内日志按用户令 2 落到 logs/build/',
-        counts=dict(by), files=kept), ensure_ascii=False, indent=1), encoding='utf-8')
-    print(f'已写 {P.rel(f)}')
-    return 0
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--dry-run', action='store_true')
-    ap.add_argument('--stash', default=None)
-    ap.add_argument('--find', action='store_true',
-                    help='只看旁边的 .zip 里哪个真含产物 (人工诊断用, 不写盘)')
-    ap.add_argument('--refresh', action='store_true',
-                    help='只维护台账: 把盘上已不存在的条目从 _provenance.json 去掉 (不需要任何随包件)')
-    a = ap.parse_args()
-    if a.refresh:
-        return refresh_ledger()
-    if a.find:
-        found = find_stash_zip()
-        if not found:
-            print('旁边的 .zip 里没有含' + f'outputs/{P.farm()}/ 产物的包')
-            return 0
-        print(f'含产物的包: {P.rel(found[0])}({found[1]} 件 outputs/{P.farm()}/ 条目)')
-        print('  注意: 本器只作**离线人工补救**; 运行期不要从交付包补齐(用户令 2026-09-17)')
-        return 0
-    try:
-        stash = stash_dir(a.stash)
-    except StashMissing as e:
-        # 没有暂存区 → 这一步没得做。**不许静默、也不许把整条重算链打断**(见 StashMissing 的说明)。
-        print(f'[跳过] {e}')
-        print('       处置(运行期): ① 放原始件到 data/raw/<场站>/ 后 python scripts/rebuild_all.py;'
-              ' ② 没有生成端的族由研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6)。')
-        print('       处置(离线人工补救, 非运行链): --stash <随包产物目录 或 含产物的包.zip>')
-        return StashMissing.exit_code
-    # ★2026-09-16: 原写法 `P.STORE if hasattr(P,'STORE') else ROOT/'outputs'/'rudong'` —— P.STORE 根本
-    #   不存在, 于是**恒**落到硬编码的 rudong, 多场部署下会把 rudong 的随包件补进别的场 (静默串场)。
-    dest_root = P.out_root()
-    print(f'随包件: {P.rel(stash)}')
-    print(f'产物仓: {dest_root.relative_to(ROOT)}\n')
-
-    prov = {}
-
-    def take(rel: str, src_path=None, src_bytes=None):
-        """补齐一件 (或在位则只记账)。src_path=目录来源, src_bytes=zip 来源。
-
-        ★ 日志不补进产物仓 (2026-09-17): 历史交付包里把构建日志也当产物发过 (如
-          `m5_cms_tcm/shared_component_bandpower.log`)。用户令 2 定的是"运行日志只在 logs/",
-          所以这里把它直接落到 `logs/build/<场>/…` —— 数据没丢, 但不再算产物、也不进台账
-          (否则每从旧包补一次, `guanlan.py check` 的「日志统一」就红一次: 日志跑到 logs/ 外面了)。
-        """
-        low = rel.lower()
-        if low.endswith(('.log', '.jsonl')):
-            from src import logfile as _lf
-            dst_log = _lf.build_log(rel, P.farm())
-            prov[rel] = dict(source='log-relocated', why='包内日志: 按用户令 2 落到 logs/build/, 不算产物')
-            if not a.dry_run:
-                dst_log.parent.mkdir(parents=True, exist_ok=True)
-                if src_bytes is not None:
-                    dst_log.write_bytes(src_bytes)
-                elif src_path is not None:
-                    shutil.copy2(src_path, dst_log)
-            return
-        dst = dest_root / rel
-        if dst.exists():
-            # 已存在的件分两类: 我们自己重算的(raw-derived) 与 上轮已补齐的(shipped)。
-            # ★不能因为"本轮没拷"就不记账 —— 早先写成"存在即不列", 于是重复跑一次台账就被清成 0/0,
-            #   页面上的来源统计跟着一起错 (2026-09-12 实逮)。
-            if rel in RAW_DERIVED:
-                prov[rel] = dict(source='raw-derived', builder=RAW_DERIVED[rel])
-            else:
-                prov[rel] = dict(source='shipped', why='包内无生成端 / 规则未复现 → 随包件补齐 (本轮已在位)')
-            return
-        prov[rel] = dict(source='shipped', why='包内无生成端 / 规则未复现 → 用随包件补齐')
-        if a.dry_run:
-            return
-        dst.parent.mkdir(parents=True, exist_ok=True)
-        if src_bytes is not None:
-            dst.write_bytes(src_bytes)
-        else:
-            shutil.copy2(src_path, dst)
-
-    if stash.is_file():
-        # 交付包 zip 形态 (2026-09-16): 按需从包里取 outputs/<场>/** 补齐 ——
-        # 这是"清除不留备份"之后**唯一**的随包件来源, 所以把它做成一等输入。
-        import zipfile
-        with zipfile.ZipFile(stash) as zf:
-            pref = f'outputs/{P.farm()}/'
-            members = [n for n in zf.namelist() if n.startswith(pref) and not n.endswith('/')]
-            print(f'  从交付包读取 {len(members)} 件 (前缀 {pref})')
-            for name in sorted(members):
-                if a.dry_run:
-                    take(name[len(pref):])
-                else:
-                    take(name[len(pref):], src_bytes=zf.read(name))
-    else:
-        for src in sorted(stash.rglob('*')):
-            if not src.is_file():
-                continue
-            take(src.relative_to(stash).as_posix(), src_path=src)
-
-    # 重算件里有些**不在随包件里**(如我们新造的 turbine_params.parquet), 也要记进台账
-    for rel, builder in RAW_DERIVED.items():
-        if rel not in prov and (dest_root / rel).exists():
-            prov[rel] = dict(source='raw-derived', builder=builder)
-
-    # 构建脚本的**自登记** (src/derived_manifest.py): 振动侧的产物名随窗/分片变, 写不进上面的精确表,
-    # 按名字通配又会误伤同名旧件 → 由"谁算的谁登记", 这里只认登记。缺失不影响其余记账。
-    try:
-        from src.derived_manifest import load as _load_manifest, prune as _prune_manifest
-        _gone = _prune_manifest(dest_root)      # 先清掉"登记了但盘上已删"的条目 (如被删的 _reimport 窗)
-        if _gone:
-            print(f'  自登记清理: {_gone} 条指向已删文件的条目')
-        _man = (_load_manifest(dest_root).get('files') or {})
-    except Exception:
-        _man = {}
-    for rel, info in _man.items():
-        if (dest_root / rel).exists():
-            prov[rel] = dict(source='raw-derived',
-                             builder=f"{info.get('builder', '?')} (自登记 by {info.get('by', '?')})")
-        else:
-            prov[rel] = dict(source='raw-derived',
-                             builder=f"{info.get('builder', '?')} (自登记, 但该件当前不在盘上)")
-
-    by_store = {}
-    for rel, m in prov.items():
-        store = rel.split('/')[0]
-        d = by_store.setdefault(store, {'raw-derived': 0, 'shipped': 0, 'log-relocated': 0})
-        d.setdefault(m['source'], 0)
-        d[m['source']] += 1
-    moved = sum(d['log-relocated'] for d in by_store.values())
-    print(f'{"仓":26s} {"raw 重算":>9s} {"随包补齐":>9s}' + (f' {"日志归位":>9s}' if moved else ''))
-    for store, d in sorted(by_store.items()):
-        print(f'  {store:24s} {d["raw-derived"]:9d} {d["shipped"]:9d}'
-              + (f' {d["log-relocated"]:9d}' if moved else ''))
-    tot = {k: sum(d[k] for d in by_store.values()) for k in ('raw-derived', 'shipped', 'log-relocated')}
-    print(f'  {"合计":24s} {tot["raw-derived"]:9d} {tot["shipped"]:9d}'
-          + (f' {tot["log-relocated"]:9d}' if moved else ''))
-    if moved:
-        print(f'  (日志不落产物仓: {moved} 件按用户令 2 落到 logs/build/, 不计入产物台账)')
-
-    if a.dry_run:
-        print('\n(dry-run, 未写盘)')
-        return 0
-    out = dest_root / '_provenance.json'
-    out.write_text(json.dumps(dict(
-        at=time.strftime('%Y-%m-%d %H:%M:%S'),
-        note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端, 用随包件补齐',
-        counts=tot, files=prov), ensure_ascii=False, indent=1), encoding='utf-8')
-    print(f'\n已写来源台账 {P.rel(out)}')
-    return 0
-
-
-if __name__ == '__main__':
-    # 控制台可能是 GBK: print 里的非 GBK 字形编不出来会抛异常, 干成了却退出码 1
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/products_restore_missing.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/products_restore_missing.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/products_restore_missing.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import products_restore_missing as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 15 - 850
scripts/products_reverse_audit.py

@@ -1,864 +1,29 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-r"""**反向**呼应审计:自输出 → 功能与算法 → 输入(用户令 2026-09-17)。
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/products_reverse_audit.py`。**本文件不得新增逻辑**。
 
-## 与 `inventory_products.py` 的区别(两个方向,各管一件事)
-
-· `inventory_products.py` 是**正向**:从 `data/raw/<场>/` 的每一类输入出发,找它喂出来的产物,
-  比跨度/条数("输入到了 2026-07,产物只到 2026-04 → 未重算")。它只覆盖 5 类输入、十几件产物。
-· 本器是**反向**:从 `outputs/<场>/` 的**每一件产物**出发,问三个问题:
-    ① 它是谁算出来的?(功能与算法 = 生成端)
-    ② 它从哪份输入算出来的?(`data/raw/<场>/` 的哪一类)
-    ③ 这条"输出 ↔ 输入"的呼应关系**成立吗**?(生成端在位 ∧ 输入在位 ∧ 可机检的对应判据通过)
-  逐件判定,最后给出"成立 / 不成立(无生成端)/ 无法验证"三类账。
-
-## 为什么必须做反向
-
-正向只查"我关心的输入有没有被算成产物",查不出**"盘上这件产物到底有没有来路"**。
-2026-09-17 现场就是栽在这里:清了产物之后,页面缺的 `windscada/index.html`、
-`m5_cms_tcm/handoff_vibration_v2.json` 这类件**根本没有生成端**(全库 0 处写入方),
-正向那几条跨度判据一条都不会报 —— 它们压根不在 `INPUTS` 的 `feeds` 里。
-反向一对账就清楚了:**有来路的件**(生成端 + 输入 + 判据都过)与**没来路的件**(只能从交付包补)。
-
-## 判定口径(每条都写进结果里,不含糊)
-
-    ✓ 呼应成立          生成端在位 ∧ 输入在位 ∧ 判据通过(跨度 ⊆ 输入 / 键集 ⊆ 输入 / 计数一致 / 行数 > 0)
-    ~ 输入不在位         生成端知道,但 `data/raw/<场>/<类>` 不在 ⇒ **现在无法验证**(放数据后复跑本器)
-    ✗ 无生成端           全库 0 处写入方 ⇒ **输出↔输入的呼应在原理上不成立**:这件产物无法由输入推导出来,
-                        **无法由重算生出来**;按用户令 2026-09-17 #1 运行期也不从交付包补齐 ⇒ 只能由研发补生成端(本器 --feasibility 给出逐族可逆性)
-    ? 未归类             既不在族表里、台账里也没有 —— 需要人工认领(本器把它当缺口报出来)
-
-## 用法
-
-    python scripts/products_reverse_audit.py            # 全量反向审计(人看)
-    python scripts/products_reverse_audit.py --check    # 只出结论(有 ✗/? 时退出码 5)
-    python scripts/products_reverse_audit.py --write-doc # 把族表写进 docs/系统设计说明.md §13.6
-
-退出码: 0 全部成立(允许 ✗,只要它们都在台账里如实标了 shipped)· 5 有未归类件或判据失败
+入口路径(`scripts/products_reverse_audit.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
 """
 from __future__ import annotations
 
-import argparse
-import fnmatch
-import json
 import pathlib
+import runpy
 import sys
 
-ROOT = pathlib.Path(__file__).resolve().parents[1]
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
 sys.path.insert(0, str(ROOT))
-sys.path.insert(0, str(ROOT / 'scripts'))
-from src import paths as P                                              # noqa: E402
-import inventory_products as INV                                        # noqa: E402
-
-DOC_BEGIN, DOC_END = '<!-- REVERSE-AUDIT:BEGIN -->', '<!-- REVERSE-AUDIT:END -->'
-
-# ── 族表: 输出(glob) → 功能 / 算法(生成端) / 输入类 / 判据 ────────────────────────────
-#   pred: span=产物时间跨度必须落在输入跨度内; turbines=产物机组集 ⊆ 输入机组集;
-#         rows=行数>0; count=件数/计数与说明一致; manifest=与清单自记一致
-FAMILIES: list[dict] = [
-    dict(id='vib_window_index', glob='m5_cms_tcm/windows/*/index.parquet', kind='raw-derived',
-         func='振动摄入 · 窗索引', algo='scripts/rudong_tcm_index.py(逐 decode JSON 解析 54 列:turbine/'
-              'sensor_name/meas_name/trigger_time/rpm/condition_key/alarm_type)',
-         gen='scripts/rudong_tcm_index.py', input='windcms', pred=('span', 'turbines', 'rows'),
-         time_col='trigger_time'),
-    dict(id='vib_spectra', glob=['m5_cms_tcm/windows/*/spectra/**', 'm5_cms_tcm/windows/*/spectra_meta.parquet'],
-         kind='raw-derived',
-         func='振动摄入 · 谱库', algo='scripts/rudong_tcm_spectra.py(FFT_ 测量 → npz 幅值数组, 分片存 `spectra/p<NN>/`)',
-         gen='scripts/rudong_tcm_spectra.py', input='windcms', pred=('npz',)),
-    dict(id='vib_raw_manifest', glob='m5_cms_tcm/vib_raw_manifest.json', kind='raw-derived',
-         func='振动摄入 · 清单', algo='scripts/vib_raw_build.py(汇总窗/谱件数、时间跨度、missing_chain 缺口)',
-         gen='scripts/vib_raw_build.py', input='windcms', pred=('manifest',)),
-    dict(id='scada_slim10min', glob='windscada/slim10min/*', kind='raw-derived',
-         func='按窗重算底座 · SCADA 10min 窄仓',
-         algo='scripts/scada_slim_build.py(逐台 load_10min 的公共列子集约 46 列:功率/限电命令/风况/转速/'
-              '桨距/温度 _mean 27/偏航/液压;不裁剪行。用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座:'
-              '一次全量扫 ≈2 分钟,之后任何窗的判级与七镜头重算都只是窄仓上的过滤+分组)',
-         gen='scripts/scada_slim_build.py', input='scada_10min', pred=('rows', 'turbines'),
-         time_col='ts'),
-    dict(id='scada_alarms', glob='windscada/alarms.parquet', kind='raw-derived',
-         func='三门台账 · 报警', algo='scripts/windscada_alarms_ingest.py(SpreadsheetML *.xls → 事件表)',
-         gen='scripts/windscada_alarms_ingest.py', input='故障报警', pred=('span', 'rows'), time_col='t_on'),
-    dict(id='scada_workorders', glob='windscada/workorders.parquet', kind='raw-derived',
-         func='三门台账 · 工单', algo='scripts/windscada_workorder_ingest.py(检修台账 → 工单表)',
-         gen='scripts/windscada_workorder_ingest.py', input='风机故障记录', pred=('span', 'rows'),
-         time_col='t_report'),
-    dict(id='scada_oil', glob='windscada/oil_samples_index.parquet', kind='raw-derived',
-         func='三门台账 · 油样', algo='scripts/windscada_watch_channels_build.py(油样 PDF → 索引)',
-         gen='scripts/windscada_watch_channels_build.py', input='油样报告', pred=('span', 'rows'),
-         time_col='date'),
-    dict(id='scada_monthly', glob='windscada/temp_monthly.parquet', kind='raw-derived',
-         func='月度派生件', algo='scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494)',
-         gen='scripts/windscada_monthly_build.py', input='scada_10min', pred=('span', 'rows'), time_col='month'),
-    dict(id='scada_derived', glob='windscada/*.parquet', kind='raw-derived',
-         func='SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助)',
-         algo='src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,'
-              'hydraulic,thermal_chain} + taxonomy.py',
-         gen='src/windscada/perf/powercurve.py', input='scada_10min', pred=('rows',)),
-    dict(id='ontology_core', glob='ontology/{objects.json,retrieval_index.json,turbine_params.parquet}',
-         kind='raw-derived',
-         func='本体层 · 码表/对象库/检索索引/实机参数',
-         algo='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;'
-              'src.ontology.retrieval.build;src.ontology.maintenance.refresh_params',
-         gen='app_ontology/common/app_ontology_guanlan/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
-
-    dict(id='ontology_aux_build', glob=['ontology/_id_alias.json', 'ontology/retrieval_vec.npy'],
-         kind='raw-derived',
-         func='本体层 · 别名表与向量索引缓存', algo='src.ontology.kb_ingest(别名)/ '
-              'src.ontology.retrieval.build(use_vec=True)(向量)',
-         gen='app_ontology/common/app_ontology_guanlan/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
-    dict(id='ontology_domain', glob=['ontology/oil_2026H2_huabiao.json', 'ontology/parts_cost_public.json',
-                                     'ontology/scenario_29_repair.json'],
-         kind='shipped',
-         func='本体层 · 领域数据件(油样台账/备件价格/场景修复)', algo='(无生成端, 领域正本出件)',
-         gen=None, input=None, pred=(), why='领域正本随包发来, 不由 data/raw 推导'),
-
-    # ── 以下族: 全库 0 处写入方 ⇒ 反向呼应**在原理上不成立**(如实记账, 不假装成立)────
-    #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
-    #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
-    #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
-    dict(id='vib_model_run_l6', glob=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json'],
-         kind='raw-derived',
-         func='六层链 model_run 步 · L6 过闸谱线表 + 层小结',
-         algo='scripts/rudong_model_run.py(候选线=oem_scan_plan 的 11 部件×{BPFI,BPFO,BSF}; 过闸='
-              'src/sop/discriminators.py::spectral_line_gates G1–G8; 定级=vib_verdict_and_writeback)',
-         gen='scripts/rudong_model_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='★2026-09-19 按口径重建(**无标准答案**: 这两件从未随包, 无法逐值对拍); 峰值拾取仍是本器自定'
-             '(目标频率 ±2 bin 取最大), 不得当"复现"用 —— 见 docs/振动六层链_接口规格与缺口_v0.1.md §3'),
-    dict(id='vib_fusion_38', glob='m5_cms_tcm/fusion_38.csv', kind='raw-derived',
-         func='六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源)',
-         algo='scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 '
-              'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
-         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
-    # ★2026-09-19 源代码化(第三批):三族由观澜自算顶上。★族表是**首个命中生效**,故这几族必须
-    #   排在通配族(`vib_handoff_and_scans` = `m5_cms_tcm/*.{json,parquet,csv,md,txt}`、`tcm_replay`、
-    #   旧的 `ontology_releases`)**之前**,否则新件会被旧族认领成"无生成端"。
-    dict(id='vib_line_energy_share', glob='m5_cms_tcm/line_energy_share.parquet', kind='raw-derived',
-         func='六层链 energy_share 步 · 线能量占比',
-         algo='scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan)',
-         gen='scripts/rudong_line_energy_share.py', input='m5_cms_tcm', pred=('rows',),
-         why='2026-09-19 补上: 该步脚本此前未随包(chain_gap_check 报"1 个步骤脚本未随包"); 只报占比不定级'),
-    dict(id='vib_mask_thresholds', glob=['tcm_compatible_replay/model/mask_thresholds.tsv',
-                                         'tcm_compatible_replay/model/mask_thresholds.说明.json'],
-         kind='raw-derived', func='TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线)',
-         algo='scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; '
-              '调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现)',
-         gen='scripts/tcm_mask_thresholds_build.py', input='m5_cms_tcm', pred=('rows',),
-         why='★**观澜自算**阈值, 不是厂商阈值: 现场导出件里没有掩码定义(25,679 个 decode 只有 NumberOfMasks: 175) '
-             '⇒ 厂商阈值无法复原; 口径写在同目录 mask_thresholds.说明.json, 页面据此标注(workbench.py 分支)'),
-    dict(id='ontology_releases', glob='ontology/release_*/*', kind='raw-derived',
-         func='本体发布层 r1/r2(网关 /release/ 只读暴露)',
-         algo='scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; '
-              'r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库)',
-         gen='scripts/ontology_release_build.py', input='西门子4.0技术资料', pred=('rows',),
-         why='2026-09-19 由观澜自算顶上: 原为随包件(0 处写入方) ⇒ 新机器 /release/ 两件必 503; '
-             'manifest 里写 source.sha16, 谁都能核出两层是同一份对象库的投影'),
-    dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
-         func='振动线 · 全场标量 z 值 (fusion 步出件)',
-         algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
-              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
-         # input 写**原始件目录名**(族表口径: data/raw/<场>/<input>); 真实取数经窗索引一跳,
-         # 而窗索引本身是 raw-derived(vib_window_index 族) —— 写 windows/_*.parquet 会让审计判"输入不在位"。
-         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
-             '使 /api/fleet 的振动标量 z 面不再空白'),
-    dict(id='vib_fusion_handoff', glob='m5_cms_tcm/handoff_vibration_v2.json', kind='raw-derived',
-         func='融合面 handoff (观澜自算, 同结构于振动线手交件)',
-         algo='scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, '
-              'meta.from 标明"观澜自算")',
-         gen='scripts/rudong_fusion_handoff.py', input='m5_cms_tcm', pred=('rows',),
-         why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
-             '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
-             '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这两件此前 kind=shipped(振动线人交手交件),
-    #   新机器上 ⑦ 的 `src.ontology.trend_ingest` 报"源缺失"、CMS 报告的三层基线卡为空。
-    #   现由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环; 窗不足时如实为空
-    #   并把数据边界逐条写进 meta) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
-    #   ★必须排在通配族 `vib_handoff_and_scans` **之前** (族表首个命中生效), 否则又被认领成"无生成端"。
-    dict(id='vib_history_baseline',
-         glob=['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json'],
-         kind='raw-derived',
-         func='振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对)',
-         algo='scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; '
-              '窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + '
-              'relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限)',
-         gen='scripts/component_history_build.py', input='windcms', pred=('rows',),
-         why='输入是 CMS 原始导出的**重算产物**(窗索引), 其 raw 祖先是 data/raw/<场>/windcms; '
-             '旧随包件是振动线手交件(全库 0 处写入方); 两件都在重算链 ④d/④e, 口径与数据边界写进件内 meta'),
-    dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
-         kind='shipped',
-         func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
-                                            '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
-                                            '或由现场正本随原始件进 data/raw 后重算'),
-    # (2026-09-19: `line_energy_share` / `mask_thresholds` / `ontology_releases` 三族已移到前面按
-    #  raw-derived 记账 —— 族表是**首个命中生效**, 必须排在通配族 `vib_handoff_and_scans` / `tcm_replay`
-    #  / `ontology_releases(旧)` 之前, 否则新件会被旧族认领成"无生成端")
-    dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
-         func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='随包快照(振动线出件)'),
-    # ★2026-09-19 拆族 (用户令「所有的计算均要形成观澜的源代码」): `windscada/index.html` 是
-    #   `/detail/v2` 的「全场状态总览」iframe 目标, 此前整族记为"无生成端" ⇒ 新机器上那块必然显示
-    #   "产物不在位"。现在它由 `scripts/windscada_overview_build.py` 用**组件同一个快照烘焙器**
-    #   (src/windscada/ui/snapshot.py::bake, /v2/snapshot 用的就是它)从重算产物生成 ⇒ 单列一族。
-    dict(id='windscada_overview', glob='windscada/index.html', kind='raw-derived',
-         func='总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开)',
-         algo='scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 '
-              'src/windscada/ui/snapshot.py::bake 烘焙)',
-         gen='scripts/windscada_overview_build.py', input='scada_10min', pred=('rows',),
-         why='2026-09-19 用户令后上链: 该页此前是随包件(全库 0 处写入方), 清过产物的机器上 iframe 只能'
-             '显示"产物不在位"; 现在由观澜自算, 每次重算随数据更新'),
-    dict(id='windscada_pages', glob='windscada/{turbines/*,review/*}', kind='shipped',
-         func='逐台静态页 + 评审记录(当前由组件按请求动态出页, 静态件仍属随包快照)', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='逐台页在运行期由 /turbine/<台> 动态生成, 静态副本无写入方; '
-                                             '运行期不从交付包补(用户令 2026-09-17)'),
-    # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
-    #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
-    #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。
-    dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
-         func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
-              '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
-    # ★2026-09-18 补 (用户令"页面只能基于输入数据重算"之后): `windcms/**` 整族原先一律按
-    #   "无生成端"记账 —— 但**报告两步的生成端就在包内**(scripts/windcms.py report → src/windcms/
-    #   report.py + report_std.py), 只要 data/raw/<场>/windcms 里有 decode 导出就出得来。
-    #   实测(2026-09-18): 从重算出的窗 w0316 一键跑出标准模版报告 + 38 台逐台页 + 总览, 判级 38 台
-    #   (报警 3 / 优秀 35), 缺的只有 model_run/fusion 两步的融合级列(如实写 —)。故拆成两族按 raw-derived 记。
-    dict(id='windcms_report_std', glob=['windcms/报告_CMS振动状态评估报告_*.md',
-                                        'windcms/报告_CMS振动状态评估报告_*.docx',
-                                        'windcms/登记_三轴_38台.csv'], kind='raw-derived',
-         func='CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议)',
-         algo='src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; '
-              '由 scripts/windcms.py report 调用)',
-         gen='src/windcms/report_std.py', input='windcms', pred=('rows',),
-         why='★同名件有两种来源: 观澜自算(本族: 从 data/raw/<场>/windcms 的 *_decode.json 算) 与 '
-             '厂家报告转录(scripts/vib_reports_build.py, 输入是现场振动分析报告 PDF)。按名字认领不了'
-             '这层区别 ⇒ 以"是否由本步生成"为准, 判据看 m5_cms_tcm/vib_raw_manifest.json 的 steps'),
-    dict(id='windcms_report_overview', glob=['windcms/report.md', 'windcms/index.html',
-                                             'windcms/index_eng.html', 'windcms/overview.html',
-                                             'windcms/turbines/*'], kind='raw-derived',
-         func='CMS 诊断总览页 + 逐台页 + 结构化层报告',
-         algo='src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + '
-              'report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—")',
-         gen='src/windcms/report.py', input='windcms', pred=('rows',)),
-    dict(id='windcms_kb', glob='windcms/kb.json', kind='raw-derived',
-         func='CMS 知识库索引 (问答检索用)',
-         algo='src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成)',
-         gen='src/windcms/knowledge.py', input='windcms', pred=('rows',)),
-    dict(id='windcms_pages', glob='windcms/**', kind='shipped',
-         func='windcms 里其余件 (厂家报告转录 / 知识库出件等)',
-         algo='(无随包生成端)', gen=None, input=None, pred=(),
-         why='上述两族与 cache 之外剩下的 windcms 件: 运行期不从交付包补(用户令 2026-09-17), '
-             '要出件须研发补生成端'),
-    # ★2026-09-19 用户令「要让观澜从重算台账生成 claim」: findings 与三份底稿改由**观澜自算**
-    #   (scripts/sop_findings_from_ledger.py 从 alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6
-    #   算 claim, 按契约构建器要的字段结构写) ⇒ 单列一族; 契约四件随之由 scripts/guanlan_facts_contract.py 生成。
-    dict(id='ledger_claims', glob=['sop/findings.json', 'paradigm_r1/experiments/*/底稿*.json'],
-         kind='raw-derived',
-         func='事实契约的 claim 本体与底稿 (观澜自算, 替代人裁底稿)',
-         algo='scripts/sop_findings_from_ledger.py(六族 claim: 报警集中/重复检修/停机损失/温度离群/功率曲线偏离/振动过闸线; '
-              '每条带 coverage·缺证据·证伪判据, 相对判据封顶「候选」)',
-         gen='scripts/sop_findings_from_ledger.py', input='scada_10min', pred=('rows',),
-         why='输入是**重算产物**(windscada/*.parquet + m5_cms_tcm/*): 其 raw 祖先是 故障报警/风机故障记录/scada_10min; '
-             '本族单列以区别于旧的人裁底稿(同路径旧件无生成端)'),
-    dict(id='sop_workspace', glob='sop/**', kind='shipped',
-         func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='评审过程件, 属人工作业留痕'),
-    dict(id='tcm_replay', glob='tcm_compatible_replay/**', kind='shipped',
-         func='TCM 兼容链回放资产(模型表/掩码阈值/裁决记录)', algo='(无生成端)', gen=None, input=None,
-         pred=(), why='随包快照'),
-    dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
-         func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='实验底稿, 事实契约的输入'),
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 变桨面两件原来 kind=shipped(无生成端)
-    #   ⇒ 换台机器重算后必然缺件、变桨面恒"不可判"。现由 scripts/pitch_face_build.py 从 data/raw 的
-    #   scada_10min(液压/润滑/柱塞/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 并进重算链 ④c。
-    dict(id='pitch_face', glob='pitch/**', kind='raw-derived',
-         func='变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表)',
-         algo='scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 '
-              '三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json)',
-         gen='scripts/pitch_face_build.py', input='scada_10min', pred=('rows',),
-         why='输入是 10min 与 1min 两路 SCADA 原始导出件(scada_source: CSV 优先/MDB 回落);'
-             '零位按用户令两口径分列(停机顺桨段/满发段),并据实测反推补上**运行段同工况分档**这一判据轴'
-             '(它才能把现场确诊的 19# 单独拎出来)'),
-    # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
-    #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
-    #   这一族从"无生成端"改成 raw-derived —— 生成端 guanlan_facts_contract.py 本来就在包内,
-    #   原来缺的是**它的输入**(人裁底稿); 现在输入由上游族 ledger_claims 自算
-    #   (scripts/sop_findings_from_ledger.py) ⇒ 新机器上重算即得, /api/facts 不再 503。
-    dict(id='guanlan_contract', glob='guanlan/**', kind='raw-derived',
-         func='事实契约与对外派生(门户结论段/取数)',
-         algo='scripts/guanlan_facts_contract.py(build→render: 契约 + portal_claims/detail_cards/qa_refs/'
-              'report_summary; 输入=上游族 ledger_claims 自算的 sop/findings.json + 三份底稿)',
-         gen='scripts/guanlan_facts_contract.py', input='scada_10min', pred=('rows',),
-         why='输入与 data/raw 隔着两跳(重算台账 → claims → 契约), 但**两跳的生成端都在包内**; '
-             'claim 的判据/缺证据/证伪逐条写在件里, 相对判据封顶「候选」'),
-    dict(id='windscada_release_manifest', glob='windscada/release-manifest.json', kind='source-derived',
-         func='发布清单快照(`/healthz` 与版本信息读它)',
-         algo='scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 '
-              '{css_tokens, nav, dom, sha256, source blobs})',
-         gen='scripts/guanlan_baseline_manifest.py', input=None, pred=(),
-         why='★ 2026-09-17 修正: 它一度被我按"交证件"错分成非产物; 实际**包内有生成端**'
-             '(guanlan_baseline_manifest.py 就写这个路径), 且 guanlan_gateway.py 运行期读它 ⇒ 是产物'),
-    dict(id='human_deliverables',
-         glob=['windscada/mblub_alarm_cross.json', 'windscada/review_presented.json',
-               'windscada/给振动线_温度轴回复_20260826.json', 'windscada/给振动线_温度轴回复_20260826.md',
-               'windscada/送审_系统结论与判定_20260828.md'],
-         kind='human',
-         func='**人工正本/交证件**:与振动线的跨线通知、送审结论、发布评审快照',
-         algo='(人写的, 不是算出来的)', gen=None, input=None, pred=(),
-         why='这几件是"人对人的交证件/回执", 内容由分析结论抄写与人判断坐实而成; '
-             'mblub_alarm_cross.json 还被 src/ontology/populate.py 当作**证据来源**引用 ⇒ '
-             '必须随包留在原位, 但**不参与呼应校验**(它本来就没有生成端, 也不该有)'),
-    dict(id='scratch_in_products', glob=['**/_*.js', '**/_*audit*.md', '**/*.out', '**/*.err',
-                                        '**/*_test_*.parquet', '**/*_test_*.json'],
-         kind='not-product',
-         func='**非产物**:自审脚本/记录与模型测试输出(躺在产物仓里)', algo='(工具脚本与运行痕迹, 不是产物)',
-         gen=None, input=None, pred=(),
-         why='2026-09-17: 17 件已移出到 reference/非产物留痕_20260917/ 并留了 _来源说明.json ⇒ '
-             '本族现在应为空; 一旦再出现(新脚本往产物仓里写 .out/_audit.js 之类), 本器当场报出来'),
-]
-
-
-def _files_of(glob) -> list[str]:
-    """族 glob(str 或 list)→ 台账里的相对键(支持 `{a,b}` 花括号与 `*`)。
-
-    ★ 深度必须**显式对齐**(2026-09-17 第一版踩过): `fnmatch` 的 `*` 是**跨 `/`** 的, 于是
-      `windscada/*.parquet` 会把 `windscada/turbines/xx.parquet`、`windscada/_pre_rebuild_*/xx.parquet`
-      一起吞进来 —— 反向审计最怕"族把不该管的件认领了", 那样账就假了。
-      规则: 模式里没有 `**` 时, 要求的 `/` 个数必须与键的 `/` 个数**相等**(逐条展开后各自判)。
-    """
-    pats = []
-    for g in ([glob] if isinstance(glob, str) else list(glob)):
-        if '{' in g:
-            head, tail = g.split('{', 1)
-            opts, rest = tail.split('}', 1)
-            pats += [head + o + rest for o in opts.split(',')]
-        else:
-            pats.append(g)
-    out = []
-    for rel in LEDGER:
-        for pt in pats:
-            if '**' not in pt and rel.count('/') != pt.count('/'):
-                continue
-            if fnmatch.fnmatch(rel, pt) or rel == pt:
-                out.append(rel)
-                break
-    return sorted(out)
-
-
-LEDGER: dict[str, dict] = {}
-
-
-def load_ledger(farm: str) -> dict[str, dict]:
-    """台账 = `_provenance.json`(逐件来源) + `_derived_manifest.json`(生成端自登记)。"""
-    out: dict[str, dict] = {}
-    root = P.out_root(farm)
-    for fn in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):
-        f = root / fn
-        if not f.is_file():
-            continue
-        try:
-            d = json.loads(f.read_text(encoding='utf-8'))
-        except Exception:
-            continue
-        for rel, v in (d.get('files') or {}).items():
-            if isinstance(v, dict):
-                out.setdefault(rel, dict(source=v.get('source') or 'raw-derived',
-                                         builder=v.get('builder') or v.get('by') or '',
-                                         why=v.get('why') or '',))
-    return out
-
-
-def _input_home(station: pathlib.Path, sub: str) -> pathlib.Path | None:
-    """输入类目录在哪 —— 优先场站目录下, 其次 raw 根下。
-
-    ★ 机理层资料按 A2 约定**不在场站目录下**(`data/raw/西门子4.0技术资料/`, 见 src/windscada/config.py)。
-      只查场站目录会把本体层判成"输入不在位"(2026-09-17 第一版就这么误报过 3 件)。
-    """
-    for base in (station, station.parent):
-        d = base / sub
-        if d.is_dir():
-            return base
-    return None
-
-
-def _parquet_span(p: pathlib.Path, col: str | None):
-    import pandas as pd
-    try:
-        d = pd.read_parquet(p)
-    except Exception:
-        return None, None, 0
-    n = len(d)
-    if col and col in d.columns:
-        s = pd.to_datetime(d[col], errors='coerce')
-        if s.notna().any():
-            return str(s.min())[:10], str(s.max())[:10], n
-    return None, None, n
-
-
-def _npz_ok(p: pathlib.Path) -> bool:
-    """振动谱件: npz 能读出来且非空(列数不是"行", 不能拿 parquet 的行数判它)。"""
-    try:
-        import numpy as np
-        with np.load(p, allow_pickle=False) as z:
-            return any(z[k].size > 0 for k in z.files)
-    except Exception:
-        return False
-
-
-# 已知"含人工裁决/校准"的随包快照(按用户令 2026-09-17 #3 显式登记为人工件)。依据是文档里已经写明的性质:
-# docs §7「handoff_vibration_v2.json / component_history.json 是随包快照, 该件含人工裁决/校准更新,
-# 不是测量数据的函数」; baseline_38.json 的三层基线 self/absolute 两层由人工坐实; findings.json 是判级发现台账。
-HUMAN_SNAPSHOTS = {
-    'm5_cms_tcm/component_history.json',
-    'm5_cms_tcm/baseline_38.json',
-    'm5_cms_tcm/handoff_vibration_v1.json',
-    'm5_cms_tcm/findings.json',
-}
-
-# ── 逆向工程可行性 (用户令 2026-09-17 #2) ────────────────────────────────────────────
-# 问的是: "这批没有生成端的产物, 能不能从原始件/在包生成端**推**出来?"
-# 判定不靠印象, 靠两条机器证据:
-#   ① 生成端在不在包内(在 → 只是没接上, 跑一次就有);
-#   ② 产物正文里有没有**人工判断**的痕迹("裁决/审核/校准/评审/经验"这类字段)——
-#      含人工判断的件不是任何输入的纯函数, 逆向工程推不出来, 只能把那一步人工工作重做。
-import re as _re                                                         # noqa: E402
-JUDGE_PAT = _re.compile(r'人工|裁决|审核|复核|校准|评审|经验值|专家|judg|review|manual|calibrat|verdict|sign[_ ]?off',
-                        _re.I)
-# ★ 只认"**字段**叫这个名字"(`"裁决": …` / `裁决,`),不认正文里顺嘴提一句 ——
-#   2026-09-17 第一版拿整篇子串匹配, 结果把**页面**里的展示标签(`index.html` 里的"评审/人工")当成
-#   人工判断, 于是把"页面"整族误判成不可逆。这属于"判据太糙 → 结论假"。
-JUDGE_KEY_PAT = _re.compile(r'["\']?[^"\',:]{0,24}(人工|裁决|审核|复核|校准|评审|经验值|专家|'
-                            r'judg|review|manual|calibrat|verdict|sign[_ ]?off)[^"\',:]{0,24}["\']?\s*[:=]',
-                            _re.I)
-
-
-def judgement_evidence(farm: str, rels: list[str], cap: int = 8) -> tuple[int, list[str], int]:
-    """→ (含人工判断字段的抽样比例%, 样例, 被检查件数)。
-
-    只查**数据/文本件**(.json/.csv/.md/.txt); **页面(.html/.js)不算** —— 页面是产物的渲染,
-    里面出现"评审/人工"是展示标签, 不代表这份件内含判断。
-    """
-    hit, samples, checked, pages = 0, [], 0, 0
-    for rel in rels[:cap]:
-        p = P.out_root(farm) / rel
-        suf = p.suffix.lower()
-        if suf in ('.html', '.js', '.css', '.htm'):
-            pages += 1
-            continue
-        try:
-            if suf == '.json':
-                txt = p.read_text(encoding='utf-8', errors='replace')[:200000]
-                pat = JUDGE_KEY_PAT
-            elif suf in ('.csv', '.txt'):
-                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
-                pat = JUDGE_KEY_PAT
-            elif suf == '.md':
-                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
-                pat = JUDGE_PAT
-            else:
-                continue
-        except Exception:
-            continue
-        checked += 1
-        if pat.search(txt):
-            hit += 1
-            samples.append(rel)
-    return (100 * hit // checked if checked else 0), samples, pages
-
-
-def feasibility(fam: dict, rels: list[str], gen_ok: bool, in_ok: bool,
-                judge_pct: int, judge_samples: list[str], pages: int = 0) -> tuple[str, str]:
-    """→ (可逆性判定, 依据)。四类: 可逆(直接) / 可逆(需反推口径) / 不可逆(人工判断) / 应移出产物仓。"""
-    if fam['kind'] == 'not-product':
-        return '应移出产物仓', '它本就不是产物(工具脚本/交证件/测试输出) ⇒ 该从产物仓移走, 而不是"补生成端"'
-    if gen_ok and in_ok:
-        return '可逆(直接)', f"生成端在包内({fam['gen']})且输入在位 ⇒ 接上/重跑即可"
-    if pages and pages >= max(3, len(rels[:8]) // 2):
-        return '可逆(页面可再生)', ('这一族主要是**页面**(.html): 页面是产物的渲染, 不是独立数据 ⇒ '
-                                    '接上在包的页面生成端重出即可(未必逐字节同, 能力与数据一致)')
-    m_n = sum(1 for r in rels if r.endswith(('.parquet', '.csv', '.npz')))
-    t_n = len(rels) - m_n
-    split = f'({m_n} 件测量形态 / {t_n} 件文本·判断件)' if (m_n and t_n) else ''
-    # 两类都占相当比重时, 结论必须是"混合"而不是挑一类代表全族 —— 否则一句判定就把另一半骗过去了
-    if m_n and t_n and min(m_n, t_n) * 4 >= len(rels):
-        return '混合(测量件可反推 / 文本件需人定)', split + \
-               '测量形态的件可由 data/raw 反推口径 + 逐值对拍; 文本/判断件(评审、裁决、回复)推不出来'
-    if judge_pct >= 50 and judge_samples:
-        return '不可逆(人工判断)', (f"抽样 {len(judge_samples)} 件里都写着人工判断字段(如 {judge_samples[0]}) "
-                                    f"⇒ 不是输入的纯函数, 逆向工程推不出来")
-    if m_n and m_n * 2 >= len(rels):
-        return '可逆(需反推口径)', split + '测量形态的件是测量数据的函数 ⇒ 可从 data/raw 反推口径 + 逐值对拍' \
-                                        '(做法同 temp_monthly: 反推 → 逐值一致才敢用)'
-    if gen_ok:
-        return '半可逆(生成端在包, 输入不足)', '生成端在包内, 缺的是上游输入 ⇒ 上游补齐后即可重出'
-    if judge_pct > 0:
-        return '半可逆(需人工裁定)', f'抽样里 {judge_pct}% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定'
-    return '需人工裁定', '既无生成端、形态也不是测量函数 ⇒ 需研发给口径或领域正本'
-
-
-def _turbines_of(p: pathlib.Path, col: str = 'turbine', cap: int = 200000):
-    import pandas as pd
-    try:
-        d = pd.read_parquet(p, columns=[col]) if col else pd.read_parquet(p)
-    except Exception:
-        return None
-    try:
-        return {str(x).upper() for x in d[col].dropna().unique()} if col in d.columns else None
-    except Exception:
-        return None
-
-
-def _input_turbines(station: pathlib.Path, sub: str):
-    """输入侧机组集合: 逐台 CSV 文件名 / 目录名里的 WTG 号。"""
-    d = station / sub
-    if not d.is_dir():
-        return None
-    out = set()
-    for p in d.rglob('*'):
-        if p.is_file():
-            for m in __import__('re').finditer(r'(WTG\s?\d{1,2})', p.name.upper()):
-                out.add(m.group(1).replace(' ', ''))
-    return out or None
-
-
-def classify_human(farm: str, rels: list[str], cap: int = 600) -> set[str]:
-    """逐件判"是不是人工件"(用户令 2026-09-17 #3:人工件不再按自动产物参与呼应校验)。
-
-    判据:**非测量形态**的件(.md/.json/.csv/.txt)里出现人工判断字段(按字段名匹配)。
-    测量形态件(.parquet/.csv 数值、.npz)一律不算人工件 —— 它们是数据的函数,属"可反推"那一路。
-    """
-    human: set[str] = set()
-    for rel in rels[:cap]:
-        if rel in HUMAN_SNAPSHOTS:            # ① 文档里已写明含人工裁决/校准的随包快照
-            human.add(rel)
-            continue
-        suf = pathlib.Path(rel).suffix.lower()
-        if suf == '.md':                      # ② 产物仓里的 .md 一律是报告/交证/评审记录(人写的)
-            human.add(rel)
-            continue
-        if suf == '.txt':
-            pat = JUDGE_PAT
-        elif suf in ('.json', '.csv'):
-            pat = JUDGE_KEY_PAT
-        else:
-            continue
-        p = P.out_root(farm) / rel
-        try:
-            txt = p.read_text(encoding='utf-8', errors='replace')[:120000]
-        except Exception:
-            continue
-        if pat.search(txt):                   # ③ 正文里有"人工判断字段"
-            human.add(rel)
-    return human
-
-
-def write_human_manifest(farm: str | None = None) -> int:
-    """把人工件逐件落成 `outputs/<场>/_human_artifacts.json`(机器台账,供自检与审计引用)。"""
-    farm = farm or P.farm()
-    global LEDGER
-    LEDGER = load_ledger(farm)
-    entries: dict[str, dict] = {}
-    for fam in FAMILIES:
-        if fam['kind'] not in ('shipped', 'human', 'not-product'):
-            continue
-        rels = [r for r in _files_of(fam['glob']) if r not in entries]
-        if not rels:
-            continue
-        if fam['kind'] == 'human':
-            for r in rels:
-                entries[r] = dict(kind='human', family=fam['id'], evidence='族内显式登记为人工正本/交证件')
-            continue
-        for r in classify_human(farm, rels):
-            if r not in entries:
-                entries[r] = dict(kind='human', family=fam['id'],
-                                  evidence='件内含人工判断字段(按字段名匹配),不是输入的纯函数')
-    out = P.out_root(farm) / '_human_artifacts.json'
-    out.write_text(json.dumps(dict(
-        at=__import__('time').strftime('%Y-%m-%d %H:%M:%S'),
-        note='人工件台账(用户令 2026-09-17 #3):这些件由人写成/坐实,不是任何输入的纯函数 ⇒ '
-             '不参与"输出↔输入呼应"校验;随包留在原位,重算不会也不该重写它们。'
-             '本文件由 scripts/products_reverse_audit.py --write-human-manifest 生成。',
-        count=len(entries), files=entries), ensure_ascii=False, indent=1), encoding='utf-8')
-    by_fam: dict[str, int] = {}
-    for v in entries.values():
-        by_fam[v['family']] = by_fam.get(v['family'], 0) + 1
-    print(f'人工件台账: {len(entries)} 件 → {P.rel(out)}')
-    for k, v in sorted(by_fam.items(), key=lambda kv: -kv[1]):
-        print(f'   {k:24s} {v:4d} 件')
-    return 0
-
-
-def audit(farm: str | None = None, verbose: bool = True):
-    farm = farm or P.farm()
-    global LEDGER
-    LEDGER = load_ledger(farm)
-    # ★ 场站原始件目录用唯一取用口 (场站名可与 farm 键不同: 键 = rudong, 目录 = data/raw/如东)
-    from src.windscada.config import raw_station_dir
-    station = pathlib.Path(raw_station_dir(farm))
-    verdict: dict[str, dict] = {}
-    fam_rows = []
-    fails: list[str] = []
-
-    for fam in FAMILIES:
-        # ★ 族按**声明顺序**认领, 先声明的先拿 (否则 `m5_cms_tcm/*` 这种宽通配会把 windows/ 下
-        #   1704 件也吞进来 —— fnmatch 的 `*` 是跨 `/` 的, 2026-09-17 第一版就这么误判过)
-        rels = [r for r in _files_of(fam['glob']) if r not in verdict]
-        rels = [r for r in rels if not r.lower().endswith(('.log', '.jsonl'))]
-        if not rels:
-            continue
-        row = dict(id=fam['id'], n=len(rels), func=fam['func'], algo=fam['algo'], input=fam['input'],
-                   pred='+'.join(fam['pred']) or '—', verdict='', note='')
-        if fam['kind'] == 'not-product':
-            # ★ 不是产物, 却躺在产物仓里: 反向审计对它的结论是"它压根不该按产物管" ——
-            #   既不需要生成端, 也谈不上与输入呼应(这一类最该被人看见, 故单独一类, 不当失败计)。
-            jp, js, pg = judgement_evidence(farm, rels)
-            fv, fw = feasibility(fam, rels, False, False, jp, js, pg)
-            row['verdict'] = '✗ 非产物'
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = fv, fw
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict='✗',
-                                  why='非产物(过程留痕/工具脚本躺在产物仓里): ' + fam.get('why', ''))
-        elif fam['kind'] == 'human':
-            # ★ 人工正本/交证件 (用户令 2026-09-17 #3): 由人写、被运行期当证据引用,
-            #   **不参与呼应校验**——它本来就没有、也不该有生成端; 单独计数, 不混进"无生成端"的失败堆里。
-            row['verdict'] = '◆ 人工件'
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = '人工件(正本/交证)', '人写的, 不参与呼应校验; 随包留在原位'
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict='◆',
-                                  why='人工件(人工正本/交证件): ' + fam.get('why', ''))
-        elif fam['kind'] == 'shipped':
-            jp, js, pg = judgement_evidence(farm, rels)
-            gen_file = ROOT / fam['gen'] if fam.get('gen') else None
-            gen_ok0 = bool(gen_file and gen_file.is_file())
-            # ★ 逐件分人工件 (用户令 2026-09-17 #3): 含人工判断字段的件**不按自动产物参与呼应校验**,
-            #   单独计 ◆; 余下才是真正的"无生成端"。
-            human = classify_human(farm, rels)
-            rest = [r for r in rels if r not in human]
-            fv, fw = feasibility(fam, rest or rels, gen_ok0, False, jp, js, pg)
-            row['verdict'] = '✗ 无生成端' if not human else f'✗ 无生成端 + ◆ 人工件 {len(human)}'
-            row['n'] = len(rest)
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = fv, fw
-            if jp:
-                row['rev_why'] += f';人工判断字段抽样命中 {jp}%' + (f'(如 {js[0]})' if js else '')
-            for r in rest:
-                verdict[r] = dict(fam=fam['id'], verdict='✗', why='无生成端(全库 0 处写入方): ' + fam['why'])
-            for r in human:
-                verdict[r] = dict(fam=fam['id'], verdict='◆',
-                                  why='人工件(含人工判断字段): 不参与呼应校验, 随包留在原位')
-        else:
-            # ① 生成端在位
-            gen = ROOT / fam['gen'] if fam.get('gen') else None
-            gen_ok = bool(gen and gen.is_file())
-            # ② 输入在位(场站目录优先, 机理层资料在 raw 根下 —— 见 _input_home)
-            spec = INV.INPUTS.get(fam['input'] or '')
-            i_s = i_e = None
-            i_n = 0
-            i_gran = '年'
-            home = _input_home(station, fam['input']) if fam['input'] else None
-            if home and spec:
-                i_s, i_e, i_n, _note, i_gran = INV.input_span(home, fam['input'], spec)
-            elif home:
-                # 机理层资料这类"没有跨度判据"的输入: 按件数清点即算在位(INV.INPUTS 里没有它们的
-                # 跨度口径 —— 技术资料是文档而不是时序数据, 拿跨度比毫无意义)
-                i_n = sum(1 for p in (home / fam['input']).rglob('*') if p.is_file())
-                i_gran = '年'
-            elif fam['kind'] == 'source-derived':
-                # ★ 输入是**包内源码**而不是 data/raw(如发布清单快照: 从 src/windscada/ui 重建页再摘要)。
-                #   生成端在位 = 输入在位, 这类产物的"呼应"是对源码的, 不是对原始件的。
-                i_n = 1
-                i_s = i_e = '包内源码'
-            in_ok = i_n > 0
-            passed, notes = [], []
-            if fam['pred'] and in_ok:
-                if 'span' in fam['pred']:
-                    p_s, p_e, n = _parquet_span(P.out_root(farm) / rels[0], fam.get('time_col'))
-                    if p_s and i_s and (p_s[:7] < i_s[:7]):
-                        # ★ 年粒度输入(按文件名年份推的)不判失败: 与正向检查同一条教训 ——
-                        #   台账 xls 里可能含比文件名年份更早的历史记录, 拿年粒度当"输入起点"会造假缺口
-                        #   (2026-09-17 实测: 工单产物起点 2020-01 而输入文件名最早 2021 ⇒ 不是数据串了)。
-                        msg = f'产物起点 {p_s} 早于输入起点 {i_s}'
-                        if i_gran in ('日', '月'):
-                            fails.append(f"{fam['id']}: {msg}(跨窗混入 / 键错)")
-                            notes.append('⚠ ' + msg)
-                        else:
-                            notes.append(f'{msg}(输入为年粒度, 只作参考: 台账可能含更早的历史行)')
-                    passed.append(f'跨度 {p_s}~{p_e} ⊆ 输入 {i_s}~{i_e}')
-                if 'turbines' in fam['pred']:
-                    pt = _turbines_of(P.out_root(farm) / rels[0])
-                    it = _input_turbines(home, fam['input']) if home else None
-                    if pt and it:
-                        extra = pt - it
-                        if extra:
-                            fails.append(f"{fam['id']}: 产物含输入里没有的机组 {sorted(extra)[:5]}")
-                        passed.append(f'机组 {len(pt)} 台 ⊆ 输入 {len(it)} 台')
-                if 'rows' in fam['pred']:
-                    tot = 0
-                    for r in rels[:400]:
-                        _s, _e, n = _parquet_span(P.out_root(farm) / r, None) if r.endswith('.parquet') \
-                            else (None, None, 1)
-                        tot += n
-                    if tot <= 0:
-                        fails.append(f"{fam['id']}: 产物行数合计为 0")
-                    passed.append(f'件内数据行合计 {tot:,}' + ('(抽样 400 件)' if len(rels) > 400 else ''))
-                if 'npz' in fam['pred']:
-                    sample = rels[:5]
-                    bad = [r for r in sample if not _npz_ok(P.out_root(farm) / r)]
-                    if bad:
-                        fails.append(f"{fam['id']}: 抽样 {len(bad)}/{len(sample)} 件 npz 读不出或为空")
-                    passed.append(f'{len(rels)} 件谱(抽样 {len(sample)} 件 npz 均可读非空)· 输入源 {i_n} 件')
-                if 'manifest' in fam['pred']:
-                    try:
-                        mf = json.loads((P.out_root(farm) / rels[0]).read_text(encoding='utf-8'))
-                        passed.append('清单自记: ' + ', '.join(f'{k}={v}' for k, v in list(mf.items())[:3]))
-                    except Exception as e:
-                        fails.append(f"{fam['id']}: 清单读不出来 ({type(e).__name__})")
-            if not gen_ok:
-                row['verdict'] = '✗ 生成端不在位'
-                row['note'] = f"缺 {fam['gen']}"
-                fails.append(f"{fam['id']}: 生成端不在位 {fam['gen']}")
-            elif not in_ok:
-                row['verdict'] = '~ 输入不在位'
-                row['note'] = (f"缺 {(home / fam['input']) if home else station / str(fam['input'])}"
-                               ' ⇒ 现在无法验证(放数据后复跑本器)')
-            else:
-                row['verdict'] = '✓ 呼应成立'
-                row['note'] = ';'.join(notes + passed)
-                row['rev'], row['rev_why'] = '已在重算链上', '生成端在位、输入在位、判据通过 —— 无需逆向工程'
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict=row['verdict'][0],
-                                  why=row['note'] if row['verdict'][0] != '✓' else row['note'][:120])
-        fam_rows.append(row)
-
-    # 未归类件 (台账里有, 但没有任何族接住)
-    unclassified = []
-    for rel, v in LEDGER.items():
-        if rel.lower().endswith(('.log', '.jsonl')):
-            continue
-        if rel in verdict:
-            continue
-        # 产物走通配没接住的, 也按台账来源判定
-        unclassified.append(rel)
-        verdict[rel] = dict(fam='(未归类)', verdict='?', why='既不在族表、也无法从台账判定来路')
-
-    counts = {}
-    for v in verdict.values():
-        counts[v['verdict']] = counts.get(v['verdict'], 0) + 1
-    if verbose:
-        print(f'== 反向呼应审计 · 场站 {farm} · 台账 {len(LEDGER)} 件 · 判定 {len(verdict)} 件 ==')
-        print(f'   输入根: {P.rel(station)}')
-        print()
-        print(f'{"族":26s} {"件数":>6s} {"判定":14s} {"输入类":12s} {"功能 / 算法 / 依据"}')
-        for r in fam_rows:
-            print(f'  {r["id"]:24s} {r["n"]:6d} {r["verdict"]:14s} {str(r["input"] or "—"):12s} '
-                  f'{r["func"][:34]}')
-            if r['note']:
-                print(f'      └─ {r["note"][:150]}')
-        if unclassified:
-            print(f'\n  【未归类 {len(unclassified)} 件】')
-            for r in unclassified[:20]:
-                print(f'      ? {r}')
-        print('\n  判定汇总: ' + ' · '.join(f'{k}={v}' for k, v in sorted(counts.items())))
-        print(f'  判据失败 {len(fails)} 条' + (':' + ';'.join(fails[:3]) if fails else ''))
-        ok = counts.get('✓', 0)
-        print(f'  结论: 输出↔输入呼应**成立** {ok} 件 / **不成立(无生成端)** {counts.get("✗", 0)} 件 / '
-              f'**人工件** {counts.get("◆", 0)} 件 / 无法验证 {counts.get("~", 0)} 件 / 未归类 {counts.get("?", 0)} 件')
-        # ── 逆向工程可行性 (用户令 #2): 没有呼应关系的那些, 能不能推出来 ──
-        import collections as _c
-        by_rev = _c.Counter()
-        for r in fam_rows:
-            by_rev[r.get('rev', '?')] += r['n']
-        print()
-        print('== 反向「可逆性」矩阵(用户令 2026-09-17 #2: 无生成端的件能否从原件推出来)==')
-        print(f'{"族":26s} {"件数":>6s} {"可逆性":22s} 依据')
-        for r in fam_rows:
-            if r['verdict'][0] == '✓':
-                continue
-            print(f'  {r["id"]:24s} {r["n"]:6d} {r.get("rev", "?"):22s} {r.get("rev_why", "")[:96]}')
-        print('  按件数汇总: ' + ' · '.join(f'{k}={v}' for k, v in by_rev.most_common()))
-    rc = 5 if (fails or unclassified) else 0
-    return fam_rows, verdict, counts, fails, unclassified, rc
-
-
-def doc_block(farm: str) -> str:
-    fam_rows, verdict, counts, fails, unclassified, _rc = audit(farm, verbose=False)
-    L = [DOC_BEGIN, '### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)', '',
-         f'台账 {len(verdict)} 件产物逐件回溯:**呼应成立 {counts.get("✓", 0)} 件** · '
-         f'**不成立(无生成端){counts.get("✗", 0)} 件** · 无法验证 {counts.get("~", 0)} 件 · '
-         f'未归类 {counts.get("?", 0)} 件。'
-         '判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。', '',
-         '| 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |',
-         '|---|---:|---|---|---|---|---|---|']
-    for r in fam_rows:
-        fam = next(f for f in FAMILIES if f['id'] == r['id'])
-        L.append(f'| `{fam["glob"]}` | {r["n"]} | {r["verdict"]} | {r["func"]} | {r["algo"]} | '
-                 f'{r["input"] or "—(无生成端)"} | {r["pred"]} | {r["note"][:160]} |')
-    if unclassified:
-        L += ['', f'**未归类 {len(unclassified)} 件**(需人工认领):' + '、'.join(f'`{x}`' for x in unclassified[:12])]
-    L += ['', '#### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)', '',
-          '判据两条机器证据:① 生成端在不在包内 ② 产物正文里有没有**人工判断**字段'
-          '(`人工/裁决/审核/校准/评审/经验/judge/review/calibrat/verdict`,抽样读件统计命中率)。'
-          '含人工判断的件不是任何输入的纯函数 ⇒ 逆向工程推不出来,只能把那一步人工工作重做。', '',
-          '| 输出族 | 件数 | 可逆性 | 依据 |', '|---|---:|---|---|']
-    for r in fam_rows:
-        if r['verdict'][0] == '✓':
-            continue
-        L.append(f'| `{r["id"]}` | {r["n"]} | {r.get("rev", "?")} | {r.get("rev_why", "")} |')
-    import collections as _c
-    by_rev = _c.Counter()
-    for r in fam_rows:
-        by_rev[r.get('rev', '?')] += r['n']
-    L += ['', '**按件数汇总**:' + ' · '.join(f'{k} = {v} 件' for k, v in by_rev.most_common()), '',
-          '**人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),'
-          '它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:'
-          'python scripts/products_reverse_audit.py --write-human-manifest。', '',
-          '> 结论口径:`可逆(直接)` = 包内已有生成端,接上即可;`可逆(需反推口径)` = 内容是测量数据的函数,'
-          '照 `temp_monthly` 的办法反推 + 逐值对拍;`不可逆(人工判断)` = 逆向工程推不出来,'
-          '要么由研发补生成端、要么承认它是人工件(不该按产物管)。', DOC_END]
-    return '\n'.join(L)
-
-
-def write_doc(farm: str) -> int:
-    doc = ROOT / 'docs' / '系统设计说明.md'
-    txt = doc.read_text(encoding='utf-8')
-    blk = doc_block(farm)
-    i, j = txt.find(DOC_BEGIN), txt.find(DOC_END)
-    if i >= 0 and j > i:
-        txt = txt[:i] + blk + txt[j + len(DOC_END):]
-    else:                                             # 首次: 追加到文末
-        txt = txt.rstrip('\n') + '\n\n' + blk + '\n'
-    doc.write_text(txt, encoding='utf-8', newline='\n')
-    print(f'已写入 {P.rel(doc)} (§13.6 反向呼应审计块)')
-    return 0
 
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/products_reverse_audit.py'
 
-def main() -> int:
-    ap = argparse.ArgumentParser(description='反向呼应审计: 输出 → 功能与算法 → 输入')
-    ap.add_argument('--farm', default=None)
-    ap.add_argument('--check', action='store_true', help='只出结论 (有未归类/判据失败 → rc=5)')
-    ap.add_argument('--write-doc', action='store_true', help='把族表写进 docs/系统设计说明.md §13.6')
-    ap.add_argument('--write-human-manifest', action='store_true',
-                    help='生成人工件台账 outputs/<场>/_human_artifacts.json (用户令 2026-09-17 #3)')
-    a = ap.parse_args()
-    if a.write_human_manifest:
-        return write_human_manifest(a.farm)
-    if a.write_doc:
-        return write_doc(a.farm or P.farm())
-    _rows, _v, counts, fails, uncl, rc = audit(a.farm, verbose=True)
-    if a.check:
-        print(f'[{"OK" if rc == 0 else "X"}] 反向呼应审计 rc={rc}'
-              + (f'(未归类 {len(uncl)} 件 / 判据失败 {len(fails)} 条)' if rc else '(未归类 0 件、判据无失败)'))
-    return rc
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import products_reverse_audit as _impl                                # noqa: E402
 
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
 
 if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try:
-            _s.reconfigure(errors='replace')
-        except Exception:
-            pass
-    sys.exit(main())
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 117
scripts/products_state.py

@@ -1,117 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""产物开关 —— 把 outputs\<场>\ 下的产物**直接清掉** / 看当前状态 (人工检查页面空状态用)。
-
-## 2026-09-16 用户令: 清除产物**不留备份**
-
-原设计是"移动到 `_products_off/<场>/` + 清单留痕, 一条命令还原"。用户令改为**不备份**:
-清掉就是清掉。于是本脚本:
-  · `--off --yes`  真删除 (rmtree), 打印删了什么; **不再产生 `_products_off*` 目录**;
-  · `--status`     看产物在位/已清 (清掉后就是"已清", 没有备份清单可列);
-  · `--on`         已**移除**: 没有备份就没有"挪回"。恢复缺失的**随包件**按用户令 2026-09-17
-                   **不由交付包补齐**: 放原始件到 `data/raw/<场站>/` 后 `python scripts/rebuild_all.py`;
-                   没有生成端的族由研发补生成端 (逐族可逆性见 `docs/系统设计说明.md` §13.6)。
-                   (离线人工补救的口子仍在 `scripts/products_restore_missing.py --stash <…>`,
-                    但它**不在运行链里**, 也不该被运行期依赖。)
-
-## 保留不动的
-
-- `data\raw\` 现场数据 (检查的是"产物没了会怎样", 不是"数据没了会怎样")
-- `release\` 门户与三维/交付件 (那是外壳, 不是数据产物)
-- `logs\` `run\` 运行期文件
-- ★ `outputs\<场>\windscada\_pre_rebuild_20260911\` **不再特殊保留**: 它曾是"随包基线备份"而在清除时豁免,
-  现在按"不留备份"一并删掉 —— 等价验收因此暂时没有标准答案(如实报 rc=5, 不假装通过);
-  要恢复验收能力, 就用**当前在位且已核验的产物**重新落一份基线(见 docs §7 那条)。
-
-用法:
-    python scripts/products_state.py --status            # 看当前产物状态
-    python scripts/products_state.py --off --yes         # 清掉全部产物 (真删除, 不可恢复; --yes 是防手滑)
-"""
-from __future__ import annotations
-
-import argparse
-import pathlib
-import shutil
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import paths as P                                        # noqa: E402
-
-LEGACY_OFF = ROOT / '_products_off'                                # 旧版暂存区 (只用于提示, 不再写入)
-
-
-def farms() -> list[pathlib.Path]:
-    out = ROOT / 'outputs'
-    return sorted(p for p in out.iterdir() if p.is_dir()) if out.is_dir() else []
-
-
-def plan() -> list[pathlib.Path]:
-    """→ 待清除的产物目录列表 (产物仓本身)。"""
-    return [d for fam in farms() for d in sorted(fam.iterdir())]
-
-
-def count(p: pathlib.Path) -> tuple[int, float]:
-    if p.is_file():
-        return 1, p.stat().st_size / 1024 / 1024
-    fs = [f for f in p.rglob('*') if f.is_file()]
-    return len(fs), sum(f.stat().st_size for f in fs) / 1024 / 1024
-
-
-def do_off(assume_yes=False) -> int:
-    items = [p for p in plan() if p.exists()]
-    if not items:
-        print('产物已经是空的 (用 --status 看)')
-        return 0
-    total_n = total_mb = 0
-    rows = []
-    for src in items:
-        n, mb = count(src)
-        rows.append((P.rel(src), n, mb))
-        total_n += n
-        total_mb += mb
-    print('即将**删除**以下产物 (按用户令: 不留备份, 删除后不可恢复):')
-    for rel, n, mb in rows:
-        print(f'  {rel:34s} {n:5d} 件 {mb:8.1f} MB')
-    print(f'  合计 {len(rows)} 项 / {total_n} 件 / {total_mb:.1f} MB')
-    print('  恢复办法: 放原始件到 data/raw/<场站>/ 后 python scripts/rebuild_all.py; 缺生成端的族由研发补 (docs §13.6)')
-    if not assume_yes:
-        print('\n[X] 未执行: 这是不可恢复操作, 请显式加 --yes (运维控制台里点按钮时已由前端确认过一次)')
-        return 2
-    for src in items:
-        shutil.rmtree(src, ignore_errors=True) if src.is_dir() else src.unlink(missing_ok=True)
-    if LEGACY_OFF.exists():
-        print(f'  [!] 检测到旧版暂存区 {P.rel(LEGACY_OFF)} —— 按"不留备份"的口径, 本次**未删除**它; '
-              f'确认不需要后请手工删掉 (它是旧设计留下的产物备份)')
-    print(f'\n已删除 {len(rows)} 项 / {total_n} 件 / {total_mb:.1f} MB (无备份)')
-    return 0
-
-
-def do_status() -> int:
-    items = [p for p in plan() if p.exists()]
-    if not items:
-        print('当前: 产物**已清空** (页面应呈空状态)')
-        print('  恢复: python scripts/rebuild_all.py (缺生成端的族由研发补, 见 docs §13.6)')
-        return 0
-    print(f'当前: 产物**在位** ({len(items)} 项)')
-    for src in items:
-        n, mb = count(src)
-        print(f'    {P.rel(src):34s} {n:5d} 件 {mb:8.1f} MB')
-    print('  说明: 按 2026-09-16 用户令, 清除产物不留备份 (没有 _products_off/ 可还原)')
-    return 0
-
-
-if __name__ == '__main__':
-    ap = argparse.ArgumentParser()
-    g = ap.add_mutually_exclusive_group(required=True)
-    g.add_argument('--off', action='store_true', help='清掉全部产物 (**直接删除, 不留备份**)')
-    g.add_argument('--status', action='store_true', help='看当前状态')
-    g.add_argument('--on', action='store_true', help='[已移除] 旧版的"挪回" —— 见文件头说明')
-    ap.add_argument('--yes', action='store_true', help='确认执行不可恢复的清除 (--off 必带)')
-    a = ap.parse_args()
-    if a.on:
-        print('[X] --on 已移除: 按用户令"清除产物不留备份", 没有备份可挪回。\n'
-              '    运行期恢复: 放原始件后 python scripts/rebuild_all.py (缺生成端的族由研发补, docs §13.6)\n'
-              '    要重新算出数据面产物: python scripts/rebuild_all.py (SCADA 侧加 --scada)')
-        sys.exit(3)
-    sys.exit(do_off(assume_yes=a.yes) if a.off else do_status())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/products_state.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/products_state.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/products_state.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import products_state as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 100
scripts/security_scan.py

@@ -1,100 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""交付前安全核查 (出"安全包"用). 扫**将要发出去的文件**, 报四类:
-  S1 密钥/凭据: API key / token / 私钥 / .env / 口令
-  S2 个人信息: 邮箱 / 手机号 / 身份证 / 家目录用户名
-  S3 D3 敏感: 业主与集团名 / 经济数字(¥·万元) / OEM 原件
-  S4 内部件: 内部文档、日志、测试快照、开发机绝对路径、git 元数据
-用法: python scripts/security_scan.py [--root .] [--zip <包>] [--out logs/security_scan.json] [--strict]
-  --strict: S1 任一命中, 或 S3 在**对外面孔**(门户/报告/契约)命中 → exit 2。
-判据说明: 本系统是给业主自己用的内网软件, 所以"场名/台号/业主名"在**包内产物**里是正常的 (那就是他们的数据);
-真正不能出的是 S1, 以及把 S3 放进**要上公网或转发第三方**的面孔。两者分开报, 不混。"""
-from __future__ import annotations
-import argparse, json, re, sys, time, zipfile
-from pathlib import Path
-ROOT_DEFAULT = Path(__file__).resolve().parents[1]
-PAT = {
-    # AIza 真 key = 39 位且两侧不是 base64 字符 (2026-09-08 实逮: 内联 base64 里 104 位的巧合子串 = 假阳性)
-    "S1.api_key": re.compile(r"(?<![A-Za-z0-9+/=])(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{35})(?![A-Za-z0-9+/=])"),
-    "S1.secret_kv": re.compile(r"(?i)\b(api[_-]?key|secret|token|passwd|password|access[_-]?key)\b\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{12,}"),
-    "S1.private_key": re.compile(r"-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----"),
-    "S2.email": re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
-    # 手机/身份证: 只在**像人写的文本**里算 (前后有中文或常见标签词); 纯数字数据列 (时间戳/ID/哈希) 不算
-    "S2.phone_cn": re.compile(r"(电话|手机|联系方式|Tel|Phone)\D{0,6}(1[3-9]\d{9})(?!\d)"),
-    "S2.idcard_cn": re.compile(r"(身份证|证件号|ID ?No)\D{0,6}(\d{17}[\dXx])(?!\d)"),
-    "S2.home_path": re.compile(r"/Users/[a-z][a-z0-9_\-]*|C:\\Users\\[A-Za-z][A-Za-z0-9_\-]*|/home/[a-z][a-z0-9_\-]*"),
-    "S3.owner": re.compile(r"中广核|华电|国电投|国电|大唐|华能|三峡|龙源|中节能|京能|华润"),
-    # 真金额: ¥ 或 万元/亿元/元每千瓦; **不含** 万kWh/万行/万m³ (那是电量与计数单位, 2026-09-09 实逮误判)
-    "S3.money": re.compile(r"[¥¥]\s?\d|\d[\d,.]*\s?(万元|亿元|元/kW|元/千瓦)"),
-    "S4.internal_doc": re.compile(r"TASKS\.md|SESSION_LOG|violations_log|CLAUDE\.md|AGENTS\.md"),
-}
-TEXT_EXT = {".py", ".js", ".json", ".yaml", ".yml", ".md", ".txt", ".html", ".htm", ".css", ".csv", ".sh", ".ps1", ".bat", ".cfg", ".ini", ".toml"}
-# 对外面孔: 会被转发/上公网的东西 (门户/报告/事实契约/说明书)
-PUBLIC_FACE = re.compile(r"release/portal\.html|report.*\.(html|md|docx)|facts_contract|说明书|docs/.*\.md")
-SKIP = re.compile(r"(^|/)(\.git|__pycache__|\.venv|wheels|vendor|runtime|dist|node_modules)(/|$)")
-# 白名单: 脱敏词表本身当然含业主名 (它就是用来拦这些词的), 不算泄露
-WHITELIST = re.compile(r"scripts/guanlan_facts_contract\.py|scripts/security_scan\.py|src/windscada/deid.*\.py|configs/terms/")
-
-
-def scan_text(name: str, text: str, out: list, cap=200):
-    if WHITELIST.search(name): return
-    face = bool(PUBLIC_FACE.search(name))
-    for k, rx in PAT.items():
-        hits = rx.findall(text)
-        if not hits: continue
-        flat = [h if isinstance(h, str) else next((x for x in h if x), "") for h in hits]
-        uniq = sorted({str(x)[:60] for x in flat if x})[:5]
-        out.append(dict(file=name, rule=k, n=len(hits), samples=uniq, public_face=face))
-
-
-def iter_files(root: Path):
-    for p in sorted(root.rglob("*")):
-        rel = p.relative_to(root).as_posix()
-        if p.is_dir() or SKIP.search(rel): continue
-        yield rel, p
-
-
-def main():
-    ap = argparse.ArgumentParser(); ap.add_argument("--root", default=str(ROOT_DEFAULT)); ap.add_argument("--zip"); ap.add_argument("--out", default="logs/security_scan.json"); ap.add_argument("--strict", action="store_true"); a = ap.parse_args()
-    root = Path(a.root).resolve(); found = []; n_files = n_text = 0; big = []
-    if a.zip:
-        with zipfile.ZipFile(a.zip) as z:
-            for i in z.infolist():
-                if i.is_dir() or SKIP.search(i.filename): continue
-                n_files += 1
-                if i.file_size > 50_000_000: big.append((i.filename, i.file_size))
-                if Path(i.filename).suffix.lower() in TEXT_EXT and i.file_size < 8_000_000:
-                    n_text += 1; scan_text(i.filename, z.read(i).decode("utf-8", "ignore"), found)
-    else:
-        for rel, p in iter_files(root):
-            n_files += 1
-            if p.stat().st_size > 50_000_000: big.append((rel, p.stat().st_size))
-            if p.suffix.lower() in TEXT_EXT and p.stat().st_size < 8_000_000:
-                n_text += 1; scan_text(rel, p.read_text(encoding="utf-8", errors="ignore"), found)
-    by = {}
-    for f in found: by.setdefault(f["rule"], []).append(f)
-    s1 = [f for f in found if f["rule"].startswith("S1")]
-    s3_face = [f for f in found if f["rule"].startswith("S3") and f["public_face"]]
-    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), target=a.zip or str(root), n_files=n_files, n_text_scanned=n_text,
-               by_rule={k: dict(files=len({x["file"] for x in v}), hits=sum(x["n"] for x in v)) for k, v in sorted(by.items())},
-               blockers=dict(S1_secrets=len(s1), S3_on_public_face=len(s3_face)),
-               big_files=[dict(file=f, mb=round(s / 1e6, 1)) for f, s in sorted(big, key=lambda x: -x[1])[:10]], findings=found)
-    o = Path(a.out) if Path(a.out).is_absolute() else root / a.out; o.parent.mkdir(parents=True, exist_ok=True)
-    o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
-    print(f"扫 {n_files} 文件 (文本 {n_text}) → {o}")
-    for k, v in rep["by_rule"].items(): print(f"  {k:20s} 文件 {v['files']:4d}  命中 {v['hits']:6d}")
-    print(f"  阻断项: 密钥 {len(s1)} · 对外面孔上的 D3 {len(s3_face)}")
-    for f in (s1 + s3_face)[:8]: print(f"    ✗ {f['rule']} {f['file']} {f['samples'][:2]}")
-    if a.strict and (s1 or s3_face): return 2
-    return 0
-
-
-if __name__ == "__main__":
-    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
-    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
-    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
-    import sys as _sys
-    for _s in (_sys.stdout, _sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/audits/security_scan.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/security_scan.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/audits/security_scan.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import audits as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.audits import security_scan as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')

+ 29 - 101
scripts/version_log.py

@@ -1,101 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""版本记录生成/校验 (2026-09-17 用户令: 源代码开发维护与打包版本管理)。
-
-## 为什么不是"再手写一个 CHANGELOG.md"
-
-手写版本记录的老毛病是**和代码里的版本号对不上**:改了 `src/version.py` 忘了改 md,或者反过来
-(本仓 0.4.0 期间就出现过三处版本号各说各话)。这里把事实只放在 `src/version.py` 的 `HISTORY` 里,
-`docs/版本记录.md` 由本器**生成**:
-
-    python scripts/version_log.py            # 打印(人看)
-    python scripts/version_log.py --write    # 写进 docs/版本记录.md(自动块)
-    python scripts/version_log.py --check    # 校验 md 与 src/version.py 是否一致(rc=6 表示不一致)
-
-`guanlan.py check` 与打包器都会调用 `--check` 这一条:**版本记录与版本号必须一致**,
-否则交付包里会出现"包说 2.5.0、记录表最后一行是 2.4.0"这种事。
-
-退出码: 0 一致/已写 · 6 不一致(需要跑 --write) · 2 文件缺失
-"""
-from __future__ import annotations
-
-import argparse
-import pathlib
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import version as V                                            # noqa: E402
-
-DOC = ROOT / 'docs' / '版本记录.md'
-BEGIN, END = '<!-- VERSION-LOG:BEGIN -->', '<!-- VERSION-LOG:END -->'
-
-LEVEL_CN = {'major': '大', 'minor': '中', 'patch': '小', 'legacy': '旧编号', 'same': '无', '?': '?'}
-
-
-def body() -> str:
-    """生成自动块正文(规则 + 当前版本 + 版本表 + 怎么改)。"""
-    L = ['## 版本号规则(用户令 2026-09-17)', '',
-         f'编号 **`{V.RULE}`**,例如 `v{V.VERSION}`。', '',
-         '| 级别 | 含义 |', '|---|---|']
-    for k in ('major', 'minor', 'patch'):
-        # LEVEL_MEANING 里是"大版本号 —— 系统解决方案…"整句(与用户令措辞一致),
-        # 表头已经有"级别"一列,这里只取破折号后的定义,免得读成"大版本号 | 大版本号 —— …"。
-        L.append(f'| {LEVEL_CN[k]}版本号 | {V.LEVEL_MEANING[k].split(" —— ", 1)[-1]} |')
-    L += ['', f'**怎么改**:{V.BUMP_RULE}', '',
-          f'**打包文件名** = `{V.package_name()}`(规则:`{V.PACKAGE_STEM}_v<版本号>.zip`)。', '',
-          f'版本号只写在 `src/version.py` 一行(`VERSION = \'{V.VERSION}\'`);'
-          '打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。', '',
-          '| 版本 | 日期 | 级别 | 主要变更 | 说明 |', '|---|---|---|---|---|']
-    for h in V.HISTORY:
-        L.append(f"| `v{h.get('version', '?')}` | {h.get('date', '?')} | {LEVEL_CN.get(h.get('level', '?'), '?')} | "
-                 f"{h.get('title', '')} | {h.get('note', '')} |")
-    L += ['', '## 本次交付', '',
-          f'* 版本:`v{V.VERSION}`({V.NAME})',
-          f'* 交付包:`{V.package_name()}`',
-          f'* 服务名:`{V.SERVICE_NAME}`(Windows 服务 / systemd 单元)',
-          f'* 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`', '']
-    return '\n'.join(L)
-
-
-def render() -> str:
-    return (f'# 版本记录({V.NAME})\n\n'
-            '> 本文件由 `scripts/version_log.py` 生成,**勿手改**:事实(版本号、级别、变更)写在\n'
-            '> `src/version.py` 的 `HISTORY` 里,改完跑 `python scripts/version_log.py --write`。\n'
-            '> `guanlan.py check` 与打包器都会校验本文件与代码里的版本号是否一致。\n\n'
-            f'{BEGIN}\n{body()}{END}\n')
-
-
-def block_of(text: str) -> str:
-    i, j = text.find(BEGIN), text.find(END)
-    return text[i + len(BEGIN):j].strip() if i >= 0 and j > i else ''
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser(description='版本记录生成/校验')
-    ap.add_argument('--write', action='store_true', help='写进 docs/版本记录.md')
-    ap.add_argument('--check', action='store_true', help='只校验是否一致 (rc=6 不一致)')
-    a = ap.parse_args()
-    want = body().strip()
-    if a.write:
-        DOC.parent.mkdir(parents=True, exist_ok=True)
-        DOC.write_text(render(), encoding='utf-8', newline='\n')
-        print(f'已写入 {DOC.relative_to(ROOT).as_posix()}(版本 v{V.VERSION},{len(V.HISTORY)} 条历史)')
-        return 0
-    if a.check:
-        if not DOC.is_file():
-            print(f'[X] 缺 {DOC.relative_to(ROOT).as_posix()} —— 跑 python scripts/version_log.py --write')
-            return 2
-        got = block_of(DOC.read_text(encoding='utf-8'))
-        if got != want:
-            print(f'[X] 版本记录与 src/version.py 不一致(版本 v{V.VERSION})'
-                  f' —— 跑 python scripts/version_log.py --write')
-            return 6
-        print(f'[OK] 版本记录一致(v{V.VERSION},{len(V.HISTORY)} 条)')
-        return 0
-    print(render())
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""兼容转发(P7,2026-09-28):实现已迁到 `app_qualityGate.common.app_qualityGate_guanlan/docs/version_log.py`。**本文件不得新增逻辑**。
+
+入口路径(`scripts/version_log.py`)被根引导脚本(install.ps1 / install.sh / uninstall.* / check.bat / pack.*)、
+`guanlan.py check` 的 importlib 加载与文档引用,由此保持不变:公开名同面转发 + CLI 转 `main()`;
+没有 `main()` 则 `runpy` 按原样执行。
+"""
+from __future__ import annotations
+
+import pathlib
+import runpy
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]                       # scripts/ 的上一层 = 安装根
+sys.path.insert(0, str(ROOT))
+
+IMPL = ROOT / 'app_qualityGate/common/app_qualityGate_guanlan/docs/version_log.py'
+
+from app_qualityGate.common.app_qualityGate_guanlan import docs as _pkg                                        # noqa: E402
+from app_qualityGate.common.app_qualityGate_guanlan.docs import version_log as _impl                                # noqa: E402
+
+for _k, _v in vars(_impl).items():                                       # 公开名同面转发
+    if not _k.startswith('__'):
+        globals().setdefault(_k, _v)
+
+if __name__ == '__main__':
+    if hasattr(_impl, 'main'):
+        raise SystemExit(_impl.main())
+    runpy.run_path(str(IMPL), run_name='__main__')