Jelajahi Sumber

移植性: 新增 check_transferable.py 大扫描 + 移植操作手册; 并实测"拷包到另一路径独立运行"

用户令: 检查、核验并确保本包能移植到其他电脑上独立运行。

## 一、先说结论(实测, 不是推断)

**拷包到另一个路径 + 在副本里跑一次安装 → 独立运行成立**, 已实测:
  · robocopy 到 `F:\porttest\guanlan-app`(排除 data/.git/.venv/暂存区, 1.26 GB);
  · 副本里 `install.ps1` **离线**跑通(exit 0, 190 s): 自动建 `.venv`、用包内轮子装依赖、写配置、自检;
    副本的 `pyvenv.cfg` 显示 `command = … F:\porttest\guanlan-app\.venv` —— 即 venv 是**在新路径重建的**;
  · 把随包产物放进副本 outputs(589 件) → 停掉原件 → 副本独立启动:
    `/` 200 (20,225,828 B) · `/detail/` 200 (229,643) · `/cms/` 200 (414,141) · `/sim/` `/sim/sys/` `/viewer/` `/ops` 全 200
    —— **字节数与原件完全一致**; healthz 6/7 ok; 锚点 39211/5876/19494/9702 全对;
    维护页显示的是 `<安装目录>\data\raw\…` **占位符**, 没有原件路径泄漏 → 路径无关性成立;
  · 副本里的运维控制台也正常: 停组件(组件 down/网关 up) → 启动(/cms/ 回 200)。

**为什么"直接搬 .venv"不行**: `.venv/pyvenv.cfg` 的 `home = D:\Program Files\Python` 指**基础解释器**,
还有一批写死绝对路径的 .exe shim。所以换机必须重跑一次安装 —— 而这一步在 Windows 上**完全离线**
(包内 42 个轮子 + 便携运行时), Linux·macOS 侧包内**没有**轮子(install.sh 会自动改为联网安装)。

## 二、新增 scripts/check_transferable.py (比 check_portability 查得更宽)

① 所有文本类型(.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg)里的机器相关绝对路径(不只源码);
② `.venv/pyvenv.cfg` 的 home 指向; ③ `wheels/<平台>` 是否齐、与 requirements 对不对得上;
④ `vendor/python/…` 便携运行时是否在、MANIFEST 登记是否全; ⑤ serve.json 里有没有绝对路径。
实测: 全包 350 处机器相关路径, 但**都不在运行路径上**(release/viewer 的 source_path 出处注记 186 ·
产物里的 provenance 95 · src/sop/farm_paths.py 盘符映射表本体 · 开发机 OEM 盘的工具脚本 · 一次性修复脚本留档)。

## 三、修掉的问题(都是本轮扫描/实测发现的)

1. **文档示例用了本机路径**(19 处): `docs/重算操作手册_v0.1.md` 6+2+1+1 处、`docs/数据目录结构与落位约定_v0.2.md` 4 处、
   `docs/重算缺口与补件清单_v0.1.md` 1 处、`_修复记录_20260911/README.md` 3 处 → 全换成
   `<安装目录>` / `<现场包目录>` / `<暂存目录>` / `<基线指纹.json>` 等占位符(换台机器不再误导现场)。
2. **我自己的 rebuild_all.py 文档里写了 `F:\temp\如东风场数据`** → 被 check_portability 判 ERROR(退出码 1);
   改成 `<现场包目录>`; 并给新增的 check_transferable.py 在 ALLOW 里具名登记(它的正则里必须写出这些模式)。
3. **`--with-verify` 会"验收没做却报通过"**: `products_state --off` 会把随包基线挪到
   `_products_off/_baseline_kept/`, 而 `rebuild_from_raw.py --verify` 只认常规位置 → 打印
   "[X] 没有随包基线备份" 并返回 4, 而 rebuild_all 把 4 当"已知的人工项"**容忍掉了**(用户 12:00 那次
   `rebuild_all.py --with-verify` 就是这样: 14 步全 OK, 但其中验收那步其实什么也没比)。
   修法: `verify()` 新增 `baseline_dir()` 自动找两处(常规位置 + `_products_off*/_baseline_kept/`),
   真找不到才返 **5**; rebuild_all 只容忍 4, 5 视为失败。实测: 现在能自动定位暂存区里的基线并逐值比对
   (报警 39211/39211 逐值完全一致, 结论"1 处需人工看"= 已知油样 102 行缺口, exit 4 ✔ 语义正确)。
4. rebuild_all 的"退出码属预期"提示原先是一句通用话(degraded 是正常状态), 会**误导**到别的步骤上 →
   改成按步的 note(只有 serve 那步才说 degraded 正常)。
5. `check_transferable.py` 自己解析 `pyvenv.cfg` 时漏了键去空格(`home = …` 等号前有空格) → 修。

## 四、新增文档 docs/移植与独立运行_v0.1.md

三种情形(同机换目录 ✅ / 换机同路径通常 ✅ / 换机不同路径必须重装) · Windows 与 Linux·macOS 的移植步骤 ·
离线边界 · 端口与配置(含"网关 ROUTES 上游端口写死, 改端口要同步"的已知限制) · 拷过去后的验收清单 ·
**交付时建议清掉的本地痕迹**(_products_off* 673 MB / logs / run/pids.json / .git / data/raw 视约定 /
release/viewer 里的 source_path 建议脱敏)。

README 也补了离线边界那一句: Windows 全离线可装, Linux·macOS 侧包内没轮子会自动改联网。
zhouyang.xie 4 minggu lalu
induk
melakukan
18da80be90

+ 3 - 1
README_先读我.txt

@@ -7,7 +7,9 @@
   Windows : 解压到 D:\guanlan\app → 双击 install.bat        (自动选 Python → 建 .venv → 包内轮子离线装依赖)
   Linux   : 解压到 /opt/guanlan  → sh install.sh            (麒麟 V10 / 统信 UOS 20 / Ubuntu / CentOS / macOS;
                                                             多版本机器: GUANLAN_PY=/usr/bin/python3.11 sh install.sh)
-  没装 Python 的机器也能装: 包内有便携运行时, 安装脚本自动用。
+  没装 Python 的机器也能装: 包内有便携运行时(Windows / Linux x86_64 / Linux aarch64), 安装脚本自动用。
+  离线边界: Windows 侧包内带全部轮子, 完全离线可装; **Linux·macOS 侧包内没带轮子**, install.sh 会自动
+  改为联网安装 —— 要离线装就把对应平台轮子放进 wheels/linux_x86_64 或 wheels/macos_arm64 再装。
   模型(可选, 只影响问答与本地审核页): ollama pull qwen3:8b / deepseek-r1:14b / bge-m3
 
 二、启动 / 停止

+ 3 - 3
_修复记录_20260911/README.md

@@ -67,7 +67,7 @@ start.bat.bak-healthz
 (见 `_static()` 里 `return self._json(dict(err="not found", path=path), 404)`)。
 顺带核过: 门户里指向 `release/如东/` 的引用**只有这一条**, 所以不是路径写错, 是交付件没随 v0.2.0 发出来。
 
-**修复**: 从本机的 v0.1.0 全量包 `F:\temp\guanlan-rudong-v2_0.1.0_all.zip`(这份交付件在里面是齐的)
+**修复**: 从本机的 v0.1.0 全量包 `<临时目录>/guanlan-rudong-v2_0.1.0_all.zip`(这份交付件在里面是齐的)
 把整个 `release/如东/` 恢复到 v0.2.0 安装目录 —— 75 个文件 / 51.0 MB
 (`如东治理清单_交付_20260901/` 33 件、`如东取数单_2026-08-21/` 18 件、`观澜离线版.app/` 10 件、根下散件 14 件)。
 `release/如东/` 按 `.gitignore` 的既有约定不入库(客户交付物只在受保护工作区), 所以 git 里看不到这次恢复。
@@ -99,7 +99,7 @@ start.bat.bak-healthz
 
 **清理的机器绝对路径**(换机必失配, 且是静默故障): windcms `MAIN_ROOT` 默认 mac 检出、windcms/ontology 写死的
 mac venv 解释器、`kb_ingest` 的 mac 技术资料路径、`ingest_ops_2025` 的 `/Volumes/BIG/…`、`place_raw_data` 的
-`F:\temp\…`、界面占位符里的 `/Users/…`。**清理的 cwd 相对路径**: 20+ 处 `Path('outputs/rudong/…')`(ontology 14 个
+`<临时目录>/…`、界面占位符里的 `/Users/…`。**清理的 cwd 相对路径**: 20+ 处 `Path('outputs/rudong/…')`(ontology 14 个
 模块、windscada 4 个、windcms、serve/gateway、sop 8 个)全部归到 `paths`; `sop` 里写死的 `rudong` 改为跟场走。
 
 **配置/安装器**: `configs\serve.json` 的 `python` 改为相对(`.venv/Scripts/python.exe`); 启动器 `py()` 支持
@@ -234,7 +234,7 @@ L0 仓 16 件 1.78 MB (随包 118 件 —— 差额就是"包内没有生成端"
 等价验收: **alarms 39211/39211 逐值完全一致**; workorders 8 行差异全部可归类(旧链时间解析缺陷)
 + 69 行本链新增覆盖; 唯一人工项是油样 506→404。
 
-**用户追问"缺的数据从 F:\temp\如东风场数据 抽"** → `place_raw_data.py --scope mech` 落 355 件 15.5 GB:
+**用户追问"缺的数据从 <现场包目录> 抽"** → `place_raw_data.py --scope mech` 落 355 件 15.5 GB:
 `data\raw\西门子4.0技术资料\`(317 件, 本体层的源件) 与 `<场站>\scada_1min\`(38 件 12.8 GB)。
 抽完本体层就从"包内没有源件⛔"变成**可重算**。**仍然缺的**: 油样那 102 行的源件
 (`BG-2026-07-YP013 … .pdf`) 不在包里(整个包只有 `2025年油样` 一批) → 那 102 行找不回来。

+ 4 - 4
docs/数据目录结构与落位约定_v0.2.md

@@ -1,7 +1,7 @@
 # 观澜 · 如东样板 v2 目录结构设计与落位约定
 
 > 版本: v0.2.0 现场版 · 2026-09-11
-> 适用: `F:\temp\guanlan-rudong-v2_0.2.0`(安装目录, 下称 `<安装目录>`)
+> 适用: `<安装目录>`(安装目录, 下称 `<安装目录>`)
 > 本文回答三件事: ①离线数据往哪放、系统怎么认;②每个目录干什么用;③放/不放数据时系统分别表现什么。
 
 ---
@@ -172,9 +172,9 @@
 **场景① 清空 `data\raw` + 挪开产物 → 系统无数据呈现** ✅ 通过
 
 ```powershell
-Move-Item .\data\raw\如东 F:\temp\_raw_off            # 数据挪走
+Move-Item .\data\raw\如东 <暂存目录>            # 数据挪走
 # 把「这四类源产出的产物」也挪走 (只清源不动产物, 页面照旧显示旧数 —— 设计如此, 见 §3)
-Move-Item .\outputs\rudong\windscada\{alarms,workorders,oil_samples_index,loss_monthly,powercurve_bins,powercurve_dev}.parquet F:\temp\_prod_off\
+Move-Item .\outputs\rudong\windscada\{alarms,workorders,oil_samples_index,loss_monthly,powercurve_bins,powercurve_dev}.parquet <暂存目录>\
 .venv\Scripts\python.exe guanlan.py stop; .venv\Scripts\python.exe guanlan.py serve
 ```
 
@@ -189,7 +189,7 @@ Move-Item .\outputs\rudong\windscada\{alarms,workorders,oil_samples_index,loss_m
 **场景② 按约定结构放回 `data\raw` → 系统有数据呈现** ✅ 通过
 
 ```powershell
-Move-Item F:\temp\_raw_off .\data\raw\如东
+Move-Item <暂存目录> .\data\raw\如东
 .venv\Scripts\python.exe scripts\rebuild_from_raw.py        # 三类台账 (实测: 报警 0→39211, 工单 0→5876, 油样 0→404)
 # loss_monthly 另跑 SCADA 两步 (powercurve 31s + availability 88s)
 .venv\Scripts\python.exe guanlan.py serve

+ 97 - 0
docs/移植与独立运行_v0.1.md

@@ -0,0 +1,97 @@
+# 移植到另一台电脑并独立运行 (v0.1, 2026-09-12)
+
+**给谁看**: 要把 `<安装目录>` 整个包搬到另一台机器(或另一个盘/目录)上跑起来的人。
+**一句话结论**: **代码与配置是路径无关的(已实测),但 `.venv` 不能直接搬** —— 换机请拷包后**跑一次安装**
+(`install.bat` / `sh install.sh`, 离线), 之后即可独立运行, 不再需要联网、不需要开发机。
+
+---
+
+## 一、三种情形, 分别能不能直接跑
+
+| 情形 | 能不能直接跑 | 原因 / 要做什么 |
+|---|---|---|
+| **同一台机器, 换个目录** | ✅ 能 (代码全部相对路径) | 直接跑; 只有 `.venv\Scripts\*.exe` 那几个 shim 里存的绝对路径会失效 —— 用 `.venv\Scripts\python.exe` 跑脚本不受影响 (它靠同级的 `pyvenv.cfg` 找基础解释器) |
+| 换机, 但路径与基础 Python 完全相同 | ⚠ 通常能 | 例如目标机也把 Python 装在 `D:\Program Files\Python` 且包放在同一路径 |
+| **换机, 路径不同 / 没装 Python** | ❌ 直接跑不行 → **跑一次安装即可** | `.venv/pyvenv.cfg` 里 `home = D:\Program Files\Python` 指的是**基础解释器**; 目标机没有它, `.venv\Scripts\python.exe` 起不来。安装脚本会重建 `.venv`(可用包内便携运行时), 之后独立运行 |
+
+> 为什么不能"绿色版"直接搬 venv: Python 虚拟环境按设计就绑基础解释器(还带一批写死绝对路径的 `.exe` shim)。
+> 包内因此备了**便携运行时**(`vendor/python/*.tar.gz`: Windows / Linux x86_64 / Linux aarch64)与**离线轮子**,
+> 目标机上重跑安装是**离线**的, 不需要开发机、不需要联网。
+
+## 二、移植步骤
+
+### Windows 目标机
+
+```bat
+:: ① 拷包 (建议排除本地痕迹, 见 §五): 整个目录复制到无空格、无中文的路径, 例如 D:\guanlan\app
+:: ② 安装 (离线; 自动选 Python → 建 .venv → 用包内轮子装依赖 → 写配置 → 自检)
+双击 install.bat          或:  powershell -ExecutionPolicy Bypass -File install.ps1
+:: ③ 自检 → 启动
+双击 check.bat            :: 期望全 [OK] (模型那行 FAIL 只影响问答, 其余页面正常)
+双击 start.bat            :: 自动打开 http://127.0.0.1:28084/
+```
+
+### Linux / macOS 目标机
+
+```bash
+# ① 拷包到无空格无中文路径(如 /opt/guanlan)
+# ② 安装 (离线; 无合适系统 Python 时用包内便携运行时; 多版本机器: GUANLAN_PY=/usr/bin/python3.11 sh install.sh)
+sh install.sh
+# ③ 起停
+.venv/bin/python guanlan.py check
+.venv/bin/python guanlan.py serve      # 停止: .venv/bin/python guanlan.py stop
+```
+
+**离线边界(如实)**: Windows 侧的轮子在 `wheels/win_amd64/`(42 个, 与 `requirements.txt` 对得上) → **完全离线**;
+**Linux·macOS 侧包内没有轮子**, `install.sh` 找不到 `wheels/linux_<arch>`(或 `wheels/macos_arm64`)时会
+**自动改成联网安装**。要在 Linux/macOS 上离线装, 先在**有网的同类机器**上
+`pip download -r requirements.txt -d wheels/linux_x86_64`(注意 `--platform/--python-version`),
+把目录放进包内再拷过去。
+
+## 三、端口与配置
+
+- 真源是 `configs/serve.json`: `host` / `gateway` / `detail` / `cms` / `sim` / `sim_sys` / `viewer` / `ollama`,
+  以及 `python`(相对) · `raw_dir`(相对) · `release_dir` · `viewer_dir` · `sim_dir` —— **都是相对路径**。
+- ⚠ **已知限制**: `scripts/guanlan_gateway.py` 的 `ROUTES` 表里**上游端口是写死的**, 只支持 `GUANLAN_OLLAMA_PORT`
+  环境变量覆盖 ollama; 要改 18033/18020/18791/18792/64292 得**同时改 `serve.json` 与 ROUTES**, 否则网关
+  探活/代理会对不上(网关启动时不会自动读 `serve.json` 的上游端口)。
+- 服务只绑 `127.0.0.1`, 不对外开端口。
+
+## 四、拷过去之后怎么算"成了"(验收清单)
+
+```bat
+<PY> scripts\check_portability.py      :: 路径可移植门禁 (0 ERROR)
+<PY> scripts\check_transferable.py     :: 移植性大扫描 (见 §六)
+<PY> guanlan.py check                  :: 依赖/产物/发布件/端口/模型 逐项
+```
+| 检查 | 期望 |
+|---|---|
+| `guanlan.py serve` | `状态 degraded ... 模块 6/7 ok`, 只有 `/local-ai/` 可能 DOWN(本机模型未装) |
+| 页面 | `/` 门户 200 · `/detail/` 工作台 200 · `/cms/` 200 · `/sim/` `/sim/sys/` `/viewer/` 200 · `/ops` 运维控制台 200 |
+| 产物锚点 | 报警 39211 行 · 工单 5876 · 油样 404 · `temp_monthly` 19494 · 本体 9702 对象 |
+| 控制台 | `/ops` 按钮随真实状态启用; 点"执行重算"能跑完 12 步(退出码 0) |
+
+## 五、交付/打包时建议清掉的本地痕迹
+
+| 项 | 为什么 |
+|---|---|
+| `_products_off/` `_products_off_prev_*/` | 本机"清除产物"的暂存与存档(每份都是整份产物 ~220 MB); 目标机用不到 |
+| `logs/` `run/pids.json` | 本机运行日志与**旧 PID**; pids.json 到新机器上是无效引用(启动器会重写) |
+| `.git/` | 55 MB 仓库; 交付不需要(自己留版除外) |
+| `data/raw/`(视交付约定) | 30.9 GB 现场原始件; 按约定"原始件不随包分发"时不必带(缺它只影响"重算原始数据", 不影响页面) |
+| `release/viewer/*.json` 里的 `source_path` | 带着**开发机磁盘结构**(如 `/Volumes/T5 EVO/风电数据/…`); 不影响渲染, 但对外交付建议清掉(脱敏) |
+
+## 六、`scripts/check_transferable.py` 查什么
+
+比 `check_portability.py`(只看 `.py` 的路径写法)更宽 —— 因为"能不能独立运行"还取决于环境:
+
+1. **所有文本类型**里的机器相关绝对路径(`.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg`), 不只源码;
+2. **虚拟环境**: `.venv/pyvenv.cfg` 的 `home` 指向哪个基础解释器(换机是否还在);
+3. **离线依赖**: `wheels/<平台>` 有没有、与 `requirements.txt` 对不对得上;
+4. **便携运行时**: `vendor/python/…` 在不在、`vendor/MANIFEST.json` 登记是否齐全;
+5. **配置与端口**: `serve.json` 里有没有绝对路径、有多少地方提到端口。
+
+> 现状(2026-09-12 实测): 文本里仍有 350 处机器相关路径, **但都不在运行路径上** —— 分布是
+> `release/viewer/*`(三维单位的 `source_path` 出处注记, 186 处) · `outputs/**`(产物里记的历史路径/provenance, 95) ·
+> `src/sop/farm_paths.py`(盘符映射表本体, 门禁具名登记) · `scripts/build_oem_lexicon.py`(开发机 OEM 资料盘) ·
+> `_修复记录_20260911/fix_*.py`(一次性修复脚本留档) · 文档里的反面示例。**都不影响在别的机器上运行**。

+ 11 - 11
docs/重算操作手册_v0.1.md

@@ -2,7 +2,7 @@
 
 **给谁看**: 要手工把 `data\raw` 下的原始数据算成系统能显示的东西的人。
 **前提**: 已经跑过 `install.bat`(有 `.venv`)、`.venv\Scripts\python.exe` 存在。
-**所有命令都在安装根目录下敲**(`F:\temp\guanlan-rudong-v2_0.2.0\`) —— 脚本按自身位置解析路径, 但相对路径参数
+**所有命令都在安装根目录下敲**(`<安装目录>\`) —— 脚本按自身位置解析路径, 但相对路径参数
 (如 `--src`)会按当前目录解释, 站在根目录最省事。
 
 > 一句话原理: 页面的数来自 `outputs\rudong\` 里的**产物**; 产物由 `data\raw\` 下的**原始件**算出来;
@@ -24,7 +24,7 @@ cd /d <安装目录>
 .venv\Scripts\python.exe scripts\rebuild_all.py --skip-scada
 
 :: 连"放数据"一起 (现场包目录)
-.venv\Scripts\python.exe scripts\rebuild_all.py --src F:\temp\如东风场数据
+.venv\Scripts\python.exe scripts\rebuild_all.py --src <现场包目录>
 
 :: 其它开关
 .venv\Scripts\python.exe scripts\rebuild_all.py --dry-run       :: 只打印计划, 不执行
@@ -134,9 +134,9 @@ cd /d <安装目录>
 
 ```bat
 :: 先看要落什么
-.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full --dry-run
+.venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full --dry-run
 :: 真落位
-.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+.venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full
 ```
 
 | 范围 | 落什么 |
@@ -293,9 +293,9 @@ rmdir /S /Q "outputs\rudong\windscada\_pre_rebuild_20260911"
 **不想用开关也行**(整目录搬走, 最直观):
 
 ```bat
-Move-Item outputs\rudong F:\temp\products_off_20260912       :: 清
+Move-Item outputs\rudong <暂存目录>       :: 清
 .venv\Scripts\python.exe guanlan.py stop ; .venv\Scripts\python.exe guanlan.py serve
-Move-Item F:\temp\products_off_20260912 outputs\rudong       :: 还原
+Move-Item <暂存目录> outputs\rudong       :: 还原
 ```
 
 > 清的是 `outputs\<场>\`(产物仓), **不动** `data\raw\`(现场数据)、`release\`(门户外壳与交付件)、
@@ -305,7 +305,7 @@ Move-Item F:\temp\products_off_20260912 outputs\rudong       :: 还原
 
 ```bat
 :: ① 放数据 (现场包 → 约定目录; 同尺寸自动跳过)
-.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+.venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full
 
 :: ② 台账 + SCADA 侧 10 个构建器 (后者约 15 分钟)
 .venv\Scripts\python.exe scripts\rebuild_from_raw.py
@@ -356,8 +356,8 @@ Move-Item F:\temp\products_off_20260912 outputs\rudong       :: 还原
 **回归尺子**(与出厂基线比对 10 个端点):
 
 ```bat
-.venv\Scripts\python.exe scripts\page_fingerprint.py --save F:\temp\fp_now.json
-.venv\Scripts\python.exe scripts\page_fingerprint.py --diff F:\temp\fp_before.json
+.venv\Scripts\python.exe scripts\page_fingerprint.py --save <本次指纹.json>
+.venv\Scripts\python.exe scripts\page_fingerprint.py --diff <基线指纹.json>
 ```
 
 ## 7. 会遇到的"算不出来"(都不是操作错)
@@ -388,8 +388,8 @@ Move-Item F:\temp\products_off_20260912 outputs\rudong       :: 还原
 :: 现状
 .venv\Scripts\python.exe scripts\scan_stations.py
 
-:: 放数据(现场包还在 F:\temp\如东风场数据)
-.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+:: 放数据(现场包还在 <现场包目录>)
+.venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full
 
 :: 算
 .venv\Scripts\python.exe scripts\rebuild_from_raw.py

+ 1 - 1
docs/重算缺口与补件清单_v0.1.md

@@ -39,7 +39,7 @@
 ### A1 · 油样 2026-07 批(102 行)
 
 - **缺的**: 一份**合并报告** `BG-2026-07-YP013 中广核新能源如东海上风电场.pdf`(一份覆盖多台, 台号/部件只写在 PDF 表格里)。
-- **证据**: 现场包 `F:\temp\如东风场数据` 里油样只有一批 ——
+- **证据**: 现场包 `<现场包目录>` 里油样只有一批 ——
   `数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/`(404 件); 全包按 `YP013` 搜 **0 命中**,
   按批次目录枚举也只有 `2025年油样` 这一批, 没有 2026-07。从零重算的结果是 `oil_samples_index` 404 行,
   比随包 506 行少 102 行 —— 这也是 `--verify` 唯一的"需人工看"项。

+ 1 - 0
scripts/check_portability.py

@@ -116,6 +116,7 @@ ALLOW = {
     'src/windscada/fast_agent.py': '注释里的实测记录出处',
     'src/paths.py': '本模块的文档字符串在讲这条约定本身',
     'scripts/check_portability.py': '本门禁自己 (规则里必须写出这些模式)',
+    'scripts/check_transferable.py': '移植性大扫描自己 (规则里必须写出这些模式, 否则没法检出它们)',
 }
 DELIVERY_ALLOW = {
     'scripts/sim_hub/': '开发期仿真合页生成器 (mac 检出路径), 不进交付/运行路径',

+ 200 - 0
scripts/check_transferable.py

@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
+
+比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
+
+  ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
+     —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
+  ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
+     机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
+  ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
+  ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
+  ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
+
+用法:
+    python scripts/check_transferable.py            # 全量检查 + 结论
+    python scripts/check_transferable.py --brief    # 只报问题
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
+RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
+# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
+TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
+SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
+SKIP_PATH_HINT = ('_products_off_prev',)
+# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
+ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
+              'D:/guanlan', '示例', '例:', '例如')
+
+
+def walk_files():
+    for p in ROOT.rglob('*'):
+        if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
+            continue
+        rel = p.relative_to(ROOT).as_posix()
+        parts = set(p.relative_to(ROOT).parts)
+        if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
+            continue
+        if p.stat().st_size > 8 * 1024 * 1024:      # 超大文本(如 20MB 门户)另算, 见下
+            continue
+        yield p
+
+
+def scan_paths(verbose=True):
+    hits = []
+    for p in walk_files():
+        try:
+            text = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        for i, line in enumerate(text.splitlines(), 1):
+            if any(a in line for a in ALLOW_LINE):
+                continue
+            if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
+                continue
+            m = RE_ABS.search(line)
+            if m:
+                hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
+    print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
+    if hits and verbose:
+        for rel, i, found, line in hits[:25]:
+            print(f'     {rel}:{i}  ← {found}\n        {line}')
+        if len(hits) > 25:
+            print(f'     … 另有 {len(hits) - 25} 处')
+    return hits
+
+
+def scan_big_text():
+    """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
+    out = []
+    for p in ROOT.rglob('*.html'):
+        rel = p.relative_to(ROOT).as_posix()
+        if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
+            continue
+        try:
+            t = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        n = len(RE_ABS.findall(t))
+        if n:
+            out.append((rel, n, p.stat().st_size))
+    if out:
+        print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
+        for rel, n, sz in out[:10]:
+            print(f'     {rel}  {n} 处 ({sz/1e6:.1f} MB)')
+    else:
+        print('② HTML 里没有烘进的绝对路径')
+    return out
+
+
+def check_venv():
+    cfg = ROOT / '.venv' / 'pyvenv.cfg'
+    print('③ 虚拟环境')
+    if not cfg.is_file():
+        print('     [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
+        return 'absent'
+    kv = {}
+    for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
+        if '=' in l:
+            k, v = l.split('=', 1)
+            kv[k.strip()] = v.strip()          # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
+    home = (kv.get('home') or '').strip()
+    print(f'     home(基础解释器) = {home}')
+    print(f'     版本 {kv.get("version", "?").strip()}')
+    here = pathlib.Path(home).is_dir() if home else False
+    print(f'     本机存在该目录: {here}')
+    print('     ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
+          '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
+    return kv
+
+
+def check_wheels():
+    w = ROOT / 'wheels'
+    print('④ 离线依赖轮子')
+    if not w.is_dir():
+        print('     [X] 没有 wheels/ 目录 —— 装依赖需要联网')
+        return []
+    dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
+    print(f'     平台目录: {dirs or "(无)"}')
+    req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
+    req = [r for r in req if r]
+    names = {r.split('==')[0].lower().replace('-', '_') for r in req}
+    for d in dirs:
+        have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
+        miss = sorted(n for n in names if n not in have)
+        tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
+        print(f'     {d}: {len(list((w / d).glob("*.whl")))} 个轮子  {tag}')
+    plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
+    if plat not in dirs:
+        print(f'     [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
+    return dirs
+
+
+def check_vendor():
+    v = ROOT / 'vendor'
+    print('⑤ 便携运行时 / 离线件')
+    if not v.is_dir():
+        print('     [--] 没有 vendor/')
+        return
+    for p in sorted(v.rglob('*')):
+        if p.is_file():
+            print(f'     {p.relative_to(ROOT).as_posix()}  {p.stat().st_size/1e6:.1f} MB')
+    man = v / 'MANIFEST.json'
+    if man.is_file():
+        m = json.loads(man.read_text(encoding='utf-8'))
+        files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
+        undoc = sorted(files - set(m))
+        if undoc:
+            print(f'     [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
+
+
+def check_configs():
+    print('⑥ 配置与端口')
+    p = ROOT / 'configs' / 'serve.json'
+    if p.is_file():
+        c = json.loads(p.read_text(encoding='utf-8-sig'))
+        print(f'     serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
+              f'raw_dir={c.get("raw_dir")!r}')
+        for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
+            v = str(c.get(k) or '')
+            if re.match(r'^[A-Za-z]:[\\/]|^/', v):
+                print(f'     [!] {k} 是绝对路径: {v}')
+    # 写死的本机端口/入口(允许出现, 但要知道)
+    n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
+    print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--brief', action='store_true')
+    a = ap.parse_args()
+    print(f'移植性大扫描 @ {ROOT}\n')
+    hits = scan_paths(verbose=not a.brief)
+    big = scan_big_text()
+    kv = check_venv()
+    dirs = check_wheels()
+    check_vendor()
+    check_configs()
+    bad = len(hits) + len(big)
+    print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
+          f' 虚拟环境/轮子/便携运行时见上面各节 —— '
+          f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
+    return 0 if not bad else 1
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 16 - 12
scripts/rebuild_all.py

@@ -23,7 +23,7 @@ r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算
 
     python scripts/rebuild_all.py                      # 全套 (会跑 SCADA 那 15 分钟)
     python scripts/rebuild_all.py --skip-scada         # 只换台账时的常用档
-    python scripts/rebuild_all.py --src F:\temp\如东风场数据   # 连"放数据"一起
+    python scripts/rebuild_all.py --src <现场包目录>   # 连"放数据"一起
     python scripts/rebuild_all.py --no-restart         # 不碰服务(自己管)
     python scripts/rebuild_all.py --with-verify        # 末尾加等价验收(需随包基线在位)
     python scripts/rebuild_all.py --dry-run            # 只打印计划
@@ -41,9 +41,9 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
 PY = sys.executable
 
 
-def step_cmd(name, cmd, tolerate=()):
-    """tolerate: 允许的退出码 (不算失败)。"""
-    return (name, cmd, tuple(tolerate))
+def step_cmd(name, cmd, tolerate=(), note=''):
+    """tolerate: 允许的退出码 (不算失败); note: 该步非零退出时打给人看的解释(按步, 别用一句通用话糊过去)。"""
+    return (name, cmd, tuple(tolerate), note)
 
 
 def build_plan(a) -> list:
@@ -62,7 +62,8 @@ def build_plan(a) -> list:
         #     连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。
         # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。
         plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))
-        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,)))
+        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,),
+                             note='serve 在"有模块 DOWN"时故意返回 1 —— degraded 是正常状态(例如本机模型未启动)'))
     plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
     plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
     plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))
@@ -71,10 +72,11 @@ def build_plan(a) -> list:
     plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
     plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
     if a.with_verify:
-        # 退出码 4 = 有"无法归类"的差异; 本包已知的差异是"油样 102 行源件不在现场包里"(属人工项),
-        # 所以容忍 4 但把完整报告打给用户看 —— 真出现新差异时报告里会点名。
-        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 表示有需人工看的差异, 属已知)', 
-                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4,)))
+        # 只有 4 = "有需人工看的差异"(本包已知项: 油样 102 行的源件不在现场包里) 才容忍;
+        # 5 = 找不到基线(等于没验收) 必须当失败 —— 2026-09-12 就是因为放过了它, 出现过"验收没做却报通过"。
+        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 = 有需人工看的差异, 属已知)',
+                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4,),
+                             note='rc=5 表示找不到随包基线, 那是"没验收"不是通过'))
     return plan
 
 
@@ -91,7 +93,7 @@ def main() -> int:
     print(f'重算全部 · {len(plan)} 步 · 根目录 {ROOT}')
     print(f'  跳过 SCADA: {a.skip_scada} | 重启服务: {not a.no_restart} | 等价验收: {a.with_verify}\n')
     if a.dry_run:
-        for name, cmd, tol in plan:
+        for name, cmd, tol, note in plan:
             print(f'  {name:34s} {" ".join(cmd[1:])[:90]}')
         print('\n(dry-run, 未执行)')
         return 0
@@ -100,7 +102,7 @@ def main() -> int:
     # 子脚本 print 里的 ✔/✗ 会抛 UnicodeEncodeError, 明明干成了却以退出码 1 结束。
     env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
     results, t_all = [], time.time()
-    for i, (name, cmd, tol) in enumerate(plan, 1):
+    for i, (name, cmd, tol, note) in enumerate(plan, 1):
         print(f'\n{"=" * 78}\n[{i}/{len(plan)}] {name}\n$ {" ".join(cmd)}\n{"=" * 78}', flush=True)
         t0 = time.time()
         rc = subprocess.run(cmd, cwd=str(ROOT), env=env).returncode
@@ -109,9 +111,11 @@ def main() -> int:
         results.append((name, rc, dt, ok, tol))
         if not ok:
             print(f'\n[X] 第 {i} 步失败 (退出码 {rc}): {name} —— 后面的步骤依赖它, 先停下。')
+            if note:
+                print(f'    该步说明: {note}')
             break
         if rc != 0:
-            print(f'\n    (退出码 {rc} 属预期: {name} 的 degraded 是正常状态 —— 例如本机模型未启动)')
+            print(f'\n    (退出码 {rc} 属预期{(": " + note) if note else ""})')
 
     print(f'\n{"=" * 78}\n汇总 (总耗时 {time.time() - t_all:.0f}s)')
     for name, rc, dt, ok, tol in results:

+ 27 - 5
scripts/rebuild_from_raw.py

@@ -45,7 +45,8 @@ from collections import Counter
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
 
-BASE = P.rel(P.store()) + '/_pre_rebuild_20260911'       # 随包基线备份
+KEEP_BASE = '_pre_rebuild_20260911'                      # 随包台账基线的目录名 (products_state.py 也认这个名字)
+BASE = P.rel(P.store()) + '/' + KEEP_BASE                # 常规位置 (相对安装根)
 STEPS = [
     ('报警事件', 'scripts/windscada_alarms_ingest.py', 'alarms.parquet'),
     ('检修工单台账', 'scripts/windscada_workorder_ingest.py', 'workorders.parquet'),
@@ -116,13 +117,34 @@ def _canon(df, cols=None):
     return Counter(out)
 
 
+def baseline_dir():
+    """随包台账基线在哪 —— 两处都找, 找不到才报错。
+
+    ① `outputs/<场>/windscada/_pre_rebuild_20260911/` (常规位置);
+    ② `_products_off*/_baseline_kept/_pre_rebuild_20260911/` —— `products_state.py --off` 会把它挪到这里
+       (它刻意**不**算产物, 就是为了随时能还原做验收)。
+    2026-09-12 实逮: 从运维控制台点过"清除产物"之后跑 `rebuild_all.py --with-verify`, 验收步骤报
+    "[X] 没有随包基线备份" **却被编排当成"退出码 4 属预期"放过** —— 等于"验收没做却报通过"。
+    修法: 本函数自动找第二处; 真找不到才返回 5(硬失败, 编排不容忍)。
+    """
+    cands = [P.store() / KEEP_BASE]
+    for p in sorted(ROOT.glob(f'_products_off*/_baseline_kept/{KEEP_BASE}')):
+        cands.append(p)
+    for c in cands:
+        if c.is_dir():
+            return c
+    return None
+
+
 def verify() -> int:
     import pandas as pd
     store = P.store()
-    base = ROOT / BASE
-    if not base.is_dir():
-        print(f'[X] 没有随包基线备份 {BASE} —— 无法做等价验收')
-        return 4
+    base = baseline_dir()
+    if base is None:
+        print(f'[X] 找不到随包基线备份 (找过 {P.rel(P.store())}/{KEEP_BASE} 与 _products_off*/_baseline_kept/) '
+              f'—— 无法做等价验收 (退出码 5 = 没验收, 不等于通过)')
+        return 5
+    print(f'(基线: {P.rel(base)})')
     bad = 0
     print('== 等价验收: 重算件 vs 随包基线 ==')