Jelajahi Sumber

2.11.1 模块化重构 P1: 公共层9个平台件实体迁移+安装根按标记查找+旧路径兼容转发壳; config_audit 豁免改为按取用口文件判; 新增需求分析版本表覆盖门

zhouyang.xie 1 Minggu lalu
induk
melakukan
6087781124

+ 1 - 1
app_common/README.md

@@ -6,7 +6,7 @@
 
 **对外公开面**:`common/app_common_guanlan/api.py` —— 模块间调用**只许**走这里(`scripts/module_boundary_audit.py` 机器检查)。
 
-**当前实现落点(P0 转发目标)**:src/{paths,version,logfile,proc,entry_refs,console,opsjob,derived_manifest,tabfmt}.py
+**当前实现落点(P1 已实体迁入)**:src/{paths,version,logfile,proc,entry_refs,console,opsjob,derived_manifest,tabfmt}.py
 
 **迁移计划**:P1:把上述 9 个平台件实体迁到本目录,旧路径留转发壳。
 

+ 24 - 0
app_common/common/app_common_guanlan/_root.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+r"""安装根推算(公共层内部助手,2026-09-22 P1 迁移时新增)。
+
+为什么需要它: 平台件原先各自写 `pathlib.Path(__file__).resolve().parents[1]` 推安装根 ——
+那是按"文件在 `src/` 下一层"这个**位置假设**写的;P1 把它们迁到
+`app_common/common/app_common_guanlan/`(深三层)后,同一个式子会算成 `app_common/common`。
+所以改成**按标记找根**:从文件所在目录向上找同时含 `configs/` 与 `guanlan.py`(或 `install-info.json`)
+的那一层;找不到才回退到原先的三层上跳(迁移前的布局)。
+
+保持不变: `WINDSCADA_ROOT` 环境变量覆盖仍然优先(见 paths.py)。
+"""
+from __future__ import annotations
+
+import pathlib
+
+
+def install_root(start=None, fallback_up: int = 3) -> pathlib.Path:
+    p = pathlib.Path(start).resolve() if start else pathlib.Path(__file__).resolve()
+    base = p if p.is_dir() else p.parent
+    for cand in [base, *base.parents]:
+        if (cand / 'configs').is_dir() and ((cand / 'guanlan.py').is_file()
+                                            or (cand / 'install-info.json').is_file()):
+            return cand
+    return base.parents[fallback_up - 1]

+ 29 - 0
app_common/common/app_common_guanlan/console.py

@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+"""控制台输出兜底 —— 中文 Windows 默认控制台代码页是 936 (GBK), 而脚本正文里常用 ✔ ✘ → 等符号。
+
+踩过的坑: `scripts/page_fingerprint.py` 比对结果"全部一致", 却因为最后一行 print 里的 ✔
+编不出来抛 `UnicodeEncodeError: 'gbk' codec can't encode character '\\u2714'`, 进程以退出码 1 结束 ——
+**闸门报告失败, 而实际是通过的**。2026-09-11 因此误判过一次 (同一坑在 portal_build.py 也踩了一次)。
+
+所以: 凡是"以退出码讲话"的脚本 (回归闸门/校验/巡检), 入口先调 `soft()`, 把 stdout/stderr 的
+错误处理降级为 replace (编不出的符号写成 `?`), 而不是让整条校验因为一个装饰性符号崩掉。
+
+只降 errors, 不改 encoding: 控制台是 GBK 时中文照旧可读; 控制台是 UTF-8 时一切正常。
+"""
+from __future__ import annotations
+
+import sys
+
+
+def soft(streams=None) -> None:
+    """把 stdout/stderr 的编码错误策略降级为 'replace' (幂等, 失败静默)。"""
+    for s in (streams or (sys.stdout, sys.stderr)):
+        try:
+            s.reconfigure(errors='replace')
+        except Exception:
+            pass
+
+
+def setup() -> None:
+    """别名, 语义同 soft (给"入口初始化"读起来更顺)。"""
+    soft()

+ 82 - 0
app_common/common/app_common_guanlan/derived_manifest.py

@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+"""产物来源**自登记**: 构建脚本落盘后把"这一件是我从 data/raw 算出来的"记进 `outputs/<场>/_derived_manifest.json`。
+
+## 为什么要有它
+
+`outputs/<场>/_provenance.json` 是**逐件来源台账**(raw-derived = 由 data/raw 重算 / shipped = 包内无生成端,
+用随包件补齐)。它由 `scripts/products_restore_missing.py` 生成, 而那个脚本是按**随包快照**逐件走一遍的 ——
+于是**新造的、快照里根本没有的产物**不会自动进台账 (既不算 raw-derived 也不算 shipped)。
+
+早期的做法是在 `products_restore_missing.py` 里维护一张 `RAW_DERIVED` 精确路径表。对"件数少、名字固定"
+的产物够用; 但振动侧的产物是 `<窗>/index.parquet` + `<窗>/spectra/*.npz`(分片名带序号) —— 窗名与分片数
+都随数据变, 写不进精确表, 而**按名字通配**又会误伤同名旧件 (例如 `报告_CMS振动状态评估报告_*.md`
+既有随包/自产的、也有厂家报告转录的, 名字形态一样)。
+
+所以改成**自登记**: 谁算的谁登记, 台账只认这份登记。名字对不上不是问题, 因为登记的是**相对路径本身**。
+
+用法 (构建脚本内):
+    from src.derived_manifest import record
+    record(P.out_root('rudong'), {rel: 'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同构)'},
+           by='scripts/vib_raw_build.py')
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+import time
+
+FILENAME = '_derived_manifest.json'
+
+
+def path_of(store_root) -> pathlib.Path:
+    return pathlib.Path(store_root) / FILENAME
+
+
+def load(store_root) -> dict:
+    p = path_of(store_root)
+    if not p.exists():
+        return {}
+    try:
+        return json.loads(p.read_text(encoding='utf-8'))
+    except Exception:
+        return {}
+
+
+def prune(store_root) -> int:
+    """删掉**登记了但盘上已不存在**的条目, 返回删除数。
+
+    为什么需要 (2026-09-16 实逮): 振动摄入对同一批数据重跑时会落 `<窗>_reimport_<时分>` 窗
+    (设计如此, 该窗被 `data.EXCLUDE_DEFAULT` 排除在生产集外), 而登记是**追加式**的 ——
+    只补不删。重算几次后 `_derived_manifest.json` 里就攒了成百上千条指向已删目录的条目,
+    `_provenance.json` 的 raw-derived 计数随之虚增 (实测 1,740 → 3,444, 而盘上并没有多出这些件)。
+    台账是本包的"来源正本", 虚高等于说假话 ⇒ 每次生成台账前先 prune。
+    """
+    store_root = pathlib.Path(store_root)
+    cur = load(store_root)
+    files = cur.get('files') or {}
+    keep = {rel: v for rel, v in files.items() if (store_root / rel).exists()}
+    gone = len(files) - len(keep)
+    if gone:
+        cur['files'] = keep
+        cur['pruned'] = f'{time.strftime("%Y-%m-%d %H:%M")} 清理 {gone} 条不在盘的登记'
+        path_of(store_root).write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8')
+    return gone
+
+
+def record(store_root, files: dict, by: str) -> pathlib.Path:
+    """把 {相对产物仓的路径: 构建器说明} 合并进登记 (幂等: 同路径后写覆盖先写)。
+
+    幂等很关键 —— 重跑摄入不该让登记无限膨胀; 同时**不删**别的构建器登记的条目
+    (振动摄入与厂家报告摄入是两个脚本, 各登各的)。"""
+    store_root = pathlib.Path(store_root)
+    cur = load(store_root)
+    entries = cur.get('files') or {}
+    for rel, builder in files.items():
+        entries[pathlib.Path(rel).as_posix()] = dict(builder=builder, by=by,
+                                                     at=time.strftime('%Y-%m-%d %H:%M:%S'))
+    cur = dict(note='产物来源自登记: 由构建脚本落盘后写入; _provenance.json 生成时把这些件记为 raw-derived',
+               at=time.strftime('%Y-%m-%d %H:%M:%S'), files=entries)
+    p = path_of(store_root)
+    p.parent.mkdir(parents=True, exist_ok=True)
+    p.write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8')
+    return p

+ 245 - 0
app_common/common/app_common_guanlan/entry_refs.py

@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
+
+## 为什么要这个模块
+
+交付包 v0.4.0 第一次打出来时, 目标机 (现场安装目录 <安装目录>) 双击 `start.bat` 报:
+
+    无法找到脚本文件 "<安装目录>\start_hidden.vbs"
+
+原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
+`start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
+不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
+
+    凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
+    它就**必须**出现在包里(或被打包器自动补进去)。
+
+· 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
+  按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
+· `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
+· 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
+
+★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
+  但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
+
+单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
+"""
+from __future__ import annotations
+
+import pathlib
+import re
+
+# 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
+# ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
+#   `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
+# ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。
+# ★ 2026-09-17 用户令"增加卸载脚本": `uninstall.bat` / `uninstall.sh` 同样是用户会直接双击的入口
+#   —— 它引用的 `scripts\guanlan_uninstall.py` 少打进包, 后果就是"装得上、卸不掉"。
+ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh',
+               'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh')
+
+# 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
+_REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
+                  r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
+                  r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
+
+_STRIP = ('dp0', 'DP0')          # %~dp0 / %dp0 展开后的残前缀
+
+
+def _candidates(tok: str):
+    """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
+    t = tok.replace('\\', '/').lstrip('./')
+    cands = [t]
+    for p in _STRIP:
+        if t.startswith(p) and len(t) > len(p):
+            cands.append(t[len(p):])
+    parts = t.split('/')
+    cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
+    return [c for c in dict.fromkeys(cands) if c]
+
+
+def _norm(name: str) -> str:
+    return name.replace('\\', '/').lstrip('./').lower()
+
+
+def entry_refs(path: pathlib.Path) -> set[str]:
+    """一个入口脚本里出现的全部"像文件"的路径 token。"""
+    try:
+        txt = path.read_text(encoding='utf-8-sig', errors='replace')
+    except OSError:
+        return set()
+    return {m.group(0) for m in _REF.finditer(txt)}
+
+
+def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
+    """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
+
+    本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
+    """
+    root = pathlib.Path(root)
+    out: dict[str, set[str]] = {}
+    for name in ENTRY_FILES:
+        p = root / name
+        if not p.is_file():
+            continue
+        found: set[str] = set()
+        for tok in entry_refs(p):
+            for cand in _candidates(tok):
+                if (root / cand).is_file():
+                    found.add(_norm(cand))
+                    break
+        if found:
+            out[name] = found
+    return out
+
+
+_SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
+              'outputs', 'data', 'logs', 'run'}
+
+
+def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
+    """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
+    import os
+    for dp, dn, fns in os.walk(root):
+        dn[:] = [d for d in dn if d not in _SKIP_WALK]
+        for fn in fns:
+            if fn.lower().endswith(exts):
+                yield pathlib.Path(dp) / fn
+
+
+def encoding_problems(root: pathlib.Path) -> list[str]:
+    r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
+
+    · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
+      代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
+      (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
+      `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
+      ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
+        所以这条必须由机器守, 不能靠"我记得"。
+    · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
+      (`@echo off`) 失效并被当命令执行。
+    · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
+      POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
+      `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
+      2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
+      也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
+    """
+    root = pathlib.Path(root)
+    bad: list[str] = []
+    for p in _iter_scripts(root, ('.ps1',)):
+        b = p.read_bytes()
+        rel = p.relative_to(root).as_posix()
+        if b[:3] != b'\xef\xbb\xbf':
+            bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
+        if b.count(b'\n') != b.count(b'\r\n'):
+            bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
+    for p in _iter_scripts(root, ('.bat',)):
+        b = p.read_bytes()
+        rel = p.relative_to(root).as_posix()
+        if b.count(b'\n') != b.count(b'\r\n'):
+            bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
+        if b[:3] == b'\xef\xbb\xbf':
+            bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
+        # ★ 2026-09-17: .bat 必须**纯 ASCII**。原因: 批处理的解析与输出都跟控制台代码页绑在一起
+        #   (cp936 / cp65001 / 系统区域设置)。现场实测: 中文注释行被 cmd 拆开当成命令执行
+        #   ("'本身是控制台程序' 不是内部或外部命令") —— 放在注释里都不安全。
+        #   所有中文说明改放 README / docs; .bat 只留英文。
+        nonascii = [x for x in b if x > 0x7F]
+        if nonascii:
+            bad.append(f'{rel} 含 {len(nonascii)} 个非 ASCII 字节 (.bat 必须纯 ASCII: 中文在批处理里会随代码页被曲解, '
+                       f'现场出现过"注释被当命令执行"; 中文说明放 README/docs)')
+        txt = b.decode('utf-8', 'replace')
+        if '<<' in txt:
+            bad.append(f'{rel} 含 `<<` —— 在批处理里是重定向, `echo <x>` 这种写法会直接报错')
+        # `>` 只在**非注释行**上查重定向语义: 本机实测 `rem` 行里的 `>` 不会被当重定向 (但 `echo <x>` 会),
+        # 所以注释行放行 —— 免得逼着人把说明写成天书, 同时保住真正会炸的那种写法。
+        for i, ln in enumerate(txt.splitlines(), 1):
+            st = ln.strip()
+            if not st or st.lower().startswith(('rem', '::')):
+                continue
+            if re.search(r'>{1,2}(?!\s|"|nul\b|&)', ln):
+                bad.append(f'{rel} 第 {i} 行的 `>` 后面不是重定向目标 '
+                           f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)')
+                break
+    for p in _iter_scripts(root, ('.sh',)):
+        b = p.read_bytes()
+        if b'\r\n' in b:
+            bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
+                       f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
+    return bad
+
+
+# 装机时才有 / 只在本机有效的文件 —— 入口脚本**可以**引用它们, 但它们**不该在包里**
+# (所以闭合检查必须放行, 否则这条守卫会得出相反的结论, 见 missing_refs 的注释)。
+MACHINE_ARTIFACTS = ('install-info.json',)      # 安装时写的"本机装没装/哪一版"凭据
+
+
+def _is_machine_artifact(rel: str) -> bool:
+    name = rel.replace('\\', '/').split('/')[-1].lower()
+    return name in MACHINE_ARTIFACTS or name.endswith('.lnk')
+
+
+def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
+    """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
+
+    provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
+
+    ★ 2026-09-17: **放行"装机产物"** (见 MACHINE_ARTIFACTS)。这条守卫的本意是"包内不许有指向空气的
+      入口"(曾经漏带 start_hidden.vbs, 目标机双击即报错), 而 `install-info.json` 是**故意不发**的:
+      它是安装时才写的本机凭据, 装之前本来就该不在, 而 `install.ps1`/`install.sh` 的版本检查那一步
+      正是靠"文件在不在"判断装没装的。原先这两件事会打架 —— 把 install-info.json 排除出包之后,
+      闭合检查反过来报"install.ps1 引用了它但包里没有 ⇒ 不能交付", 于是包被闸删掉。
+      判据修正为: 引用的文件必须"在包内**或在目标机上必然存在**(装机产物/安装时自建)"。
+    """
+    have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
+    bad: list[tuple[str, str]] = []
+    root = pathlib.Path(root)
+    for entry, refs in sorted(referenced_tree_files(root).items()):
+        for rel in sorted(refs):
+            if _is_machine_artifact(rel):
+                continue
+            ok = (rel in have) if have is not None else (root / rel).is_file()
+            if not ok:
+                bad.append((entry, rel))
+    return bad
+
+
+def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
+    """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
+
+    只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
+    缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
+    ★ 装机产物 (`install-info.json` / `*.lnk`) 不在此列 —— 它们本机有, 但**不该随包**(见 MACHINE_ARTIFACTS)。
+    """
+    got = {_norm(x) for x in already}
+    root = pathlib.Path(root)
+    add: list[pathlib.Path] = []
+    for _, refs in referenced_tree_files(root).items():
+        for rel in refs:
+            if '/' in rel or rel in got or _is_machine_artifact(rel):
+                continue
+            p = root / rel
+            if p.is_file():
+                add.append(p)
+                got.add(rel)
+    return sorted(set(add), key=lambda p: p.name)
+
+
+if __name__ == '__main__':      # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
+    import sys
+    r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
+    refs = referenced_tree_files(r)
+    miss = missing_refs(r)
+    enc = encoding_problems(r)
+    for e in sorted(refs):
+        print(f'   {e:18s} → ' + ', '.join(sorted(refs[e])))
+    for e, rel in miss:
+        print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
+    for m in enc:
+        print(f'[X] 编码守则: {m}')
+    if not miss:
+        print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
+              f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
+    if not enc:
+        print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
+    sys.exit(1 if (miss or enc) else 0)

+ 239 - 0
app_common/common/app_common_guanlan/logfile.py

@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""日志目录/命名/格式的唯一口径 (2026-09-17 用户令 2「统一日志输出目录及日志文件命名、内容格式」)。
+
+## 统一前是什么样 (实测, 不是推测)
+
+  · 目录: 运行日志散在 `logs/`, 但 CMS 插件把 `analyze_stdout.log` 写进了**产物目录**
+    (`outputs/<场>/windcms/...`), `_proc_reg.log` 这种自检残留也躺在 `logs/` 里;
+  · 命名: 服务日志是 `<组件>.log` (gateway/detail/cms/sim/sim_sys/viewer), 但对不上服务键的还有
+    `serve.log`、`start_hidden.log`; 运维动作是 `ops_<动作>_<YYYYmmdd_HHMMSS>.log` 直接堆在 `logs/` 顶层
+    —— 实测 34 个文件里 22 个是历史动作日志, 没有保留策略, 只会越堆越多;
+  · 内容: **没有时间戳、没有级别、编码与行尾都不统一** —— `detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF!),
+    `cms.log` 首行甚至是一条历史 `SyntaxWarning`; 机器审计反而叫 `.jsonl` 混在 `.log` 里;
+  · 全仓 `import logging` 的文件数 = **0** —— 没有统一设施, 每个进程各 print 各的。
+
+## 统一后的口径 (机器可查, 见 scripts/log_audit.py)
+
+    logs/<组件>.log                长驻服务与启动器 (组件名 = configs/serve.json 的键 + gateway + serve/start_hidden)
+    logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   运维动作日志 (保留最近 20 份 / 30 天, 超出的自动清理)
+    logs/audit/<名字>.jsonl        机器审计流水 (JSON Lines: 一行一条 JSON)
+
+    行格式 (每一行都要满足, 校验正则见 LINE_RE):
+        YYYY-MM-DD HH:MM:SS LEVEL 组件 消息
+    级别: DEBUG/INFO/WARN/ERROR;  UTF-8 无 BOM;  行尾 LF;  不含 ANSI 颜色码。
+
+服务侧怎么落地: 各服务不用改自己的 print —— 入口处调一次 `prefix_stdout(组件名)`,
+之后**每一行**都会自动带上时间戳/级别/组件 (实现见下面 _Prefixed 包装器)。这样"内容格式统一"
+不是靠自觉, 而是由设施保证。
+"""
+from __future__ import annotations
+
+try:
+    from ._root import install_root as _install_root
+except ImportError:                     # 直接当脚本跑(python <本文件>)时没有包上下文
+    from _root import install_root as _install_root
+
+import datetime as dt
+import json
+import os
+import pathlib
+import re
+import sys
+
+import pathlib as _p
+
+ROOT = _install_root(__file__)
+LOGS = _p.Path(os.environ.get('WINDSCADA_LOGS') or (ROOT / 'logs'))
+OPS_DIR = LOGS / 'ops'
+AUDIT_DIR = LOGS / 'audit'
+BUILD_DIR = LOGS / 'build'          # 构建/摄入类脚本的日志 (按产物子路径归档; 不许写在产物目录里)
+LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR')
+
+# 一行日志的规范形式: 时间戳 + 级别 + 组件 + 消息 (组件名允许中文/点/下划线/连字符)
+LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:DEBUG|INFO|WARN|ERROR) [\w\u4e00-\u9fff.\-]+ ')
+ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
+
+ACTION_KEEP = 20          # 运维动作日志保留份数
+ACTION_DAYS = 30          # 运维动作日志保留天数
+
+
+def now() -> str:
+    return dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+
+def line(level: str, comp: str, msg: str, ts: str | None = None) -> str:
+    """拼一行规范日志 (纯函数, 便于测试)。多行消息会被逐行加前缀。"""
+    lv = (level or 'INFO').upper()
+    if lv not in LEVELS:
+        lv = 'INFO'
+    out = []
+    for i, part in enumerate(str(msg).replace('\r\n', '\n').replace('\r', '\n').split('\n')):
+        out.append(f'{ts or now()} {lv} {comp} {part}')
+    return '\n'.join(out)
+
+
+def component_log(comp: str) -> pathlib.Path:
+    """长驻服务/启动器的日志路径: logs/<组件>.log"""
+    return LOGS / f'{comp}.log'
+
+
+def action_log(tag: str, when: dt.datetime | None = None) -> pathlib.Path:
+    """运维动作日志路径: logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log (写入前会先按保留策略清理)"""
+    w = when or dt.datetime.now()
+    return OPS_DIR / f'{tag}_{w:%Y%m%d-%H%M%S}.log'
+
+
+def audit_log(name: str) -> pathlib.Path:
+    """机器审计流水路径: logs/audit/<名字>.jsonl"""
+    return AUDIT_DIR / (name if name.endswith(('.jsonl', '.json')) else f'{name}.jsonl')
+
+
+def ensure_dirs() -> None:
+    for d in (LOGS, OPS_DIR, AUDIT_DIR, BUILD_DIR):
+        d.mkdir(parents=True, exist_ok=True)
+
+
+def build_log(rel: str, farm: str | None = None) -> pathlib.Path:
+    """构建/摄入脚本的日志路径: `logs/build/<场>/<相对路径>.log`
+
+    ★ 2026-09-17 用户令 2 的由来: 交付包里 39 个构建日志原本**躺在产物目录里**
+    (`outputs/<场>/windscada/*.log` 等), 还被产物台账登记成 shipped 随包件 ——
+    "日志在产物里"正是这次要统一掉的不一致。新脚本用本函数落 `logs/build/`, 旧的已迁移过去。
+    """
+    p = pathlib.Path(rel)
+    parts = [farm] if farm else []
+    return BUILD_DIR.joinpath(*parts, *p.parts)
+
+
+def write(comp: str, msg: str, level: str = 'INFO', path: pathlib.Path | None = None) -> pathlib.Path:
+    """追加一行规范日志 (UTF-8, LF)。"""
+    ensure_dirs()
+    p = pathlib.Path(path) if path else component_log(comp)
+    p.parent.mkdir(parents=True, exist_ok=True)
+    with open(p, 'a', encoding='utf-8', newline='\n') as f:
+        f.write(line(level, comp, msg) + '\n')
+    return p
+
+
+def append_jsonl(name: str, rec: dict) -> pathlib.Path:
+    """机器审计流水: 一行一条 JSON (ensure_ascii=False, 时间戳字段 ts)。"""
+    ensure_dirs()
+    p = audit_log(name)
+    rec = dict(rec)
+    rec.setdefault('ts', now())
+    with open(p, 'a', encoding='utf-8', newline='\n') as f:
+        f.write(json.dumps(rec, ensure_ascii=False, default=str) + '\n')
+    return p
+
+
+def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool = False) -> list[pathlib.Path]:
+    """运维动作日志保留策略: 只留最近 keep 份、且不超过 days 天。→ 删掉的文件列表。"""
+    if not OPS_DIR.is_dir():
+        return []
+    files = sorted((f for f in OPS_DIR.glob('*.log') if f.is_file()), key=lambda f: f.stat().st_mtime, reverse=True)
+    cutoff = dt.datetime.now().timestamp() - days * 86400
+    gone = []
+    for i, f in enumerate(files):
+        if i < keep and f.stat().st_mtime >= cutoff:
+            continue
+        gone.append(f)
+        if not dry:
+            try:
+                f.unlink()
+            except OSError:
+                pass
+    return gone
+
+
+def normalize_file(path: pathlib.Path, comp: str, when: float | None = None) -> int:
+    """把一份**裸输出**日志就地补成统一格式 → 补了几行。
+
+    为什么需要: 运维动作的日志是"子进程直接写 fd"的产物 (`_ops_run.py` 把本进程的 stdout 句柄交给子命令,
+    见那里的注释 —— 用管道收输出曾经导致日志空白/任务看起来卡住), 所以子命令的 print **绕过**了 Python
+    层的流包装。这里在动作结束时补一次前缀: 已经是规范行的原样保留, 其余行补 `时间戳 级别 组件.raw`,
+    并在开头插一行说明"以下为原样输出、前缀是事后补的" —— 不假装是原始时刻写的。
+    """
+    if not path or not pathlib.Path(path).is_file():
+        return 0
+    p = pathlib.Path(path)
+    ts = dt.datetime.fromtimestamp(when if when is not None else p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+    raw = p.read_text(encoding='utf-8', errors='replace').replace('\r\n', '\n').split('\n')
+    fixed, n = [], 0
+    for ln in raw:
+        if not ln.strip():
+            continue
+        if LINE_RE.match(ln):
+            fixed.append(ln)
+            continue
+        fixed.append(f'{ts} INFO {comp}.raw {ln}')
+        n += 1
+    if n:
+        fixed.insert(0, f'{ts} INFO {comp} === 以下 {n} 行为子进程原样输出 (前缀由 logfile.normalize_file 事后补齐) ===')
+        p.write_text('\n'.join(fixed) + '\n', encoding='utf-8', newline='\n')
+    return n
+
+
+# ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
+class _Prefixed:
+    """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。
+
+    刻意做成**流包装**而不是要求 6 个服务各自改用 logging:
+    服务里已有大量 print (启动横幅/请求日志/进度), 逐个改既费事又会漏; 包一层之后
+    "内容格式统一"由设施保证。级别默认 INFO; 含 'error'/'traceback' 字样的行按 ERROR 记。
+    """
+
+    def __init__(self, stream, comp: str):
+        self._s = stream
+        self._comp = comp
+        self._buf = ''
+
+    def write(self, data):
+        if not isinstance(data, str):
+            data = str(data)
+        self._buf += data
+        while '\n' in self._buf:
+            ln, self._buf = self._buf.split('\n', 1)
+            self._emit(ln)
+        return len(data)
+
+    def _emit(self, ln: str):
+        clean = ANSI_RE.sub('', ln.rstrip('\r'))
+        lv = 'ERROR' if re.search(r'error|traceback|失败|异常', clean, re.I) else 'INFO'
+        try:
+            self._s.write(line(lv, self._comp, clean) + '\n')
+            self._s.flush()
+        except Exception:
+            pass
+
+    def flush(self):
+        if self._buf:
+            self._emit(self._buf)
+            self._buf = ''
+        try:
+            self._s.flush()
+        except Exception:
+            pass
+
+    def __getattr__(self, item):
+        return getattr(self._s, item)
+
+
+def prefix_stdout(comp: str) -> None:
+    """服务入口调一次: 之后 stdout/stderr 的每一行都符合统一格式 (幂等)。"""
+    if getattr(sys.stdout, '_guanlan_prefixed', False) or os.environ.get('GUANLAN_LOG_RAW'):
+        return
+    so, se = _Prefixed(sys.stdout, comp), _Prefixed(sys.stderr, comp)
+    so._guanlan_prefixed = se._guanlan_prefixed = True
+    sys.stdout, sys.stderr = so, se
+
+
+if __name__ == '__main__':      # 直接跑 = 打印口径 + 清洗一次动作日志
+    ensure_dirs()
+    print(f'logs 目录: {LOGS}')
+    print(f'  服务日志   logs/<组件>.log       (组件: configs/serve.json 的键 + gateway/serve/start_hidden)')
+    print(f'  动作日志   logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   保留最近 {ACTION_KEEP} 份 / {ACTION_DAYS} 天')
+    print(f'  审计流水   logs/audit/<名字>.jsonl')
+    print(f'  行格式     {line("INFO", "示例", "一行长这样")}')
+    gone = prune_actions(dry=True)
+    print(f'  现有动作日志 {len(list(OPS_DIR.glob("*.log")))} 份, 按保留策略该清理 {len(gone)} 份')

+ 62 - 0
app_common/common/app_common_guanlan/opsjob.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+r"""运维动作(重算/清产物)的**当前状态**读取口(单一实现)。
+
+为什么需要它: 2026-09-19 用户报"重算后页面没有输出"。实况是——门户里点了「清除产物 → 执行重算」,
+产物被清掉后重算要跑很久(本次 ④b 振动侧摄入要处理 150 GB 的 CMS 导出), 期间各页面只能显示
+"无产物"。但那句话现在只说"要么还没放数据, 要么刚清过产物", **没说"重算正在跑"** ——
+于是同一现象被读成"系统坏了"。
+
+状态真源: `scripts/_ops_run.py` 写的 `run/ops_job.json`(每 15 s 心跳更新 mtime)。
+判定"在跑"= `status == 'running'` **且** 文件 mtime 在 `fresh_s` 秒内(只看 pid 会被复用骗过)。
+"""
+from __future__ import annotations
+
+try:
+    from ._root import install_root as _install_root
+except ImportError:                     # 直接当脚本跑(python <本文件>)时没有包上下文
+    from _root import install_root as _install_root
+
+import json
+import pathlib
+import time
+
+ROOT = _install_root(__file__)
+JOB = ROOT / 'run' / 'ops_job.json'
+
+
+def current(fresh_s: int = 90) -> dict:
+    """→ dict(running=bool, kind=, cmd=, started=, elapsed_s=, age_s=, stale=bool, note=)。读不到就 running=False。"""
+    out = dict(running=False, kind=None, cmd=None, started=None, elapsed_s=None, age_s=None,
+               stale=False, note=None, job_file=str(JOB))
+    if not JOB.is_file():
+        return out
+    try:
+        j = json.loads(JOB.read_text(encoding='utf-8'))
+    except Exception as e:
+        return {**out, 'note': f'任务文件读不出 ({type(e).__name__})'}
+    age = time.time() - JOB.stat().st_mtime
+    out.update(kind=j.get('kind'), cmd=j.get('cmd'), started=j.get('started'), age_s=round(age),
+               status=j.get('status'), rc=j.get('rc'))
+    running = (j.get('status') == 'running') and age < fresh_s
+    out['running'] = bool(running)
+    out['stale'] = (j.get('status') == 'running') and age >= fresh_s
+    if out.get('started'):
+        try:
+            t0 = time.mktime(time.strptime(out['started'], '%Y-%m-%d %H:%M:%S'))
+            out['elapsed_s'] = round(time.time() - t0)
+        except Exception:
+            pass
+    if out['stale']:
+        out['note'] = (f"任务文件写着 running 但已 {round(age / 60, 1)} 分钟没心跳 —— 可能是被强杀, "
+                       f"不是正在跑")
+    return out
+
+
+def running_text() -> str:
+    """给人看的一句话(页面/接口用): 在跑就写明"起点 + 已跑多久", 否则空串。"""
+    s = current()
+    if not s['running']:
+        return ''
+    mins = (s['elapsed_s'] or 0) / 60
+    return (f"⚠ 检测到**重算正在进行中**({s['kind']},起于 {s['started']},已跑 {mins:.0f} 分钟)—— "
+            f"产物是一步步长出来的,页面会在对应步骤跑完后自己回来(无需重启服务)。")

+ 255 - 0
app_common/common/app_common_guanlan/paths.py

@@ -0,0 +1,255 @@
+# -*- coding: utf-8 -*-
+r"""观澜 v2 路径中心 —— 跨平台部署的唯一路径真源 (2026-09-11 用户令)。
+
+## 约定 (硬约束)
+
+1. **代码与配置里只写相对路径**, 相对 **安装根 (ROOT)** —— 不写机器相关绝对路径
+   (`/Users/…`、`/Volumes/…`、`C:\…`、`D:\…`、`F:\…`)。
+2. 运行时**由本模块解析成绝对路径**, 解析基准是 ROOT —— **不是 cwd**。
+   cwd 相对是隐形坑: 从别处调用同一个脚本 (`python <绝对路径>/scripts/x.py`) 或换工作目录,
+   路径就会指到别处; 本仓 2026-09-11 实测有 20+ 处 `Path('outputs/rudong/…')` 属此类, 已全部归到本模块。
+3. 需要写进产物/清单/页面的路径字符串, 用 **POSIX 相对形式** (`outputs/rudong/…`, 见 `rel()`),
+   Windows 与 Linux 通用; 不要用 `os.sep` 拼库存字符串 (那只适合"给人看"的显示, 见 `disp()`)。
+4. ROOT 的确定: 环境变量 `WINDSCADA_ROOT` (冻结打包时由启动器设) → 否则按本文件位置回溯
+   (`src/paths.py` 的上一级) —— 因此**整个安装目录可以整个拷到别的电脑/别的盘**, 不需要改任何路径。
+
+## 跨平台要点
+
+- 分隔符: 一律 `pathlib`, 需要字符串时 `.as_posix()`;
+- 大小写: Linux 区分大小写 —— 目录名只用本模块常量, 不靠大小写变体;
+- 解释器: venv 按平台探测 (`.venv/Scripts/python.exe` / `.venv/bin/python`, 见 `venv_python()`);
+- 中文目录名 (如 `data/raw/如东`、`故障报警`): UTF-8 存写, 两侧平台均可; 页面显示走 `disp()`。
+"""
+from __future__ import annotations
+
+try:
+    from ._root import install_root as _install_root
+except ImportError:                     # 直接当脚本跑(python <本文件>)时没有包上下文
+    from _root import install_root as _install_root
+
+import os
+import pathlib
+import sys
+
+# ---- 安装根: env → 本文件位置回溯 (src/paths.py → <安装目录>) ----------------------------
+ROOT = pathlib.Path(os.environ.get('WINDSCADA_ROOT') or _install_root(__file__))
+
+# ---- 输入侧 (离线数据): data/raw, 下一级目录 = 场站名 (见 docs/数据目录结构与落位约定) ----
+RAW_ROOT = pathlib.Path(os.environ.get('WINDSCADA_RUDONG_SRC') or (ROOT / 'data' / 'raw'))
+
+# ---- 非场站维度的固定位置 (全部为 ROOT 相对) -------------------------------------------
+CONFIGS = ROOT / 'configs'
+FARMS = CONFIGS / 'farms'
+REFERENCE = ROOT / 'reference'
+RELEASE = ROOT / 'release'
+PORTAL = RELEASE / 'portal.html'
+VIEWER = RELEASE / 'viewer'
+RESOURCES = ROOT / 'resources'
+SIM_DIR = RESOURCES / 'oem_envision_sc1_rudong2014'
+DOCS = ROOT / 'docs'
+LOGS = ROOT / 'logs'
+RUN = ROOT / 'run'
+SCRIPTS = ROOT / 'scripts'
+SRC = ROOT / 'src'
+WHEELS = ROOT / 'wheels'
+DATA = ROOT / 'data'
+SERVE_JSON = CONFIGS / 'serve.json'
+MODELS_JSON = CONFIGS / 'models.json'
+
+# ---- 配置目录约定与唯一取用口 (2026-09-17 用户令 2: 统一配置目录及配置文件) ----------------
+# 为什么要有这一节: 原来各模块自己拼 `ROOT / 'configs' / 'xxx.yaml'` (实测 9 个模块各拼各的),
+# 于是"配置放哪"随时间漂移: 顶层散着 serve.json/models.json/portal_pages.yaml, 还混进过一个
+# `serve.json.bak-bomfix`; `scripts/audit_chinese_terms.py` 甚至要**试三个位置**才找得到 terms 库。
+# 现在: 目录分工写死在下面, 代码**只从 `config()` / `config_dir()` 取路径**, 不许再手拼字符串;
+# `scripts/config_audit.py` 会按这套约定查实物与代码(见 docs §11)。
+#
+#   configs/<域>/<名字>.<yaml|json|csv>     域 = canonical | contracts | farms | terms | <新增>
+#   configs/serve.json                      运行期单件配置 (端口/路径真源)
+#   configs/models.json                     运行期单件配置 (本机模型档)
+#   configs/portal_pages.yaml               运行期单件配置 (门户页面归口登记表, 见 §10)
+TOP_LEVEL_CONFIGS = ('serve.json', 'models.json', 'portal_pages.yaml')
+CONFIG_DOMAINS = ('canonical', 'contracts', 'farms', 'terms')
+CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv')
+
+
+def config(*parts: str, must_exist: bool = False) -> pathlib.Path:
+    """配置文件的唯一取用口: `P.config('terms', 'display_map.yaml')` / `P.config('serve.json')`。
+
+    只做路径解析, 不读文件 (读法由调用方决定: yaml/json/csv 各不相同);
+    `must_exist=True` 时不存在就抛 FileNotFoundError —— 配置缺失应该在启动时报出来, 别静默用默认值。
+    """
+    p = CONFIGS.joinpath(*parts)
+    if must_exist and not p.is_file():
+        raise FileNotFoundError(f'缺配置文件 {p} (约定见 src/paths.py 配置一节 / docs §11)')
+    return p
+
+
+def config_dir(*parts: str) -> pathlib.Path:
+    """配置目录 (域) 的取用口: `P.config_dir('farms')`。"""
+    return CONFIGS.joinpath(*parts)
+
+
+def farm_config(name: str | None = None) -> pathlib.Path | None:
+    """场定义配置文件 —— **格式统一为 YAML**, 兼容历史 `.json` (有就优先用)。
+
+    2026-09-17 实测的坑: `configs/farms/` 下有 8 个场定义是 `.yaml`, 而 `available()` 只认 `*.json`
+    ⇒ 这些场**根本列不出来**(等于配置写了没人看见); 目录里那个模板还叫 `_模板.json.example`,
+    与实物格式相反。现在两边都认, 且约定"新的场定义写 yaml"。
+    """
+    f = farm(name)
+    for ext in ('.yaml', '.yml', '.json'):
+        p = FARMS / f'{f}{ext}'
+        if p.is_file():
+            return p
+    return None
+
+
+def farm(name: str | None = None) -> str:
+    """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。
+
+    这里不 import src.windscada.config 以免循环 (config 反过来要用本模块)。
+    需要 set_current() 那种运行期切换时, 调用方把场名显式传进来即可。"""
+    return name or os.environ.get('WINDSCADA_FARM') or 'rudong'
+
+
+# ---- 产物侧: outputs/<场名>/… (页面取数的仓) -------------------------------------------
+def out_root(name: str | None = None) -> pathlib.Path:
+    return ROOT / 'outputs' / farm(name)
+
+
+def store(name: str | None = None) -> pathlib.Path:
+    """L0 标准仓 (parquet) —— 页面主取数处。"""
+    return out_root(name) / 'windscada'
+
+
+def ont(name: str | None = None) -> pathlib.Path:
+    """本体对象库目录 (objects.json / 检索索引 / turbine_params)。"""
+    return out_root(name) / 'ontology'
+
+
+def objects_json(name: str | None = None) -> pathlib.Path:
+    return ont(name) / 'objects.json'
+
+
+def cms(name: str | None = None) -> pathlib.Path:
+    """CMS 振动诊断产物目录。"""
+    return out_root(name) / 'windcms'
+
+
+def m5(name: str | None = None) -> pathlib.Path:
+    """振动线 handoff / TCM 兼容件目录。"""
+    return out_root(name) / 'm5_cms_tcm'
+
+
+def tcm_replay(name: str | None = None) -> pathlib.Path:
+    return out_root(name) / 'tcm_compatible_replay'
+
+
+def sop(name: str | None = None) -> pathlib.Path:
+    """SOP 中间件与评审落盘目录。"""
+    return out_root(name) / 'sop'
+
+
+def guanlan(name: str | None = None) -> pathlib.Path:
+    """事实契约与对外派生 (可上云面孔)。"""
+    return out_root(name) / 'guanlan'
+
+
+def pitch(name: str | None = None) -> pathlib.Path:
+    return out_root(name) / 'pitch'
+
+
+def paradigm(name: str | None = None) -> pathlib.Path:
+    """范式实验件 (E3/E5/E8 底稿) —— 事实契约的输入之一 (2026-09-16 补: 原先直接用
+    `ROOT/'outputs'/'rudong'/'paradigm_r1'` 拼, 既写死场名又绕过了本模块)。"""
+    return out_root(name) / 'paradigm_r1'
+
+
+def report_dir(name: str | None = None) -> pathlib.Path:
+    """报告交付件目录 (`交接_振动→状态评估报告_*.md` / `现场单_*.md`) —— 由振动线出件,
+    并被 `src/windcms/config.py` 的 knowledge_docs 引用 (2026-09-16 补: 该目录在 v0.2.0 里
+    没有明确归属, 一直以 `out_root()/'report'` 的裸拼形式出现)。"""
+    return out_root(name) / 'report'
+
+
+def cloud(name: str | None = None) -> pathlib.Path:
+    """可上云面孔 (脱敏后的契约/派生件/页面) —— `scripts/guanlan_cloud_*.py` 的落点。"""
+    return guanlan(name) / 'cloud'
+
+
+def contract(name: str | None = None) -> pathlib.Path:
+    """场契约 (机型判据参数), 属 reference 侧, 不在 outputs。"""
+    return REFERENCE / farm(name) / 'windscada_contract.yaml'
+
+
+def station_dir(name: str | None = None) -> pathlib.Path:
+    """本场原始件目录 data/raw/<场站名称> —— 具体由场配置扫描结果决定,
+    这里只给"约定位置"兜底 (场配置里 windscada.config.farm()['raw_station_dir'] 才是权威)。"""
+    return RAW_ROOT / farm(name)
+
+
+# ---- 解释器与字符串形式 ----------------------------------------------------------------
+def venv_python() -> pathlib.Path | None:
+    """本安装目录下的 venv 解释器 (跨平台); 不存在返回 None。"""
+    for rel in (('Scripts', 'python.exe'), ('bin', 'python'), ('bin', 'python3')):
+        p = ROOT / '.venv' / pathlib.Path(*rel)
+        if p.exists():
+            return p
+    return None
+
+
+def resolve(p) -> pathlib.Path:
+    """把"可能是相对路径"的值解析成绝对路径: 相对基准是 ROOT (**不是 cwd**)。"""
+    q = pathlib.Path(p)
+    return q if q.is_absolute() else (ROOT / q)
+
+
+def rel(p) -> str:
+    """给人/给清单的**相对**路径字符串 (POSIX 形式); 不在 ROOT 内则给绝对 POSIX。"""
+    q = pathlib.Path(p)
+    try:
+        return q.resolve().relative_to(ROOT.resolve()).as_posix()
+    except ValueError:
+        return q.as_posix()
+
+
+def disp(p) -> str:
+    """给人看的显示路径: 安装目录内的写成 `<安装目录>/…`, 分隔符随本机; 其余原样绝对路径。
+    (只用于显示 —— 不要把它写进产物或清单。)"""
+    q = pathlib.Path(p)
+    try:
+        return str(pathlib.Path('<安装目录>') / q.resolve().relative_to(ROOT.resolve()))
+    except ValueError:
+        return str(q)
+
+
+# 显示用分隔符: 只服务于给人看的文本 (页面「位置」列)。写进产物/清单的路径请用 rel() 的 POSIX 形式。
+SEP = os.sep
+
+
+def disp_dir(p) -> str:
+    """目录的**显示形**: 末尾带本机分隔符 (页面「位置」列用).
+    路径本身一律走 `rel()` 的 POSIX 形式; 这里的 os.sep 只服务于"给人看"。"""
+    return disp(p) + os.sep
+
+
+def python_exe() -> str:
+    """跑子进程/脚本用的解释器: venv → 否则当前解释器。"""
+    v = venv_python()
+    return str(v) if v else sys.executable
+
+
+def platform_tag() -> str:
+    """平台标识 (探测脚本/日志用): windows / linux / darwin。"""
+    return {'nt': 'windows', 'posix': 'linux'}.get(os.name, os.name) if sys.platform != 'darwin' else 'darwin'
+
+
+if __name__ == '__main__':       # 自检: python -m src.paths  或  python src/paths.py
+    print(f'ROOT       : {ROOT}   (存在: {ROOT.is_dir()})')
+    print(f'平台       : {platform_tag()}   cwd: {pathlib.Path.cwd()}')
+    print(f'原始件根   : {rel(RAW_ROOT)}   (存在: {RAW_ROOT.is_dir()})')
+    print(f'venv 解释器: {venv_python() or "(无, 用 " + sys.executable + ")"}')
+    for label, p in (('store', store()), ('ontology', ont()), ('windcms', cms()),
+                     ('m5', m5()), ('sop', sop()), ('guanlan', guanlan()),
+                     ('release', RELEASE), ('sim_dir', SIM_DIR)):
+        print(f'  {label:9s} {rel(p):42s} 存在={p.exists()}')

+ 161 - 0
app_common/common/app_common_guanlan/proc.py

@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+"""子进程创建的统一口径 —— **不弹命令窗口** (2026-09-16 用户令: 启动/操作观澜时不弹命令窗口)。
+
+## 为什么要收敛到一处
+
+Windows 上"**无控制台的父进程** + 裸 spawn 一个控制台程序" = 系统给子进程**新建一个可见控制台窗口**。
+本项目里这类父进程很多:
+  · 网关 `guanlan_gateway.py` 自己是被 `DETACHED_PROCESS` 起来的 (无控制台) → 它调的 `tasklist` / `git` 会闪窗;
+  · 运维动作进程 (`scripts/_ops_launch.py` → `_ops_run.py`) 同样无控制台 → 从页面点"重算"时,
+    动作全过程都在一个可见窗口里跑 (最长 20 分钟), 页面每 2 s 轮询 `tasklist` 还会**反复闪窗**;
+  · 组件服务原先有的地方用 `DETACHED_PROCESS` (子进程干脆没有控制台), 有的地方什么都不加 (于是弹窗),
+    两种写法混用 —— 实测现场会攒下多个标题为 `.venv\\Scripts\\python.exe` 的黑窗。
+
+统一到本模块后: 只认 `NO_WINDOW` (`CREATE_NO_WINDOW`) 一种写法。
+★ `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` **互斥**, 不要叠加 —— 前者是"给一个没有窗口的控制台",
+  后者是"不给控制台"; 叠在一起行为依赖 Windows 版本。需要"子进程活过父进程"时用
+  `NEW_GROUP` (`CREATE_NEW_PROCESS_GROUP`) + 不共享控制台即可, 不需要 DETACHED。
+
+## 日志去哪了 (hide 窗口不等于看不见)
+
+窗口藏起来后, 子进程的 stdout/stderr 一律重定向到 `logs/<name>.log` (`spawn(log=…)`),
+`/ops` 页面也会显示任务日志尾巴 —— 排障路径不变, 只是不再靠一个黑窗。
+
+## 用法
+
+    from src.proc import spawn, run, NO_WINDOW
+    pid = spawn([py, 'scripts/x.py'], log=P.LOGS / 'x.log', env=e, cwd=ROOT)   # 后台, 不弹窗
+    r = run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True, text=True)  # 等待, 不弹窗
+"""
+from __future__ import annotations
+
+try:
+    from ._root import install_root as _install_root
+except ImportError:                     # 直接当脚本跑(python <本文件>)时没有包上下文
+    from _root import install_root as _install_root
+
+import os
+import pathlib
+import subprocess
+import sys
+
+WIN = os.name == 'nt'
+# CREATE_NO_WINDOW = 0x08000000: 给子进程一个**没有窗口**的控制台 (stdio 仍可重定向)
+NO_WINDOW = 0x08000000 if WIN else 0
+# CREATE_NEW_PROCESS_GROUP = 0x00000200: 子进程不受父进程 Ctrl-C 影响, 也不共享父的控制台事件
+NEW_GROUP = 0x00000200 if WIN else 0
+
+ROOT = _install_root(__file__)
+
+
+def flags(*, new_group: bool = True) -> int:
+    """本模块的统一 creationflags (非 Windows 返回 0, 调用方无需分支)。"""
+    f = NO_WINDOW
+    if new_group:
+        f |= NEW_GROUP
+    return f
+
+
+def _kw(kw: dict, *, new_group: bool = True) -> dict:
+    if WIN:
+        kw.setdefault('creationflags', flags(new_group=new_group))
+    else:
+        kw.setdefault('start_new_session', True)
+    return kw
+
+
+def _inherit_stdio(kw: dict) -> dict:
+    """把父进程**当前**的标准句柄显式交给子进程 (STARTF_USESTDHANDLES)。
+
+    为什么必须显式 (2026-09-16 实逮, 是本模块第一版引入的坑): Windows 上 `CREATE_NO_WINDOW` 会给
+    子进程新建一个"没有窗口的控制台", 而**新建控制台会把该进程的标准句柄重指到新控制台的缓冲区** ——
+    于是"父进程 stdout 已被重定向到日志文件"这件事, 在**孙子辈**就丢了。
+    现场表现: 从页面点"执行重算" → `_ops_launch`(显式 stdout=日志) → `_ops_run`(输出进了日志 ✔)
+    → `rebuild_all.py`(没显式句柄 → 输出掉进那个隐形控制台, 页面只剩"运行中"、日志里一个字都没有)。
+    显式传 `sys.stdout/stderr` 即可让整条链写进同一个日志; `sys.stdout is None` (pythonw) 时跳过。
+    """
+    if kw.get('capture_output'):
+        # ★ `capture_output=True` 与显式 `stdout=` **互斥**, 同给会抛 ValueError。
+        #   2026-09-16 实逮: 忘了这一步 → guanlan_ops.job_running() 里的 `tasklist` 每次都抛,
+        #   异常被 except 吞成 alive=False ⇒ **任何在跑的重算都被立刻改写成"被强杀"**,
+        #   页面显示完成、重算按钮重新可点(可能并发起两个重算)。
+        #   这里什么都不加就对了; 若调用方自己又传了 stdout, 让 Python 照旧抛错 (不替它吞)。
+        return kw
+    if 'stdout' not in kw:
+        s = sys.stdout
+        if s is not None and hasattr(s, 'fileno'):
+            try:
+                s.fileno()
+                kw['stdout'] = s
+            except Exception:
+                pass
+    if 'stderr' not in kw:
+        s = sys.stderr
+        if s is not None and hasattr(s, 'fileno'):
+            try:
+                s.fileno()
+                kw['stderr'] = s            # 不合并到 stdout: 让调用方自己决定 (spawn(log=) 才合并)
+            except Exception:
+                pass
+    return kw
+
+
+def spawn(cmd, log=None, env=None, cwd=None, *, new_group: bool = True, stdin_devnull: bool = True, **popen_kw):
+    """后台起一个**无窗口**子进程。`log` 给路径时把 stdout/stderr 追加进该文件。
+
+    `**popen_kw` 透传给 `Popen` —— 调用方要自己给 `stdout=`/`stderr=` 句柄时 (例如"日志由子进程
+    自己写、父进程只留 fd") 用得上; 给了就**不覆盖**它。没给则继承父进程当前的标准句柄 (见 _inherit_stdio)。
+    → Popen 对象 (拿 `.pid`)。
+    """
+    kw = dict(cwd=str(cwd or ROOT), env=env)
+    if stdin_devnull and 'stdin' not in popen_kw:
+        kw['stdin'] = subprocess.DEVNULL
+    fh = None
+    if log is not None and 'stdout' not in popen_kw:
+        log = pathlib.Path(log)
+        log.parent.mkdir(parents=True, exist_ok=True)
+        fh = open(log, 'ab')
+        kw['stdout'] = fh
+        kw['stderr'] = subprocess.STDOUT
+    kw.update(popen_kw)
+    _inherit_stdio(kw)
+    _kw(kw, new_group=new_group)
+    p = subprocess.Popen([str(c) for c in cmd], **kw)
+    if fh is not None:
+        # 父进程不一定等子进程结束; 句柄由子进程持有, 这里不要 close 掉它 —— 交给 GC/进程退出即可。
+        p._dsh_log = fh          # 仅作引用保存, 避免过早回收
+    return p
+
+
+def run(cmd, *, new_group: bool = True, **kw):
+    """前台等待的 `subprocess.run`, 但**不弹窗** (tasklist / git / 短命令都该走这里)。
+
+    ★ 文本解码请显式带 `errors='replace'`: `tasklist` 的输出是**控制台代码页**(中文 Windows = GBK),
+      而本进程可能是 PYTHONUTF8=1 起的 (默认文本编码 UTF-8) → 解码失败会让 `r.stdout` 变成 None,
+      调用方再 `str(pid) in r.stdout` 就 TypeError (2026-09-12 实逮过)。
+    ★ 未显式给 stdout/stderr 时继承父进程当前句柄 (见 _inherit_stdio) —— 否则 `CREATE_NO_WINDOW`
+      新建的控制台会把子进程输出"吸走", 日志里什么都看不到。
+    """
+    _inherit_stdio(kw)
+    _kw(kw, new_group=new_group)
+    return subprocess.run([str(c) for c in cmd], **kw)
+
+
+def run_text(cmd, **kw):
+    """`run` + text=True + errors='replace' (控制台输出来源的默认姿势)。"""
+    kw.setdefault('capture_output', True)
+    kw.setdefault('text', True)
+    kw.setdefault('errors', 'replace')
+    return run(cmd, **kw)
+
+
+def python_exe() -> str:
+    """跑脚本用的解释器 (venv 优先)。放这里是为了让调用方一处取值, 别再各写一遍。"""
+    try:
+        from src import paths as P
+        v = P.venv_python()
+        if v:
+            return str(v)
+    except Exception:
+        pass
+    return sys.executable

+ 60 - 0
app_common/common/app_common_guanlan/tabfmt.py

@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+r"""Markdown 表格渲染(**零可选依赖**)。
+
+为什么需要它(2026-09-19 实逮,一次真实事故):
+`src/windcms/report.py` 用 `DataFrame.to_markdown()` 渲染"L4 过闸谱线"表 —— 而 pandas 的
+`to_markdown` 依赖**可选包 `tabulate`**,本交付包没有它。此前 `model_run_l6.parquet` 是空的
+(六层链那两步没实现),`len(l6)` 为 0 走的是 `'无'` 分支 ⇒ **没人踩到**;2026-09-19 我把
+`model_run` 按口径重建、L6 真的有 14 行之后,重算链在 `report` 步当场炸:
+
+    ImportError: `Import tabulate` failed.  Use pip or conda to install the tabulate package.
+        at src/windcms/report.py:504  l6[cols].to_markdown(index=False)
+
+后果不是"少一张表",而是**整条重算链停在第 4 步**:后面的 ⑥重启 / ⑦本体(`ontology/objects.json` 等)
+全不执行 ⇒ 页面上"决策链/本体/报告"一片空白。教训与 memory `optional-dep-in-hot-path` 同型:
+**热路径不许依赖可选包**,何况交付包是离线整包(不能 pip)。
+
+这里用纯 Python 渲染同样的 Markdown 表(列宽按显示宽度补空格,中文按 2 列宽算)。
+"""
+from __future__ import annotations
+
+
+def _w(s) -> int:
+    """显示宽度: CJK 与全角标点算 2, 其余算 1(对齐用, 不追求像素级)。"""
+    n = 0
+    for ch in str(s):
+        n += 2 if ('\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f'
+                   or '\uff00' <= ch <= '\uffef') else 1
+    return n
+
+
+def to_md(rows: list[list], header: list[str] | None = None) -> str:
+    """[[单元格…], …] → Markdown 表(首行可作表头)。空数据返回 ''。"""
+    if not rows:
+        return ''
+    esc = lambda x: ('' if x is None else str(x)).replace('|', '\\|')      # 竖线要转义, 否则表被拆列
+    body = [[esc(c) for c in r] for r in rows]
+    if header:
+        cols = [str(h) for h in header]
+        body = [cols] + body
+    ncol = max(len(r) for r in body)
+    body = [r + [''] * (ncol - len(r)) for r in body]
+    width = [max(_w(r[i]) for r in body) for i in range(ncol)]
+    out = []
+    for i, r in enumerate(body):
+        out.append('| ' + ' | '.join(str(c) + ' ' * (width[j] - _w(c)) for j, c in enumerate(r)) + ' |')
+        if i == 0:
+            out.append('|' + '|'.join('-' * (width[j] + 2) for j in range(ncol)) + '|')
+    return '\n'.join(out)
+
+
+def df_to_md(df, cols=None, index: bool = False) -> str:
+    """DataFrame → Markdown 表(`to_markdown(index=False)` 的零依赖替代)。"""
+    if df is None or len(df) == 0:
+        return ''
+    d = df[cols] if cols else df
+    head = ([d.index.name or ''] if index else []) + [str(c) for c in d.columns]
+    rows = []
+    for idx, r in zip(d.index, d.itertuples(index=False)):
+        rows.append(([idx] if index else []) + list(r))
+    return to_md(rows, header=head)

+ 521 - 0
app_common/common/app_common_guanlan/version.py

@@ -0,0 +1,521 @@
+# -*- coding: utf-8 -*-
+"""版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
+
+为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
+改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
+
+    src/version.py            ← 唯一真源(本文件)
+    scripts/pack_dist.py      ← 读它写进 dist-manifest.json 与默认包名
+    install.ps1 / install.sh  ← 读它写进 <安装目录>/install-info.json,并与已装的比对
+    guanlan.py / 各文档        ← 读它(文档里的人工版本号以它为准)
+
+`install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
+卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
+"""
+from __future__ import annotations
+
+try:
+    from ._root import install_root as _install_root
+except ImportError:                     # 直接当脚本跑(python <本文件>)时没有包上下文
+    from _root import install_root as _install_root
+
+import datetime as dt
+import json
+import pathlib
+
+NAME = '观澜·如东样板 v2'
+VERSION = '2.11.1'         # ★ 版本只改这里
+EDITION = 'offline-single-package'
+PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
+
+INSTALL_INFO = 'install-info.json'      # 相对安装根
+SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
+SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
+
+# ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
+#   编号:  v<大版本号>.<中版本号>.<小版本号>          例: v2.5.0
+#   定义:  大版本号 —— 系统解决方案、架构或核心功能改变
+#          中版本号 —— 非核心功能新增、减少、修改
+#          小版本号 —— 消缺完善
+#   包名:  打包文件名 = app_guanlang_v<版本号>.zip   例: app_guanlang_v2.5.0.zip
+#   用法:  用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
+#          (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
+RULE = 'v<大版本号>.<中版本号>.<小版本号>'
+LEVEL_MEANING = {
+    'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
+    'minor': '中版本号 —— 非核心功能新增、减少、修改',
+    'patch': '小版本号 —— 消缺完善',
+}
+BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
+             '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
+             '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
+
+# 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
+#   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
+#   旧编号体系(0.x,未按本规则),仅作历史对账用。
+HISTORY: tuple[dict, ...] = (
+    dict(version='2.11.1', date='2026-09-22', level='patch',
+         title='模块化重构 P1:公共层九个平台件实体迁移(旧路径留兼容转发壳,零行为变化)',
+         note='重构推进(源码组织层)⇒ 小版本 +1。落地: '
+              '① 九个平台件(paths/version/logfile/proc/entry_refs/console/opsjob/derived_manifest/tabfmt)'
+              '实体迁入 app_common/common/app_common_guanlan/; '
+              '② 安装根推算由 __file__.parents[1] 改为 _root.install_root()(按 configs/ 与 guanlan.py 标记'
+              '向上查找, 位置无关; 移深三层后仍算得对); '
+              '③ 旧路径 src/<件>.py 变**兼容转发壳**(sys.modules 别名, 含私有名)⇒ '
+              'from src import paths as P / from src.proc import NO_WINDOW / importlib.import_module(src.version) '
+              '三种写法全部照旧, 全仓约 120 处引用无需一次性改完; '
+              '④ config_audit 的「配置取用口豁免」由按旧路径判改为**按取用口所在文件**判(位置无关), '
+              '以后 paths.py 再搬也不会误报 R6。'
+              '验证: 旧路径导入与安装根实测正确(P.ROOT/P.RAW_ROOT/P.store/P.config 全对, 模块身份同一); '
+              'raw_scan --check rc=0、rebuild_all --dry-run rc=0(23 步计划不变)、三份交付文档重渲通过; '
+              'guanlan.py check、config_audit、pages_audit、chain_gap、version_log、detail_deps、'
+              'check_portability、module_boundary_audit 全 rc=0。'),
+    dict(version='2.11.0', date='2026-09-22', level='minor',
+         title='源码模块化重构 P0:七个模块目录 + 接口层 + 模块边界门(每模块只经 api 调用,可插拔)',
+         note='源码组织层重构 ⇒ 中版本 +1(运行形态、交付形态与核心功能未变)。'
+              '用户令: 「对观澜的源代码,按算法、数据接入管理、前端、后端 等系统模块进行重构,每个模块一个目录;'
+              '遵循组件化、高内聚低耦合、可复用、可扩展(支持热插拔)、兼容性、性能优化、高可用、分布式、'
+              '面向对象、代码精简;系统组件: TiDB community、MinIO、Redis、Nginx」。'
+              '本轮按用户选择执行:**只重构目录与接口**(暂不引入四个组件,只留接入点)、'
+              '**零行为变化、逐版本可回滚**。落地: '
+              '① 七个模块目录(每模块一个目录): app_common(公共层)/ app_ETL(数据接入管理)/ '
+              'app_algorithmModel(算法)/ app_ontology(本体与知识层)/ app_backEnd(后端)/ app_frontEnd(前端)/ '
+              'app_qualityGate(质量门与审计 + 打包安装);每个模块下 `common/app_<模块>_guanlan/` 放观澜实现,'
+              '`api.py` 是**唯一对外公开面**(PEP 562 惰性转发到既有实现,因此行为与重构前一致)。'
+              '② `configs/modules.yaml` 模块登记表(职责/允许依赖/实现落点/迁移阶段/组件接入点)。'
+              '③ 新增 `scripts/module_boundary_audit.py` 并接入 `guanlan.py check`: 查结构齐、模块间只经 api、'
+              '依赖方向合规(不得反向成环)、公共层不依赖业务模块、api 转发目标真实存在;并统计迁移进度。'
+              '④ 四个系统组件的接入点先立**接口契约**(无实现,默认仍走本地/进程内): '
+              'TiDB community ↔ app_ETL 的标准仓读写接口、MinIO ↔ 源件与产物对象存储接口、'
+              'Redis ↔ app_backEnd 的按时间窗缓存与作业状态接口、Nginx ↔ 统一入口与路由表接口。'
+              '⑤ 设计说明新增「源码模块化布局与边界」一节(模块表 + 目录树 + 边界规则 + 分阶段迁移 P1–P7),'
+              '方案全文见 `docs/重构方案_模块化_v0.1.md`。'
+              '⑥ 三份交付文档随版本号改名重渲(内容口径不变)。'
+              '验证: 边界审计 rc=0;旧路径(src/**、scripts/**)与页面/CLI/产物零变化;'
+              '`guanlan.py check`、配置统一、页面归口、反向呼应、可移植性、可转移、文档三检 全绿。'),
+    dict(version='2.10.4', date='2026-09-22', level='patch',
+         title='数据要求说明补测点:安全链与数字输入、执行器与热管理、计数账、CMS 采集参数、位号字典',
+         note='纯交付物修订 ⇒ 小版本 +1。用户令: 「测点遗漏体检的方案补进数据要求说明」。'
+              '体检口径(六组真源): 现场件表头 595 个 10 分钟通道(9 前缀)· 标准仓契约 164 登记项(10 中文域)·'
+              '窄仓 48 列 · 1 分钟转发层 76 列 · CMS 振动 71 项 · 状态与告警与工单与整定值 44 字段;'
+              '逐列判定(参考/覆盖/缺失/待判): 595/325/151/119、164/117/31/16、48/46/0/2、76/61/11/4、71/50/19/2、44/33/6/5。'
+              '三块结构性遗漏: ① din_ 数字输入 93 列几乎整片未登记(烟雾/急停/外部停机/UPS/油位/滤芯/接地刀闸/能见度);'
+              '② dot_ 执行器 69 列整片为空(泵/阀/加热器/冷却器/通风),而文档把「热链与冷却」列为支撑功能;'
+              '③ cnt_ 计数账 69 列只登记 6 条(外部错误小时= IEC 责任剥离并列账、OK 与可用小时、各类停机小时、寿命小时)。'
+              '补齐内容: 10 分钟节新增「数字输入与安全链」「执行器命令与热管理」两域,扩充计数与统计及 tur/flg/int/prs/grd;'
+              '秒级节补 11 条;CMS 节新增「采集参数与溯源要求」小节(键相/转速脉冲、窗函数、采样频率、量程与过载、'
+              '序列号与配置、触发时刻、标称频率等 —— 没有键相无法做真阶次跟踪,窗函数在振动侧两侧都缺);'
+              '第 5 章增「位号释义」质量要求;第 8 章收资清单增「测点与位号字典」一项'
+              '(119 条待判通道没有位号释义就永远只能存而不解)。'
+              '口径不变: 只写中文业务含义名 + 度量单位 + 必须性,不出现英文列名/文件名/落盘位置/机组编号/样本场现状;'
+              '单位只取自已取证口径(canonical 词典 unit_majority、机型契约 unit),查不到写「未取证」;'
+              '中文业务名取自 src/windscada/i18n.py 的 STEM 中译(源西门子 WTC-3 IO 表)与产物列名。'
+              '验证: 严格模式(英文标识/扩展名/路径/机组编号/现状字样)0 命中;去标识化 0 命中;'
+              '21 类逐类仍有四列测点表;表号引用闭合;guanlan.py check 全绿;三份 docx 重渲到 2.10.4。'),
+    dict(version='2.10.3', date='2026-09-22', level='patch',
+         title='交付件定名:设计说明改为「系统设计说明」(与需求分析、数据要求说明命名对齐)',
+         note='纯交付物修订(文档内容未变,只改交付件名称与随之而来的引用)⇒ 小版本 +1。'
+              '用户令: 「设计说明_观澜 word 文档,改名为:系统设计说明_观澜_[版本号].docx」。做法: '
+              '① 渲染器的交付件定义改名 —— `docs/设计说明_观澜_<版本>.docx` → '
+              '`docs/系统设计说明_观澜_<版本>.docx`(封面标题、页眉、markdown 源件名一并改为「系统设计说明」);'
+              '② 三份稿里指向该交付件的交叉引用与「编写依据」路径同步改名(`《设计说明_观澜_…》`→'
+              '`《系统设计说明_观澜_…》`、`docs/src/设计说明_观澜_….md`→`docs/src/系统设计说明_观澜_….md`),'
+              '以及"三份交付文档(需求分析、设计说明、数据要求说明)"这类并列清单;'
+              '③ **仓库内的内部文档 `docs/系统设计说明.md` 不受影响**(替换模式带 `_观澜_` 或书名号,'
+              '不会误伤同名内部件);④ 三份 markdown 源件随批次改名到 2.10.3,正文"当前版本"口径'
+              '(系统版本行、包名、本文版本、变更级别、版本表新增 2.10.3 一行、HISTORY 计数 15 条)同步更新,'
+              '历史行保留。文档内容与图表未变。'
+              '**2026-09-22 用户复核补记**: 用户对交付的《数据要求说明》做了**一处口径修改** —— 秒级 SCADA 的采样频率由「1 秒或 5 秒(二选一)」放宽为「**1 秒至 30 秒之间**」(共 7 个落点: 第 3.2 节正文、第 5 章质量要求、数据分类总表、必须性分档表、收资 21 项对照表、面向现场的收资清单、附录 B 收资原文第 2 项)。已按「相关内容以后照此描述」并入源件,并把该口径写进 docs/系统设计说明.md §17 的描述口径清单;附录 B 的标题与说明同步改为「第 2 项采样频率按现场最新口径」,不再声称逐字。**验证**: 三份 markdown 与 docx 重渲染后 `delivery_docs_build --verify` rc=0(目录/页码域、'
+              '去标识化、数据稿去实现细节、表号引用闭合);新文件名含当前版本号,`guanlan.py check` 的'
+              '「交付文档三件」门按新名核验通过;包内只带 2.10.3 三份 docx,无同名旧版残留。'),
+    dict(version='2.10.2', date='2026-09-22', level='patch',
+         title='数据要求说明改成纯数据需求规格(去现状/去实现细节)+ 新增 SCADA 秒级数据要求',
+         note='纯交付物修订 ⇒ 小版本 +1。用户令: 「数据要求说明不要体现样本场的接入情况、英文测点名称、'
+              '机组名称、文件名称、落盘位置,只体现测点真实业务含义名称;每类数据的测点以列表呈现'
+              '(测点/字段名、业务含义、度量单位、是否必须);增加 SCADA 秒级数据的要求」。做法: '
+              '① **删掉一切样本场现状**: 到位情况/缺口/催缴状态、件数体量、行数、时间覆盖、日期全部不再出现'
+              '(该文只写"对数据的要求",不描述任何具体场站);机组范围改写为「全场机组(按场实际台数)」。'
+              '② **去实现细节**: 正文与表格不再出现英文测点名(snake_case)、文件名与扩展名、目录与落盘位置、'
+              '机组编号;测点一律用中文业务含义名称(有功功率、齿轮箱油温、机舱风速…),单位取自'
+              '`configs/canonical/dictionary.yaml` 的 unit_majority 与机型—场站契约的 unit,查不到就写「未取证」。'
+              '③ **测点列表化**: 每类数据一节 + 一张四列表(测点/字段名 | 业务含义 | 度量单位 | 是否必须,'
+              '分级只在必须/建议/可选三档),另加"测点汇总索引"章。'
+              '④ **新增 SCADA 秒级数据要求**: 1 s 或 5 s、至少 3 个月、全场全测点,并给出必需测点清单'
+              '(功率、风速风向、发电机/主轴/叶轮转速、三叶桨距与一致性、发电机绕组与齿轮箱温度、机舱与环境温度、'
+              '偏航角度与压力、液压与蓄能压力、运行状态位、限电与功率给定、振动触发量…),写清它与 10 分钟数据、'
+              '故障录波、机舱振动数据的分工与配合。'
+              '⑤ **机器硬门**: `delivery_docs_build.py` 为该文加 `strict_terms` 检查(snake_case 标识/文件扩展名/'
+              '目录路径/机组编号/现状字样),`--check`(扫源件)与 `--verify`(扫渲染后的 docx)命中即 FAIL;'
+              '该文不再配插图(原四张是样本场现状图,按用户令不再体现),图表器移除 dat 组与对应四张 PNG。'
+              '验证: 新稿 markdown 与 docx 对上述各类 0 命中,21 类每类都有四列测点表,秒级节含测点表;'
+              '`guanlan.py check` 全绿;三份 docx 重渲(req/des 内容不变,仅版本号与文件名随批次升到 2.10.2)。'),
+    dict(version='2.10.1', date='2026-09-22', level='patch',
+         title='交付文档对外版:去样本场标识(全文不体现具体风电场)+ 新增多场适用性章节',
+         note='纯交付物修订(系统功能未变)⇒ 小版本 +1。用户令: 「修改三份文档,内容参考如东风电场,'
+              '但不体现如东风电场,且具有不同风电场适用性」。做法: '
+              '① **去标识化**(中文与罗马化形态一并去): 场名/业主/地域/OEM/第三方机构/系统名里的样本场代号'
+              '一律换成中性表述(样本风电场 / 本场 / 业主单位(从略)/ 主机制造商(OEM,名称从略)/ '
+              '4.0 MW 级海上机组(机型代号从略)/ 观澜 v2(风电场智能分析系统));'
+              '**路径写成模板**(`data/raw/<场站>/…`、`outputs/<场>/…`、`reference/<场>/…`、'
+              '`configs/contracts/<机型>_<场>.yaml`、`configs/farms/<场>.yaml`、`WINDSCADA_FARM=<场>`),'
+              '并在「编写依据」表下写明占位符替换规则;实测数字与结论全部保留,标注「样本场实测(2026-09-22)」。'
+              '② **多场适用性**(用户令第二句): 需求分析新增第 11 章(FR-44…FR-52 + NFR-12: 场配置化、'
+              '机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、'
+              '换场验收检查表);设计说明新增第 16 章(场抽象层与配置 schema、场无关引擎 vs 场相关参数分层表、'
+              '换场作业单与检查表、接新 OEM/新数据形态的扩展点、换场会失效的假设如实列);'
+              '数据要求说明新增第 12 章(通用必选/可选判定规则、场配置字段对照、数据源形态适配、'
+              '换场收资差异清单、按场裁剪步骤)。'
+              '③ **去标识化做成机器可查的硬门**(不靠人自觉): `scripts/delivery_docs_build.py` 增 `FORBIDDEN` '
+              '词表并在 `--check/--verify` 里逐份扫描(命中即报 FAIL),`guanlan.py check` 的「交付文档三件」'
+              '门同步带上该扫描;图表器里出现的样本场字样一并中性化(图题写「样本场实测」)。'
+              '验证: 三份 markdown 源件与 docx 对 12 个禁用词 0 命中;`delivery_docs_build --verify` rc=0;'
+              '`guanlan.py check` 全绿;三份 docx 与插图重渲,图表数字仍与正文同源(图解析文档表)。'),
+    dict(version='2.10.0', date='2026-09-22', level='minor',
+         title='交付文档三件(需求分析 / 设计说明 / 数据要求说明)与源代码化生成器',
+         note='非核心功能新增(交付物)⇒ 中版本 +1。用户令: 「整理观澜的需求/设计/数据接入,各写一份 Word 文档,'
+              '要求区分章节目录、文表图并茂、字体字号分类统一;数据要求说明结合现场《数据分析收资要求-v3.docx》」。'
+              '① 新增三份交付文档(落 `docs/`,文件名带本版本号): '
+              '`需求分析_观澜_2.10.0.docx`(需求来源与演进、角色场景、功能需求 FR、非功能需求 NFR、'
+              '页面与信息架构、验收门、需求跟踪矩阵)、'
+              '`设计说明_观澜_2.10.0.docx`(总体架构、目录与路径真源、重算链、判级与算法、时间窗口径、'
+              '服务与前端、本体与模型、运维编排、安装与版本、质量保证、安全与边界、可移植性)、'
+              '`数据要求说明_观澜_2.10.0.docx`(数据分类总表、逐类要求、核心测点/字段/单位/必须性、'
+              '质量与对齐、落位流程、收资要求 v3 的 21 项逐条对照、缺失与替代、核对锚点、面向现场的收资清单); '
+              '② 两份源件均**源代码化**(用户令「所有的计算均要形成观澜的源代码」): '
+              '`scripts/delivery_docs_build.py` 把 `docs/src/*.md` 渲染成 docx(Word 域目录 + 标题/正文/表格/图题'
+              '四级字体字号分类统一: 黑体标题 + 宋体正文小四 + 表格五号 + 图题五号居中, 页眉页脚页码, 附录排版规范); '
+              '`scripts/delivery_docs_figures.py` 生成 14 张插图, 每张图的数字都从真件取: `src/version.py` 的 HISTORY、'
+              '`configs/portal_pages.yaml`、`configs/serve.json`、`data/raw/<场>/**`、`outputs/<场>/**`、'
+              '并落 `guanlan.py check` 与 `rebuild_all.py --dry-run` 的实跑底稿到 `docs/src/_*.txt`(可复核); '
+              '③ 文档口径遵循用户令 §17: 含"窗"且指时间窗口写全"时间窗"、影响机组写"影响机组数"、'
+              '英文简写写"中文(英文简写)"(平均无故障间隔(MTBF)/平均停机间隔(MTBO)/单次停机时长(MDT)); '
+              '④ 不确定与缺件一律如实写(测风塔零交付、m5_cms_tcm 正本缺失由观澜自算件顶上、故障录波仅 4 台、'
+              '远端未装 Ollama),不编造; 每份文档附「编写依据」表逐章列出源文件; '
+              '⑤ `guanlan.py check` 增一行「交付文档三件 · v<版本>」: 校验三份 docx 在位**且文件名含当前版本号**、'
+              'markdown 源件与所引插图都在位 —— 否则升一次版本号, 旧文档名就悄悄过期(同名旧版会跟着进包); '
+              '★2026-09-22 用户令「三份文档不同步到远端服务器」⇒ 该行先看 `docs/src` 在不在: 不在 = 本机是'
+              '**不随文档的部署**(远端演示机即此),只报提示行、不 FAIL,且在 import python-docx 之前分流'
+              '(那台机未装该依赖,直接进渲染器会炸成 ModuleNotFoundError)。'),
+    dict(version='2.9.2', date='2026-09-22', level='patch',
+         title='升级后验收补缺:版本/文档漂移有了自动拦截点(重算链增 ⑧d 版本记录门)',
+         note='纯消缺 ⇒ 小版本 +1。检查: 远端 2.9.1 升级 + 重算完成后跑 `guanlan.py check`,'
+              '出现两处 FAIL —— ① `docs/版本记录.md` 与 `src/version.py` 不一致(version_log rc=6);'
+              '② 安装根多出 `_pre290_backup/`(16 件扁平旧副本)让 config_audit rc=8。'
+              '根因: 这次升级的差异盘点只覆盖 `src/`+`scripts/`+`configs/`(`remote_src_inv.py`),'
+              '**文档从不在盘点面上** ⇒ 代码升到 2.9.1、文档留在旧版(版本记录 13,277 vs 21,284 字节、'
+              '系统设计说明缺 §17);而**重算链里没有版本门**(rebuild_all 只到 ⑧c 页面归口审计),'
+              '于是 24/24 步全 OK、控制台写"重算完成",漂移照样静默过关 —— 没有任何自动拦截点。'
+              '解决: ① `scripts/rebuild_all.py` 增「⑧d 版本记录一致性」(`version_log.py --check`,'
+              'tolerate=(6,) —— 与 ⑧c 同口径: 缺的是"文档同步"这一路,报出来但不打断整条链);'
+              '② 远端补齐 4 份文档(版本记录/系统设计说明/输入数据放置指导/detail 页面依赖台账),'
+              '逐件 sha256 与本地一致;③ 远端把 `_pre290_backup` 移出安装根到 '
+              '`D:\\产品\\_pre290_backup_20260921`(备份保留,不删除)。'
+              '验证: 远端 `version_log.py --check` rc=0、`config_audit.py` rc=0;'
+              '本地 `guanlan.py check` 全绿 + 反向审计/页面归口/chain_gap 全 rc=0。'
+              '遗留(如实记录,未处理): 远端**没有装 Ollama**(无二进制、PATH 无、11434 拒绝连接)'
+              '⇒ 本机模型探针在远端必然 FAIL,问答/升档类功能在远端不可用;'
+              '要不要在远端装 Ollama 与拉哪几档模型,等用户定。'),
+    dict(version='2.9.1', date='2026-09-22', level='patch',
+         title='人工测试 7 项消缺(描述口径 + 依据空白 + 问题页空 + 门户链接 404 + 闭环文案 + Ollama 档位)',
+         note='纯消缺(无功能增减)⇒ 小版本 +1。逐项「检查→根因→解决→验证」: '
+              '① 描述口径: 「窗」确实指时间窗者一律写「时间窗」(预设窗→预设时间窗、判级窗→判级时间窗、'
+              '窗内→时间窗内、证据窗→证据时间窗、随所选窗→随所选时间窗…;天气窗/作业窗/预览窗/观测窗'
+              '属领域词,保持原样);「影响台」→「影响机组」(含报告构建器表头);英文简写改「中文(英文简写)」'
+              '(平均停机间隔(MTBO)/平均无故障间隔(MTBF)/单次停机时长(MDT)),英文映射键同步; '
+              '② 需要关注「查看完整依据」空白: 根因=行的来源是**融合面链盘**(d.fus.链盘.rows),依据却只从 '
+              'SCADA 的 d.watch 查 ⇒ 两边机组集合不同时 why[t] 为空(实测 WTG09 齿轮箱 bad 台)。'
+              '改为按本行字段自组句(部件/链路进度/卡在哪步/证据源),永不空; '
+              '③ 报警机组「该问题页」打开空白 + ⑤ 命中系统链接: 根因=链接传**slug**(pitch)而页面/接口按'
+              '**中文系统名**取数 ⇒ /api/problem 返回 0 条,页面还写"该系统在判级窗内无非常态"(对报警机组是假陈述)。'
+              '改为: 链接传中文名 + 服务端 `sys_norm()` 双向归一(slug/中文/大小写都认)+ 页面区分'
+              '"系统标识无法识别 / 本时间窗无异常"两者,绝不把认不出说成正常; '
+              '④ /turbine 页「返回观澜门户」→ 原写死 `http://127.0.0.1:18084/观澜_如东样板_门户_单文件.html`'
+              '(用户机器上等于访问自己的 loopback,且该文件服务端没有)⇒ 网关回 not found。'
+              '修法: 用根相对 `/`,并给**网关加 `data-abs="1"` 豁免**(作者显式声明某链接不按组件前缀改写,'
+              '否则根相对链接一律被改写成 /detail/… 永远回不到门户),普通链接前缀行为不变(有单测); '
+              '⑥ 振动「端到端闭环」文案: 去掉内部黑话 handoff,改为'
+              '「暂无端到端闭环证据:现场提交的振动数据包里没有「报警→检修→复测」的成对记录」; '
+              '⑦ 观澜↔本机 Ollama: 链路实测可用(探针 OK、/api/ask 提交成功、8B 出答但**未过校闸**→触发升档),'
+              '但升档目标**硬编码**在 `src/ontology/fast_agent.py` 的 `ESCALATE`(写的是本机没装的 `qwen3.8:27b`,'
+              '`configs/models.json` 只是同处笔误的另一份)⇒ 升档 digest=None、ask 终态 error(用户只看到"复核中")。'
+              '修法: ESCALATE 改 `qwen3:32b` + 新增 `installed_models()`/`escalate_target()` —— 升档目标按'
+              '「配置档位且**确已安装**」解析,没装则**响亮回落**并打日志,不再把答案静默卡死;'
+              '初答文案里的"27B"改为实际目标名;BUDGET_S 随档位更新(32B 给 120 s)。'
+              '验证: `escalate_target("qwen3:8b") → qwen3:32b`(本机实装 4 档实测);'
+              '另: 远端 106.120.102.238 与本地各跑一遍 HTTP 抽样(门户链接=/、slug 归一出 1 条问题、'
+              '文案含时间窗/影响机组/(MTBO)、闭环新文案、WTG09 依据不再空),网关改写 `data-abs` 豁免单测通过,'
+              'config_audit/pages_audit/反向审计/raw_scan selftest 全 rc=0,`guanlan.py check` 全绿'),
+    dict(version='2.9.0', date='2026-09-21', level='minor',
+         title='时间窗真正生效(用户令):「部件问题」与「发电性能」随所选窗重算,并新增自定义起止日期窗',
+         note='功能增减改 ⇒ 中版本 +1。要点: '
+              '① 窗口词表新增**自定义起止日期**(`YYYY-MM-DD~YYYY-MM-DD`,**含两端**):'
+              '`months_of()` 取相交月、新增 `win_range()/in_win()` 供日粒度件做含端过滤;'
+              'v2 顶栏加两个日期输入框 + 应用(校验 a≤b),月度件按所跨月取整、日粒度件按日精确,页上写明; '
+              '② 判级四轴按窗重算:`taxonomy.system_matrix(span=…)` 把窗透到 变桨(日粒度件切片, 精确)/'
+              '偏航/蓄能/温度(走新窄仓重算);`reliability.overview(span=…)` 让部件可靠性表按窗;'
+              '温度面的 ref/ref_sm 对照窗**仍固定**(季节解耦基线,跟着漂就失去对照意义); '
+              '③ 发电性能按窗:`/api/curves?win=` 七镜头按窗重算 + 7 张月度时序图按窗过滤;'
+              '选到 2025-07~2025-12(限电前干净判别窗)时直接用正式产物不重算;'
+              '④ 新产物 `windscada/slim10min/*`(公共列子集,不裁剪行)作**按窗重算底座**:'
+              '一次全量扫≈2 分钟,把"每换一个窗重读 15 GB"降到秒级;已进重算链 ③b 并登记反向审计族表; '
+               '④b **M9 控制参数一致性**(就摆在发电性能页上)此前仍钉死 2025-H2 ⇒ `control.registry(span=…)` 按窗重算'
+               '(窄仓补 `grd_wtc_ActPower_max`/`tur_wtc_GenRpm_max` 两列 ⇒ 48 列;≈0.6 s/窗,P封顶中位 4190(干净窗) → 4181.7(2026年) / 4177.5(2026Q1)),'
+               'fleet 响应加 `m9_pending/win_pending` 并由页面轮询(5 s)—— 至此判级四轴 + 可靠性 + 控制参数 + 七镜头 + 7 张月度图**全部随窗**;'
+               '远端两窗对比只剩 all_months/note/*_pending 三个元数据键不变; '
+              '⑤ 按窗重算一律"后台算+进程缓存"(判级≈25 s/窗、镜头≈10~45 s/窗):命中即回,'
+              '未命中先回 `pending/building` 并由页面轮询,**不静默拿旧口径当新窗**;'
+              '启动时后台预热 6 个预设窗(判级+默认窗镜头); '
+              '⑥ 实测(本机 38 台): 判级逐系统报警台数随窗变(变桨 15/13/10、偏航 15/11/5、齿轮箱 7/4/4 对应'
+              '2026年/2025H2/2026-07),曲线图注窗=所选窗且样本量随窗变;验收: 反向审计 3548 件 0 未归类、'
+              'pages_audit/detail_deps/config_audit/raw_scan(selftest,rc=0)/chain_gap 全绿;'
+              '⑦ 远端部署(D:\\产品\\app,2026-09-21)实逮两处并已修: (a) 预热与请求各自起线程 ⇒ 同时算两份'
+              '按窗中间帧,改为**重活全局串行**(`_HEAVY_LOCK`)并打印可用内存;(b) 退化窗("近30日"=2026-09'
+              '只有 1 个节拍 ⇒ 比档件为空)时 `groupby().apply()` 返回空表 ⇒ `.rename("dev_K")` 抛 TypeError 把'
+              '整面判级打断,改为**如实"不可判(样本不足)"**;另: 远端进程须由 Windows 服务 `guanlan` 托管'
+              '(SSH 会话里起的进程会随会话关闭而死,实测全端口消失 ⇒ 已装服务并 RUNNING),'
+              '远端核验: fleet 两窗 pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、'
+              'curves 图注"窗 2026-07-01 ~ 2026-07-31 (随所选窗)"、v2 页含自定义起止控件'),
+    dict(version='2.8.2', date='2026-09-21', level='patch',
+         title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
+               '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'
+               '(`<场站>/scada_10min_<YYYYMM>.csv`)被当成"未归类";两者每次扫描都计一次变化 ⇒ '
+               '`raw_scan --check` 永远 rc=4,`rebuild_all.py --auto` 与 raw_watch 会**每轮都以为来了新数据**',
+         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
+              '① `CONSUME` 新增 `upstream` 口径(信息级):类目库是 10min 同台补充件的**上游**,'
+              '取数层不直接读它 ⇒ 不再算缺口、不计变化,改按"上游归档件(要先转换才被消费)"列出并写清转换口径; '
+              '② 新增 `IGNORE_PATTERNS`:`scada_\\d+min_\\d{6}\\.csv` 是按月提取/核对件(位置由用户指定、'
+              '不是摄入源)⇒ 不报未归类、不计变化;'
+              '③ 实测(2026-09-21 落位 7-8 月交付后):修前 `--check` rc=4 且每轮都报"2 处变化",'
+              '修后 `--check` rc=0「与上次快照一致,没有新数据」,`--selftest` 通过,24 件类目库如实以信息级列出; '
+              '④ 口径写进 `docs/输入数据放置指导_v0.1.md` §4.1(含 12 组中文目录↔类目码、9 类↔主件 595 通道一一对应、'
+              'float32+ISO 对齐、以及"类目库→同台补充件"的落位转换)'),
+    dict(version='2.8.1', date='2026-09-20', level='patch',
+         title='消缺:重算链末步「台账等价验收」在全新机器上必然 rc=5(交付包不含产物 ⇒ 没有随包基线),'
+               '原先把它判成失败 ⇒ 门户上写"重算失败"(实测服务器 2.8.0 一轮 24/24 步全 OK 却整轮 rc=1)',
+         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
+              '① `rebuild_all.py` 的 ⑧ 台账等价验收由 `tolerate=(4,)` 改成 `tolerate=(4, 5)` —— '
+              'rc=5 = "找不到随包基线 ⇒ 没做验收",与 ④ 的口径对齐(④ 一直容忍 5 并写明"跳过等价验收≠通过");'
+              '步名与步说明都改写清楚"rc=4 = 有需人工看的差异 / rc=5 = 没基线 ⇒ 没验收(不是失败,也不是通过)",'
+              '并指路 `scripts/set_baseline.py`(用已核实的当前产物快照登记基线,之后这一项才会真比对); '
+              '② 远端复核(服务器 D:\\产品\\app): 该轮 24 个实质步骤全绿(①b 扫描 53.7s · ② 164s · ③ 1025s · '
+              '④ 99s · ④b 2118s · ④c 385s · ④d 41.6s · ④e 15.3s · ⑤b/⑤c/⑤a/⑤ · ⑥ · ⑦×6 · ⑦b 78s · ⑧ 7.1s · '
+              '⑧b 3s · ⑧c 9.5s),只有第 25 步判失败;且同批数据已真进产物:`temp_monthly` 21 个月、末四格 '
+              '2026-06/07/**08**/09、2026-08 = 1,026 行(服务器 `scada_10min` 也有 76 件含 38 个 `WTG??-B2.csv` '
+              '同台补充件,说明步骤 B 的同台多件合并已在服务器上生效); '
+              '③ 已把修好的 `scripts/rebuild_all.py`(+ `src/version.py` / `docs/版本记录.md`)单文件部署到服务器,'
+              '无需重装整包。交付包 app_guanlang_v2.8.1.zip'),
+    dict(version='2.8.0', date='2026-09-20', level='minor',
+         title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
+               '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
+         note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
+              '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
+              '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
+              '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
+              'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
+              '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
+              '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
+              'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
+              '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
+              '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
+              '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
+              '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
+              '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
+              '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
+              '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
+              '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
+              '**最新的变化**算」逐条查证并补了三处: '
+              '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
+              '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
+              '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
+              '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
+              '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
+              '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
+              '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
+              '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
+              '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
+              '要旧行为用 `--reimport-as-new`)。'
+              '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
+              '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
+              '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
+              '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
+              '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
+              '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
+              '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
+              '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
+              '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
+              '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
+              '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
+              '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
+              '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
+              '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
+              '交付包 app_guanlang_v2.8.0.zip'),
+    dict(version='2.7.0', date='2026-09-19', level='minor',
+         title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
+               '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',
+         note='一次消缺 + 一项非核心功能新增(对外监听)⇒ 按规则取最高一级(minor)。要点: '
+              '① ★远端实逮(服务器 D:\\产品\\app):索引 719 s + 谱 894 s 落进 windows\\_staging_ingest 后,'
+              '`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问`(360 实时扫描/索引器持着'
+              '刚写的 1710 件小文件的句柄,Windows 的目录改名要求树里无打开句柄)⇒ ④b 失败 ⇒ 链停在第 4 步,'
+              '页面表现为「需要关注 0 台 + 融合面缺件」。修法:发布改 `_move_tree()`(整目录 rename 退避重试 '
+              '→ 逐件搬,rename 不行就 copy+删 → 仍锁住的逐条如实报出);并加 `--publish-only`:已算完的暂存窗'
+              '可直接发布,不必白等 25 分钟重跑索引/谱; '
+              '② 用户令「观澜改为监听所有 IP」:`configs/serve.json` 增 `public_host`(空=跟 host=只本机;'
+              '设 0.0.0.0=所有网卡),**只有门户网关**用它,detail/cms/viewer/sim 仍绑 127.0.0.1 ⇒ 对外只有 '
+              '28084 一个入口;启动时打印对外地址并提示"页面无鉴权,请在防火墙侧限来源"; '
+              '③ 消缺: 新生成端在 GBK 控制台打印 m/s² / ⇒ 时抛 UnicodeEncodeError(远端 baseline_38 整件没出)'
+              '⇒ 四个生成端加 stdout 守卫;trend_ingest 的"台账止 2024-11"过期断言改实话;'
+              'CMS 三层基线卡的"健康期窗 N"重复措辞; '
+              '④ 部署口径: 用 SSH 起的服务在会话断开时会被一起收掉 ⇒ 现场改注册 Windows 服务(`guanlan`)'
+              '(`scripts/service_ctl.py install`),开机自启、SCM 崩溃重启。交付包 app_guanlang_v2.7.0.zip'),
+    dict(version='2.6.0', date='2026-09-19', level='minor',
+         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
+               '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
+               '交付包带输入数据目录结构(不含文件)',
+         note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
+              '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
+              '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
+              '② 融合面 handoff 与总览页 windscada/index.html 由观澜自算生成(原为"包内无生成端",'
+              '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
+              '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
+              '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
+              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
+              '重建并逐分片对拍); '
+              '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
+              '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
+              '现场确诊的 19# 单独拎出来); '
+              '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
+              '落成生成端(窗数不足时如实空表并写明数据边界); '
+              '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
+              '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
+              '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
+              'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
+    dict(version='2.5.0', date='2026-09-17', level='minor',
+         title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
+         note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
+              '交付包 app_guanlang_v2.5.0.zip'),
+    dict(version='0.4.0', date='2026-09-17', level='legacy',
+         title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
+               '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
+         note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
+    dict(version='0.2.0', date='2026-09-01', level='legacy',
+         title='含产物的旧交付基线(历史上用作"包内无生成端"那批产物的补救源;该用途 2026-09-19 起已失效)',
+         note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip —— ★2026-09-19 用户令从工作树'
+              '清理(943 MB):① 「所有的计算均要形成观澜的源代码」落地后无生成端件 = 0(反向呼应审计 '
+              '1,800 件全部 raw-derived)⇒ 它作为"补救源"的用途消失;② 该包仍留在 git 历史里'
+              '(blob 可随时 git 取回),需要时用 git show 恢复即可'),
+)
+
+
+def package_name(version: str | None = None, ext: str = 'zip') -> str:
+    """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
+    return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
+
+
+def level_of(old: str, new: str = VERSION) -> str:
+    """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
+    a, b = _parts(old), _parts(new)
+    if a == b:
+        return 'same'
+    if len(a) < 3 or len(b) < 3:
+        return '?'
+    if b[0] != a[0]:
+        return 'major'
+    if b[1] != a[1]:
+        return 'minor'
+    return 'patch'
+
+
+def rule_lines() -> list[str]:
+    """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
+    return [f'编号: {RULE}   例: v{VERSION}',
+            *[f'  {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
+            f'包名: 打包文件名 = {package_name()}']
+
+
+def info_path(root: pathlib.Path) -> pathlib.Path:
+    return pathlib.Path(root) / INSTALL_INFO
+
+
+def read_installed(root: pathlib.Path) -> dict | None:
+    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
+
+    ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
+      (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
+      于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
+    """
+    p = info_path(root)
+    if not p.is_file():
+        return None
+    try:
+        d = json.loads(p.read_text(encoding='utf-8-sig'))
+        return d if isinstance(d, dict) else None
+    except Exception:
+        return None
+
+
+def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
+    """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
+    root = pathlib.Path(root)
+    rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+               python=python or '', mode=mode, host=str(root))
+    rec.update(extra or {})
+    info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
+    return rec
+
+
+def _parts(v: str) -> tuple:
+    out = []
+    for seg in str(v).strip().lstrip('vV').split('.'):
+        num = ''.join(ch for ch in seg if ch.isdigit())
+        out.append(int(num) if num else 0)
+    return tuple(out)
+
+
+def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
+    """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
+
+    → dict(state, installed_version, pkg_version, message, action)
+       state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
+              newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
+       action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
+    """
+    cur = (installed or {}).get('version') or ''
+    if not installed:
+        return dict(state='none', installed_version='', pkg_version=pkg_version,
+                    message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
+    if not cur:
+        return dict(state='unknown', installed_version='', pkg_version=pkg_version,
+                    message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
+                            f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
+    if _parts(cur) == _parts(pkg_version):
+        return dict(state='same', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
+    if _parts(cur) < _parts(pkg_version):
+        return dict(state='older', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
+    return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
+                message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
+                        f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
+
+
+def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
+    """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
+    out = [cmp_res['message']]
+    if installed:
+        out.append(f"  现有安装: 装在 {installed.get('installed_at', '?')}"
+                   + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
+                   + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
+    return out
+
+
+if __name__ == '__main__':
+    import sys
+    print(f'{NAME} v{VERSION} ({EDITION})')
+    print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
+    root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else _install_root(__file__)
+    ins = read_installed(root)
+    c = compare(ins)
+    print(f'安装记录: {info_path(root)}')
+    for ln in summary_lines(c, ins):
+        print('  ' + ln)
+    print(f"  → state={c['state']} action={c['action']}")

+ 2 - 2
configs/modules.yaml

@@ -20,8 +20,8 @@ modules:
     cn: 公共层
     resp: 路径真源/版本真源/日志/进程/入口引用/控制台输出/运行态作业文件/产物派生台账
     allow: []                         # 公共层不依赖任何业务模块
-    impl_now: src/{paths,version,logfile,proc,entry_refs,console,opsjob,derived_manifest,tabfmt}.py
-    phase: P1
+    impl_now: app_common/common/app_common_guanlan/{paths,version,logfile,proc,entry_refs,console,opsjob,derived_manifest,tabfmt}.py(旧路径 src/<件>.py 为兼容转发壳)
+    phase: P1 已完成(2026-09-22)
     change_rate: 极少
 
   - name: app_ETL

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

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

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

@@ -1,4 +1,4 @@
-# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.11.0
+# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.11.1
 
 ## 1 文档说明
 
@@ -6,19 +6,19 @@
 
 ### 1.1 目的与范围
 
-本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.11.0;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
+本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.11.1;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
 
 本文覆盖十五个设计面:总体架构与分层、目录结构与路径真源、数据接入与重算链、判级与算法、时间窗口径、服务与前端、本体与知识层、本机模型接入、运维控制台与重算编排、安装与服务化与版本管理、质量保证、安全与离线边界、可移植性与资源占用、多场适用性与换场迁移、已知边界与未实现。
 
-本文不重复需求条目本身(那是《需求分析_观澜_2.11.0.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
+本文不重复需求条目本身(那是《需求分析_观澜_2.11.1.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.0.docx》的对应关系
+### 1.3 与《需求分析_观澜_2.11.1.docx》的对应关系
 
-需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.11.0.docx》的数据侧口径去向见 16.6 节。
+需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.11.1.docx》的数据侧口径去向见 16.6 节。
 
 | 本文章节 | 需求分析对应章 | 对应关系说明 |
 |---|---|---|
@@ -41,7 +41,7 @@
 
 ### 1.4 口径与依据
 
-本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.11.0;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.11.0.zip。
+本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.11.1;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.11.1.zip。
 
 本文遵循四条写作口径:含"窗"且确实指时间窗口的,一律写全"时间窗"(天气窗、作业窗、预览窗、观测窗属领域词,保持原样);影响的是风电机组时写"影响机组"或"影响机组数";使用英文简写时必须写成"中文(英文简写)"形式,如平均无故障间隔(MTBF)、平均停机间隔(MTBO)、单次停机时长(MDT);全文用简体中文与半角数字与单位。
 
@@ -196,6 +196,8 @@
 **本轮(P0)只建目录与接口**:实现仍在既有路径(`src/**`、`scripts/**`),因此页面、CLI、产物与门禁**零行为变化**;
 P1–P7 分阶段实体迁移,每阶段独立提交、随时可回退,方案与验收口径见 `docs/重构方案_模块化_v0.1.md`。
 
+**P1 已落地(公共层实体迁移,2026-09-22)**:`src/` 下的九个平台件(路径真源、版本真源、日志、进程、入口引用、控制台输出、运行态作业文件、产物派生台账、表格式)已实体迁入 `app_common/common/app_common_guanlan/`;安装根推算由 `__file__.parents[1]` 改为**按 `configs/` 与 `guanlan.py` 标记向上查找**(位置无关,再搬也不会算错);旧路径 `src/<件>.py` 保留为**兼容转发壳**(`sys.modules` 别名,含私有名),因此脚本、审计器与文档里既有的 `src/paths.py`、`src/version.py` 一类引用**照旧可用**,不必一次性改完。P2–P7 按同一方式推进,每阶段独立提交、随时回退。
+
 **四个系统组件(TiDB community / MinIO / Redis / Nginx)**:本轮只建立**接口契约**(标准仓读写、对象存储、
 按时间窗缓存与作业状态、统一入口与路由表),默认实现仍是本地目录与进程内缓存 —— 所以离线单包交付形态与现有
 验收链保持不变;后续接实现时只换实现、不动上层调用。
@@ -813,7 +815,7 @@ Windows 安装入口是 install.bat 与 install.ps1,步骤号写死在输出
 
 ### 12.3 安装记录与版本三守卫
 
-安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.11.0,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
+安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.11.1,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
 
 | 字段 | 含义 |
 |---|---|
@@ -1174,7 +1176,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 ### 16.6 与同批交付文档的对应关系
 
-本章对应《需求分析_观澜_2.11.0.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.11.0.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
+本章对应《需求分析_观澜_2.11.1.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.11.1.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
 
 ***
 
@@ -1240,7 +1242,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 | 章节 | 来源文件 |
 |---|---|
-| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.11.0 条目)、docs/需求分析_观澜_2.11.0.docx、scripts/delivery_docs_figures.py |
+| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.11.1 条目)、docs/需求分析_观澜_2.11.1.docx、scripts/delivery_docs_figures.py |
 | 2 设计目标与原则 | docs/系统设计说明.md(四条设计铁律)、src/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(路径约定与统一记录)、本次目录实测 |

+ 23 - 21
docs/src/需求分析_观澜_2.11.0.md → docs/src/需求分析_观澜_2.11.1.md

@@ -1,4 +1,4 @@
-# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.11.0
+# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.11.1
 
 ## 1 文档说明
 
@@ -6,7 +6,7 @@
 
 ### 1.1 目的
 
-本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.11.0.docx》《数据要求说明_观澜_2.11.0.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
+本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.11.1.docx》《数据要求说明_观澜_2.11.1.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
 
 本文的写作口径是**只写能取证的事实**:每个数字、每条结论都能指到仓库里的某个文件、某次实跑输出或某条版本记录;查不到、取不到的一律写明「未取证」或「未到位」,不做推测性补全。因此文中会出现少量「未取证」的说明句,那是刻意留下的诚实边界,不是遗漏。
 
@@ -18,19 +18,19 @@
 
 ### 1.3 版本对应关系
 
-系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.11.0,版本记录(HISTORY)共 17 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
+系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.11.1,版本记录(HISTORY)共 18 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
 
 表 1-1 版本与文档的对应关系
 
 | 项 | 值 | 取证方式 |
 |---|---|---|
-| 系统版本 | 2.11.0 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.11.0」 |
-| 交付包名 | app_guanlang_v2.11.0.zip | src/version.py 的 package_name();同一条 check 输出 |
-| 本文版本 | 2.11.0(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
+| 系统版本 | 2.11.1 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.11.1」 |
+| 交付包名 | app_guanlang_v2.11.1.zip | src/version.py 的 package_name();同一条 check 输出 |
+| 本文版本 | 2.11.1(与系统版本同号) | 本文标题与 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,《数据要求说明》按测点遗漏体检补齐:安全链与数字输入、执行器与热管理、计数账、CMS 采集参数、测点与位号字典;系统功能未变) | HISTORY 中 2.11.0 条目的 level 字段 |
+| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.11.1 条目的 level 字段 |
 
 本版相对 2.10.0 的实质变化是**交付文档对外化**:三份交付文档(需求分析、系统设计说明、数据要求说明)全文不体现具体风电场(去标识化),并新增多风电场适用性一章;系统功能未变,属交付物修订。依据用户令原文:「修改三份文档,内容参考样本风电场,但不体现样本风电场,且具有不同风电场适用性」(引用时把样本场名按去标识化口径写成「样本风电场」)。
 
@@ -119,10 +119,12 @@
 
 ### 3.2 需求演进时间轴
 
-表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 17 条)
+表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 18 条)
 
 | 版本 | 日期 | 用户令要点 | 变化级别 |
 |---|---|---|---|
+| 2.11.1 | 2026-09-22 | 模块化重构 P1:公共层九个平台件实体迁移 | 小(patch) |
+| 2.11.0 | 2026-09-22 | 源码模块化重构 P0:七个模块目录 + 接口层 + 模块边界门 | 中(minor) |
 | 2.10.4 | 2026-09-22 | 数据要求说明补测点:安全链与数字输入、执行器与热管理、计数账、CMS 采集参数、测点与位号字典(系统功能未变) | 小(patch) |
 | 2.10.3 | 2026-09-22 | 交付件定名:《设计说明》定名为《系统设计说明》,与另外两份的交付命名对齐(文档内容未变) | 小(patch) |
 | 2.10.2 | 2026-09-22 | 数据要求说明改为纯数据需求规格:去样本场现状与实现细节、测点按业务含义列表、新增 SCADA 秒级数据要求(系统功能未变) | 小(patch) |
@@ -140,11 +142,11 @@
 | 0.4.0 | 2026-09-17 | 打包默认不含输入数据与产物与日志与临时文件;无窗口启动;统一配置与日志;页面归口;服务化 | 旧编号(legacy) |
 | 0.2.0 | 2026-09-01 | 含产物的旧交付基线,历史上用作「包内无生成端」那批产物的补救源 | 旧编号(legacy) |
 
-需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 16 条记录(即两级旧编号 0.2.0 与 0.4.0,以及从 2.5.0 起按规则递增的十四个版本)。按级别统计,这十四个版本里「中」级变化六条(2.5.0、2.6.0、2.7.0、2.8.0、2.9.0、2.10.0),「小」级变化八条(2.8.1、2.8.2、2.9.1、2.9.2、2.10.1、2.10.2、2.10.3、2.10.4),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
+需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 18 条记录(即两级旧编号 0.2.0 与 0.4.0,以及从 2.5.0 起按规则递增的十六个版本)。按级别统计,这十六个版本里「中」级变化七条(2.5.0、2.6.0、2.7.0、2.8.0、2.9.0、2.10.0、2.11.0),「小」级变化九条(2.8.1、2.8.2、2.9.1、2.9.2、2.10.1、2.10.2、2.10.3、2.10.4、2.11.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
 
 ![图 1-1 观澜需求演进时间轴(依据 src/version.py 的 HISTORY)](figures/fig-req-01-需求演进时间轴.png)
 
-从时间轴能看出三个事实。第一,2.5.0 到 2.7.0 三天内出了三个中版本,全部是「装上、卸掉、打包、对外监听」这类交付能力,说明这一阶段的需求主线从「算得对」转向「交付得出去」。第二,2.7.0 之后密集出现小版本消缺,且每一条都带实跑验证,说明需求进入了「现场怎么用就怎么改」的收敛期。第三,2.10.0 是规格化的标志:需求不再只由代码与脚本承载,而是被写成可评审的文档,且文档本身也源代码化,可机器重生成。2.10.1 在这条线上再走一步:文档去样本场标识并补上多风电场适用性一章,使同一份文档能被别的风电场直接拿去用。2.10.2 把《数据要求说明》改成纯数据需求规格:不写样本场的接入现状、不写英文测点名与文件名与落盘位置,测点一律用业务含义名称按类列表(含单位与必须性),并补上 SCADA 秒级数据的要求。2.10.3 把《设计说明》定名为《系统设计说明》,与另外两份交付文档的命名对齐(文档内容未变)。2.10.4 按测点遗漏体检补齐《数据要求说明》:新增安全链与数字输入、执行器与热管理两域,扩充计数账与 CMS 采集参数,并加上"测点与位号字典"这项收资要求。
+从时间轴能看出三个事实。第一,2.5.0 到 2.7.0 三天内出了三个中版本,全部是「装上、卸掉、打包、对外监听」这类交付能力,说明这一阶段的需求主线从「算得对」转向「交付得出去」。第二,2.7.0 之后密集出现小版本消缺,且每一条都带实跑验证,说明需求进入了「现场怎么用就怎么改」的收敛期。第三,2.10.0 是规格化的标志:需求不再只由代码与脚本承载,而是被写成可评审的文档,且文档本身也源代码化,可机器重生成。2.10.1 在这条线上再走一步:文档去样本场标识并补上多风电场适用性一章,使同一份文档能被别的风电场直接拿去用。2.10.2 把《数据要求说明》改成纯数据需求规格:不写样本场的接入现状、不写英文测点名与文件名与落盘位置,测点一律用业务含义名称按类列表(含单位与必须性),并补上 SCADA 秒级数据的要求。2.10.3 把《设计说明》定名为《系统设计说明》,与另外两份交付文档的命名对齐(文档内容未变)。2.10.4 按测点遗漏体检补齐《数据要求说明》:新增安全链与数字输入、执行器与热管理两域,扩充计数账与 CMS 采集参数,并加上"测点与位号字典"这项收资要求。2.11.0 起按用户令做源码模块化重构(七个模块目录 + 接口层 + 模块边界门),2.11.1 把公共层九个平台件实体迁入模块目录、旧路径留兼容转发壳——两步都是源码组织层变化,行为零变化。
 
 ### 3.3 需求如何被人工程序化
 
@@ -159,7 +161,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.0」 |
+| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.11.1」 |
 | 观澜改为监听所有 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 条目;本版三份文档 |
@@ -375,7 +377,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.0.docx》第 8 章与第 12 章 |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.11.1.docx》第 8 章与第 12 章 |
 
 ### 6.2 性能与耗时实测
 
@@ -411,7 +413,7 @@
 
 | 真源 | 管什么 | 守卫与实跑结果 |
 |---|---|---|
-| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.11.0 且记录表与代码一致 |
+| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.11.1 且记录表与代码一致 |
 | src/paths.py | 一切路径解析的基准与助手 | 配置审计器检查不手拼路径;实跑无不一致 |
 | configs 目录与登记表 | 端口、模型档、场配置、页面归口、配置登记 | 配置审计实跑已知缺口与白名单 14 条、提示 7 条 |
 | src/logfile.py | 日志目录、命名、行格式、保留策略 | 日志审计实跑无不一致、提示 87 条 |
@@ -422,7 +424,7 @@
 
 ### 7.1 数据族与功能映射
 
-系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.11.0.docx》。
+系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.11.1.docx》。
 
 表 7-1 数据族到功能的映射(样本场实测(2026-09-22))
 
@@ -455,7 +457,7 @@
 
 ### 7.3 与数据要求说明的分工
 
-本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.11.0.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
+本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.11.1.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
 
 ## 8 页面与信息架构需求
 
@@ -537,7 +539,7 @@
 | 运行环境与依赖 | 10 | Python 版本不低于 3.11(实测 3.12.10);九个第三方依赖逐个导入 | 全 OK |
 | 静态质量门 | 4 | 源码可编译 229 个文件;语言包 845 条成对;入口引用闭合 48 条;入口脚本编码守则 | 全 OK |
 | 子进程口径 | 3 | 捕获输出可用;无窗口启动输出进日志;无窗口位已设,标志位 0x8000200 | 全 OK |
-| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.11.0,包名 app_guanlang_v2.11.0.zip);卸载入口两个都在位 | 全 OK |
+| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.11.1,包名 app_guanlang_v2.11.1.zip);卸载入口两个都在位 | 全 OK |
 | 制品与台账审计 | 5 | 反向呼应 3,548 件成立;页面归口检查 21 项;配置统一;日志统一;输入数据放置体检 26,503 件结构合规 | 全 OK |
 | 产物与发布件在位 | 10 | 标准仓、本体对象库、findings、事实契约、门户、仿真合页服务与资料包、三维资产、仿真回放资产、治理清单交付件 | 全 OK |
 | 原始件目录 | 1 | 原始件目录存在(无数据时此项不影响页面) | 全 OK |
@@ -663,7 +665,7 @@
 
 本章有两个词要先定义。「本场」指**当前被选中的场**,由配置选定,不必然等于本文取数的样本风电场;「换场」指从一场切到另一场运行的整套动作(改配置、放数据、重跑、按检查表复验),不是把两场的数据混在一棵树里。本章沿用的占位符与附录 A 一致:`<场>`、`<场站>`、`<机型>` 按实际风电场替换。
 
-本章需求与《数据要求说明_观澜_2.11.0.docx》《系统设计说明_观澜_2.11.0.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
+本章需求与《数据要求说明_观澜_2.11.1.docx》《系统设计说明_观澜_2.11.1.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
 
 取证边界要如实写一句:FR-44 至 FR-51 与 NFR-12 有既有的场配置层、摄入层与场级字段语义登记作依据;FR-52 的跨场横向对比是本次新增的口径要求,其实跑记录与机器守卫随后续版本补齐——本文不谎称已经通过。
 
@@ -742,7 +744,7 @@
 
 | 编号 | 需求 | 判据 | 依据文件 |
 |---|---|---|---|
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.11.0.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.11.1.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
 
 ### 11.9 换场验收需求
 
@@ -885,8 +887,8 @@
 | 场定义与机型与物理约束参数的写法样例 | configs/farms/<场>.yaml |
 | 机型—场站数据契约与字段单位真源 | configs/contracts/<机型>_<场>.yaml 与 configs/canonical/dictionary.yaml |
 | 场级字段语义登记与其它厂商导出形态的对齐 | configs/farms/<场>/field_semantic_registry.yaml |
-| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.11.0.md |
-| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.11.0.md |
+| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.11.1.md |
+| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.11.1.md |
 
 表中 `<场>`、`<场站>`、`<机型>` 为占位符,按实际风电场替换。
 
@@ -929,4 +931,4 @@
 | 换场 | — | 从一场切到另一场运行的整套动作:改配置、放数据、重跑、按检查表复验 | 本文 §11.9 |
 | 缺族降级 | — | 某族数据缺失时按缺件如实标注并降低该族功能,不造数、不用他场数字顶替 | 本文 FR-49 |
 | 按场标定 | — | 把阈值、时间窗锚点、限电窗、温度档带宽等场相关参数按本场取值 | 本文 FR-50 |
-| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.11.0.md |
+| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.11.1.md |

TEMPAT SAMPAH
docs/数据要求说明_观澜_2.11.0.docx → docs/数据要求说明_观澜_2.11.1.docx


File diff ditekan karena terlalu besar
+ 4 - 3
docs/版本记录.md


TEMPAT SAMPAH
docs/系统设计说明_观澜_2.11.0.docx → docs/系统设计说明_观澜_2.11.1.docx


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

@@ -87,7 +87,7 @@ app_qualityGate/
 | 阶段 | 内容 | 验收(全绿才进下一阶段) | 回滚 |
 |---|---|---|---|
 | **P0(本轮)** | 建 7 个模块目录 + `common/<pkg>/{__init__,api}.py` + README;`configs/modules.yaml` 登记;新增 `scripts/module_boundary_audit.py` 并接入 `guanlan.py check` | 边界审计 rc=0;`guanlan.py check` 全绿;三份交付文档重渲通过 | 删目录即可 |
-| P1 | `app_common` 平台件**实体迁移**,`src/<同名>.py` 变转发壳 | 全部门禁 + 页面抽样对拍 | revert 该提交 |
+| **P1(已完成 2026-09-22)** | `app_common` 平台件**实体迁移**,`src/<同名>.py` 变转发壳 | 旧路径可导入 + 安装根正确 + 全部门禁 rc=0 | revert 该提交 |
 | P2 | `app_ETL` 迁移(构建器按 CLI 入口迁,`scripts/` 留转发) | 重算链 `--dry-run` 计划不变 + 全门禁 | 同上 |
 | P3 | `app_algorithmModel` 迁移(判级/曲线/可靠性/融合纯函数化) | 判级与曲线**逐值对拍**(同一时间窗结果一致)+ 全门禁 | 同上 |
 | P4 | `app_backEnd` 迁移(服务、网关、编排、CLI) | 端口/入口/日志口径不变 + 页面 5/5 | 同上 |
@@ -120,4 +120,5 @@ app_qualityGate/
 ## 7 当前进度(2026-09-22)
 
 - **P0 已完成**:7 个模块目录、接口与 README、`configs/modules.yaml`、`scripts/module_boundary_audit.py`(接入 `guanlan.py check`)。
-- P1–P7 未开始;本轮**未移动任何业务代码**,行为与产物零变化。
+- **P1 已完成(2026-09-22)**:公共层 9 个平台件(paths/version/logfile/proc/entry_refs/console/opsjob/derived_manifest/tabfmt)实体迁入 `app_common/common/app_common_guanlan/`;安装根推算改为按标记查找;旧路径留兼容转发壳;实测旧路径三种导入写法照旧、安装根正确、全部门禁 rc=0。
+- P2–P7 未开始;业务代码(算法/数据接入/前端/后端/本体/质量门)仍在既有路径,行为与产物零变化。

TEMPAT SAMPAH
docs/需求分析_观澜_2.11.0.docx → docs/需求分析_观澜_2.11.1.docx


+ 7 - 1
scripts/config_audit.py

@@ -171,7 +171,13 @@ def audit():
                                 '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6))
         for i, ln in enumerate(t.splitlines(), 1):
             s = ln.strip()
-            if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py':
+            # 豁免"配置取用口自己的定义文件": 原先按旧路径 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

+ 14 - 0
scripts/delivery_docs_build.py

@@ -588,6 +588,20 @@ def verify_docx(key: str) -> int:
     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」。

+ 18 - 29
src/console.py

@@ -1,29 +1,18 @@
-# -*- coding: utf-8 -*-
-"""控制台输出兜底 —— 中文 Windows 默认控制台代码页是 936 (GBK), 而脚本正文里常用 ✔ ✘ → 等符号。
-
-踩过的坑: `scripts/page_fingerprint.py` 比对结果"全部一致", 却因为最后一行 print 里的 ✔
-编不出来抛 `UnicodeEncodeError: 'gbk' codec can't encode character '\\u2714'`, 进程以退出码 1 结束 ——
-**闸门报告失败, 而实际是通过的**。2026-09-11 因此误判过一次 (同一坑在 portal_build.py 也踩了一次)。
-
-所以: 凡是"以退出码讲话"的脚本 (回归闸门/校验/巡检), 入口先调 `soft()`, 把 stdout/stderr 的
-错误处理降级为 replace (编不出的符号写成 `?`), 而不是让整条校验因为一个装饰性符号崩掉。
-
-只降 errors, 不改 encoding: 控制台是 GBK 时中文照旧可读; 控制台是 UTF-8 时一切正常。
-"""
-from __future__ import annotations
-
-import sys
-
-
-def soft(streams=None) -> None:
-    """把 stdout/stderr 的编码错误策略降级为 'replace' (幂等, 失败静默)。"""
-    for s in (streams or (sys.stdout, sys.stderr)):
-        try:
-            s.reconfigure(errors='replace')
-        except Exception:
-            pass
-
-
-def setup() -> None:
-    """别名, 语义同 soft (给"入口初始化"读起来更顺)。"""
-    soft()
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/console.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.console` 指向真实现,于是
+`from src import console` / `from src.console import X` / `import src.console` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import console as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'console', _impl)
+
+sys.modules[__name__] = _impl

+ 18 - 82
src/derived_manifest.py

@@ -1,82 +1,18 @@
-# -*- coding: utf-8 -*-
-"""产物来源**自登记**: 构建脚本落盘后把"这一件是我从 data/raw 算出来的"记进 `outputs/<场>/_derived_manifest.json`。
-
-## 为什么要有它
-
-`outputs/<场>/_provenance.json` 是**逐件来源台账**(raw-derived = 由 data/raw 重算 / shipped = 包内无生成端,
-用随包件补齐)。它由 `scripts/products_restore_missing.py` 生成, 而那个脚本是按**随包快照**逐件走一遍的 ——
-于是**新造的、快照里根本没有的产物**不会自动进台账 (既不算 raw-derived 也不算 shipped)。
-
-早期的做法是在 `products_restore_missing.py` 里维护一张 `RAW_DERIVED` 精确路径表。对"件数少、名字固定"
-的产物够用; 但振动侧的产物是 `<窗>/index.parquet` + `<窗>/spectra/*.npz`(分片名带序号) —— 窗名与分片数
-都随数据变, 写不进精确表, 而**按名字通配**又会误伤同名旧件 (例如 `报告_CMS振动状态评估报告_*.md`
-既有随包/自产的、也有厂家报告转录的, 名字形态一样)。
-
-所以改成**自登记**: 谁算的谁登记, 台账只认这份登记。名字对不上不是问题, 因为登记的是**相对路径本身**。
-
-用法 (构建脚本内):
-    from src.derived_manifest import record
-    record(P.out_root('rudong'), {rel: 'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同构)'},
-           by='scripts/vib_raw_build.py')
-"""
-from __future__ import annotations
-
-import json
-import pathlib
-import time
-
-FILENAME = '_derived_manifest.json'
-
-
-def path_of(store_root) -> pathlib.Path:
-    return pathlib.Path(store_root) / FILENAME
-
-
-def load(store_root) -> dict:
-    p = path_of(store_root)
-    if not p.exists():
-        return {}
-    try:
-        return json.loads(p.read_text(encoding='utf-8'))
-    except Exception:
-        return {}
-
-
-def prune(store_root) -> int:
-    """删掉**登记了但盘上已不存在**的条目, 返回删除数。
-
-    为什么需要 (2026-09-16 实逮): 振动摄入对同一批数据重跑时会落 `<窗>_reimport_<时分>` 窗
-    (设计如此, 该窗被 `data.EXCLUDE_DEFAULT` 排除在生产集外), 而登记是**追加式**的 ——
-    只补不删。重算几次后 `_derived_manifest.json` 里就攒了成百上千条指向已删目录的条目,
-    `_provenance.json` 的 raw-derived 计数随之虚增 (实测 1,740 → 3,444, 而盘上并没有多出这些件)。
-    台账是本包的"来源正本", 虚高等于说假话 ⇒ 每次生成台账前先 prune。
-    """
-    store_root = pathlib.Path(store_root)
-    cur = load(store_root)
-    files = cur.get('files') or {}
-    keep = {rel: v for rel, v in files.items() if (store_root / rel).exists()}
-    gone = len(files) - len(keep)
-    if gone:
-        cur['files'] = keep
-        cur['pruned'] = f'{time.strftime("%Y-%m-%d %H:%M")} 清理 {gone} 条不在盘的登记'
-        path_of(store_root).write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8')
-    return gone
-
-
-def record(store_root, files: dict, by: str) -> pathlib.Path:
-    """把 {相对产物仓的路径: 构建器说明} 合并进登记 (幂等: 同路径后写覆盖先写)。
-
-    幂等很关键 —— 重跑摄入不该让登记无限膨胀; 同时**不删**别的构建器登记的条目
-    (振动摄入与厂家报告摄入是两个脚本, 各登各的)。"""
-    store_root = pathlib.Path(store_root)
-    cur = load(store_root)
-    entries = cur.get('files') or {}
-    for rel, builder in files.items():
-        entries[pathlib.Path(rel).as_posix()] = dict(builder=builder, by=by,
-                                                     at=time.strftime('%Y-%m-%d %H:%M:%S'))
-    cur = dict(note='产物来源自登记: 由构建脚本落盘后写入; _provenance.json 生成时把这些件记为 raw-derived',
-               at=time.strftime('%Y-%m-%d %H:%M:%S'), files=entries)
-    p = path_of(store_root)
-    p.parent.mkdir(parents=True, exist_ok=True)
-    p.write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8')
-    return p
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/derived_manifest.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.derived_manifest` 指向真实现,于是
+`from src import derived_manifest` / `from src.derived_manifest import X` / `import src.derived_manifest` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import derived_manifest as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'derived_manifest', _impl)
+
+sys.modules[__name__] = _impl

+ 10 - 237
src/entry_refs.py

@@ -1,245 +1,18 @@
 # -*- coding: utf-8 -*-
-r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/entry_refs.py`。
 
-## 为什么要这个模块
-
-交付包 v0.4.0 第一次打出来时, 目标机 (现场安装目录 <安装目录>) 双击 `start.bat` 报:
-
-    无法找到脚本文件 "<安装目录>\start_hidden.vbs"
-
-原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
-`start_hidden.vbs`(当时的无窗口启动器) 时忘了把它写进去, 于是包里 `start.bat` 指向一个
-不存在的文件。手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
-
-    凡是包内入口脚本 (.bat/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
-    它就**必须**出现在包里(或被打包器自动补进去)。
-
-· 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
-  按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
-· `%~dp0xxx` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
-· 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
-
-★ 该 `.vbs` 已于 2026-09-16 按用户令删除 (无窗口启动改用 `pythonw.exe` + Python 启动器),
-  但这份检查留着: 它守的是"入口引用的文件必须齐全"这条性质, 与用什么语言实现无关。
-
-单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.entry_refs` 指向真实现,于是
+`from src import entry_refs` / `from src.entry_refs import X` / `import src.entry_refs` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
 """
 from __future__ import annotations
 
-import pathlib
-import re
-
-# 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
-# ★ 2026-09-16 用户令"把 VBScript 替换掉"之后 `start_hidden.vbs` 已删除: 无窗口启动改由
-#   `pythonw.exe` + `scripts/guanlan_start_hidden.py` 承担, 不再经过 Windows 脚本宿主。
-# ★ 2026-09-17 用户令"实现打包功能": `pack.bat` / `pack.sh` 也是入口, 一并纳入闭合检查。
-# ★ 2026-09-17 用户令"增加卸载脚本": `uninstall.bat` / `uninstall.sh` 同样是用户会直接双击的入口
-#   —— 它引用的 `scripts\guanlan_uninstall.py` 少打进包, 后果就是"装得上、卸不掉"。
-ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'pack.bat', 'pack.sh',
-               'install.bat', 'install.ps1', 'install.sh', 'uninstall.bat', 'uninstall.sh')
-
-# 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
-_REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
-                  r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
-                  r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
-
-_STRIP = ('dp0', 'DP0')          # %~dp0 / %dp0 展开后的残前缀
-
-
-def _candidates(tok: str):
-    """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
-    t = tok.replace('\\', '/').lstrip('./')
-    cands = [t]
-    for p in _STRIP:
-        if t.startswith(p) and len(t) > len(p):
-            cands.append(t[len(p):])
-    parts = t.split('/')
-    cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
-    return [c for c in dict.fromkeys(cands) if c]
-
-
-def _norm(name: str) -> str:
-    return name.replace('\\', '/').lstrip('./').lower()
-
-
-def entry_refs(path: pathlib.Path) -> set[str]:
-    """一个入口脚本里出现的全部"像文件"的路径 token。"""
-    try:
-        txt = path.read_text(encoding='utf-8-sig', errors='replace')
-    except OSError:
-        return set()
-    return {m.group(0) for m in _REF.finditer(txt)}
-
-
-def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
-    """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
-
-    本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
-    """
-    root = pathlib.Path(root)
-    out: dict[str, set[str]] = {}
-    for name in ENTRY_FILES:
-        p = root / name
-        if not p.is_file():
-            continue
-        found: set[str] = set()
-        for tok in entry_refs(p):
-            for cand in _candidates(tok):
-                if (root / cand).is_file():
-                    found.add(_norm(cand))
-                    break
-        if found:
-            out[name] = found
-    return out
-
-
-_SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
-              'outputs', 'data', 'logs', 'run'}
-
-
-def _iter_scripts(root: pathlib.Path, exts: tuple[str, ...]):
-    """自带脚本文件的遍历 (跳过 .venv/node_modules/outputs 等大目录)。"""
-    import os
-    for dp, dn, fns in os.walk(root):
-        dn[:] = [d for d in dn if d not in _SKIP_WALK]
-        for fn in fns:
-            if fn.lower().endswith(exts):
-                yield pathlib.Path(dp) / fn
-
-
-def encoding_problems(root: pathlib.Path) -> list[str]:
-    r"""入口脚本的**编码守则**检查 —— 这几条都是实机踩出来的, 违反了就是"双击/一跑即报错"。
-
-    · `install.ps1` 必须 **UTF-8 带 BOM + CRLF**: Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI
-      代码页(中文机 = GBK)解码 → 中文变乱码, 相邻的转义反引号被吞 → 引号不配对 → 级联 ParserError
-      (报在看起来没问题的行上)。2026-09-16 真的发出去过一个这样的包: 开箱验证里
-      `install 退出码 1, 耗时 1s`, 报的正是 `The '<' operator is reserved for future use`。
-      ★ 起因很隐蔽: 编辑工具保存 .ps1 **不会替你保留 BOM** —— 在规范化之后再改一次文件, BOM 就没了。
-        所以这条必须由机器守, 不能靠"我记得"。
-    · `.bat` 必须 CRLF 且**不能**带 BOM: 批处理按字节读, 行尾 LF 出怪问题; 开头 BOM 会让第一行
-      (`@echo off`) 失效并被当命令执行。
-    · `.sh` 必须 LF: CRLF 会让 `#!/bin/sh` 的 shebang 与每个词尾都粘上一个 `\r` ——
-      POSIX 里只有空格/Tab/换行分隔词, 所以 `set -e` 变成 `set "-e\r"`(非法选项)、
-      `RT=""` 变成 `RT="\r"`(后面对 `-n "$RT"` 的判断直接翻面)。
-      2026-09-16 实测发现 `install.sh` **从写出来那天起就是 CRLF**(git HEAD blob 就是 CRLF, 不是某个编辑器改的),
-      也就是说此前所有交付包的 Linux/macOS 安装脚本都是坏的。已改 LF, 并用 .gitattributes 钉住。
-    """
-    root = pathlib.Path(root)
-    bad: list[str] = []
-    for p in _iter_scripts(root, ('.ps1',)):
-        b = p.read_bytes()
-        rel = p.relative_to(root).as_posix()
-        if b[:3] != b'\xef\xbb\xbf':
-            bad.append(f'{rel} 缺 UTF-8 BOM (PS 5.1 会按 ANSI/GBK 解码 → 中文乱码 + ParserError)')
-        if b.count(b'\n') != b.count(b'\r\n'):
-            bad.append(f'{rel} 有裸 LF (PowerShell 脚本按 CRLF 交付)')
-    for p in _iter_scripts(root, ('.bat',)):
-        b = p.read_bytes()
-        rel = p.relative_to(root).as_posix()
-        if b.count(b'\n') != b.count(b'\r\n'):
-            bad.append(f'{rel} 有裸 LF (.bat 行尾必须是 CRLF)')
-        if b[:3] == b'\xef\xbb\xbf':
-            bad.append(f'{rel} 带 UTF-8 BOM (.bat 不能有 BOM, 否则第一行失效)')
-        # ★ 2026-09-17: .bat 必须**纯 ASCII**。原因: 批处理的解析与输出都跟控制台代码页绑在一起
-        #   (cp936 / cp65001 / 系统区域设置)。现场实测: 中文注释行被 cmd 拆开当成命令执行
-        #   ("'本身是控制台程序' 不是内部或外部命令") —— 放在注释里都不安全。
-        #   所有中文说明改放 README / docs; .bat 只留英文。
-        nonascii = [x for x in b if x > 0x7F]
-        if nonascii:
-            bad.append(f'{rel} 含 {len(nonascii)} 个非 ASCII 字节 (.bat 必须纯 ASCII: 中文在批处理里会随代码页被曲解, '
-                       f'现场出现过"注释被当命令执行"; 中文说明放 README/docs)')
-        txt = b.decode('utf-8', 'replace')
-        if '<<' in txt:
-            bad.append(f'{rel} 含 `<<` —— 在批处理里是重定向, `echo <x>` 这种写法会直接报错')
-        # `>` 只在**非注释行**上查重定向语义: 本机实测 `rem` 行里的 `>` 不会被当重定向 (但 `echo <x>` 会),
-        # 所以注释行放行 —— 免得逼着人把说明写成天书, 同时保住真正会炸的那种写法。
-        for i, ln in enumerate(txt.splitlines(), 1):
-            st = ln.strip()
-            if not st or st.lower().startswith(('rem', '::')):
-                continue
-            if re.search(r'>{1,2}(?!\s|"|nul\b|&)', ln):
-                bad.append(f'{rel} 第 {i} 行的 `>` 后面不是重定向目标 '
-                           f'(.bat 里 `>` 是重定向符, 想显示要用 ^> 转义)')
-                break
-    for p in _iter_scripts(root, ('.sh',)):
-        b = p.read_bytes()
-        if b'\r\n' in b:
-            bad.append(f'{p.relative_to(root).as_posix()} 含 CRLF '
-                       f'(POSIX shell 需要 LF: 词尾会粘 \\r, 判断与 shebang 都会出错)')
-    return bad
-
-
-# 装机时才有 / 只在本机有效的文件 —— 入口脚本**可以**引用它们, 但它们**不该在包里**
-# (所以闭合检查必须放行, 否则这条守卫会得出相反的结论, 见 missing_refs 的注释)。
-MACHINE_ARTIFACTS = ('install-info.json',)      # 安装时写的"本机装没装/哪一版"凭据
-
-
-def _is_machine_artifact(rel: str) -> bool:
-    name = rel.replace('\\', '/').split('/')[-1].lower()
-    return name in MACHINE_ARTIFACTS or name.endswith('.lnk')
-
-
-def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
-    """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
-
-    provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
-
-    ★ 2026-09-17: **放行"装机产物"** (见 MACHINE_ARTIFACTS)。这条守卫的本意是"包内不许有指向空气的
-      入口"(曾经漏带 start_hidden.vbs, 目标机双击即报错), 而 `install-info.json` 是**故意不发**的:
-      它是安装时才写的本机凭据, 装之前本来就该不在, 而 `install.ps1`/`install.sh` 的版本检查那一步
-      正是靠"文件在不在"判断装没装的。原先这两件事会打架 —— 把 install-info.json 排除出包之后,
-      闭合检查反过来报"install.ps1 引用了它但包里没有 ⇒ 不能交付", 于是包被闸删掉。
-      判据修正为: 引用的文件必须"在包内**或在目标机上必然存在**(装机产物/安装时自建)"。
-    """
-    have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
-    bad: list[tuple[str, str]] = []
-    root = pathlib.Path(root)
-    for entry, refs in sorted(referenced_tree_files(root).items()):
-        for rel in sorted(refs):
-            if _is_machine_artifact(rel):
-                continue
-            ok = (rel in have) if have is not None else (root / rel).is_file()
-            if not ok:
-                bad.append((entry, rel))
-    return bad
-
-
-def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
-    """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
+import sys
 
-    只自动补**根目录**文件 (如曾经的 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
-    缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
-    ★ 装机产物 (`install-info.json` / `*.lnk`) 不在此列 —— 它们本机有, 但**不该随包**(见 MACHINE_ARTIFACTS)。
-    """
-    got = {_norm(x) for x in already}
-    root = pathlib.Path(root)
-    add: list[pathlib.Path] = []
-    for _, refs in referenced_tree_files(root).items():
-        for rel in refs:
-            if '/' in rel or rel in got or _is_machine_artifact(rel):
-                continue
-            p = root / rel
-            if p.is_file():
-                add.append(p)
-                got.add(rel)
-    return sorted(set(add), key=lambda p: p.name)
+from app_common.common.app_common_guanlan import entry_refs as _impl
 
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'entry_refs', _impl)
 
-if __name__ == '__main__':      # 直接跑 = 对当前安装目录做一次闭合检查 + 编码守则检查
-    import sys
-    r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
-    refs = referenced_tree_files(r)
-    miss = missing_refs(r)
-    enc = encoding_problems(r)
-    for e in sorted(refs):
-        print(f'   {e:18s} → ' + ', '.join(sorted(refs[e])))
-    for e, rel in miss:
-        print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
-    for m in enc:
-        print(f'[X] 编码守则: {m}')
-    if not miss:
-        print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
-              f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
-    if not enc:
-        print('[OK] 入口脚本编码守则 (install.ps1 = UTF-8 BOM + CRLF; .bat = CRLF 无 BOM; .sh = LF)')
-    sys.exit(1 if (miss or enc) else 0)
+sys.modules[__name__] = _impl

+ 18 - 234
src/logfile.py

@@ -1,234 +1,18 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""日志目录/命名/格式的唯一口径 (2026-09-17 用户令 2「统一日志输出目录及日志文件命名、内容格式」)。
-
-## 统一前是什么样 (实测, 不是推测)
-
-  · 目录: 运行日志散在 `logs/`, 但 CMS 插件把 `analyze_stdout.log` 写进了**产物目录**
-    (`outputs/<场>/windcms/...`), `_proc_reg.log` 这种自检残留也躺在 `logs/` 里;
-  · 命名: 服务日志是 `<组件>.log` (gateway/detail/cms/sim/sim_sys/viewer), 但对不上服务键的还有
-    `serve.log`、`start_hidden.log`; 运维动作是 `ops_<动作>_<YYYYmmdd_HHMMSS>.log` 直接堆在 `logs/` 顶层
-    —— 实测 34 个文件里 22 个是历史动作日志, 没有保留策略, 只会越堆越多;
-  · 内容: **没有时间戳、没有级别、编码与行尾都不统一** —— `detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF!),
-    `cms.log` 首行甚至是一条历史 `SyntaxWarning`; 机器审计反而叫 `.jsonl` 混在 `.log` 里;
-  · 全仓 `import logging` 的文件数 = **0** —— 没有统一设施, 每个进程各 print 各的。
-
-## 统一后的口径 (机器可查, 见 scripts/log_audit.py)
-
-    logs/<组件>.log                长驻服务与启动器 (组件名 = configs/serve.json 的键 + gateway + serve/start_hidden)
-    logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   运维动作日志 (保留最近 20 份 / 30 天, 超出的自动清理)
-    logs/audit/<名字>.jsonl        机器审计流水 (JSON Lines: 一行一条 JSON)
-
-    行格式 (每一行都要满足, 校验正则见 LINE_RE):
-        YYYY-MM-DD HH:MM:SS LEVEL 组件 消息
-    级别: DEBUG/INFO/WARN/ERROR;  UTF-8 无 BOM;  行尾 LF;  不含 ANSI 颜色码。
-
-服务侧怎么落地: 各服务不用改自己的 print —— 入口处调一次 `prefix_stdout(组件名)`,
-之后**每一行**都会自动带上时间戳/级别/组件 (实现见下面 _Prefixed 包装器)。这样"内容格式统一"
-不是靠自觉, 而是由设施保证。
-"""
-from __future__ import annotations
-
-import datetime as dt
-import json
-import os
-import pathlib
-import re
-import sys
-
-import pathlib as _p
-
-ROOT = _p.Path(__file__).resolve().parents[1]
-LOGS = _p.Path(os.environ.get('WINDSCADA_LOGS') or (ROOT / 'logs'))
-OPS_DIR = LOGS / 'ops'
-AUDIT_DIR = LOGS / 'audit'
-BUILD_DIR = LOGS / 'build'          # 构建/摄入类脚本的日志 (按产物子路径归档; 不许写在产物目录里)
-LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR')
-
-# 一行日志的规范形式: 时间戳 + 级别 + 组件 + 消息 (组件名允许中文/点/下划线/连字符)
-LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:DEBUG|INFO|WARN|ERROR) [\w\u4e00-\u9fff.\-]+ ')
-ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
-
-ACTION_KEEP = 20          # 运维动作日志保留份数
-ACTION_DAYS = 30          # 运维动作日志保留天数
-
-
-def now() -> str:
-    return dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
-
-def line(level: str, comp: str, msg: str, ts: str | None = None) -> str:
-    """拼一行规范日志 (纯函数, 便于测试)。多行消息会被逐行加前缀。"""
-    lv = (level or 'INFO').upper()
-    if lv not in LEVELS:
-        lv = 'INFO'
-    out = []
-    for i, part in enumerate(str(msg).replace('\r\n', '\n').replace('\r', '\n').split('\n')):
-        out.append(f'{ts or now()} {lv} {comp} {part}')
-    return '\n'.join(out)
-
-
-def component_log(comp: str) -> pathlib.Path:
-    """长驻服务/启动器的日志路径: logs/<组件>.log"""
-    return LOGS / f'{comp}.log'
-
-
-def action_log(tag: str, when: dt.datetime | None = None) -> pathlib.Path:
-    """运维动作日志路径: logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log (写入前会先按保留策略清理)"""
-    w = when or dt.datetime.now()
-    return OPS_DIR / f'{tag}_{w:%Y%m%d-%H%M%S}.log'
-
-
-def audit_log(name: str) -> pathlib.Path:
-    """机器审计流水路径: logs/audit/<名字>.jsonl"""
-    return AUDIT_DIR / (name if name.endswith(('.jsonl', '.json')) else f'{name}.jsonl')
-
-
-def ensure_dirs() -> None:
-    for d in (LOGS, OPS_DIR, AUDIT_DIR, BUILD_DIR):
-        d.mkdir(parents=True, exist_ok=True)
-
-
-def build_log(rel: str, farm: str | None = None) -> pathlib.Path:
-    """构建/摄入脚本的日志路径: `logs/build/<场>/<相对路径>.log`
-
-    ★ 2026-09-17 用户令 2 的由来: 交付包里 39 个构建日志原本**躺在产物目录里**
-    (`outputs/<场>/windscada/*.log` 等), 还被产物台账登记成 shipped 随包件 ——
-    "日志在产物里"正是这次要统一掉的不一致。新脚本用本函数落 `logs/build/`, 旧的已迁移过去。
-    """
-    p = pathlib.Path(rel)
-    parts = [farm] if farm else []
-    return BUILD_DIR.joinpath(*parts, *p.parts)
-
-
-def write(comp: str, msg: str, level: str = 'INFO', path: pathlib.Path | None = None) -> pathlib.Path:
-    """追加一行规范日志 (UTF-8, LF)。"""
-    ensure_dirs()
-    p = pathlib.Path(path) if path else component_log(comp)
-    p.parent.mkdir(parents=True, exist_ok=True)
-    with open(p, 'a', encoding='utf-8', newline='\n') as f:
-        f.write(line(level, comp, msg) + '\n')
-    return p
-
-
-def append_jsonl(name: str, rec: dict) -> pathlib.Path:
-    """机器审计流水: 一行一条 JSON (ensure_ascii=False, 时间戳字段 ts)。"""
-    ensure_dirs()
-    p = audit_log(name)
-    rec = dict(rec)
-    rec.setdefault('ts', now())
-    with open(p, 'a', encoding='utf-8', newline='\n') as f:
-        f.write(json.dumps(rec, ensure_ascii=False, default=str) + '\n')
-    return p
-
-
-def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool = False) -> list[pathlib.Path]:
-    """运维动作日志保留策略: 只留最近 keep 份、且不超过 days 天。→ 删掉的文件列表。"""
-    if not OPS_DIR.is_dir():
-        return []
-    files = sorted((f for f in OPS_DIR.glob('*.log') if f.is_file()), key=lambda f: f.stat().st_mtime, reverse=True)
-    cutoff = dt.datetime.now().timestamp() - days * 86400
-    gone = []
-    for i, f in enumerate(files):
-        if i < keep and f.stat().st_mtime >= cutoff:
-            continue
-        gone.append(f)
-        if not dry:
-            try:
-                f.unlink()
-            except OSError:
-                pass
-    return gone
-
-
-def normalize_file(path: pathlib.Path, comp: str, when: float | None = None) -> int:
-    """把一份**裸输出**日志就地补成统一格式 → 补了几行。
-
-    为什么需要: 运维动作的日志是"子进程直接写 fd"的产物 (`_ops_run.py` 把本进程的 stdout 句柄交给子命令,
-    见那里的注释 —— 用管道收输出曾经导致日志空白/任务看起来卡住), 所以子命令的 print **绕过**了 Python
-    层的流包装。这里在动作结束时补一次前缀: 已经是规范行的原样保留, 其余行补 `时间戳 级别 组件.raw`,
-    并在开头插一行说明"以下为原样输出、前缀是事后补的" —— 不假装是原始时刻写的。
-    """
-    if not path or not pathlib.Path(path).is_file():
-        return 0
-    p = pathlib.Path(path)
-    ts = dt.datetime.fromtimestamp(when if when is not None else p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
-    raw = p.read_text(encoding='utf-8', errors='replace').replace('\r\n', '\n').split('\n')
-    fixed, n = [], 0
-    for ln in raw:
-        if not ln.strip():
-            continue
-        if LINE_RE.match(ln):
-            fixed.append(ln)
-            continue
-        fixed.append(f'{ts} INFO {comp}.raw {ln}')
-        n += 1
-    if n:
-        fixed.insert(0, f'{ts} INFO {comp} === 以下 {n} 行为子进程原样输出 (前缀由 logfile.normalize_file 事后补齐) ===')
-        p.write_text('\n'.join(fixed) + '\n', encoding='utf-8', newline='\n')
-    return n
-
-
-# ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
-class _Prefixed:
-    """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。
-
-    刻意做成**流包装**而不是要求 6 个服务各自改用 logging:
-    服务里已有大量 print (启动横幅/请求日志/进度), 逐个改既费事又会漏; 包一层之后
-    "内容格式统一"由设施保证。级别默认 INFO; 含 'error'/'traceback' 字样的行按 ERROR 记。
-    """
-
-    def __init__(self, stream, comp: str):
-        self._s = stream
-        self._comp = comp
-        self._buf = ''
-
-    def write(self, data):
-        if not isinstance(data, str):
-            data = str(data)
-        self._buf += data
-        while '\n' in self._buf:
-            ln, self._buf = self._buf.split('\n', 1)
-            self._emit(ln)
-        return len(data)
-
-    def _emit(self, ln: str):
-        clean = ANSI_RE.sub('', ln.rstrip('\r'))
-        lv = 'ERROR' if re.search(r'error|traceback|失败|异常', clean, re.I) else 'INFO'
-        try:
-            self._s.write(line(lv, self._comp, clean) + '\n')
-            self._s.flush()
-        except Exception:
-            pass
-
-    def flush(self):
-        if self._buf:
-            self._emit(self._buf)
-            self._buf = ''
-        try:
-            self._s.flush()
-        except Exception:
-            pass
-
-    def __getattr__(self, item):
-        return getattr(self._s, item)
-
-
-def prefix_stdout(comp: str) -> None:
-    """服务入口调一次: 之后 stdout/stderr 的每一行都符合统一格式 (幂等)。"""
-    if getattr(sys.stdout, '_guanlan_prefixed', False) or os.environ.get('GUANLAN_LOG_RAW'):
-        return
-    so, se = _Prefixed(sys.stdout, comp), _Prefixed(sys.stderr, comp)
-    so._guanlan_prefixed = se._guanlan_prefixed = True
-    sys.stdout, sys.stderr = so, se
-
-
-if __name__ == '__main__':      # 直接跑 = 打印口径 + 清洗一次动作日志
-    ensure_dirs()
-    print(f'logs 目录: {LOGS}')
-    print(f'  服务日志   logs/<组件>.log       (组件: configs/serve.json 的键 + gateway/serve/start_hidden)')
-    print(f'  动作日志   logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   保留最近 {ACTION_KEEP} 份 / {ACTION_DAYS} 天')
-    print(f'  审计流水   logs/audit/<名字>.jsonl')
-    print(f'  行格式     {line("INFO", "示例", "一行长这样")}')
-    gone = prune_actions(dry=True)
-    print(f'  现有动作日志 {len(list(OPS_DIR.glob("*.log")))} 份, 按保留策略该清理 {len(gone)} 份')
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/logfile.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.logfile` 指向真实现,于是
+`from src import logfile` / `from src.logfile import X` / `import src.logfile` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import logfile as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'logfile', _impl)
+
+sys.modules[__name__] = _impl

+ 18 - 57
src/opsjob.py

@@ -1,57 +1,18 @@
-# -*- coding: utf-8 -*-
-r"""运维动作(重算/清产物)的**当前状态**读取口(单一实现)。
-
-为什么需要它: 2026-09-19 用户报"重算后页面没有输出"。实况是——门户里点了「清除产物 → 执行重算」,
-产物被清掉后重算要跑很久(本次 ④b 振动侧摄入要处理 150 GB 的 CMS 导出), 期间各页面只能显示
-"无产物"。但那句话现在只说"要么还没放数据, 要么刚清过产物", **没说"重算正在跑"** ——
-于是同一现象被读成"系统坏了"。
-
-状态真源: `scripts/_ops_run.py` 写的 `run/ops_job.json`(每 15 s 心跳更新 mtime)。
-判定"在跑"= `status == 'running'` **且** 文件 mtime 在 `fresh_s` 秒内(只看 pid 会被复用骗过)。
-"""
-from __future__ import annotations
-
-import json
-import pathlib
-import time
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-JOB = ROOT / 'run' / 'ops_job.json'
-
-
-def current(fresh_s: int = 90) -> dict:
-    """→ dict(running=bool, kind=, cmd=, started=, elapsed_s=, age_s=, stale=bool, note=)。读不到就 running=False。"""
-    out = dict(running=False, kind=None, cmd=None, started=None, elapsed_s=None, age_s=None,
-               stale=False, note=None, job_file=str(JOB))
-    if not JOB.is_file():
-        return out
-    try:
-        j = json.loads(JOB.read_text(encoding='utf-8'))
-    except Exception as e:
-        return {**out, 'note': f'任务文件读不出 ({type(e).__name__})'}
-    age = time.time() - JOB.stat().st_mtime
-    out.update(kind=j.get('kind'), cmd=j.get('cmd'), started=j.get('started'), age_s=round(age),
-               status=j.get('status'), rc=j.get('rc'))
-    running = (j.get('status') == 'running') and age < fresh_s
-    out['running'] = bool(running)
-    out['stale'] = (j.get('status') == 'running') and age >= fresh_s
-    if out.get('started'):
-        try:
-            t0 = time.mktime(time.strptime(out['started'], '%Y-%m-%d %H:%M:%S'))
-            out['elapsed_s'] = round(time.time() - t0)
-        except Exception:
-            pass
-    if out['stale']:
-        out['note'] = (f"任务文件写着 running 但已 {round(age / 60, 1)} 分钟没心跳 —— 可能是被强杀, "
-                       f"不是正在跑")
-    return out
-
-
-def running_text() -> str:
-    """给人看的一句话(页面/接口用): 在跑就写明"起点 + 已跑多久", 否则空串。"""
-    s = current()
-    if not s['running']:
-        return ''
-    mins = (s['elapsed_s'] or 0) / 60
-    return (f"⚠ 检测到**重算正在进行中**({s['kind']},起于 {s['started']},已跑 {mins:.0f} 分钟)—— "
-            f"产物是一步步长出来的,页面会在对应步骤跑完后自己回来(无需重启服务)。")
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/opsjob.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.opsjob` 指向真实现,于是
+`from src import opsjob` / `from src.opsjob import X` / `import src.opsjob` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import opsjob as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'opsjob', _impl)
+
+sys.modules[__name__] = _impl

+ 9 - 241
src/paths.py

@@ -1,250 +1,18 @@
 # -*- coding: utf-8 -*-
-r"""观澜 v2 路径中心 —— 跨平台部署的唯一路径真源 (2026-09-11 用户令)。
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/paths.py`。
 
-## 约定 (硬约束)
-
-1. **代码与配置里只写相对路径**, 相对 **安装根 (ROOT)** —— 不写机器相关绝对路径
-   (`/Users/…`、`/Volumes/…`、`C:\…`、`D:\…`、`F:\…`)。
-2. 运行时**由本模块解析成绝对路径**, 解析基准是 ROOT —— **不是 cwd**。
-   cwd 相对是隐形坑: 从别处调用同一个脚本 (`python <绝对路径>/scripts/x.py`) 或换工作目录,
-   路径就会指到别处; 本仓 2026-09-11 实测有 20+ 处 `Path('outputs/rudong/…')` 属此类, 已全部归到本模块。
-3. 需要写进产物/清单/页面的路径字符串, 用 **POSIX 相对形式** (`outputs/rudong/…`, 见 `rel()`),
-   Windows 与 Linux 通用; 不要用 `os.sep` 拼库存字符串 (那只适合"给人看"的显示, 见 `disp()`)。
-4. ROOT 的确定: 环境变量 `WINDSCADA_ROOT` (冻结打包时由启动器设) → 否则按本文件位置回溯
-   (`src/paths.py` 的上一级) —— 因此**整个安装目录可以整个拷到别的电脑/别的盘**, 不需要改任何路径。
-
-## 跨平台要点
-
-- 分隔符: 一律 `pathlib`, 需要字符串时 `.as_posix()`;
-- 大小写: Linux 区分大小写 —— 目录名只用本模块常量, 不靠大小写变体;
-- 解释器: venv 按平台探测 (`.venv/Scripts/python.exe` / `.venv/bin/python`, 见 `venv_python()`);
-- 中文目录名 (如 `data/raw/如东`、`故障报警`): UTF-8 存写, 两侧平台均可; 页面显示走 `disp()`。
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.paths` 指向真实现,于是
+`from src import paths` / `from src.paths import X` / `import src.paths` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
 """
 from __future__ import annotations
 
-import os
-import pathlib
 import sys
 
-# ---- 安装根: env → 本文件位置回溯 (src/paths.py → <安装目录>) ----------------------------
-ROOT = pathlib.Path(os.environ.get('WINDSCADA_ROOT') or pathlib.Path(__file__).resolve().parents[1])
-
-# ---- 输入侧 (离线数据): data/raw, 下一级目录 = 场站名 (见 docs/数据目录结构与落位约定) ----
-RAW_ROOT = pathlib.Path(os.environ.get('WINDSCADA_RUDONG_SRC') or (ROOT / 'data' / 'raw'))
-
-# ---- 非场站维度的固定位置 (全部为 ROOT 相对) -------------------------------------------
-CONFIGS = ROOT / 'configs'
-FARMS = CONFIGS / 'farms'
-REFERENCE = ROOT / 'reference'
-RELEASE = ROOT / 'release'
-PORTAL = RELEASE / 'portal.html'
-VIEWER = RELEASE / 'viewer'
-RESOURCES = ROOT / 'resources'
-SIM_DIR = RESOURCES / 'oem_envision_sc1_rudong2014'
-DOCS = ROOT / 'docs'
-LOGS = ROOT / 'logs'
-RUN = ROOT / 'run'
-SCRIPTS = ROOT / 'scripts'
-SRC = ROOT / 'src'
-WHEELS = ROOT / 'wheels'
-DATA = ROOT / 'data'
-SERVE_JSON = CONFIGS / 'serve.json'
-MODELS_JSON = CONFIGS / 'models.json'
-
-# ---- 配置目录约定与唯一取用口 (2026-09-17 用户令 2: 统一配置目录及配置文件) ----------------
-# 为什么要有这一节: 原来各模块自己拼 `ROOT / 'configs' / 'xxx.yaml'` (实测 9 个模块各拼各的),
-# 于是"配置放哪"随时间漂移: 顶层散着 serve.json/models.json/portal_pages.yaml, 还混进过一个
-# `serve.json.bak-bomfix`; `scripts/audit_chinese_terms.py` 甚至要**试三个位置**才找得到 terms 库。
-# 现在: 目录分工写死在下面, 代码**只从 `config()` / `config_dir()` 取路径**, 不许再手拼字符串;
-# `scripts/config_audit.py` 会按这套约定查实物与代码(见 docs §11)。
-#
-#   configs/<域>/<名字>.<yaml|json|csv>     域 = canonical | contracts | farms | terms | <新增>
-#   configs/serve.json                      运行期单件配置 (端口/路径真源)
-#   configs/models.json                     运行期单件配置 (本机模型档)
-#   configs/portal_pages.yaml               运行期单件配置 (门户页面归口登记表, 见 §10)
-TOP_LEVEL_CONFIGS = ('serve.json', 'models.json', 'portal_pages.yaml')
-CONFIG_DOMAINS = ('canonical', 'contracts', 'farms', 'terms')
-CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv')
-
-
-def config(*parts: str, must_exist: bool = False) -> pathlib.Path:
-    """配置文件的唯一取用口: `P.config('terms', 'display_map.yaml')` / `P.config('serve.json')`。
-
-    只做路径解析, 不读文件 (读法由调用方决定: yaml/json/csv 各不相同);
-    `must_exist=True` 时不存在就抛 FileNotFoundError —— 配置缺失应该在启动时报出来, 别静默用默认值。
-    """
-    p = CONFIGS.joinpath(*parts)
-    if must_exist and not p.is_file():
-        raise FileNotFoundError(f'缺配置文件 {p} (约定见 src/paths.py 配置一节 / docs §11)')
-    return p
-
-
-def config_dir(*parts: str) -> pathlib.Path:
-    """配置目录 (域) 的取用口: `P.config_dir('farms')`。"""
-    return CONFIGS.joinpath(*parts)
-
-
-def farm_config(name: str | None = None) -> pathlib.Path | None:
-    """场定义配置文件 —— **格式统一为 YAML**, 兼容历史 `.json` (有就优先用)。
-
-    2026-09-17 实测的坑: `configs/farms/` 下有 8 个场定义是 `.yaml`, 而 `available()` 只认 `*.json`
-    ⇒ 这些场**根本列不出来**(等于配置写了没人看见); 目录里那个模板还叫 `_模板.json.example`,
-    与实物格式相反。现在两边都认, 且约定"新的场定义写 yaml"。
-    """
-    f = farm(name)
-    for ext in ('.yaml', '.yml', '.json'):
-        p = FARMS / f'{f}{ext}'
-        if p.is_file():
-            return p
-    return None
-
-
-def farm(name: str | None = None) -> str:
-    """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。
-
-    这里不 import src.windscada.config 以免循环 (config 反过来要用本模块)。
-    需要 set_current() 那种运行期切换时, 调用方把场名显式传进来即可。"""
-    return name or os.environ.get('WINDSCADA_FARM') or 'rudong'
-
-
-# ---- 产物侧: outputs/<场名>/… (页面取数的仓) -------------------------------------------
-def out_root(name: str | None = None) -> pathlib.Path:
-    return ROOT / 'outputs' / farm(name)
-
-
-def store(name: str | None = None) -> pathlib.Path:
-    """L0 标准仓 (parquet) —— 页面主取数处。"""
-    return out_root(name) / 'windscada'
-
-
-def ont(name: str | None = None) -> pathlib.Path:
-    """本体对象库目录 (objects.json / 检索索引 / turbine_params)。"""
-    return out_root(name) / 'ontology'
-
-
-def objects_json(name: str | None = None) -> pathlib.Path:
-    return ont(name) / 'objects.json'
-
-
-def cms(name: str | None = None) -> pathlib.Path:
-    """CMS 振动诊断产物目录。"""
-    return out_root(name) / 'windcms'
-
-
-def m5(name: str | None = None) -> pathlib.Path:
-    """振动线 handoff / TCM 兼容件目录。"""
-    return out_root(name) / 'm5_cms_tcm'
-
-
-def tcm_replay(name: str | None = None) -> pathlib.Path:
-    return out_root(name) / 'tcm_compatible_replay'
-
-
-def sop(name: str | None = None) -> pathlib.Path:
-    """SOP 中间件与评审落盘目录。"""
-    return out_root(name) / 'sop'
-
-
-def guanlan(name: str | None = None) -> pathlib.Path:
-    """事实契约与对外派生 (可上云面孔)。"""
-    return out_root(name) / 'guanlan'
-
-
-def pitch(name: str | None = None) -> pathlib.Path:
-    return out_root(name) / 'pitch'
-
-
-def paradigm(name: str | None = None) -> pathlib.Path:
-    """范式实验件 (E3/E5/E8 底稿) —— 事实契约的输入之一 (2026-09-16 补: 原先直接用
-    `ROOT/'outputs'/'rudong'/'paradigm_r1'` 拼, 既写死场名又绕过了本模块)。"""
-    return out_root(name) / 'paradigm_r1'
-
-
-def report_dir(name: str | None = None) -> pathlib.Path:
-    """报告交付件目录 (`交接_振动→状态评估报告_*.md` / `现场单_*.md`) —— 由振动线出件,
-    并被 `src/windcms/config.py` 的 knowledge_docs 引用 (2026-09-16 补: 该目录在 v0.2.0 里
-    没有明确归属, 一直以 `out_root()/'report'` 的裸拼形式出现)。"""
-    return out_root(name) / 'report'
-
-
-def cloud(name: str | None = None) -> pathlib.Path:
-    """可上云面孔 (脱敏后的契约/派生件/页面) —— `scripts/guanlan_cloud_*.py` 的落点。"""
-    return guanlan(name) / 'cloud'
-
-
-def contract(name: str | None = None) -> pathlib.Path:
-    """场契约 (机型判据参数), 属 reference 侧, 不在 outputs。"""
-    return REFERENCE / farm(name) / 'windscada_contract.yaml'
-
-
-def station_dir(name: str | None = None) -> pathlib.Path:
-    """本场原始件目录 data/raw/<场站名称> —— 具体由场配置扫描结果决定,
-    这里只给"约定位置"兜底 (场配置里 windscada.config.farm()['raw_station_dir'] 才是权威)。"""
-    return RAW_ROOT / farm(name)
-
-
-# ---- 解释器与字符串形式 ----------------------------------------------------------------
-def venv_python() -> pathlib.Path | None:
-    """本安装目录下的 venv 解释器 (跨平台); 不存在返回 None。"""
-    for rel in (('Scripts', 'python.exe'), ('bin', 'python'), ('bin', 'python3')):
-        p = ROOT / '.venv' / pathlib.Path(*rel)
-        if p.exists():
-            return p
-    return None
-
-
-def resolve(p) -> pathlib.Path:
-    """把"可能是相对路径"的值解析成绝对路径: 相对基准是 ROOT (**不是 cwd**)。"""
-    q = pathlib.Path(p)
-    return q if q.is_absolute() else (ROOT / q)
-
-
-def rel(p) -> str:
-    """给人/给清单的**相对**路径字符串 (POSIX 形式); 不在 ROOT 内则给绝对 POSIX。"""
-    q = pathlib.Path(p)
-    try:
-        return q.resolve().relative_to(ROOT.resolve()).as_posix()
-    except ValueError:
-        return q.as_posix()
-
-
-def disp(p) -> str:
-    """给人看的显示路径: 安装目录内的写成 `<安装目录>/…`, 分隔符随本机; 其余原样绝对路径。
-    (只用于显示 —— 不要把它写进产物或清单。)"""
-    q = pathlib.Path(p)
-    try:
-        return str(pathlib.Path('<安装目录>') / q.resolve().relative_to(ROOT.resolve()))
-    except ValueError:
-        return str(q)
-
-
-# 显示用分隔符: 只服务于给人看的文本 (页面「位置」列)。写进产物/清单的路径请用 rel() 的 POSIX 形式。
-SEP = os.sep
-
-
-def disp_dir(p) -> str:
-    """目录的**显示形**: 末尾带本机分隔符 (页面「位置」列用).
-    路径本身一律走 `rel()` 的 POSIX 形式; 这里的 os.sep 只服务于"给人看"。"""
-    return disp(p) + os.sep
-
-
-def python_exe() -> str:
-    """跑子进程/脚本用的解释器: venv → 否则当前解释器。"""
-    v = venv_python()
-    return str(v) if v else sys.executable
-
-
-def platform_tag() -> str:
-    """平台标识 (探测脚本/日志用): windows / linux / darwin。"""
-    return {'nt': 'windows', 'posix': 'linux'}.get(os.name, os.name) if sys.platform != 'darwin' else 'darwin'
+from app_common.common.app_common_guanlan import paths as _impl
 
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'paths', _impl)
 
-if __name__ == '__main__':       # 自检: python -m src.paths  或  python src/paths.py
-    print(f'ROOT       : {ROOT}   (存在: {ROOT.is_dir()})')
-    print(f'平台       : {platform_tag()}   cwd: {pathlib.Path.cwd()}')
-    print(f'原始件根   : {rel(RAW_ROOT)}   (存在: {RAW_ROOT.is_dir()})')
-    print(f'venv 解释器: {venv_python() or "(无, 用 " + sys.executable + ")"}')
-    for label, p in (('store', store()), ('ontology', ont()), ('windcms', cms()),
-                     ('m5', m5()), ('sop', sop()), ('guanlan', guanlan()),
-                     ('release', RELEASE), ('sim_dir', SIM_DIR)):
-        print(f'  {label:9s} {rel(p):42s} 存在={p.exists()}')
+sys.modules[__name__] = _impl

+ 18 - 156
src/proc.py

@@ -1,156 +1,18 @@
-# -*- coding: utf-8 -*-
-"""子进程创建的统一口径 —— **不弹命令窗口** (2026-09-16 用户令: 启动/操作观澜时不弹命令窗口)。
-
-## 为什么要收敛到一处
-
-Windows 上"**无控制台的父进程** + 裸 spawn 一个控制台程序" = 系统给子进程**新建一个可见控制台窗口**。
-本项目里这类父进程很多:
-  · 网关 `guanlan_gateway.py` 自己是被 `DETACHED_PROCESS` 起来的 (无控制台) → 它调的 `tasklist` / `git` 会闪窗;
-  · 运维动作进程 (`scripts/_ops_launch.py` → `_ops_run.py`) 同样无控制台 → 从页面点"重算"时,
-    动作全过程都在一个可见窗口里跑 (最长 20 分钟), 页面每 2 s 轮询 `tasklist` 还会**反复闪窗**;
-  · 组件服务原先有的地方用 `DETACHED_PROCESS` (子进程干脆没有控制台), 有的地方什么都不加 (于是弹窗),
-    两种写法混用 —— 实测现场会攒下多个标题为 `.venv\\Scripts\\python.exe` 的黑窗。
-
-统一到本模块后: 只认 `NO_WINDOW` (`CREATE_NO_WINDOW`) 一种写法。
-★ `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` **互斥**, 不要叠加 —— 前者是"给一个没有窗口的控制台",
-  后者是"不给控制台"; 叠在一起行为依赖 Windows 版本。需要"子进程活过父进程"时用
-  `NEW_GROUP` (`CREATE_NEW_PROCESS_GROUP`) + 不共享控制台即可, 不需要 DETACHED。
-
-## 日志去哪了 (hide 窗口不等于看不见)
-
-窗口藏起来后, 子进程的 stdout/stderr 一律重定向到 `logs/<name>.log` (`spawn(log=…)`),
-`/ops` 页面也会显示任务日志尾巴 —— 排障路径不变, 只是不再靠一个黑窗。
-
-## 用法
-
-    from src.proc import spawn, run, NO_WINDOW
-    pid = spawn([py, 'scripts/x.py'], log=P.LOGS / 'x.log', env=e, cwd=ROOT)   # 后台, 不弹窗
-    r = run(['tasklist', '/FI', f'PID eq {pid}'], capture_output=True, text=True)  # 等待, 不弹窗
-"""
-from __future__ import annotations
-
-import os
-import pathlib
-import subprocess
-import sys
-
-WIN = os.name == 'nt'
-# CREATE_NO_WINDOW = 0x08000000: 给子进程一个**没有窗口**的控制台 (stdio 仍可重定向)
-NO_WINDOW = 0x08000000 if WIN else 0
-# CREATE_NEW_PROCESS_GROUP = 0x00000200: 子进程不受父进程 Ctrl-C 影响, 也不共享父的控制台事件
-NEW_GROUP = 0x00000200 if WIN else 0
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-
-
-def flags(*, new_group: bool = True) -> int:
-    """本模块的统一 creationflags (非 Windows 返回 0, 调用方无需分支)。"""
-    f = NO_WINDOW
-    if new_group:
-        f |= NEW_GROUP
-    return f
-
-
-def _kw(kw: dict, *, new_group: bool = True) -> dict:
-    if WIN:
-        kw.setdefault('creationflags', flags(new_group=new_group))
-    else:
-        kw.setdefault('start_new_session', True)
-    return kw
-
-
-def _inherit_stdio(kw: dict) -> dict:
-    """把父进程**当前**的标准句柄显式交给子进程 (STARTF_USESTDHANDLES)。
-
-    为什么必须显式 (2026-09-16 实逮, 是本模块第一版引入的坑): Windows 上 `CREATE_NO_WINDOW` 会给
-    子进程新建一个"没有窗口的控制台", 而**新建控制台会把该进程的标准句柄重指到新控制台的缓冲区** ——
-    于是"父进程 stdout 已被重定向到日志文件"这件事, 在**孙子辈**就丢了。
-    现场表现: 从页面点"执行重算" → `_ops_launch`(显式 stdout=日志) → `_ops_run`(输出进了日志 ✔)
-    → `rebuild_all.py`(没显式句柄 → 输出掉进那个隐形控制台, 页面只剩"运行中"、日志里一个字都没有)。
-    显式传 `sys.stdout/stderr` 即可让整条链写进同一个日志; `sys.stdout is None` (pythonw) 时跳过。
-    """
-    if kw.get('capture_output'):
-        # ★ `capture_output=True` 与显式 `stdout=` **互斥**, 同给会抛 ValueError。
-        #   2026-09-16 实逮: 忘了这一步 → guanlan_ops.job_running() 里的 `tasklist` 每次都抛,
-        #   异常被 except 吞成 alive=False ⇒ **任何在跑的重算都被立刻改写成"被强杀"**,
-        #   页面显示完成、重算按钮重新可点(可能并发起两个重算)。
-        #   这里什么都不加就对了; 若调用方自己又传了 stdout, 让 Python 照旧抛错 (不替它吞)。
-        return kw
-    if 'stdout' not in kw:
-        s = sys.stdout
-        if s is not None and hasattr(s, 'fileno'):
-            try:
-                s.fileno()
-                kw['stdout'] = s
-            except Exception:
-                pass
-    if 'stderr' not in kw:
-        s = sys.stderr
-        if s is not None and hasattr(s, 'fileno'):
-            try:
-                s.fileno()
-                kw['stderr'] = s            # 不合并到 stdout: 让调用方自己决定 (spawn(log=) 才合并)
-            except Exception:
-                pass
-    return kw
-
-
-def spawn(cmd, log=None, env=None, cwd=None, *, new_group: bool = True, stdin_devnull: bool = True, **popen_kw):
-    """后台起一个**无窗口**子进程。`log` 给路径时把 stdout/stderr 追加进该文件。
-
-    `**popen_kw` 透传给 `Popen` —— 调用方要自己给 `stdout=`/`stderr=` 句柄时 (例如"日志由子进程
-    自己写、父进程只留 fd") 用得上; 给了就**不覆盖**它。没给则继承父进程当前的标准句柄 (见 _inherit_stdio)。
-    → Popen 对象 (拿 `.pid`)。
-    """
-    kw = dict(cwd=str(cwd or ROOT), env=env)
-    if stdin_devnull and 'stdin' not in popen_kw:
-        kw['stdin'] = subprocess.DEVNULL
-    fh = None
-    if log is not None and 'stdout' not in popen_kw:
-        log = pathlib.Path(log)
-        log.parent.mkdir(parents=True, exist_ok=True)
-        fh = open(log, 'ab')
-        kw['stdout'] = fh
-        kw['stderr'] = subprocess.STDOUT
-    kw.update(popen_kw)
-    _inherit_stdio(kw)
-    _kw(kw, new_group=new_group)
-    p = subprocess.Popen([str(c) for c in cmd], **kw)
-    if fh is not None:
-        # 父进程不一定等子进程结束; 句柄由子进程持有, 这里不要 close 掉它 —— 交给 GC/进程退出即可。
-        p._dsh_log = fh          # 仅作引用保存, 避免过早回收
-    return p
-
-
-def run(cmd, *, new_group: bool = True, **kw):
-    """前台等待的 `subprocess.run`, 但**不弹窗** (tasklist / git / 短命令都该走这里)。
-
-    ★ 文本解码请显式带 `errors='replace'`: `tasklist` 的输出是**控制台代码页**(中文 Windows = GBK),
-      而本进程可能是 PYTHONUTF8=1 起的 (默认文本编码 UTF-8) → 解码失败会让 `r.stdout` 变成 None,
-      调用方再 `str(pid) in r.stdout` 就 TypeError (2026-09-12 实逮过)。
-    ★ 未显式给 stdout/stderr 时继承父进程当前句柄 (见 _inherit_stdio) —— 否则 `CREATE_NO_WINDOW`
-      新建的控制台会把子进程输出"吸走", 日志里什么都看不到。
-    """
-    _inherit_stdio(kw)
-    _kw(kw, new_group=new_group)
-    return subprocess.run([str(c) for c in cmd], **kw)
-
-
-def run_text(cmd, **kw):
-    """`run` + text=True + errors='replace' (控制台输出来源的默认姿势)。"""
-    kw.setdefault('capture_output', True)
-    kw.setdefault('text', True)
-    kw.setdefault('errors', 'replace')
-    return run(cmd, **kw)
-
-
-def python_exe() -> str:
-    """跑脚本用的解释器 (venv 优先)。放这里是为了让调用方一处取值, 别再各写一遍。"""
-    try:
-        from src import paths as P
-        v = P.venv_python()
-        if v:
-            return str(v)
-    except Exception:
-        pass
-    return sys.executable
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/proc.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.proc` 指向真实现,于是
+`from src import proc` / `from src.proc import X` / `import src.proc` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import proc as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'proc', _impl)
+
+sys.modules[__name__] = _impl

+ 18 - 60
src/tabfmt.py

@@ -1,60 +1,18 @@
-# -*- coding: utf-8 -*-
-r"""Markdown 表格渲染(**零可选依赖**)。
-
-为什么需要它(2026-09-19 实逮,一次真实事故):
-`src/windcms/report.py` 用 `DataFrame.to_markdown()` 渲染"L4 过闸谱线"表 —— 而 pandas 的
-`to_markdown` 依赖**可选包 `tabulate`**,本交付包没有它。此前 `model_run_l6.parquet` 是空的
-(六层链那两步没实现),`len(l6)` 为 0 走的是 `'无'` 分支 ⇒ **没人踩到**;2026-09-19 我把
-`model_run` 按口径重建、L6 真的有 14 行之后,重算链在 `report` 步当场炸:
-
-    ImportError: `Import tabulate` failed.  Use pip or conda to install the tabulate package.
-        at src/windcms/report.py:504  l6[cols].to_markdown(index=False)
-
-后果不是"少一张表",而是**整条重算链停在第 4 步**:后面的 ⑥重启 / ⑦本体(`ontology/objects.json` 等)
-全不执行 ⇒ 页面上"决策链/本体/报告"一片空白。教训与 memory `optional-dep-in-hot-path` 同型:
-**热路径不许依赖可选包**,何况交付包是离线整包(不能 pip)。
-
-这里用纯 Python 渲染同样的 Markdown 表(列宽按显示宽度补空格,中文按 2 列宽算)。
-"""
-from __future__ import annotations
-
-
-def _w(s) -> int:
-    """显示宽度: CJK 与全角标点算 2, 其余算 1(对齐用, 不追求像素级)。"""
-    n = 0
-    for ch in str(s):
-        n += 2 if ('\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f'
-                   or '\uff00' <= ch <= '\uffef') else 1
-    return n
-
-
-def to_md(rows: list[list], header: list[str] | None = None) -> str:
-    """[[单元格…], …] → Markdown 表(首行可作表头)。空数据返回 ''。"""
-    if not rows:
-        return ''
-    esc = lambda x: ('' if x is None else str(x)).replace('|', '\\|')      # 竖线要转义, 否则表被拆列
-    body = [[esc(c) for c in r] for r in rows]
-    if header:
-        cols = [str(h) for h in header]
-        body = [cols] + body
-    ncol = max(len(r) for r in body)
-    body = [r + [''] * (ncol - len(r)) for r in body]
-    width = [max(_w(r[i]) for r in body) for i in range(ncol)]
-    out = []
-    for i, r in enumerate(body):
-        out.append('| ' + ' | '.join(str(c) + ' ' * (width[j] - _w(c)) for j, c in enumerate(r)) + ' |')
-        if i == 0:
-            out.append('|' + '|'.join('-' * (width[j] + 2) for j in range(ncol)) + '|')
-    return '\n'.join(out)
-
-
-def df_to_md(df, cols=None, index: bool = False) -> str:
-    """DataFrame → Markdown 表(`to_markdown(index=False)` 的零依赖替代)。"""
-    if df is None or len(df) == 0:
-        return ''
-    d = df[cols] if cols else df
-    head = ([d.index.name or ''] if index else []) + [str(c) for c in d.columns]
-    rows = []
-    for idx, r in zip(d.index, d.itertuples(index=False)):
-        rows.append(([idx] if index else []) + list(r))
-    return to_md(rows, header=head)
+# -*- coding: utf-8 -*-
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/tabfmt.py`。
+
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.tabfmt` 指向真实现,于是
+`from src import tabfmt` / `from src.tabfmt import X` / `import src.tabfmt` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
+"""
+from __future__ import annotations
+
+import sys
+
+from app_common.common.app_common_guanlan import tabfmt as _impl
+
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'tabfmt', _impl)
+
+sys.modules[__name__] = _impl

+ 10 - 492
src/version.py

@@ -1,500 +1,18 @@
 # -*- coding: utf-8 -*-
-"""版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
+r"""兼容转发(P1,2026-09-22):实现已迁到 `app_common/common/app_common_guanlan/version.py`。
 
-为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
-改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
-
-    src/version.py            ← 唯一真源(本文件)
-    scripts/pack_dist.py      ← 读它写进 dist-manifest.json 与默认包名
-    install.ps1 / install.sh  ← 读它写进 <安装目录>/install-info.json,并与已装的比对
-    guanlan.py / 各文档        ← 读它(文档里的人工版本号以它为准)
-
-`install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
-卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
+**本文件不得新增逻辑** —— 它只做"模块别名":把 `src.version` 指向真实现,于是
+`from src import version` / `from src.version import X` / `import src.version` 三种写法全部照旧可用(含私有名)。
+迁移完成后(P7 之后)可整体删除本壳。
 """
 from __future__ import annotations
 
-import datetime as dt
-import json
-import pathlib
-
-NAME = '观澜·如东样板 v2'
-VERSION = '2.11.0'         # ★ 版本只改这里
-EDITION = 'offline-single-package'
-PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
-
-INSTALL_INFO = 'install-info.json'      # 相对安装根
-SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
-SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
-
-# ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
-#   编号:  v<大版本号>.<中版本号>.<小版本号>          例: v2.5.0
-#   定义:  大版本号 —— 系统解决方案、架构或核心功能改变
-#          中版本号 —— 非核心功能新增、减少、修改
-#          小版本号 —— 消缺完善
-#   包名:  打包文件名 = app_guanlang_v<版本号>.zip   例: app_guanlang_v2.5.0.zip
-#   用法:  用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
-#          (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
-RULE = 'v<大版本号>.<中版本号>.<小版本号>'
-LEVEL_MEANING = {
-    'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
-    'minor': '中版本号 —— 非核心功能新增、减少、修改',
-    'patch': '小版本号 —— 消缺完善',
-}
-BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
-             '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
-             '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
-
-# 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
-#   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
-#   旧编号体系(0.x,未按本规则),仅作历史对账用。
-HISTORY: tuple[dict, ...] = (
-    dict(version='2.11.0', date='2026-09-22', level='minor',
-         title='源码模块化重构 P0:七个模块目录 + 接口层 + 模块边界门(每模块只经 api 调用,可插拔)',
-         note='源码组织层重构 ⇒ 中版本 +1(运行形态、交付形态与核心功能未变)。'
-              '用户令: 「对观澜的源代码,按算法、数据接入管理、前端、后端 等系统模块进行重构,每个模块一个目录;'
-              '遵循组件化、高内聚低耦合、可复用、可扩展(支持热插拔)、兼容性、性能优化、高可用、分布式、'
-              '面向对象、代码精简;系统组件: TiDB community、MinIO、Redis、Nginx」。'
-              '本轮按用户选择执行:**只重构目录与接口**(暂不引入四个组件,只留接入点)、'
-              '**零行为变化、逐版本可回滚**。落地: '
-              '① 七个模块目录(每模块一个目录): app_common(公共层)/ app_ETL(数据接入管理)/ '
-              'app_algorithmModel(算法)/ app_ontology(本体与知识层)/ app_backEnd(后端)/ app_frontEnd(前端)/ '
-              'app_qualityGate(质量门与审计 + 打包安装);每个模块下 `common/app_<模块>_guanlan/` 放观澜实现,'
-              '`api.py` 是**唯一对外公开面**(PEP 562 惰性转发到既有实现,因此行为与重构前一致)。'
-              '② `configs/modules.yaml` 模块登记表(职责/允许依赖/实现落点/迁移阶段/组件接入点)。'
-              '③ 新增 `scripts/module_boundary_audit.py` 并接入 `guanlan.py check`: 查结构齐、模块间只经 api、'
-              '依赖方向合规(不得反向成环)、公共层不依赖业务模块、api 转发目标真实存在;并统计迁移进度。'
-              '④ 四个系统组件的接入点先立**接口契约**(无实现,默认仍走本地/进程内): '
-              'TiDB community ↔ app_ETL 的标准仓读写接口、MinIO ↔ 源件与产物对象存储接口、'
-              'Redis ↔ app_backEnd 的按时间窗缓存与作业状态接口、Nginx ↔ 统一入口与路由表接口。'
-              '⑤ 设计说明新增「源码模块化布局与边界」一节(模块表 + 目录树 + 边界规则 + 分阶段迁移 P1–P7),'
-              '方案全文见 `docs/重构方案_模块化_v0.1.md`。'
-              '⑥ 三份交付文档随版本号改名重渲(内容口径不变)。'
-              '验证: 边界审计 rc=0;旧路径(src/**、scripts/**)与页面/CLI/产物零变化;'
-              '`guanlan.py check`、配置统一、页面归口、反向呼应、可移植性、可转移、文档三检 全绿。'),
-    dict(version='2.10.4', date='2026-09-22', level='patch',
-         title='数据要求说明补测点:安全链与数字输入、执行器与热管理、计数账、CMS 采集参数、位号字典',
-         note='纯交付物修订 ⇒ 小版本 +1。用户令: 「测点遗漏体检的方案补进数据要求说明」。'
-              '体检口径(六组真源): 现场件表头 595 个 10 分钟通道(9 前缀)· 标准仓契约 164 登记项(10 中文域)·'
-              '窄仓 48 列 · 1 分钟转发层 76 列 · CMS 振动 71 项 · 状态与告警与工单与整定值 44 字段;'
-              '逐列判定(参考/覆盖/缺失/待判): 595/325/151/119、164/117/31/16、48/46/0/2、76/61/11/4、71/50/19/2、44/33/6/5。'
-              '三块结构性遗漏: ① din_ 数字输入 93 列几乎整片未登记(烟雾/急停/外部停机/UPS/油位/滤芯/接地刀闸/能见度);'
-              '② dot_ 执行器 69 列整片为空(泵/阀/加热器/冷却器/通风),而文档把「热链与冷却」列为支撑功能;'
-              '③ cnt_ 计数账 69 列只登记 6 条(外部错误小时= IEC 责任剥离并列账、OK 与可用小时、各类停机小时、寿命小时)。'
-              '补齐内容: 10 分钟节新增「数字输入与安全链」「执行器命令与热管理」两域,扩充计数与统计及 tur/flg/int/prs/grd;'
-              '秒级节补 11 条;CMS 节新增「采集参数与溯源要求」小节(键相/转速脉冲、窗函数、采样频率、量程与过载、'
-              '序列号与配置、触发时刻、标称频率等 —— 没有键相无法做真阶次跟踪,窗函数在振动侧两侧都缺);'
-              '第 5 章增「位号释义」质量要求;第 8 章收资清单增「测点与位号字典」一项'
-              '(119 条待判通道没有位号释义就永远只能存而不解)。'
-              '口径不变: 只写中文业务含义名 + 度量单位 + 必须性,不出现英文列名/文件名/落盘位置/机组编号/样本场现状;'
-              '单位只取自已取证口径(canonical 词典 unit_majority、机型契约 unit),查不到写「未取证」;'
-              '中文业务名取自 src/windscada/i18n.py 的 STEM 中译(源西门子 WTC-3 IO 表)与产物列名。'
-              '验证: 严格模式(英文标识/扩展名/路径/机组编号/现状字样)0 命中;去标识化 0 命中;'
-              '21 类逐类仍有四列测点表;表号引用闭合;guanlan.py check 全绿;三份 docx 重渲到 2.10.4。'),
-    dict(version='2.10.3', date='2026-09-22', level='patch',
-         title='交付件定名:设计说明改为「系统设计说明」(与需求分析、数据要求说明命名对齐)',
-         note='纯交付物修订(文档内容未变,只改交付件名称与随之而来的引用)⇒ 小版本 +1。'
-              '用户令: 「设计说明_观澜 word 文档,改名为:系统设计说明_观澜_[版本号].docx」。做法: '
-              '① 渲染器的交付件定义改名 —— `docs/设计说明_观澜_<版本>.docx` → '
-              '`docs/系统设计说明_观澜_<版本>.docx`(封面标题、页眉、markdown 源件名一并改为「系统设计说明」);'
-              '② 三份稿里指向该交付件的交叉引用与「编写依据」路径同步改名(`《设计说明_观澜_…》`→'
-              '`《系统设计说明_观澜_…》`、`docs/src/设计说明_观澜_….md`→`docs/src/系统设计说明_观澜_….md`),'
-              '以及"三份交付文档(需求分析、设计说明、数据要求说明)"这类并列清单;'
-              '③ **仓库内的内部文档 `docs/系统设计说明.md` 不受影响**(替换模式带 `_观澜_` 或书名号,'
-              '不会误伤同名内部件);④ 三份 markdown 源件随批次改名到 2.10.3,正文"当前版本"口径'
-              '(系统版本行、包名、本文版本、变更级别、版本表新增 2.10.3 一行、HISTORY 计数 15 条)同步更新,'
-              '历史行保留。文档内容与图表未变。'
-              '**2026-09-22 用户复核补记**: 用户对交付的《数据要求说明》做了**一处口径修改** —— 秒级 SCADA 的采样频率由「1 秒或 5 秒(二选一)」放宽为「**1 秒至 30 秒之间**」(共 7 个落点: 第 3.2 节正文、第 5 章质量要求、数据分类总表、必须性分档表、收资 21 项对照表、面向现场的收资清单、附录 B 收资原文第 2 项)。已按「相关内容以后照此描述」并入源件,并把该口径写进 docs/系统设计说明.md §17 的描述口径清单;附录 B 的标题与说明同步改为「第 2 项采样频率按现场最新口径」,不再声称逐字。**验证**: 三份 markdown 与 docx 重渲染后 `delivery_docs_build --verify` rc=0(目录/页码域、'
-              '去标识化、数据稿去实现细节、表号引用闭合);新文件名含当前版本号,`guanlan.py check` 的'
-              '「交付文档三件」门按新名核验通过;包内只带 2.10.3 三份 docx,无同名旧版残留。'),
-    dict(version='2.10.2', date='2026-09-22', level='patch',
-         title='数据要求说明改成纯数据需求规格(去现状/去实现细节)+ 新增 SCADA 秒级数据要求',
-         note='纯交付物修订 ⇒ 小版本 +1。用户令: 「数据要求说明不要体现样本场的接入情况、英文测点名称、'
-              '机组名称、文件名称、落盘位置,只体现测点真实业务含义名称;每类数据的测点以列表呈现'
-              '(测点/字段名、业务含义、度量单位、是否必须);增加 SCADA 秒级数据的要求」。做法: '
-              '① **删掉一切样本场现状**: 到位情况/缺口/催缴状态、件数体量、行数、时间覆盖、日期全部不再出现'
-              '(该文只写"对数据的要求",不描述任何具体场站);机组范围改写为「全场机组(按场实际台数)」。'
-              '② **去实现细节**: 正文与表格不再出现英文测点名(snake_case)、文件名与扩展名、目录与落盘位置、'
-              '机组编号;测点一律用中文业务含义名称(有功功率、齿轮箱油温、机舱风速…),单位取自'
-              '`configs/canonical/dictionary.yaml` 的 unit_majority 与机型—场站契约的 unit,查不到就写「未取证」。'
-              '③ **测点列表化**: 每类数据一节 + 一张四列表(测点/字段名 | 业务含义 | 度量单位 | 是否必须,'
-              '分级只在必须/建议/可选三档),另加"测点汇总索引"章。'
-              '④ **新增 SCADA 秒级数据要求**: 1 s 或 5 s、至少 3 个月、全场全测点,并给出必需测点清单'
-              '(功率、风速风向、发电机/主轴/叶轮转速、三叶桨距与一致性、发电机绕组与齿轮箱温度、机舱与环境温度、'
-              '偏航角度与压力、液压与蓄能压力、运行状态位、限电与功率给定、振动触发量…),写清它与 10 分钟数据、'
-              '故障录波、机舱振动数据的分工与配合。'
-              '⑤ **机器硬门**: `delivery_docs_build.py` 为该文加 `strict_terms` 检查(snake_case 标识/文件扩展名/'
-              '目录路径/机组编号/现状字样),`--check`(扫源件)与 `--verify`(扫渲染后的 docx)命中即 FAIL;'
-              '该文不再配插图(原四张是样本场现状图,按用户令不再体现),图表器移除 dat 组与对应四张 PNG。'
-              '验证: 新稿 markdown 与 docx 对上述各类 0 命中,21 类每类都有四列测点表,秒级节含测点表;'
-              '`guanlan.py check` 全绿;三份 docx 重渲(req/des 内容不变,仅版本号与文件名随批次升到 2.10.2)。'),
-    dict(version='2.10.1', date='2026-09-22', level='patch',
-         title='交付文档对外版:去样本场标识(全文不体现具体风电场)+ 新增多场适用性章节',
-         note='纯交付物修订(系统功能未变)⇒ 小版本 +1。用户令: 「修改三份文档,内容参考如东风电场,'
-              '但不体现如东风电场,且具有不同风电场适用性」。做法: '
-              '① **去标识化**(中文与罗马化形态一并去): 场名/业主/地域/OEM/第三方机构/系统名里的样本场代号'
-              '一律换成中性表述(样本风电场 / 本场 / 业主单位(从略)/ 主机制造商(OEM,名称从略)/ '
-              '4.0 MW 级海上机组(机型代号从略)/ 观澜 v2(风电场智能分析系统));'
-              '**路径写成模板**(`data/raw/<场站>/…`、`outputs/<场>/…`、`reference/<场>/…`、'
-              '`configs/contracts/<机型>_<场>.yaml`、`configs/farms/<场>.yaml`、`WINDSCADA_FARM=<场>`),'
-              '并在「编写依据」表下写明占位符替换规则;实测数字与结论全部保留,标注「样本场实测(2026-09-22)」。'
-              '② **多场适用性**(用户令第二句): 需求分析新增第 11 章(FR-44…FR-52 + NFR-12: 场配置化、'
-              '机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、'
-              '换场验收检查表);设计说明新增第 16 章(场抽象层与配置 schema、场无关引擎 vs 场相关参数分层表、'
-              '换场作业单与检查表、接新 OEM/新数据形态的扩展点、换场会失效的假设如实列);'
-              '数据要求说明新增第 12 章(通用必选/可选判定规则、场配置字段对照、数据源形态适配、'
-              '换场收资差异清单、按场裁剪步骤)。'
-              '③ **去标识化做成机器可查的硬门**(不靠人自觉): `scripts/delivery_docs_build.py` 增 `FORBIDDEN` '
-              '词表并在 `--check/--verify` 里逐份扫描(命中即报 FAIL),`guanlan.py check` 的「交付文档三件」'
-              '门同步带上该扫描;图表器里出现的样本场字样一并中性化(图题写「样本场实测」)。'
-              '验证: 三份 markdown 源件与 docx 对 12 个禁用词 0 命中;`delivery_docs_build --verify` rc=0;'
-              '`guanlan.py check` 全绿;三份 docx 与插图重渲,图表数字仍与正文同源(图解析文档表)。'),
-    dict(version='2.10.0', date='2026-09-22', level='minor',
-         title='交付文档三件(需求分析 / 设计说明 / 数据要求说明)与源代码化生成器',
-         note='非核心功能新增(交付物)⇒ 中版本 +1。用户令: 「整理观澜的需求/设计/数据接入,各写一份 Word 文档,'
-              '要求区分章节目录、文表图并茂、字体字号分类统一;数据要求说明结合现场《数据分析收资要求-v3.docx》」。'
-              '① 新增三份交付文档(落 `docs/`,文件名带本版本号): '
-              '`需求分析_观澜_2.10.0.docx`(需求来源与演进、角色场景、功能需求 FR、非功能需求 NFR、'
-              '页面与信息架构、验收门、需求跟踪矩阵)、'
-              '`设计说明_观澜_2.10.0.docx`(总体架构、目录与路径真源、重算链、判级与算法、时间窗口径、'
-              '服务与前端、本体与模型、运维编排、安装与版本、质量保证、安全与边界、可移植性)、'
-              '`数据要求说明_观澜_2.10.0.docx`(数据分类总表、逐类要求、核心测点/字段/单位/必须性、'
-              '质量与对齐、落位流程、收资要求 v3 的 21 项逐条对照、缺失与替代、核对锚点、面向现场的收资清单); '
-              '② 两份源件均**源代码化**(用户令「所有的计算均要形成观澜的源代码」): '
-              '`scripts/delivery_docs_build.py` 把 `docs/src/*.md` 渲染成 docx(Word 域目录 + 标题/正文/表格/图题'
-              '四级字体字号分类统一: 黑体标题 + 宋体正文小四 + 表格五号 + 图题五号居中, 页眉页脚页码, 附录排版规范); '
-              '`scripts/delivery_docs_figures.py` 生成 14 张插图, 每张图的数字都从真件取: `src/version.py` 的 HISTORY、'
-              '`configs/portal_pages.yaml`、`configs/serve.json`、`data/raw/<场>/**`、`outputs/<场>/**`、'
-              '并落 `guanlan.py check` 与 `rebuild_all.py --dry-run` 的实跑底稿到 `docs/src/_*.txt`(可复核); '
-              '③ 文档口径遵循用户令 §17: 含"窗"且指时间窗口写全"时间窗"、影响机组写"影响机组数"、'
-              '英文简写写"中文(英文简写)"(平均无故障间隔(MTBF)/平均停机间隔(MTBO)/单次停机时长(MDT)); '
-              '④ 不确定与缺件一律如实写(测风塔零交付、m5_cms_tcm 正本缺失由观澜自算件顶上、故障录波仅 4 台、'
-              '远端未装 Ollama),不编造; 每份文档附「编写依据」表逐章列出源文件; '
-              '⑤ `guanlan.py check` 增一行「交付文档三件 · v<版本>」: 校验三份 docx 在位**且文件名含当前版本号**、'
-              'markdown 源件与所引插图都在位 —— 否则升一次版本号, 旧文档名就悄悄过期(同名旧版会跟着进包); '
-              '★2026-09-22 用户令「三份文档不同步到远端服务器」⇒ 该行先看 `docs/src` 在不在: 不在 = 本机是'
-              '**不随文档的部署**(远端演示机即此),只报提示行、不 FAIL,且在 import python-docx 之前分流'
-              '(那台机未装该依赖,直接进渲染器会炸成 ModuleNotFoundError)。'),
-    dict(version='2.9.2', date='2026-09-22', level='patch',
-         title='升级后验收补缺:版本/文档漂移有了自动拦截点(重算链增 ⑧d 版本记录门)',
-         note='纯消缺 ⇒ 小版本 +1。检查: 远端 2.9.1 升级 + 重算完成后跑 `guanlan.py check`,'
-              '出现两处 FAIL —— ① `docs/版本记录.md` 与 `src/version.py` 不一致(version_log rc=6);'
-              '② 安装根多出 `_pre290_backup/`(16 件扁平旧副本)让 config_audit rc=8。'
-              '根因: 这次升级的差异盘点只覆盖 `src/`+`scripts/`+`configs/`(`remote_src_inv.py`),'
-              '**文档从不在盘点面上** ⇒ 代码升到 2.9.1、文档留在旧版(版本记录 13,277 vs 21,284 字节、'
-              '系统设计说明缺 §17);而**重算链里没有版本门**(rebuild_all 只到 ⑧c 页面归口审计),'
-              '于是 24/24 步全 OK、控制台写"重算完成",漂移照样静默过关 —— 没有任何自动拦截点。'
-              '解决: ① `scripts/rebuild_all.py` 增「⑧d 版本记录一致性」(`version_log.py --check`,'
-              'tolerate=(6,) —— 与 ⑧c 同口径: 缺的是"文档同步"这一路,报出来但不打断整条链);'
-              '② 远端补齐 4 份文档(版本记录/系统设计说明/输入数据放置指导/detail 页面依赖台账),'
-              '逐件 sha256 与本地一致;③ 远端把 `_pre290_backup` 移出安装根到 '
-              '`D:\\产品\\_pre290_backup_20260921`(备份保留,不删除)。'
-              '验证: 远端 `version_log.py --check` rc=0、`config_audit.py` rc=0;'
-              '本地 `guanlan.py check` 全绿 + 反向审计/页面归口/chain_gap 全 rc=0。'
-              '遗留(如实记录,未处理): 远端**没有装 Ollama**(无二进制、PATH 无、11434 拒绝连接)'
-              '⇒ 本机模型探针在远端必然 FAIL,问答/升档类功能在远端不可用;'
-              '要不要在远端装 Ollama 与拉哪几档模型,等用户定。'),
-    dict(version='2.9.1', date='2026-09-22', level='patch',
-         title='人工测试 7 项消缺(描述口径 + 依据空白 + 问题页空 + 门户链接 404 + 闭环文案 + Ollama 档位)',
-         note='纯消缺(无功能增减)⇒ 小版本 +1。逐项「检查→根因→解决→验证」: '
-              '① 描述口径: 「窗」确实指时间窗者一律写「时间窗」(预设窗→预设时间窗、判级窗→判级时间窗、'
-              '窗内→时间窗内、证据窗→证据时间窗、随所选窗→随所选时间窗…;天气窗/作业窗/预览窗/观测窗'
-              '属领域词,保持原样);「影响台」→「影响机组」(含报告构建器表头);英文简写改「中文(英文简写)」'
-              '(平均停机间隔(MTBO)/平均无故障间隔(MTBF)/单次停机时长(MDT)),英文映射键同步; '
-              '② 需要关注「查看完整依据」空白: 根因=行的来源是**融合面链盘**(d.fus.链盘.rows),依据却只从 '
-              'SCADA 的 d.watch 查 ⇒ 两边机组集合不同时 why[t] 为空(实测 WTG09 齿轮箱 bad 台)。'
-              '改为按本行字段自组句(部件/链路进度/卡在哪步/证据源),永不空; '
-              '③ 报警机组「该问题页」打开空白 + ⑤ 命中系统链接: 根因=链接传**slug**(pitch)而页面/接口按'
-              '**中文系统名**取数 ⇒ /api/problem 返回 0 条,页面还写"该系统在判级窗内无非常态"(对报警机组是假陈述)。'
-              '改为: 链接传中文名 + 服务端 `sys_norm()` 双向归一(slug/中文/大小写都认)+ 页面区分'
-              '"系统标识无法识别 / 本时间窗无异常"两者,绝不把认不出说成正常; '
-              '④ /turbine 页「返回观澜门户」→ 原写死 `http://127.0.0.1:18084/观澜_如东样板_门户_单文件.html`'
-              '(用户机器上等于访问自己的 loopback,且该文件服务端没有)⇒ 网关回 not found。'
-              '修法: 用根相对 `/`,并给**网关加 `data-abs="1"` 豁免**(作者显式声明某链接不按组件前缀改写,'
-              '否则根相对链接一律被改写成 /detail/… 永远回不到门户),普通链接前缀行为不变(有单测); '
-              '⑥ 振动「端到端闭环」文案: 去掉内部黑话 handoff,改为'
-              '「暂无端到端闭环证据:现场提交的振动数据包里没有「报警→检修→复测」的成对记录」; '
-              '⑦ 观澜↔本机 Ollama: 链路实测可用(探针 OK、/api/ask 提交成功、8B 出答但**未过校闸**→触发升档),'
-              '但升档目标**硬编码**在 `src/ontology/fast_agent.py` 的 `ESCALATE`(写的是本机没装的 `qwen3.8:27b`,'
-              '`configs/models.json` 只是同处笔误的另一份)⇒ 升档 digest=None、ask 终态 error(用户只看到"复核中")。'
-              '修法: ESCALATE 改 `qwen3:32b` + 新增 `installed_models()`/`escalate_target()` —— 升档目标按'
-              '「配置档位且**确已安装**」解析,没装则**响亮回落**并打日志,不再把答案静默卡死;'
-              '初答文案里的"27B"改为实际目标名;BUDGET_S 随档位更新(32B 给 120 s)。'
-              '验证: `escalate_target("qwen3:8b") → qwen3:32b`(本机实装 4 档实测);'
-              '另: 远端 106.120.102.238 与本地各跑一遍 HTTP 抽样(门户链接=/、slug 归一出 1 条问题、'
-              '文案含时间窗/影响机组/(MTBO)、闭环新文案、WTG09 依据不再空),网关改写 `data-abs` 豁免单测通过,'
-              'config_audit/pages_audit/反向审计/raw_scan selftest 全 rc=0,`guanlan.py check` 全绿'),
-    dict(version='2.9.0', date='2026-09-21', level='minor',
-         title='时间窗真正生效(用户令):「部件问题」与「发电性能」随所选窗重算,并新增自定义起止日期窗',
-         note='功能增减改 ⇒ 中版本 +1。要点: '
-              '① 窗口词表新增**自定义起止日期**(`YYYY-MM-DD~YYYY-MM-DD`,**含两端**):'
-              '`months_of()` 取相交月、新增 `win_range()/in_win()` 供日粒度件做含端过滤;'
-              'v2 顶栏加两个日期输入框 + 应用(校验 a≤b),月度件按所跨月取整、日粒度件按日精确,页上写明; '
-              '② 判级四轴按窗重算:`taxonomy.system_matrix(span=…)` 把窗透到 变桨(日粒度件切片, 精确)/'
-              '偏航/蓄能/温度(走新窄仓重算);`reliability.overview(span=…)` 让部件可靠性表按窗;'
-              '温度面的 ref/ref_sm 对照窗**仍固定**(季节解耦基线,跟着漂就失去对照意义); '
-              '③ 发电性能按窗:`/api/curves?win=` 七镜头按窗重算 + 7 张月度时序图按窗过滤;'
-              '选到 2025-07~2025-12(限电前干净判别窗)时直接用正式产物不重算;'
-              '④ 新产物 `windscada/slim10min/*`(公共列子集,不裁剪行)作**按窗重算底座**:'
-              '一次全量扫≈2 分钟,把"每换一个窗重读 15 GB"降到秒级;已进重算链 ③b 并登记反向审计族表; '
-               '④b **M9 控制参数一致性**(就摆在发电性能页上)此前仍钉死 2025-H2 ⇒ `control.registry(span=…)` 按窗重算'
-               '(窄仓补 `grd_wtc_ActPower_max`/`tur_wtc_GenRpm_max` 两列 ⇒ 48 列;≈0.6 s/窗,P封顶中位 4190(干净窗) → 4181.7(2026年) / 4177.5(2026Q1)),'
-               'fleet 响应加 `m9_pending/win_pending` 并由页面轮询(5 s)—— 至此判级四轴 + 可靠性 + 控制参数 + 七镜头 + 7 张月度图**全部随窗**;'
-               '远端两窗对比只剩 all_months/note/*_pending 三个元数据键不变; '
-              '⑤ 按窗重算一律"后台算+进程缓存"(判级≈25 s/窗、镜头≈10~45 s/窗):命中即回,'
-              '未命中先回 `pending/building` 并由页面轮询,**不静默拿旧口径当新窗**;'
-              '启动时后台预热 6 个预设窗(判级+默认窗镜头); '
-              '⑥ 实测(本机 38 台): 判级逐系统报警台数随窗变(变桨 15/13/10、偏航 15/11/5、齿轮箱 7/4/4 对应'
-              '2026年/2025H2/2026-07),曲线图注窗=所选窗且样本量随窗变;验收: 反向审计 3548 件 0 未归类、'
-              'pages_audit/detail_deps/config_audit/raw_scan(selftest,rc=0)/chain_gap 全绿;'
-              '⑦ 远端部署(D:\\产品\\app,2026-09-21)实逮两处并已修: (a) 预热与请求各自起线程 ⇒ 同时算两份'
-              '按窗中间帧,改为**重活全局串行**(`_HEAVY_LOCK`)并打印可用内存;(b) 退化窗("近30日"=2026-09'
-              '只有 1 个节拍 ⇒ 比档件为空)时 `groupby().apply()` 返回空表 ⇒ `.rename("dev_K")` 抛 TypeError 把'
-              '整面判级打断,改为**如实"不可判(样本不足)"**;另: 远端进程须由 Windows 服务 `guanlan` 托管'
-              '(SSH 会话里起的进程会随会话关闭而死,实测全端口消失 ⇒ 已装服务并 RUNNING),'
-              '远端核验: fleet 两窗 pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、'
-              'curves 图注"窗 2026-07-01 ~ 2026-07-31 (随所选窗)"、v2 页含自定义起止控件'),
-    dict(version='2.8.2', date='2026-09-21', level='patch',
-         title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
-               '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'
-               '(`<场站>/scada_10min_<YYYYMM>.csv`)被当成"未归类";两者每次扫描都计一次变化 ⇒ '
-               '`raw_scan --check` 永远 rc=4,`rebuild_all.py --auto` 与 raw_watch 会**每轮都以为来了新数据**',
-         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
-              '① `CONSUME` 新增 `upstream` 口径(信息级):类目库是 10min 同台补充件的**上游**,'
-              '取数层不直接读它 ⇒ 不再算缺口、不计变化,改按"上游归档件(要先转换才被消费)"列出并写清转换口径; '
-              '② 新增 `IGNORE_PATTERNS`:`scada_\\d+min_\\d{6}\\.csv` 是按月提取/核对件(位置由用户指定、'
-              '不是摄入源)⇒ 不报未归类、不计变化;'
-              '③ 实测(2026-09-21 落位 7-8 月交付后):修前 `--check` rc=4 且每轮都报"2 处变化",'
-              '修后 `--check` rc=0「与上次快照一致,没有新数据」,`--selftest` 通过,24 件类目库如实以信息级列出; '
-              '④ 口径写进 `docs/输入数据放置指导_v0.1.md` §4.1(含 12 组中文目录↔类目码、9 类↔主件 595 通道一一对应、'
-              'float32+ISO 对齐、以及"类目库→同台补充件"的落位转换)'),
-    dict(version='2.8.1', date='2026-09-20', level='patch',
-         title='消缺:重算链末步「台账等价验收」在全新机器上必然 rc=5(交付包不含产物 ⇒ 没有随包基线),'
-               '原先把它判成失败 ⇒ 门户上写"重算失败"(实测服务器 2.8.0 一轮 24/24 步全 OK 却整轮 rc=1)',
-         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
-              '① `rebuild_all.py` 的 ⑧ 台账等价验收由 `tolerate=(4,)` 改成 `tolerate=(4, 5)` —— '
-              'rc=5 = "找不到随包基线 ⇒ 没做验收",与 ④ 的口径对齐(④ 一直容忍 5 并写明"跳过等价验收≠通过");'
-              '步名与步说明都改写清楚"rc=4 = 有需人工看的差异 / rc=5 = 没基线 ⇒ 没验收(不是失败,也不是通过)",'
-              '并指路 `scripts/set_baseline.py`(用已核实的当前产物快照登记基线,之后这一项才会真比对); '
-              '② 远端复核(服务器 D:\\产品\\app): 该轮 24 个实质步骤全绿(①b 扫描 53.7s · ② 164s · ③ 1025s · '
-              '④ 99s · ④b 2118s · ④c 385s · ④d 41.6s · ④e 15.3s · ⑤b/⑤c/⑤a/⑤ · ⑥ · ⑦×6 · ⑦b 78s · ⑧ 7.1s · '
-              '⑧b 3s · ⑧c 9.5s),只有第 25 步判失败;且同批数据已真进产物:`temp_monthly` 21 个月、末四格 '
-              '2026-06/07/**08**/09、2026-08 = 1,026 行(服务器 `scada_10min` 也有 76 件含 38 个 `WTG??-B2.csv` '
-              '同台补充件,说明步骤 B 的同台多件合并已在服务器上生效); '
-              '③ 已把修好的 `scripts/rebuild_all.py`(+ `src/version.py` / `docs/版本记录.md`)单文件部署到服务器,'
-              '无需重装整包。交付包 app_guanlang_v2.8.1.zip'),
-    dict(version='2.8.0', date='2026-09-20', level='minor',
-         title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
-               '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
-         note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
-              '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
-              '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
-              '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
-              'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
-              '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
-              '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
-              'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
-              '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
-              '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
-              '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
-              '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
-              '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
-              '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
-              '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
-              '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
-              '**最新的变化**算」逐条查证并补了三处: '
-              '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
-              '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
-              '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
-              '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
-              '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
-              '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
-              '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
-              '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
-              '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
-              '要旧行为用 `--reimport-as-new`)。'
-              '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
-              '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
-              '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
-              '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
-              '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
-              '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
-              '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
-              '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
-              '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
-              '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
-              '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
-              '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
-              '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
-              '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
-              '交付包 app_guanlang_v2.8.0.zip'),
-    dict(version='2.7.0', date='2026-09-19', level='minor',
-         title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
-               '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',
-         note='一次消缺 + 一项非核心功能新增(对外监听)⇒ 按规则取最高一级(minor)。要点: '
-              '① ★远端实逮(服务器 D:\\产品\\app):索引 719 s + 谱 894 s 落进 windows\\_staging_ingest 后,'
-              '`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问`(360 实时扫描/索引器持着'
-              '刚写的 1710 件小文件的句柄,Windows 的目录改名要求树里无打开句柄)⇒ ④b 失败 ⇒ 链停在第 4 步,'
-              '页面表现为「需要关注 0 台 + 融合面缺件」。修法:发布改 `_move_tree()`(整目录 rename 退避重试 '
-              '→ 逐件搬,rename 不行就 copy+删 → 仍锁住的逐条如实报出);并加 `--publish-only`:已算完的暂存窗'
-              '可直接发布,不必白等 25 分钟重跑索引/谱; '
-              '② 用户令「观澜改为监听所有 IP」:`configs/serve.json` 增 `public_host`(空=跟 host=只本机;'
-              '设 0.0.0.0=所有网卡),**只有门户网关**用它,detail/cms/viewer/sim 仍绑 127.0.0.1 ⇒ 对外只有 '
-              '28084 一个入口;启动时打印对外地址并提示"页面无鉴权,请在防火墙侧限来源"; '
-              '③ 消缺: 新生成端在 GBK 控制台打印 m/s² / ⇒ 时抛 UnicodeEncodeError(远端 baseline_38 整件没出)'
-              '⇒ 四个生成端加 stdout 守卫;trend_ingest 的"台账止 2024-11"过期断言改实话;'
-              'CMS 三层基线卡的"健康期窗 N"重复措辞; '
-              '④ 部署口径: 用 SSH 起的服务在会话断开时会被一起收掉 ⇒ 现场改注册 Windows 服务(`guanlan`)'
-              '(`scripts/service_ctl.py install`),开机自启、SCM 崩溃重启。交付包 app_guanlang_v2.7.0.zip'),
-    dict(version='2.6.0', date='2026-09-19', level='minor',
-         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
-               '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
-               '交付包带输入数据目录结构(不含文件)',
-         note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
-              '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
-              '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
-              '② 融合面 handoff 与总览页 windscada/index.html 由观澜自算生成(原为"包内无生成端",'
-              '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
-              '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
-              '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
-              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
-              '重建并逐分片对拍); '
-              '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
-              '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
-              '现场确诊的 19# 单独拎出来); '
-              '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
-              '落成生成端(窗数不足时如实空表并写明数据边界); '
-              '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
-              '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
-              '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
-              'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
-    dict(version='2.5.0', date='2026-09-17', level='minor',
-         title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
-         note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
-              '交付包 app_guanlang_v2.5.0.zip'),
-    dict(version='0.4.0', date='2026-09-17', level='legacy',
-         title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
-               '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
-         note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
-    dict(version='0.2.0', date='2026-09-01', level='legacy',
-         title='含产物的旧交付基线(历史上用作"包内无生成端"那批产物的补救源;该用途 2026-09-19 起已失效)',
-         note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip —— ★2026-09-19 用户令从工作树'
-              '清理(943 MB):① 「所有的计算均要形成观澜的源代码」落地后无生成端件 = 0(反向呼应审计 '
-              '1,800 件全部 raw-derived)⇒ 它作为"补救源"的用途消失;② 该包仍留在 git 历史里'
-              '(blob 可随时 git 取回),需要时用 git show 恢复即可'),
-)
-
-
-def package_name(version: str | None = None, ext: str = 'zip') -> str:
-    """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
-    return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
-
-
-def level_of(old: str, new: str = VERSION) -> str:
-    """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
-    a, b = _parts(old), _parts(new)
-    if a == b:
-        return 'same'
-    if len(a) < 3 or len(b) < 3:
-        return '?'
-    if b[0] != a[0]:
-        return 'major'
-    if b[1] != a[1]:
-        return 'minor'
-    return 'patch'
-
-
-def rule_lines() -> list[str]:
-    """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
-    return [f'编号: {RULE}   例: v{VERSION}',
-            *[f'  {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
-            f'包名: 打包文件名 = {package_name()}']
-
-
-def info_path(root: pathlib.Path) -> pathlib.Path:
-    return pathlib.Path(root) / INSTALL_INFO
-
-
-def read_installed(root: pathlib.Path) -> dict | None:
-    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
-
-    ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
-      (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
-      于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
-    """
-    p = info_path(root)
-    if not p.is_file():
-        return None
-    try:
-        d = json.loads(p.read_text(encoding='utf-8-sig'))
-        return d if isinstance(d, dict) else None
-    except Exception:
-        return None
-
-
-def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
-    """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
-    root = pathlib.Path(root)
-    rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
-               python=python or '', mode=mode, host=str(root))
-    rec.update(extra or {})
-    info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
-    return rec
-
-
-def _parts(v: str) -> tuple:
-    out = []
-    for seg in str(v).strip().lstrip('vV').split('.'):
-        num = ''.join(ch for ch in seg if ch.isdigit())
-        out.append(int(num) if num else 0)
-    return tuple(out)
-
-
-def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
-    """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
-
-    → dict(state, installed_version, pkg_version, message, action)
-       state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
-              newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
-       action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
-    """
-    cur = (installed or {}).get('version') or ''
-    if not installed:
-        return dict(state='none', installed_version='', pkg_version=pkg_version,
-                    message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
-    if not cur:
-        return dict(state='unknown', installed_version='', pkg_version=pkg_version,
-                    message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
-                            f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
-    if _parts(cur) == _parts(pkg_version):
-        return dict(state='same', installed_version=cur, pkg_version=pkg_version,
-                    message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
-    if _parts(cur) < _parts(pkg_version):
-        return dict(state='older', installed_version=cur, pkg_version=pkg_version,
-                    message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
-    return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
-                message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
-                        f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
-
+import sys
 
-def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
-    """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
-    out = [cmp_res['message']]
-    if installed:
-        out.append(f"  现有安装: 装在 {installed.get('installed_at', '?')}"
-                   + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
-                   + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
-    return out
+from app_common.common.app_common_guanlan import version as _impl
 
+_parent = sys.modules.get(__package__ or 'src')
+if _parent is not None:
+    setattr(_parent, 'version', _impl)
 
-if __name__ == '__main__':
-    import sys
-    print(f'{NAME} v{VERSION} ({EDITION})')
-    print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
-    root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1]
-    ins = read_installed(root)
-    c = compare(ins)
-    print(f'安装记录: {info_path(root)}')
-    for ln in summary_lines(c, ins):
-        print('  ' + ln)
-    print(f"  → state={c['state']} action={c['action']}")
+sys.modules[__name__] = _impl

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini