inventory-mcp
README.md
# 库存 MCP
把方案包里那几十张飞书源表(当前 31 张)读出来、筛选去重、归一、聚合,做成十三个工具给 agent 调;每周导进飞书台账。
零依赖:MCP 走 stdio 上的 JSON-RPC,没有 `node_modules`,装到别的机器上只要有 node。
## 装
WorkBuddy 里点「MCP 列表 → 编辑配置」,或者直接改 `~/.workbuddy/mcp.json`:
```json
{
"mcpServers": {
"inventory": {
"command": "node",
"args": ["/path/to/inventory-mcp/server.mjs"]
}
}
}
```
Claude Desktop 或别的 MCP 客户端同理,配置形态一样。
## 十三个工具
| 工具 | 参数(全可选) | 回什么 |
|---|---|---|
| `查库存` | **资产类型** / 型号 或 **槽位** / 型号们 / 品牌 / 库房 / **要几根** / 明细几行 / **要可用量** / 一定要文件 | 匹配的物料键、各自根数、按库房小计(带梯队)、合计、**收尾那一行**(给了「要几根」才算),不够时**自动放宽一项**并把候选一起带回来;**回显「这次查的」**(解析后的槽位,给下一轮照抄) |
| `看分布` | 资产类型 / 前几个 | 「型号 × 库房」矩阵 + 每个库房合计 |
| `找替代` | 型号 / 资产类型 / **要几根** / 库房 / 每档几个 | 按「机器能证明多少」分四档:精确 / 规则 / 存疑 / 相近,另给「凑法」;人拍过的候选带一句「人拍过」 |
| `查SN` | 型号 / 品牌 / 库房 / 资产类型 / 物料键 / 最多列几条 / 一定要文件 | 一根根的序列号。**超过 50 个就写 CSV、只回路径**,见下 |
| `看变动` | 和哪天比 / 资产类型 / 库房 / 前几个 | 和周基线比进出了什么。**先报按 SN 算的真进出**,键级变化降级,见下 |
| `看源表` | 资产类型 / 库房 / 字段 / 要不要每张的明细 | 这些数从哪张表哪一列来的。只读方案包、不打网络,见下 |
| `看有哪些型号` | **资产类型(必填)** / 带槽位 / 带根数 | 这一类在库里的**全部标准写法 + 有哪些品牌各多少货**(封闭清单)。客户写法不标准时从里面挑,见下 |
| `看筛选` | 资产类型 / 只看变了的 | 每条筛选规则**认识哪些取值、各多少行**,以及和上次比变了什么。**这是原材料不是结论**,见下 |
| `写占用` | **工单号** / 项目 / **配件需求** `[{型号,数量}]` / 真写 | 需求满足后把配件登记进占用台账(闭环最后一步)。传 cmdb「查工单」出的**配件行**(只 内存/硬盘/光模块/网卡)。每条按可用量分:满足→占用/审批中(关联真键)、缺口→采购在途(建/复用同型号虚拟待采购键)。**默认干跑**回计划+汇报,真写传 `真写:true`(低风险可删、不弹确认框);整机/耗材/线缆在 cmdb 侧已筛掉 |
| `发通知` | **工单号** / 项目 / **配件需求** | 匹配完把通知**拼好、按收件人分段返回**(**不真发**):满足→给「资管(台账管理者)」一段、缺货/待定→给「采购(采购对接人)」一段。**你复制粘到对应飞书对话框自己发**——发件人是你、绕开机器人可用范围/租户策略,"复制→发送"就是审核那一步。缺货=匹配上库存但不够;待定=型号没对上库存(请核对写法)单独列。只读台账、不发消息、不要发消息权限。为什么不 bot 直发:实测用户身份发被租户策略挡(230027)、bot 私信到别人要那人进 app 可用范围(230013)、bot 发群要 bot 先在群里(230002)——返回文本你自发全绕开 |
| `日更` | 确认票 / 用户选择 | 每天刷新台账+注册表+在途,出「该变没变」红检查自检回执(不是光盖时间戳)。**两步确认**(真写是批量写台账/注册表):① 不带参调→干跑出回执(台账 新增/更新/置0、注册表 新发几键、在途还剩几条)+ 确认票,不写;② 你**必须先调 AskUserQuestion** 把回执给用户看、问真写吗,带票+用户选择重调才写,真写后跑完整红检查。走飞书不要公司网;工单同步要公司网、跳过并在回执标。逻辑在 `lib/日更运行.mjs`,命令行版是根目录 `日更.mjs --真写` |
| `周更` | 确认票 / 用户选择 / 认下筛选 | 每周四跑(日更的超集):重读源表 → **和上周四基线比**(周环比:出/入库、哪些键真归零→占用悬空、在途到货没)→ 品牌待补 → 存本周基线 → 写台账。**两步确认**同日更(干跑出回执+票 → AskUserQuestion → 真写)。硬规矩:有源表读不到就不存基线、不写台账。逻辑在 `lib/周更运行.mjs`,命令行薄壳 `周更.mjs`。导台账是它的子集(都调 写台账),不单独做工具 |
| `记下决定` | **全必填**:资产类型 / 要的 / 顶上的 / 结论 / 依据 / 谁拍的 | 把人当场拍板的替代决定记下来,下次不用再问。**唯一往磁盘写「判断」的工具**,见下 |
参数一律扁平,**数组只装字符串**,没有嵌套对象 —— 便宜的模型对嵌套结构容错差。
跨类一次问完写成 `型号们: ["光模块:SR4","硬盘:960G"]`,仍是扁平字符串数组。
**参数改过名就在协议层顶回去,不静默忽略。** `找替代` 的「数量」2026-08-14 并成了
「要几根」(和 `查库存` 同一个名字 —— 同一件事两个名字,模型迟早发错那个)。
静默忽略的表现是:模型拿到一份看着正常的返回,只是「够不够」那一块整个没了,
**一个不报错的缺块**。所以老名字直接回 `-32602` 让它换个名字重发。
## 型号怎么匹配:走槽位,没有字符串这条路
**「型号」不是子串匹配。** 实测过一次真事故:问 `OSFP112-800G-2*DR4-SM1310` 子串匹配命中 0,
而库里有 18,000 根 `OSFP112-RHS-800G-2*DR4-SM1310` —— 中间多一段 `RHS` 整串就对不上,
模型据此报「库里没这个货」。现在两边都解析成 `form/rate/std/media/wave` 再逐项比,
连裸关键词都吃得下(`SR4` 解析成 `{std:SR4, media:MM, wave:850}`,精确命中 35 个键)。
链路是死的:
```
模型(对着 instructions 里两张常驻表:槽位词表 351 token + 品牌名单 93 token)
定资产类型(必填,机器不猜)→ 把客户的乱写法翻成槽位 → 挑品牌标准名
↓
查库存 → 校验槽位值在词表里(不在当场报错并列出合法值)
→ 逐槽位相等才算命中 → 品牌精确匹配(不是包含)
↓
命中 0,或者命中了但不够「要几根」
→ 自动放宽收益最大的那一项,把明细直接带回来(不让模型再问一轮 ≈ 8,000 token)
→ 「没查到」+ 你的槽位是什么 + 差得最少的 5 个,每条带**逐槽位对照**
(对上的和没对上的都列 —— 只列差异的话,人分不清「其余几项真的相同」
还是「其余几项压根没比」)
```
**放宽不是只在零命中时做。** 命中 300 根、客户要 2,800 根时,放宽之后的 8,932 根才是答案,
而只在 `命中 === 0` 时算的话,这种情况一个字都不给。判据是「本档够不够 `要几根`」,
不是「有没有命中」——所以**不填 `要几根` 就不会触发**,工具只答「有多少」。
放宽是**读操作**:摆出候选不等于断言它能用。返回里写清放宽了哪一项、其余槽位逐项相同,
「能不能插」留给人和模型判(`QSFP` 和 `QSFP28` 是同一个笼子的两种写法,
`SR4 多模` 和 `LR4 单模` 就是不通)。放宽封装之后冒出好几种封装时挂一条 `❓ 这里该问人`。
**分工是死的:模型做翻译和选择,机器做判断。** 翻译的输出受词表约束、当场可验;
「两组槽位是不是同一款」一行都不交给模型 —— 交了的话同一对型号今天判同款明天判不同。
判据是**「答案在不在一个可枚举的集合里」**:在(资产类型 4 个、某类型号 80 个、
品牌 32 个、槽位值 24 个)→ 给模型挑,挑错了当场可验;不在(A 能不能替代 B)→ 机器算。
**不猜资产类型。** 原来是拿四类解析器都试、谁填的槽位多算谁 —— 189 个真型号里猜不出 61 个,
另有 5 个被多类同时认(`128GB 2Rx4 PC5-5600B` 光模块和内存都认),靠一票之差分胜负。
现在库里没这个写法又没给资产类型就报错让人补。
**但只有一处属性真的定不了类,其余都能**(2026-08-15 把四类真实写法全解析了一遍):
101 个 `槽位=值` 里 **96 个只被一类用过**,共用的只有 `rate` 的 5 个值 ——
`10G / 25G / 100G / 200G / 400G`,光模块和网卡都有。内存的 `cap`(16/64/96/128G)和硬盘的
`cap`(480G 起)**一个都不撞**。所以 instructions 里只写了这一条提示(约 122 token,
一次会话发一遍),**没有建「容量→资产类型」的索引** —— 索引强的地方(`QSFP28`→光模块、
`3.84TB`→硬盘)模型本来就对,索引弱的地方(只给一个 `400G`)它也一样卡住,
建表是在模型已经对的地方加确定性、在它不行的地方帮不上,还多一套要防过期的东西。
## 槽位这条路的两个洞(2026-08-23 量出来的)
问一个具体型号,回来的可能是**整个资产类型的库存**,而回包里写着「按槽位匹配,库里逐项相等的才算命中」。
两个洞的根子是同一个:`比一个()` 只遍历**核心槽位**,而且「需求没给的槽位 → continue」(不约束)。
一路 continue 到底,就是「每一行都逐项相等」。
**洞一:需求里一个核心槽位都没有。** 两条路都能走到:
| 怎么发生的 | 实测 |
|---|---|
| 型号解析出空槽位集。**库里 189 个真型号里有 23 个是这样**(硬盘 12 / 网卡 9 / 内存 2),全是厂商料号:`MZQL23T8HCLS-00B7C`(三星)、`SSDPF2KX038T1`(英特尔)、`MCX653105A-HDAT`(迈络思)、`900-9D3D4-00NN-H`(英伟达)—— 料号里没有人能读的规格 | 问 `MZQL23T8HCLS-00B7C` → 回硬盘 **62/62** 种 |
| 显式给的槽位全落在 core 之外。光模块的 `lanes`、硬盘的 `media` 在词表里、`解析槽位串` 会放行,但 core 里没有它们,压根不参与比较 | `lanes=2*DR4` → 光模块 **80/80**;`media=SSD` → 硬盘 **62/62** |
方向是最坏的那个 —— **多报**:少报人会追问,多报人直接许出去了。
修法:`按槽位配` 先算「有几个槽位真会被拿去比」(= core ∩ 需求)。为 0 时不再返回匹配,改成两支——
- **这个写法的字面指纹在库里** → 只回它自己那几条,标 `只按字面`,同时说清「库里可能还有同款的别的写法,这次没找」。
- **字面也不在** → 回「没查成」+ 怎么办(照词表翻成核心槽位重查,或调 `看有哪些型号` 拿清单挑)。
字面比的是 `ledger.字面指纹`(大写 + 去分隔符,和 `占用写.归一型号`、normalize 分组同一个口径),
不是严格相等 —— 严格相等量过一次,客户把料号写成小写就全找不回来了(-21 条)。
**不做前缀/包含匹配**:那条路是这个仓库特意删掉的,会漏掉「中间多一段」的同款货(实测漏 18,000 根)。
**洞二:只解出一部分核心槽位,却报成精确匹配。** 这个比洞一普遍得多 —— 189 个真型号里 **86 个**是部分覆盖。
没解出的那几项不参与比较,语义上说得通(没约束的不筛),但那句「逐项相等才算命中」一个字都没变,
于是「真精确」和「只约束了五分之一」在回包里长得一模一样。实测放大倍数:
| 解出几项核心槽位 | 平均认回几种货 |
|---|---|
| 光模块 5/5 | 1.3 |
| 光模块 4/5 | 2.4 |
| 光模块 3/5 | 4.0 |
| 光模块 1/5 | **38**(80 种里的 38。那个型号是 `AFBR-709SMZ 850nm LASER PROD`,五项里只认出 `wave=850`)|
| 硬盘 4/4 | 1.0 |
| 硬盘 1/4 | 6.7 |
修法**不是拒答**(客户说 `3.84T` 时回 11 种是对的),是把覆盖率说出来:
`按槽位配` 返回 `解析覆盖 {参与比较, 没参与, 全覆盖}`,`怎么筛的` 在没全覆盖时明说
「**这不是精确匹配** —— 4 项核心只约束了 `cap`,`bus`、`form`、`gen` 没参与比较,
所以下面 11 条里混着 bus/form/gen 各不相同的货。报给人时别说成「就是这个型号」」。
**顺带发现召回率那个 100% 有一部分是假的。** 那 23 个料号原来命中整类,真值自然在里面 → 被算成召回。
修完 `带项目尾巴` 那行掉到 88%,**逐条归因确认掉的 23 条全是这批料号、一条真回归都没有**,基线才降的
(见 `tests/召回.test.mjs` 里那段基线注释)。其余几行靠字面指纹回到了原水位:
`全小写`/`连字符换空格` 回到 100%,`去掉所有分隔符` 回到 96%(剩的 8 条是修之前就没中的真缺口)。
三道闸都从源码里拿掉验过必红:洞一那道 → `查库存.test.mjs` 红;字面改回严格相等 → `召回.test.mjs` 红;
洞二那段 → `查库存.test.mjs` 红。
## 三个「给模型看的清单」,边界是死的
```
看源表 这个数从哪张表、哪一列来的 —— 答来源
看有哪些型号 这一类有哪些标准写法和品牌 —— 答清单,给模型挑
看筛选 每条规则认识哪些取值、变了什么 —— 答原材料,给模型判
```
## `看筛选`:交出去的是原材料,不是结论
45 条规则(表 × 规则字段)各认识哪些取值、各多少行,加上和上次基线的 diff。
全量约 1,500 token,`只看变了的: true` 约 170。
**上一版这里是机器判的**:「命中率掉超过 20 个百分点 = ⚠ 明显下滑」。那个阈值是拍的、
标着未验证,而**同一个变化在三种情境下意思完全不同** —— 人主动改了筛选规则 / 源表的列被改了 /
真的进了一批新状态的货,机器分不出是哪种。实测还有三张表常年 7% / 15% / 20%(全量资产台账,
大部分行是「在线」的在用设备),任何绝对阈值都会把它们误报成异常。
现在的分法:
| 谁 | 干什么 |
|---|---|
| 机器 | 收集 45 条规则的取值分布;和上次做 **diff**(纯 diff,无判断);「读到有效行却一条都没命中」这个**零误报的绝对判据**留着 |
| 模型 | 这些变化是哪种情境;某个取值被排除掉了要不要紧(`借测 288 行` 这种) |
| 人 | **滚不滚基线** —— `./周更.mjs --认下筛选`,工具调不到这个开关。能被自己清掉的警报等于没有警报 |
## `看有哪些型号`:封闭清单,让模型挑
某一类在库里的**全部标准写法**。光模块 80 个 = 1,128 token、硬盘 62 个 = 512、内存 17 个 = 285。
客户的写法不标准、模型拿不准对应库里哪个时调它 —— 从清单里挑一个确切的再去查。
`带槽位: true` 每条附上解析结果(光模块涨到 4,025 token)。
一并给**这一类有哪些品牌、各多少货**(按根数倒序,只列有货的)—— instructions 里那份品牌名单
只有「标准名 ← 别名」,模型挑品牌时看不见分布,可能挑一个库里根本没有的,
然后拿到 0 却分不清是「没这个牌子」还是「自己翻错了」。
**那两个枚举从方案包现抠,不在 `server.mjs` 里写死**(`现抠枚举()`,`lib/ledger.mjs`)——
和「槽位解析从油猴现抠」同一条规矩。原来它们各被复制了 **9 遍**(五个工具的 inputSchema),
方案包里加一类资产或一个库房,这边毫无反应:模型筛不到它、那批货就查不出来,而且不报错。
抠不到方案包时**不给 enum,而不是给一个空 enum**——空 enum 等于「什么都不许填」,
是个静默的全禁;这时参数退化成自由字符串,第一次真调用会带着真正的原因失败。
`tests/direct.test.mjs` 判据 ㊳ 直接 grep `server.mjs`,写死一个就红。
**在库 ≠ 可用量**:在库来自方案包里那些源表(实时),占用中来自台账的占用记录汇总,
`可用量 = 在库 − 占用中`。对外承诺以可用量为准。读台账实测 4.4 秒,所以**按需读**:
| 调用 | 耗时(热态) | 答什么 |
|---|---|---|
| `查库存`(默认) | **2.5 秒** | 在库,带一句「这是在库不是可用量」 |
| `查库存 要可用量=true` | 5.1 秒 | 在库 / 占用中 / 可用量三个数 |
| `看分布` | 2.5 秒 | 不读占用 |
| `找替代`(**默认就读**) | 4.6 秒 | 「够不够」必须扣掉被占的 |
| `查SN` | 2.5 秒 | 不读占用(要的是清单,不是能不能拿) |
| `看变动` | 2.5 秒 | 不读占用 |
| `看源表` | **0 秒** | 只读 `plans.json`,一次网络都不打 |
三个工具的默认值**故意不一样**:`查库存` 问的是「有多少」,`找替代` 问的是「能不能顶上」——
后者隐含「能不能拿到」,说够了但实际被占了,人会白跑一趟采购。所以找替代不给关闭选项。
`找替代` 的每条候选**同时给「在库」和「可用量」**,`够不够` 和 `小计` 都按可用量算,
排序也按可用量(在库多但被占光的不许排前面)。两个数的差本身就是人要知道的。
相近档里**只差一个槽位、其余逐项相同**的单独标一句 `只差这一项`(`QSFP28-100G-SR4`
vs `QSFP-100G-SR4` 只差 `form`)。人拍过:**不当同一款,但相关类要做推荐** ——
所以它们仍然只进相近档,绝不进精确/规则;标的是「差在哪一项、其余哪几项相同」这个
算出来的事实,**不说「所以能替代」**,那是光模块知识、不是槽位算得出来的。
**相近档默认给「分层」,不给「前 N 条」**:按差几项分组,每层报个数、根数、前 2 个代表
(带完整差异和 `只差这一项` 整句)。实测一次查询相近档全量 192 条 ——
差 1 项 18 条/6,640 根、差 2 项 16 条/21,664 根 … 差 5 项 57 条/24,434 根。
只给排序后的前 5 条时,被截掉的 187 条只剩一句「另有 187 个规格」,人看不出它们差在哪个量级上;
分层之后「差 1 项 18 条」和「差 5 项 57 条」是两个完全不同的信号。
**同样「差几项」还要按代价再拆一层**(2026-08-15):差一个 `wave`(1310 vs 1300,能放)
和差一个 `std`(DR4 vs FR4,多模换单模、根本不通)都是「差 1 项」,混在一层里人得整层翻完
才知道哪几个值得看。分层键是「差几项 + 这几项里**最重**的那个放宽代价」,每层还带一句
`怎么读`(能放 → 先看这一层;不能放 → 除非人有别的依据否则别推)。
**取最重不取平均**:差两项里只要有一项不能放,这个候选就是不能放,另一项多好都不改结论。
排序先按差几项、同差几项按代价从轻到重(判据 ㊳㊴㊵ + 消融 10)。
**平铺的「相近候选」和分层必须同序**(判据 ㊶㊷ + 消融 11)。加分层的时候差点埋一个坑:
平铺列表还按老规矩排(差几项 → 比需求低 → 根数降序),三个候选都「差 1 项」时一路落到
按根数降序,而根数最多的恰好可能是最不该推的 —— 实测差 `std`(不能放,33 根)排第一、
差 `wave`(能放,11 根)排最后,**而分层里正好倒过来**。同一批货两种视图顺序相反,
看分层的人第一眼看到最该看的,要平铺列表的人第一眼看到最不该看的。
现在「最重代价」进了排序键,**排在根数前面**:一个插不上的货有再多根也没用。
排序后的那份全量列表**没删**(截断行为、层内排序、每条差异数这些只有它验得了,砍过一次
8 条判据当场没了依据),要它就显式传 `每档几个`。
**占用读不到时不退回在库冒充**:候选里干脆不给「可用量」这个字段(给一个等于在库的
「可用量」比不给危险得多,它看着是个已经扣过的数),`小计.按什么算的` 和 `够不够`
两处都自报是按在库判的、并带 ⚠。`tests/substitute.test.mjs` 消融 4 拿掉这一层就必须变红。
台账读不到时不让整个查询失败,改报 `⚠ 可用量算不出来`——「没人占」和「算不出来」是两回事。
**每个返回值都挂同一个「口径」块**(数据来源、读取时间和身份、在库总根数、品牌怎么补的、
坏件排除、SN 对比、筛选命中率)。不做成独立的「查数据新鲜度」工具——那样模型不会
主动去查,人就看不到。**以 `⚠` 开头的字段工具描述里要求模型必须原样转达。**
口径块里**只留会改变这次回答的字段**。各段耗时、读取秒、筛选去重链路、结构缓存、
归一改动、判据来源、台账原始行数这几项是给写代码的人调试用的,模型每次都要读完,
几轮下来是纯噪音——实测占口径块一半、占整个返回体约两成。默认不发,`INVENTORY_VERBOSE=1`
才发。**不是删掉**:出问题时那些数字是唯一的定位线索。`tests/scope.test.mjs` 守住
「该发的一条都没被瘦掉」——少发一个耗时数字没人受伤,少发一条「品牌是补的」
就是把「这个牌子是我们猜的」藏了起来,而藏起来不会报错。
### 返回体第一个字段是「怎么答」
便宜的模型会把结果写成一大段流水账。**格式指令放在返回体的第一个字段**,因为它是
从上往下读的,指令排在几千 token 数据后面基本不生效;只写在工具描述里也不行——
描述在会话开头读一次,几轮之后就被挤远了,而返回体是它每次组织答案时都要重看的。
```
怎么答: 先出一张表:品牌 | 型号 | 库房 | 在库 | 占用中 | 可用量。
表下面用短句补这几条,一条一行:⚠ 开头的每一条原样带上、
同一型号在多个库房时按库房逐行列,不许加总成一个数、数据读取时间。
别写查询过程、别复述字段名、别加收尾总结段。
```
**梯队不进表头。** 它是拿来判「这批货要不要跨库房协调」的,不是给人看的列 ——
人要的是「哪个牌子、在哪个库房、有多少」。塞进去会让每张表多一列没人看的数字。
代价约 130 token/次,换掉的是一整段「我调用了查库存工具,查询到以下结果……」。
它只管形状——哪些字段必须转达仍归各自的 `⚠` 和工具描述。
### 参数回显:把这次实际用的条件写回去
多轮追问是模型最容易错的地方,而且**错了不报错**:人问完「闵行有多少 400G DR4」接着说
「那临港呢」,模型要靠**回忆**自己上一轮传了什么 —— 记漏一个槽位查出来多一大截、
多带一个查出来少一大截,两种都拿到一个看着正常的数,人也看不出那不是他问的东西。
`查库存` 现在把这次实际用的条件回显出来,排在数据前面(排在几千 token 数据后面模型读不到):
```
这次查的: { 资产类型:'光模块', 槽位:'rate=400G,std=DR4,media=SM,wave=1310', 库房:'闵行' }
换条件时: 照抄「这次查的」改一项,别凭印象重写 —— 少一个槽位会多查出一大截、
多一个会少一大截,两种都不报错。
```
**回显的是解析后的槽位,不是原样回参数。** 上面那个例子里模型传的是 `型号:"400G DR4"`,
而 `DR4` 这个标准自动推出了单模和 1310 波长 —— **它实际问的是四个约束,自己不知道**。
回显之后它看得见,想放宽就能精确删掉 `wave=1310` 那一段,而不是整条重写。
**必须序列化成 `槽位` 参数本来收的字符串形式**(`k=v,k=v`),不能回一个对象 ——
回对象看着更结构化,但模型贴不回去,往返就断了。
**判据是往返,不是「回显里有没有这个字段」**(`tests/protocol.test.mjs` ㉜㉝㉞):
拿回显原样重查,合计必须一模一样。只验形状的话,「回显一个对象」这种写法会绿而功能是坏的。
**不上「查询 ID」那套**:那要工具端存状态,回显不用。
### 收尾那一行:工具算,模型抄(`lib/凑单.mjs`)
一批型号查完,人要的是每款一行「够不够、从哪儿调」。这一行**是人拿去下单的依据** ——
它说「山西 302 + 临港 283」,人就按这个数去两个库房调货。
让模型自己从明细里加,加错了不会有任何东西报错,货到了才发现少几百根。所以机器算:
```
一处就够 QSFPDD-400G-DR4:光迅·山西 满足
要凑好几处 QSFPDD-400G-DR4:光迅·山西 302 + 海光芯创·临港9号楼 283 = 585 满足
凑不够 QSFPDD-400G-DR4:全部 8 处合计 1073,缺 1727
没给「要几根」 QSFPDD-400G-DR4:光迅·山西 (附「这只是货最多的那一处,不代表够」)
```
三条规矩:
- **凑出来的每一处各自带数量。** 写成 `光迅+海光芯创·山西+临港9号楼 满足` 语法上没错、
读着也顺,但人不知道该去山西调多少、去临港调多少 —— 而这个错不会让任何东西红。
- **凑不够时不逐处铺开**,只报合计和缺口。那 N 处的数在「按库房」里本来就有,
摆进这一行只会让人以为「这些加起来就是答案」。
- **没给「要几根」就不许出现「满足」**(返回 `够: null`)—— 工具没算过够不够,
这时候写「满足」是模型在替人下结论。
「用几处是最少的」靠贪心,而贪心在这个问题上就是最优解:取最大的 k 个能让 k 项和最大,
所以第一次够的那个 k 就是最小 k。**前提是「每处能全取」** —— 哪天要加「某库房最多调 200 根」
这种上限,这条就不成立了,得换算法。
## 换机器 / 出问题:先跑 `./自检.mjs`
```
./自检.mjs 全查一遍(会真读几张源表,约 8 秒)
./自检.mjs --快 跳过真读那步,不打网络
```
**这套东西有三样不在这个仓库里**,光看代码看不出来:
| 缺什么 | 表现 | 怎么办 |
|---|---|---|
| `lark-cli` | 起不来 | 它**跟 WorkBuddy 走**,不是单独装的 —— 装 WorkBuddy 并打开一次。换了位置设 `INVENTORY_LARK_CLI` |
| `plans.json` | 起不来 | **按顺序找**:`INVENTORY_PLANS` → 仓库根的 `plans.json` → `(方案包目录)`。**换机器时拷进仓库根就能跑**,里面没有任何密钥。它的家在油猴那个仓库、由「品牌对照表」界面维护,所以这边不留副本 |
| 飞书那边的**读权限** | 「读不到表」 | **最容易卡、也最看不出来的一环** —— 它和路径错、网络断长得一模一样。找表的维护方开权限 |
登录态不用配:`lark-cli` 用的就是 WorkBuddy 那套(`identitySource: auto_detect`),
装的人登录自己的飞书账号就行,**不用给任何密钥**。
**`发通知` 现在不真发**(只拼文本返回,你自己粘发),所以不需要任何发消息权限。**若以后要 bot 自动发**,实测出来的门槛:用户身份发被租户策略挡(`230027`);bot 私信到别人要那人进 app `cli_aae5ee90f8f85cc5` 的**可用范围**(`230013`)、发群要 bot 先**在群里**(`230002`);bot 发消息要 `--as bot` + `im:message` scope(`lark-cli auth login --recommend`)。唯一零门槛路径是「bot 发它在的群 + `<at user_id>` @人」。
**每条红的后面都跟一句「怎么办」,而且一项坏了不挡后面的** ——
换机器时人想一次看全,不想修一个跑一次。这两条都有判据守着(`tests/自检.test.mjs`)。
## 三件静态检查(都在 15 秒那档里)
语言级的错交给该语言的工具,不自己写正则。三件都是全局装的(`shellcheck` / `eslint` 用 brew 和 npm -g),
仓库本身零 JS 依赖 —— ESLint 用全局二进制 + 仓库里一个 `eslint.config.mjs`,不要 `node_modules`。
| 工具 | 抓什么 | 今天它第一次跑就挑出的东西 |
|---|---|---|
| `shellcheck` | shell | 我刚写的 `ls \| wc -l`(SC2012)、`[ -n "$(grep …)" ]`(SC2143) |
| `eslint` | JS 的 `no-undef` / `no-unused-vars` | 三处死代码;以及回放验证时精确指到 `server.mjs:1408:11 'name' is not defined` |
| `ast-grep` | 按语法树改名(不是检查,是改代码时用) | — |
**只开 `no-undef` 和 `no-unused-vars`,风格类一条不开。** 这个仓库的取舍(中文标识符、长注释、
内联三元)是有意的,让 linter 管风格只会制造一堆要豁免的噪音,**而噪音会让人连真错也一起忽略**。
**接这类工具时防三件事**,缺一件它就会「失效时是绿的」:
- **没装不许静默跳过** —— 那样它永远「通过」。
- **看它扫了几个文件** —— 报 0 个文件和报 0 个问题长得一样,而前者是没检查。
shellcheck 还要额外看 `SC1088`:它遇到不认识的语法会**停止解析、照样退非 0**,
260 行只扫 28 行而看起来在干活(这就是 `run-tests.sh` 里函数名叫 `sec`/`run_one`/`teeth`/`chain` 的原因)。
- **只认退出码,不认输出文本** —— 第一版拿 `grep ' error '` 匹配 eslint 输出,
而它印的是 `[Error/no-undef]`(大写、没空格),一条都匹配不上,
**于是 eslint 报了错而这条检查印 ✓**。一个专治「失效时是绿的」的检查,自己失效时是绿的。
**批量改名一律用 `ast-grep`,不用 sed / 字符串替换。** 实测对比(同一段代码里「窄读」有三种身份):
```
盲替换 注释、字符串、词义不同的地方全被换 —— 4 处里 3 处是错的
ast-grep 只换标识符那 2 处,注释和字符串一个字没动
```
sed 的 `\b` 对中文不起作用(`s/\b中文\b/x/` 一个字都不会改,而你以为改了)。
**还有一个 shellcheck 也不报的形态**:`$var` 后面紧跟中文标点时,bash 会把那几个字节
当成变量名的一部分(`$es_code)` 印出乱码)。**变量后面接非 ASCII 一律写 `${var}`。**
**第三个形态,是上面那次改名自己制造的。** `e9d5aa8`(08-17 23:28,就是"函数名改 ASCII"那次)
把 `牙` 改成 `teeth` 时漏了并行链里的一处调用(`run-tests.sh:172`)。bash 只在运行时报一句
`牙: command not found`,**退出码不受影响、汇总照印 ✓** —— 于是打网络那四条链的消融
(分段读 4 个、增量 2 个、只读要用的表 2 个、写路径 2 个)**整整 15 小时一次都没跑过**,
而 `./run-tests.sh 全` 每次都是全绿。
- `bash -n` 通过 —— 命令名是运行时才解析的
- `shellcheck -S warning` 退出码 0 —— 它不检查函数有没有定义
- **发现它靠的不是任何一层检查**,是人扫输出时看见那四行 `command not found`,
外加"全档 118 秒、比记录里的 195 秒短了一截"这个对不上的数
修完重跑:118 → 218 秒,那 10 个从没跑过的消融**全部变红**(它们本身是好的,只是从没被执行过)。
**这一处至今没有会红的机制**,只有一个要人去比的秒数 —— 补法是全档末尾数一下"断言有牙"的行数
够不够链数,还没做。
## 分发那条路:让最便宜的一档也能抓到它
`tests/分发.test.mjs`,**0 秒、8 条判据、不打网络**(走 `看源表`,它只读方案包)。
**为什么单独有它**:2026-08-18 加问答日志时,我在分发处写了 `工具: name` ——
而 `name` 在那个作用域根本不存在。后果不是报错,是 **`记这次` 抛 ReferenceError、
日志一行不写,而查询照常返回正确结果**,人和模型都看不出异常。
抓到它的是 `tests/protocol.test.mjs`(打网络、几分钟)。而在那之前,
**17 秒那档里没有任何测试执行过 `tools/call` 这条分发路径**:
`资源`/`scope` 不起子进程,`通知` 起了但只调 `tools/list`。
于是分发层的错只能等最慢的那档来抓。
回放验过:把 `工具: name` 放回去 → 这个测试当场红(`tools/call 超时 20 秒`),还原后回绿。
它守的是**分发这一层**,不是任何工具的业务判据:`tools/call` 通不通、返回体没被改坏、
**分发处的钩子真的产生了副作用**(只验返回值的话,钩子静默抛异常看不出来)、
改过名的老参数当场顶回去、没有的工具名报错并列出有哪些、`ping` 回空 result。
`ping` 单说一句:它是探活,**不能掉进兜底的 `-32601`** —— 客户端拿它判断连接死活,
「不支持这个方法」和「进程已经没了」在客户端那儿是同一种表现,而 server 其实好好的。
> 一般化的教训写进全局规则了:**每条便宜的验证路径必须真覆盖到那条代码** ——
> 一条只有最贵那档才执行到的路径,等于它的错要等最久才看得见。
## 资源列表只列最近几个(`lib/资源.mjs`)
`resources/list` 原来列 30 个,其中 28 个是历史导出快照。客户端拉这个列表是想知道
「这儿有什么可读的」,答案不该是二十行时间戳。现在每一类最多列 3 个
(`INVENTORY_RESOURCE_LIST_MAX` 可调),实测 30 → 9:三个静态资源 + 最近 3 份导出 + 最近 3 份周报。
**不是怕列表失控** —— `清旧导出` 每个目录本来就只留 20 份,磁盘那头有人管。是信噪比。
**截断成立的前提是「截掉的还够得着」**,两条路缺一不可:完整清单在 `inventory://导出`
(全部文件名、大小、路径),单个文件走 `inventory://导出/{文件名}` 模板、名字可补全。
`resources/read` 从来不受列表限制,任何一个 uri 都照读。哪天把这两条撤了,截断就从
「合理的降噪」变成「列表里没有 = 没有这个文件」—— `tests/资源.test.mjs` 的 ⑯ 和 ⑭ 是一对,守的就是这个。
## 问答日志:把评测集从「我造的」换成「人问的」
`lib/问答日志.mjs` + `./问了什么.mjs`。每次工具调用记一行 JSONL 到
`~/.cache/inventory-mcp/问答日志/2026-08.jsonl`,按月切、只留最近三个月。
**为什么要它**:2026-08-18 之前一条查询日志都没有 —— **连「命中 0 发生过几次」都不知道**。
而 `tests/召回.test.mjs` 量的是 189 个真型号 × 9 种机械扰动,**那 9 种是我造的,不是人问的**:
96% 说明解析器不怕大小写和连字符,说明不了「人问的东西查得到」。这份日志是把评测集换掉的原料。
```
./问了什么.mjs 这个月:按工具/资产类型/档位分布 + 耗时和返回体分位数
./问了什么.mjs 2026-07 指定月份
./问了什么.mjs --没答好 只列命中 0 和出错的 —— 这些才是拿去改召回的样本
```
三条硬规矩,都在 `tests/问答日志.test.mjs` 里有判据:
- **参数原样记,不补默认值** ——「他没填」和「他填了默认值」是两件事,补了就分不出模型是不是漏填。
- **失败也记** —— 命中 0 和「压根跑不通」是两类问题,混在一起就分不出「这个货真没有」和「这条链坏了」。
- **不许拖慢查询** —— 追加写不 await、异常整个吞掉;日志坏了不该让人查不到货。
记的收口在 `server.mjs` 的工具分发处,**一处覆盖十三个工具** ——
分散到各个工具里记,迟早有一个新工具忘了记,而「少记了一个工具」没有任何地方会报。
**顺带补了 `tests/不膨胀.test.mjs` 的一个洞**:它原来只扫五个写死的文件名,
所以新加一个带 `mkdirSync` 的模块**它根本看不见**——目录悄悄长大,而这条守着
「会长大的东西必须有清理」的判据照样绿。改成扫全部源文件之后,加这个日志时它当场拦了我一次
(`日志目录 不在「该有的清理」名单里`),登记 `清旧日志` 才放行。
**一个防漏配的检查自己有个漏配的名单**,是这套东西里最讽刺的一种失效。
## 返回体里什么占地方(2026-08-18 实测)
一次 `查库存` 返回 8840 字符,拆开之后**大头不在我以为的地方**:
```
5487 字符 62% 明细(10 行) ← 其中 物料键 一个字段就占四成
1394 字符 16% 口径 ← 源表链接 645(答案覆盖 6 个库房)
652 字符 7% 怎么答
319 字符 4% 按库房
其余 13 个字段加起来 不到 11%
```
改了三处,降到 7480 字符(省 15%):
**明细按梯队排。** 改之前主明细**一次都没排过序**(直接切前 N 行),第一眼看到的可能是
二梯队的大库存 —— 而二梯队「需协调(有项目占着)」,一梯队才是「自由调用」。
**第一行就是人当成答案的那一行**,它必须是最容易真拿到的那批。
和 `按库房` 用同一套次序(`byTier`),两处不许各排各的;同梯队同量时按型号定死次序,两次跑输出能 diff。
**对话里的明细不带 `物料键`。** 它就是 `资产类型|品牌|型号|库房` 拼起来的,而那四列本来就在 ——
在对话里等于把每行最长的字段重复一遍。**落文件那份还带**:那是给人填占用记录用的,
四个字段自己拼一次就会拼错(分隔符、空格、大小写都得一字不差)。
**源表链接的标签去重**(`闵行/闵行:` → `闵行:`)。链接本身不动 ——
它已经只给「你这个答案覆盖的库房」,不是「这次读过的所有表」。
## 放宽代价表:按代价挑,不按捞得多挑(`lib/substitute.mjs`)
查不到货时工具会「放宽一项」再查一遍。原来挑哪一项是**按捞得最多**挑的,
而捞得最多的恰好是代价最大的那一项 —— 客户要 `100G LR4 单模`,放开 `std`
立刻报出 8,932 根 `SR4 多模`,规格逐项「相同」、数字很好看,插上不亮。
**按收益挑等于优先推荐最危险的那一项。**
`方向` 表说的是「候选比需求高算不算能用」;这张表说的是「查不到时把这一项整个不约束,
风险有多大」—— 两件事,两张表:
| | 能放 | 慎放 | 不能放 |
|---|---|---|---|
| 光模块 | `form` `wave` | `media`(单模↔多模) | `rate` `std` |
| 内存 | `speed` `rank` | `cap` | `gen`(DDR4↔DDR5) |
| 硬盘 | `gen` | `cap` `form`(2.5↔3.5 寸) | `bus`(SAS↔NVMe) |
| 网卡 | — | `chip` | `rate` `ports` |
**`wave` 判「能放」是跑出来的,不是照着道理拍的。** 拿全部光模块按 `std` 分组数波长:
23 个 `std` 里只有 2 个对应不止一个波长,而那两个都是**同一个波长的两种写法** ——
`FR4` 是 1300(597 根)/1310(64 根),`SR4` 是 850(15,872 根)/840(8 根)。
没有一个 `std` 跨到真正不同的光学波长上。所以放开 `wave` 捞回的是写法差异,不是另一种货。
(单模↔多模那条线由 `media` 管,那一格是「慎放」。)
行为:**只有「能放」的会被自动放宽**;「慎放」的摆出来、点名要人确认;
「不能放」的每条带一句「别拿它当答案」,排在最后。
**没定过代价的槽位一律按「不能放」算**(`放宽代价是()` 的兜底)——
少定一格不该变成「默认可以放」,`tests/substitute.test.mjs` ㉝ 拿解析器的 `core` 逐项对。
### 拒答:这套工具唯一能说「没有」的地方
在这张表之前,系统**说不出**「这个货真的没有」:零命中一律被解释成「匹配没配上」,
提示词里还写着「不许说没有这个货」。于是要 `100G LR4 单模`、库里只有 `SR4 多模` 时,
工具报「有 8,932 根」。**能说「没有」和敢说「有」是同一件事的两面** ——
一个永远说有的系统,说有的时候也没人信。
判据只有一条:**捞到货靠的是放开哪一档的槽位**。三档全捞不到 → 还是「匹配没配上」;
只有「不能放」的能捞到 → 「没查到」那句里明写 `这一次可以直说`,`怎么答` 里那条
「不许说没有」同时给出唯一的例外口子。实测:
```
槽位 rate=800G,std=SR8,media=SM,wave=1310
→ 「能捞到货的那几项全是不能放的(std)… 这一次可以直说「这个规格库里没有」」
(放开 std 有 18,377 根,但那是另一种货)
```
## `记下决定`:人拍过的,下次不用再问(`lib/决定.mjs`)
这是这套东西**唯一会随使用变准**的部分。在它之前:客户拍了「QSFP28 就用 QSFP 顶」,
下一轮从零开始再问一遍 —— 同一个问题每周问一次,每次答案还可能不一样。
**为什么不写进方案包的 `modelAliases`。** 那张表管的是「这两个写法是同一款货」,
写进去会把两边的库存**合成一个物料键**。而「A 能顶 B」不等于「A 就是 B」:
`QSFP-100G-SR4-MM850` 能顶 `QSFP28-100G-SR4` 用,但它们是两款货、两个键、两笔库存。
混进去的后果是库存数当场变形,而且不报错。所以另起一份(`决定.json`,跟着代码走,
`INVENTORY_DECISIONS` 可改),**只影响推荐,不影响「有多少」**。
四条护栏:
- **依据和「谁拍的」不许空** —— 这条决定将来要有人认账。和「品牌补充」同一条规矩。
- **日期由调用方给**,模块自己不取 —— 取了就没法跑两遍验幂等。
- **同一对型号只留一条**,判重方向不敏感(人拍的是「这两个能不能互顶」)、
写法归一之后比(`QSFP-100G` 和 `qsfp 100g` 是同一对)。改口时把上一版留在 `改过` 里:
「上周说能顶、这周说不能顶」本身就是要给人看的。
- **拍「不能顶」的候选不删掉,只标** —— 删了人看不出「这个我们判过」,下次还会有人再问。
读不出来(文件坏了)时 `读决定` 直接抛,**不当空表** —— 当空表等于把人拍过的决定悄悄清零,
而下一次查询只表现为「又来问一遍」。但 `挂决定` 那条路吞掉异常:决定表坏了不该让查询整个失败。
## `查SN`:大了就给文件,不往对话里倒
源表每条记录就是一根货、带 SN,聚合成物料键时那一列被压掉了 —— `查SN`(`lib/sn.mjs`)
把它还原回来。**三件事是这个工具的全部内容:**
**① 快照里的写法是归一前的,得折回去。** SN 明细来自 `收SN()` 那份 `SN\t资产类型|品牌|型号|库房`,
后三段是源表原样(`闵行` 和 `闵行库房` 是两个值,`SAMSUNG` 和 `Samsung` 也是)。
所以先用 `norm.rows` 的 `原始`(记的正是 `品牌0|型号0`)加 `PLACE` 表建一张反查索引。
少了这步,查「闵行的 128G 内存」会漏掉源表写「闵行库房」的那 5,838 根,**而漏掉的部分不报错,只是数偏小**。
一个原始写法对上两个物料键就抛——那说明归一不是个函数了,这时按哪个算都是猜。
**② SN 明细跟着 `load()` 的返回值走,不去读磁盘上那份快照。** 快照是 fire-and-forget 写的,
冷读刚返回时盘上还是上一份;热态命中压根不重写。从结果里带出来(约 10MB),
SN 和 `norm` 就一定是同一次读的,`在库 = 有SN + 缺SN` 才对得上账。
**③ 「在库根数」和「有 SN 的根数」永远分开报。** 源表 SN 列有空着的,两个数不相等;
合成一个的话,人会拿 SN 条数当库存数用。对不上时返回里是 `⚠ 有货没有 SN`。
另有 `认不出的`(全库口径):对不回任何物料键的 SN,正常是 0,非 0 说明归一和收快照两边
挑走的行不一致——**这个数不能吞,吞了那批货就此从所有 SN 查询里消失**。
超过 `最多列几条`(默认 50,`INVENTORY_SN_INLINE` 可调)就不在对话里列了,改写一个
带 BOM 的 CSV,返回里只给路径和按物料键的分组计数。**去处按「谁要这个文件」分两个**:
人明确要(`一定要文件: true`)→ **桌面**;工具因为太大塞不进对话自己落地 → `~/.cache/inventory-mcp/导出/`。
后者是工具为了省 token 的内部动作,人没要过,不该占他桌面 —— 混成一件事的后果实测过:
一下午测试跑出 20 个 CSV 糊在桌面上。两个目录各留最近 20 份,**只删自己按格式生成的名字**。
**50 是按 token 定的**:一个 SN 约 5 token,50 个约 250,整个返回体和一次普通 `查库存`
一个量级(约 1,400 token);再往上就开始挤后面几轮的余量,而人要一千个 SN 时,
他要的本来就是那个文件,不是在对话里滚屏。文件只在超上限或显式 `一定要文件` 时才写 ——
平时不写,因为写了就得有人清,而没人会去清一个每次查询都长出一个文件的目录。
零命中单独说一句「这不等于库里没这个货」:条件比的是归一后的写法,
干返一个 `0` 会被模型报成「没有这个货」。
## `看变动`:真进出按 SN 算,键级变化是噪音
「有什么变动」这个问题,口径块里的 `SN对比` **答不了** —— 它比的是「上一次任何人跑过这个
MCP」,而**任何一次查询都会把基线覆盖掉**(包括 agent 自己随手查的那次)。所以它几乎
永远显示「一致」。真正的基线是每周四 `./周更.mjs` 存的 `周基线/YYYY-MM-DD.tsv`,
`看变动` 读的是那个,**只读不写,不会动基线**。
**键级变化会骗人,这是这个工具全部的设计压力。** 实测 08-06 → 08-13:
| 口径 | 数字 |
|---|---|
| 键级:消失 30 个键 / 新增 48 个键,移动45号楼一口气少 3,573 根 | 看着像出了大事 |
| **按 SN:真出库 32 根 / 真入库 57 根** | 实际动的 |
| 只是改了写法 | **11,336 根** |
差额全是同一批货把**空品牌补成了 CLT / 光迅 / H3C** —— SN 一根没动。只筛移动45号楼时更
干净:75 个键变了,真进出 **0 / 0**。所以返回体的顺序是死的:真进出打头,
`⚠ 别把改写法当出入库` 跟上,键级的「真消失/真新增」是**摘掉改写法之后**剩的那些
(`解释改名()`,`lib/weekly.mjs`)。判据是 SN:一根 SN 两边都有就是没动,不管键怎么写。
一个键少 5 根、其中 3 根只是改写法 → 报「真少了 2 根」,不是 5 也不是 0。
`tests/weekly.test.mjs` 消融 3 拿掉这一层就必须变红。
要一份不存在的基线会**报错并列出现存的**,不返回一个「没变动」——
「没有这个基线」和「这段时间没动过」混在一起,人会以为账是平的。
## `看源表`:这批数从哪张表、哪一列来的
这个工具是被一次真实的答错逼出来的:有人问「128G 内存的所有 SN」,我去翻了**台账**那三张表
(存量明细/线下台账/占用记录)、看到没有 SN 列,就答「不存在 SN 数据」——
而 MCP 根本不读台账,它读方案包里那些源表,那里每条记录就是一根 SN。
**光看得见结果、看不见来源,就会拿错的地方当证据。**
**张数以方案包为准,别在文档和判据里写死**:2026-08-14 删掉 CPU 方案后从 34 变 31,
`tests/protocol.test.mjs` 里那两条写死 34 的判据当场变红 —— 现在改成从方案包现算。
只读方案包(`plans.json`),一次网络都不打:要的是「配置说这张表怎么读」,
不是「这张表现在有多少行」——后者是 `查库存` 的事。
**`map` 里那些「源表列名 ≠ 我们的字段名」的映射必须显式列出来。** 实测有 4 张的 SN 不叫 SN:
```
SN: { 有: "31/31 张",
列名不一样的: [ "网卡·山西/山西广灵:叫「外部SN」",
"硬盘·山西/山西台账:叫「外部SN(必填)」",
"内存·临港9号楼/B-2项目-9号楼资产表:叫「CMDBSN」", … ] }
```
只报「有 SN」会让人去源表里找一个不存在的列名。同理「货位」只有一部分表有,
而这 11 张里叫「货架-区块」「储位」「箱号」的都有。
默认只给按字段的汇总,逐表明细要显式要(实测汇总 2,336 token、逐表 8,580)。
**两个名单都截**——不截的话汇总本身 4,227 token,比它想省掉的明细还离谱;
截了必须报还剩几张(`tests/源表.test.mjs` 判据 ⑬)。
## 数据从哪来:两条路,`INVENTORY_SOURCE` 切
```
direct(默认) ledger(INVENTORY_SOURCE=ledger)
31 张源表(28 张 + 临港移动7号楼 3 张) 飞书 线下台账 (340 行)
每张 1~2 次并发调用:列宽有缓存就直接只读一类 人每周从油猴导出件粘贴
冷启动约 11 秒 / 热态约 2.4 秒 2.5 秒
约 16 万根 / 360+ 个物料键 12.9 万根 / 325 个物料键
└──────────────┬──────────────┘
库房名统一 → 品牌归一 → 型号归一 → 按物料键聚合
物料键 = 资产类型 | 品牌 | 通用型号 | 位置(粗到库房)
```
两条路**共用同一套归一**(`lib/ledger.mjs` 的 `normalize()`),差别只在数据从哪来。
直读那条多三步:按方案跑 `build_stock` 的筛选规则 → 按 SN 去重(`lib/dedup.py`)→ 一条 SN 记一根聚合成台账形态。
**默认走 direct 的判据是它又快又全**:热态约 2 秒和读台账的 2.5 秒相当,多 3 万多根,
而且差额能解释 —— 临港移动7号楼那本**线下台账**没收,其余是一周的滞后加人工环节漏的。
`ledger` 留着当退路,不删。
**这里不写死具体根数**:源表一天能被改好几次(2026-08-13 当天 09:32 / 09:48 / 13:16 各一次),
写死的数字第二天就是错的,而过期的精确数字比没有更误导。要当下的数就跑一次,口径块里有。
热态那 2 秒是并发查 9 本文档的 `revision`;全没变就用进程内缓存,**数据始终在本地**,
变了才重拉。用 `revision` 不用 `latest_modify_time` —— 后者实测有 2~5 秒延迟,刚改完查会说没变。
**方案包也算「版本」的一部分**(`方案包指纹()`):改了筛选规则、加减一张表、改了列映射,
飞书那 9 本文档的 revision **一个都不会变**,缓存却该失效。实测过:砍掉一个方案(少 9 张源表)
之后再查,热态照样答全部源表的数(当时 34 张 / 162,514 根,现在方案包里是 31 张)。而人看到 `⚠ 筛选列冒出新取值` 之后要做的第一件事,
正是改方案包 —— 不算进去的话,改完还得等某张不相干的表被人动一下才生效。
指纹**按内容算不按 mtime**:拷贝/同步/恢复都会改 mtime,按 mtime 判会白重读 6~7 秒;
读不到时给固定值而不是随机数,不然每次查询都判成「变了」。
每次重读还会做一次**逐单对比**:把 16 万条在库记录压成 `SN → 资产类型|品牌|型号|库房` 存一份
(`~/.cache/inventory-mcp/sn-snapshot.tsv`,约 11MB,每次覆盖),和上一份按 SN 求差,结果进口径块:
少了几根(出库)、多了几根(入库),以及**「SN 没动但型号写法变了」几根**。最后这类单独报是因为
它按 SN 求差集查不出来(两边都有),但聚合后长得和一进一出一模一样 —— 这是「两周库存型号对不上」
最常见的成因。SN 明细落在 `sn-diff.json`,口径块里只放数字和前几个物料键(省 token)。
读旧快照和网络请求并行发起、写新快照不等待,所以不占关键路径;热态压根不碰磁盘。
冷启动还有一层**磁盘上的结构缓存**(`~/.cache/inventory-mcp/schema.json`):存 wiki→文档 token
和每张表用到的列排到第几列,这两样几乎不变,命中时每张表省掉「先读表头」那一趟。
缓存里还记着「这张表要分段、段长收敛到几行、上次多少行」。缓存过期不会读错 —— 正文里自带表头行,
每次都拿它现验一遍缺列,对不上就丢缓存走慢路(`验表头()`,tests/direct.test.mjs 判据 ⑮~⑱ + 消融 4 守着)。
**缓存里的段长只许比当前策略小、不许比它大**(`夹段长()`):不夹的话,`每段格子` 调小之后
已缓存的表照用旧的大段长,新常数对它永远不生效,而且读得对、守恒过、检查全绿,只是慢一倍。
## 增量重读:只重读 revision 变了的那本
原来任何一本文档变了都全量重读 31 张表。现在按**文档级 revision** 决定重读哪几本,没变的直接用
盘上存着的行(`~/.cache/inventory-mcp/rows.json`,实测 36 MB)。**判据和热路那条是同一个**
(只认内容变没变),只是粒度从「全都没变才用」细到「这一本没变就用这一本」。
```
全部都变了(=全量) 14.5 秒 复用 0 张
变了最大那本(6 张) 12.4 秒 复用 25 张
变了最小那本(1 张) 7.5 秒 复用 30 张
一本都没变(热态) 2.8 秒 复用 30 张
```
收益完全取决于变的是哪本 —— 最大那本含闵行光模块那张 8 万行的,只省 2 秒。那 7.5 秒里真正
读表的只有一张,其余花在查一轮 revision(约 2 秒)+ 从盘上反序列化几十 MB(**2~3 秒,
原先估成 0.3~0.5 秒,估错近一个数量级**)+ 筛选去重 1.4 秒。
**落磁盘不留内存**:33 万行留内存实测占堆 168 MB,而这个 MCP 是 WorkBuddy 拉起的单例长驻进程,
留内存就是一直占着。落盘换的是内存不涨 —— **而且多覆盖一个内存版覆盖不了的场景**:
WorkBuddy 退出再打开时进程是新的,原来必然全量冷读,现在拿盘上的 revision 问一轮就能增量。
三种情况整份作废退回全量:方案包指纹变了(那 9 本 revision 一个都不会变,但每张表怎么读全变了,
没有「哪本变了」可言)、`force`、盘上没有或指纹对不上。盘上没记 tok 或 revision 的一律重读 ——
**「不知道它有没有变」和「它没变」是两回事**。
**变了的那本重读失败时拿不到旧行**(`复用` 对变了的文档返回 null),照常走 `缺表` 那条显式降级:
悄悄用旧行的话,人拿到的是「看起来完整但已过期」的数,比缺表严重得多。
行缓存只存读成功的表 —— 存一份空的等于把「这次读不到」固化成「这本就是没货」。
判据只有一条,但它是这个功能的全部安全性:**增量结果必须和全量逐个物料键一模一样**
(`tests/增量.test.mjs`)。不能拿「守恒过了」当判据 —— 复用了一本其实变了的文档,
总数少一截而每步守恒照样绿。造场景靠 `INVENTORY_FAKE_CHANGED=<token片段>`,
**每次调用现读环境变量**(用法就是「同一个进程里先全量跑一遍、再假装某本变了跑第二遍」,
读死的话第二遍改不动,测试只能起子进程、每遍都得全量打一次网络)。
## 只读问到的那类资产的表 + 在途去重(2026-08-17)
问一句「光模块够不够」,原来会把 31 张源表全读一遍。**光模块只占 7 张**,另外 24 张
(网卡 9 + 硬盘 9 + 内存 6)一行都用不上。更糟的是模型问「这两款各够不够」时会
**同时发两个 `查库存`**,而进程内缓存只在跑完那一刻才写,所以两个各读一整遍。
实测(改之前):
```
两个 load() 并发 55.0 秒,各自读回 332,763 行 ← 各读各的,双倍 API 调用
等第一个跑完再来第三个 2.0 秒 ← 这才走缓存
```
改完之后:
```
只问光模块(冷) 42.6 秒 读 7 张 / 237,109 行 / 119,090 根
再问网卡 8.7 秒 读 9 张,行缓存累计 16 张
两个硬盘并发 8.6 秒 只读一遍,两边拿到同一个结果对象
接着全量 9.8 秒 31 张里 25 张直接复用 —— 前面几次只读一类顺手把缓存捂热了
全量之后再问光模块 2.0 秒 走「全部」那份缓存,不重读
```
**哪些工具只读一类,判据是「这个工具的答案会不会用到别类的行」**:
`查库存` / `看分布` / `找替代` / `看有哪些型号` 只读一类;
`查SN`(SN 可能属于任何一类)、`看变动`(逐单对比是全仓口径)、`看筛选`(要全仓的取值分布)**必须全读**。
三条规矩,每条都是为了挡住一种静默错:
**只读一类一个基线都不许滚。** 拿只读了 7 张表的 SN 快照覆盖全仓那份,等于把「这次没读网卡」
固化成「网卡的货全没了」—— 下次全量一比,二十几万根全算「多了」,而每一步守恒照样绿。
所以只读一类**只回答问题、不承担监控职责**:不写 SN 快照、不滚命中基线、不写差异文件,
也不拿旧基线做对比(半份数据 vs 全份基线,`归零` 会对每一张没读的表报一次警)。
**口径必须自报。** 少了那句 `⚠ 这次只读了部分源表`,`在库总根数` 就从「全仓」悄悄变成
「这一类」,而**数字长得一模一样**,模型拿它答「一共多少根」就会答小一个数量级。
**行缓存是叠加不是覆盖。** 只读一类只带回 7 张表的行,整份覆盖会擦掉另外 24 张,
下次问网卡还得全量冷读 —— 一个本来省时间的改动反而让别的查询变慢。
**「全部」那份缓存能答任何窄问题,反过来不行**(超集,答案层照样按类型筛)。
没有这条不对称,查过全量之后再问光模块会白读一遍,优化在最常见的场景里反而变慢。
两个逃生开关,同时也是消融的注入点(一个东西关不掉,就等于没法证明它在起作用):
`INVENTORY_NO_NARROW=1` 退回全读、`INVENTORY_NO_INFLIGHT=1` 关掉在途去重。
跨方案剔重(同一个 SN 既算网卡又算硬盘)**是跨资产类型的**,只读一类时看不见跨类的撞号。
实测当前数据是 **0 条**,所以今天不影响任何数字;哪天它非零了,全量那条路照样会报
`⚠ 筛选规则有重叠`。
## 你实际等的是几秒(2026-08-17/18 实测)
**先说一个我自己搞错过的区分**:所有「冷读 41 秒」的数字都是在**空的临时目录**里量的(为了不碰真缓存),
那是新机器第一次跑的场景。你的场景是 WorkBuddy 重启——进程是新的,但盘上行缓存还在:
```
新进程 + 真缓存,问光模块 3.8 秒 ← 7 张全复用,读表 0 秒
同进程再问一次 1.9 秒
空目录冷读 7 张(新机器才遇到) 41.1 秒
```
那 3.8 秒拆开之后,**88% 集中在一处**:
```
2.00 秒 问 9 本文档的 revision(一趟网络,已经全部并发了)
0.14 秒 起 python 去重 33 万条
0.08 秒 JSON.parse 那 37.6 MB 行缓存
0.03 秒 从盘上读那 37.6 MB
0.01 秒 import
```
**那 2 秒压不下去,量过了**:`lark-cli` 起一次子进程 + 一次网络往返本身就是 1~2 秒的地板 ——
9 个 `metainfo` 并发和 1 次 `metas/batch_query` **一样快**(各 1~2 秒,和单发一个一样)。
所以「合并成批量调用」这条路是死的,`batch_query` 还只有 `latest_modify_time`(实测有 2~5 秒延迟)没有 `revision`。
### 所以把它挪出用户的等待路径:信任期
MCP 启动 1.5 秒后后台热一遍,之后**每小时**后台核一次;查询直接用上一次核过的那份,一趟网络都不打。
```
第一次(真核) 3.7 秒
信任期内 0.0 秒
后台定期核(绕过信任期) 2.1 秒
force(导台账 / 周更) 42.0 秒 ← 不受影响,永远真核真读
```
**代价是数据最多陈旧一小时**(人拍的 2026-08-17)。所以:
- **陈旧必须看得见**:口径块里带 `数据核对于: 2026-08-18 00:00:06(3 分钟前核对的,之后源表有没有被改过这次没查)`。
悄悄用一小时前的数是这套东西最不能出的错 —— 每步守恒照样绿、总数照样对,只有真去核源表才看得出来。
- **后台那趟不吃自己的信任期**(`_绕过信任`),否则它每次命中缓存、永远不去真核,信任期就成了一个永不过期的谎。
- `INVENTORY_TRUST_MS=0` 退回「每次查都核」。
再往下要快,只剩**绕开 `lark-cli` 子进程直接发 HTTP**(省掉那 1~2 秒地板),代价是自己接管飞书的鉴权和 token 刷新 —— 现在这些全是 `lark-cli` 在管。
## 读表为什么是现在这个速度(2026-08-15 实测)
**读表这一步已经并发到头了,再想快只能少读。** 三组交替实测:
```
① 单张表切几段 闵行光模块 84,952 行 × 9 列,每档三轮取中位
5 段 7.7 秒 · 17 段 3.7 秒 · 34 段 3.2 秒 · 68 段 4.2 秒(掉头)
② 元数据怎么发 串行(拿行数→再发段) 20.1 秒 · 段并发 7.8 秒 · 元数据与段同批 5.0 秒
③ 全局并发几路 31 张 33 万行:4 路 28.5 秒 · 8 路 13.0 秒 · 32 路 14.2 秒
```
**并发能重叠的是「等」,重叠不了「传」**:请求的时间 = 等(往返延迟)+ 传(占管道)。
并发把多个「等」叠起来,但字节数不会因为同时发就变少。所以曲线是先降后平再微微上翘 ——
③ 里 8 路就填满了,32 路反而慢一点(几十个 lark-cli 进程抢 CPU);① 里 68 段同理。
`INVENTORY_CONCURRENCY` 默认取 8,`每段格子` 取 45,000(17 段),都是这么定下来的。
**段长不取最快的 34 段,是拿 0.5 秒换频控余量**:调用次数随段数走(5 段约 36 次 /
17 段约 48 次 / 34 段约 65 次),而最窄一档 100 次/分,撞线的代价是十几秒。
## 每次读都跑的五道检查
数字对不对,靠的不是「算得仔细」,是**每一类错都有一条会红的检查**。五道按严重度排:
| 检查 | 挡什么 | 不挡的后果 |
|---|---|---|
| **筛选命中率** | 某张表的状态列写法被改了(「在库」→「在库中」) | 那张表一条都命中不了、几千根货一次性消失,**而每一步守恒照样绿**(进出都少了同样多) |
| **源表读失败** | 读不到其中几张 | 那几个库房的货凭空消失,人以为「那儿没有」而不是「那儿不知道」 |
| **坏件 / 坏字** | 型号带「坏」的、规格列是 `[object Object]` 的 | 坏件被归一合进好件算成可用库存;坏字污染物料键 |
| **SN 逐单对比** | 说不清「少了 106 根」是出库、改写法,还是读错了 | 每周对账靠猜 |
| **型号写法不像这一类**(只报不拦) | 整批货被归错资产类型 | **错得离谱但所有检查全绿** —— 926 根光模块被判成硬盘那次,总根数对、每步守恒、SN 对比正常、命中率正常,因为货确实都在、只是挂错了类。唯一能发现的方式是人扫一眼物料键列表,觉得「硬盘怎么叫 `QSFP112-400G-DR4-SM1310`」 |
**「归零」留在机器里,「变了多少」交给模型。** 这条线是 2026-08-14 挪的:原来机器还判
「命中率掉超过 20 个百分点 = 明显下滑」,那个阈值是拍的、标着未验证,而同一个变化在三种情境下
意思完全不同(人改了规则 / 源表的列被改了 / 真进了新状态的货),机器分不出是哪种。
现在机器只报 `{表, 命中率: "99% → 12%", 差几个点}`,算不算异常由读它的模型判(`看筛选`)。
**命中率归零是零误报的硬信号**:有效行还在却一条都没命中,不可能是正常业务。
所以它是**绝对判据,不需要基线** —— 曾经写成「上次命中 > 0 才报」,于是一张
从来就命中不了的表永远静默:第一次没基线不报,第二次基线里记的也是 0,条件永远不成立。
那正是这个探测器要防的那件事。报法:
```
⚠ 有源表的筛选一条都没命中:
网卡(导入) · 闵行/闵行网卡在库清单信息:读到 4575 行有效数据,但筛选一条都没命中(上次命中 2841 行)
这几乎一定是那张表的状态列写法改了,不是货清空了。去核对源表的筛选字段,别按下面的数字下结论。
**这条会一直报到修好为止** —— 有异常就不滚命中基线,不然警报会把自己吞掉。
```
**警报不许把自己吞掉。** 命中率基线(`table-hit.json`)原来每次读都覆盖,于是:
某张表被改坏 → 报一次 → 基线滚成「命中 0」→ **从此再也不报**。现在
`该滚基线()` 只在这次干净时才让它滚;有归零或下滑就保持不动,异常一直报到修好。
唯一的解除方式是人显式认下:`./周更.mjs --认下筛选`(`--dry` 时不认)。
**不给自动认领的口子——能被自己清掉的警报等于没有警报。**
同一个病 SN 快照也有:它每次读都覆盖,所以口径块里的 `SN对比` 比的是「上次任何人跑过
这个 MCP」,几乎永远显示「一致」。那半边的解法是 `看变动` 读**周基线**(只读不写)。
**绝对命中率不是判据。** 实测三张表常年低命中,规则都是对的:
| 表 | 规则 | 这一列的实际取值 |
|---|---|---|
| B-2临港9号楼 · 光模块(7%) | `资产状态 = 库房` | 在线 **52,341** · 库房 4,280 · RMA出库 1,330 · 「在线,无对应关系」853 |
| JYJY临港9号楼 · 光模块(15%) | 同上 | 在线 **48,777** · 库房 10,575 · 调拨出库 7,910 · 待定 50 |
| 移动7号楼 · 内存(20%) | `出库/在库 = 在库` | 出库 **23,689** · 在库 6,031 · 故障 32 |
那几张是**全量资产台账**,大部分行是已上机在用的设备。定任何绝对阈值都会把它们误报成异常。
### 第五道:型号写法不像这一类(`类不对的键`,`lib/ledger.mjs`)
**判据是「别的类解析出的核心槽位比自己多几个」,阈值 2,是量出来的不是拍的**
(2026-08-16,374 个真实键 + 那批历史错键):
```
真实键 差 ≤ 0 的 372 个 · 差 1 的 2 个 · 差 ≥ 2 的 0 个
历史错键 硬盘|QSFP112-400G-DR4-SM1310 自己 1 槽位 vs 光模块 5 → 差 4
正常硬盘 硬盘|7.68T NVMe U.2 Gen4 自己 4 槽位 vs 光模块 0 → 差 -4
```
中间空了两档,所以 2 有真实余量。判据 ㉕㉖㉗㉘ + 消融 9,其中 ㉘ 专门守「阈值不许放松」。
**它抓的不只是一种成因**:跨方案剔重把货判给先出现的方案、方案包的 `配件类型` 筛错、
某张表的 `map` 列映射错、源表里有人把货填到错误的分类下 —— 四种现象一样。
**第一版判据「自己解析出 0 个槽位」不成立**:硬盘的解析器把 `400G` 当成容量、解析出 1 个,
历史错键一个都抓不到。这正是「四类之间只有 rate 那 5 个值撞车」在这里的表现 ——
那个结论按写法种数算够用,用在「跨类比槽位数」上就不够,撞一个就把条件破坏了。
**只报不拦。** 它是启发式的,和「筛选命中率归零」那种零误报的硬信号不是一档。
### 哪些维度错了查不出来
**五道检查覆盖的是「有第二来源可以对照」的那部分,不是全部。** 一个维度错了之后
总量还是守恒的,能不能发现取决于源表里有没有第二个角度去看它:
| 维度 | 错了会怎样 | 有没有第二来源 | 现状 |
|---|---|---|---|
| **资产类型** | 整批货归错类,总数守恒 | **有** —— 型号写法能反推 | 第五道检查 |
| **库房** | 整批货挪库房,总数守恒 | **没有** —— 物料键的库房段来自方案包配置(`region`),而源表里被映射成「库房」的那列装的是**货位**(`403库房` / `CK2-H03-A01` / `库房303`),不是库房名 | 只能靠配置对 |
| **品牌** | 整批货换品牌,总数守恒 | **没有** —— 源表只有一份 | 只防「两个方案的对照表打架」(`loadBrandMap` 会抛),不防「四个方案一致地错」 |
| **在库状态** | **筛多了 → 货凭空变多** | **没有** | 命中率只抓「筛没了」(归零);**筛多了命中率反而涨,看着更健康** |
最后一行是这张表里最该记的:**「筛多了」比「筛少了」隐蔽**。人对数字变大的警惕本来就比
变小低,而机器唯一的探测器正好在往反方向看。
**表名当第二来源试过,不行**:31 张表里误报 3 张(`B-2项目9号楼` 这种表名在 `PLACE`
别名表里登记的是 `B-2临港9号楼`,差两个字),10% 的误报率做成每次跑的警报只会变噪音。
## 这套接口不会告诉你发生了什么
飞书多维表格的写接口有一个一贯风格:**说成功了,但你没法从返回值知道发生了什么**。
2026-08-15~16 两天里踩到五次,放在一起看比分散在各处的注释有用:
| 命令 | 它不告诉你什么 |
|---|---|
| `+record-batch-create` | 回 `ok:true`,`records` 是**空数组** —— 建了几条、record_id 是什么,都没有 |
| `+record-batch-update` | **不检查 record ID 存不存在**。拿一个不存在的 ID 更新,照样回 `ok:true`,表里一个字没变(帮助里自己写着这句) |
| `+table-create` | 返回体里**没有 table_id**,只回 `{fields:[…]}`。得建完按名字查回来 |
| `--dry-run` | **只回显请求体、不校验形状**。错的和对的都回 `ok:true` —— 拿它验过反而会以为没问题 |
| 写公式字段 | 被 `ignored_fields` **静默吞掉**,没有任何提示 |
**所以「写完必须回读、不认返回值」在这个项目里不是保守,是唯一可行的做法。**
2026-08-16 那次事故正反两面都验到了:回读挡下了一次(`./导台账.mjs` 补跑时报「值对不上 0 条」),
而崩在回读之前那次它没挡住 —— 所以现在写失败也要走完读回(见 `lib/ledger-write.mjs`)。
**请求体的形状只有一份**:`创建请求体()` / `更新请求体()` 在 `lib/ledger-write.mjs`,
契约测试用的就是这两个函数。原来测试里手写一份 `{update_records:{id:{数量:100}}}`,
于是它抓得住「lark-cli 又改契约了」,**却抓不住「我们的代码拼错了」**——
而 8-16 真炸的那次正是后者的相邻情况(契约变了、我们没跟上)。两份字面量各自成立,
谁改了另一份都不知道。守这条的是 `写路径` 那两个消融:**把生产那两个函数换成错形状**,
判据必须红(实测 ABLATE=1 报的就是当天那个 `800010701 Request validation failed`)——
要是哪天有人在测试里又手写一遍,消融就不再红,`run-tests.sh` 当场报「一条判据都没让它红」。
### 撞频控:一个显式的错误,被压成了要靠猜的
飞书按 API × 应用 × 租户算每分钟速率,最窄一档 **100 次/分**,撞了回 HTTP 429 + `code 99991400`。
**这是个显式错误**,可它一路传上来只剩「这次失败了」,和「表读不到」「登录态过期」长得一模一样。
代价是实打实的:2026-08-16 这一天为此多跑了四轮回归(每轮好几分钟),
每次判断「是限流还是真坏了」靠的都是**间接推理**——两次红的位置不同(#74 / #55),
位置随机才像环境问题,代码问题会稳定停在同一处。
现在三层都认它:
| 在哪 | 干什么 |
|---|---|
| `lib/direct.mjs` 的 `退避重试()` | 撞了就退避重试(3 秒、8 秒),等的时候往 **stderr** 出声;退完还撞就打 `撞频控` 标记往上传 |
| `fetchAll` 全军覆没 | 分开报:撞频控说「**代码没问题,等一分钟再跑**」,真读不到才说「查登录态和网络」 |
| `tests/消融.mjs` 的 `收尾()` | 挂的每一条都是频控 → **退出码 3**,`run-tests.sh` 印「⏸ 这轮没验成」,整套非 0 |
三处要点,每一处都是踩出来的:
**退避只退两次、最多 11 秒,不是越多越好。** 一开始写的 `[3,8,20]`,然后意识到它会和调用方的
120 秒超时打架——31 张表每张退三次能把整轮读表拖到几分钟,于是「明确的限流」又变回
「莫名其妙的超时」,**正是这次要修的那个问题的翻版**。
**「没验成」不许印成绿的。** 只有「挂的每一条都是频控」才退 3,混一条真失败就退 1——
否则频控成了挡箭牌,把真的红盖成黄的。最要紧的是消融那条循环:`牙()` 只看「非 0 就算变红」,
一个**根本没被执行到**的消融照样被数成一颗牙、印「✓ N/N 消融都变红」。
**`是频控失败()` 故意写窄。** 曾想把「超时」也算进来(历史上撞频控就表现成 tools/call 卡满
120 秒),但那会把「服务器真的卡死了」也悄悄降级成「这轮没验成」。代价是:限流若表现成纯超时、
一个字都没留,这里认不出来,照旧报红。**宁可多红。**
`INVENTORY_FAKE_RATELIMIT=N` 造这个场景(前 N 次调用一律返回限流),`INVENTORY_BACKOFF_MS`
把等待压到毫秒。注入点挡在 `退避重试()` 那一层而不是 `call()` 里面——挡在里面的话
**写台账那条路注入不进去**,而那条一年只跑五十次,撞上了没人有第二次机会看现场。
> 我一开始说这条「要等真撞一次才验得了」。**那是错的**——同一个仓库里 `INVENTORY_FAKE_FAIL`、
> `INVENTORY_FAKE_CHANGED` 已经用过两次这个模式,理由还是我自己写进注释的。
> 手上有现成的解法却没想起来用,比不知道更该记一笔。
### 筛选列冒出新取值(`收筛选取值` / `比取值`)
命中率抓不住的是**慢性**的那一类:有人把「在库」改成「在库中」,老记录还是老写法,
命中数只会一周一周地掉 —— 归零不触发,20 个点的阈值也够不着,等发现已经过了几周。
所以再加一条**离散**信号:走筛选**之前**的原始行,统计每张表每个筛选列的取值集合;
**冒出一个基线里没有的取值就报**。几乎零误报,代价是一次全量读多 0.3 秒、基线文件 7.8 KB。
```
⚠ 筛选列冒出了没见过的取值:
光模块 · 临港9号楼/B-2临港9号楼 · 资产状态:冒出新取值「在线,无对应关系」853 行
这批行现在没被算进任何一边。如果它其实是「在库」的另一种写法,那批货正在静默丢失;
如果是新的业务状态(借出、待检…),把它加进方案包的筛选规则再跑。
```
只报**新增**不报**消失**:某个状态这周没人用了是正常的。基线里没有这张表这一列时也不报,
不然第一次跑会把每种取值都当新的。同样受 `该滚基线()` 管——报了就不滚基线,一直报到处理为止。
**读表失败不再一刀切地抛**,只有全部源表都读不到才抛(那是登录态或网络问题,给半份没意义)。
只崩几张的话继续走,但**完全读不到的库房必须显式占一行、根数写 `null`**:
```json
{ "库房": "七宝", "根数": null, "读不到": "网卡、硬盘、内存、光模块的表都没读到 —— 这里不是 0 根,是不知道" }
```
让它从数组里消失,人会读成「那儿没货」——这两件事差一个数量级。有表失败时**不写进程内缓存**,
否则下次热态会拿这份残缺数据当好的用,而那几本文档 revision 一个都没变,它会一直用下去。
造这个场景用 `INVENTORY_FAKE_FAIL='方案/库房/表名的子串'`——不给注入点,这条降级路径永远没法验证。
## 型号写法:两层归一
**第一层字面**(`字面指纹` + `挑标准写法`):只吃掉大小写、空格、连字符、点号的差异。
实测合掉 11 组,`CX7-400G` ⟷ `Cx7 400G`、`128G` ⟷ `128g`、`SFP-25G-SR-LC` ⟷ `SFP.25G-SR.LC` 这种。
槽位解析救不了它们——解析器认的是规格语义,不认打字方式。
**这一层选标准写法的判据不能沾根数**:大写多 > 连字符多 > 长度长 > 字典序,四条全是型号字符串
自身的属性。原来的规则里有「根数多者胜」,而根数每周都在变——标准写法是物料键的第三段,
它一变,台账下拉框里选过的值就悬空了。
**第二层槽位**:解析出封装/速率/标准/介质/波长这些槽位,核心槽位全等才合。这层的判据
运行时从油猴脚本现抠,不复制。
### 直读这条路踩过的四个坑(都在代码里挡住了)
| 坑 | 挡法 |
|---|---|
| 闵行光模块那张整表读报 `90221` 超 10MB | **所有表都只读 `map` + `includeRules` 用得到的列**,range 结尾不带行号(给大数 `A1:H999999` 反而报超限,飞书按**请求范围**算体积)。少一列就抛,不静默降级成缺字段 |
| 那张表长到 **84,952 行 × 9 列**,只读一类也超 10MB —— 全库最大的一块(8 万多根)整张读不到 | **按行分段读再拼**,段太大就**当场砍半重来**(`分段读`,`lib/direct.mjs`)。没有「正确的段长」这回事:飞书量字节、我们数格子,一张带备注列的表在 17.5 万格就超,而短字段的表到 76 万格才超。所以 `每段格子` 只是起点猜测,超了自己按 log₂ 收敛到下限 200 行。**任一段失败整张作废** —— 半张表的数看着正常、只是合计小一截,没有一处会红。收敛到的段长记进结构缓存,之后不再打那趟注定失败的整读 |
| **表头是富文本**:山西光模块的「品牌(必填)」是白色「品牌」+ 红色「(必填)」两段,返回结构体 | `取文本()` 提取所有 `text` 拼起来。这是从 `build_stock.py` 的 `cell()` 抄的语义,`tests/direct.test.mjs` 里有一条判据拿同一批样本比对两边结果,防漂移 |
| 临港联通那张被网卡/硬盘/光模块三个方案共用,`includeRules` 没筛干净,1,333 根被算两次 | 按方案包顺序剔重,剔除数量报进口径块 —— 它涨了就说明筛选规则该修 |
| 默认读回来的是**公式文本**(台账「物料键」列 340 行全是 `IF($A2="",...)`) | 一律带 `valueRenderOption=UnformattedValue`。不带的话哪天数量列用了公式,`Number("=SUM(...)") \|\| 0` 会静默算成 **0 根**,而守恒检查照样绿 |
| **lark-cli 报错有两条路,原来只认了一条**(2026-08-15 查清):`90221` 走退出码 1 + stderr,正则抠得出 code;`91402` 走**退出码 0 + stdout**,code 藏在 `error.code` 而调用方读的是 `j.code`,拿到 `undefined` | `认错误码()` 把两条归一:失败时 code 一定是字符串、一定在顶层。**这不是报错难看的问题** —— `String(j.code)==='90221'` 对后一条路永远为假,「表长大了改走分段读」会退化成「这张表读不到」。排查读表慢时见过一次 `code=undefined`,当时归因成网络超时,归错了。`run-tests.sh` 有一条结构检查:`lib/` 里按错误码分支的文件必须用 `认错误码` |
### 位置粗到库房,货位不管
源表里的 `403库房` / `CK2-TEMP` / `二楼小仓库` 是**货位**,74 种,一律不进物料键 ——
位置取 source 的 `region`(闵行 / 临港9号楼 / 移动7号楼 / 移动45号楼 / 七宝 / 山西 / 临港联通)。货位是仓管的活。
走原生 `values` 接口,**不用封装的 `+csv-get`**:后者超 10MB 会悄悄只给你一部分(`ok:true`、数据也是好数据,只有一个 `has_more:true`),我因此在一个 9.4% 的样本上算过百分比还以为覆盖了全表。原生接口超限直接报 `90221`,静默失败变显式失败。
台账本身是**人每周从油猴导出件粘贴进来的**,不是实时的。口径块里的「读取时间」是这份数字的时刻;飞书的版本号留在 `meta.revision` 里,代码用它判断变没变,不往口径块塞——对人是一串没信息量的数字。
## 采购在途:已经许出去、但货还没到(2026-08-17)
占用表里出现了第四种闭环状态「采购在途」。做法是:人在物料表手工加一条,
**品牌和库房留空**(那两段要到货才定),占用挂在它上面。实测那条:
```
光模块||QSFPDD-400G-DR4 | 在库 0 · 占用 640 · 预计到货 2026-08-31
备注 智设合【2026】年325-004 · 工单 29640
```
**危险的不是账面上那个 −640,是到货那一刻。** 货落在源表产出的四段全的真键上
(`光模块|海光芯创|QSFPDD-400G-DR4-SM1310|闵行`),那个键的占用是 0,
于是同一批 640 根在两个地方各算了一次:空键说「许出去了」,真键说「可用」。
人拿真键那个数承诺就是**超发**。台账里 400G DR4 现成的真键有 11 个、合计 607 根,
和这笔采购一个量级。
### 挡它的是一条反方向的检查
`挂可用量` 原来只往一个方向查:**源表有、台账没有**(`该导没导` / `不进台账的`)。
反方向「台账有占用、源表却没有这个键」完全看不见 —— 那正是在途落进去的地方。
现在补上 `没挂上的占用`,出现在三处:
| 在哪 | 为什么 |
|---|---|
| `查库存` | 排在数据**前面**,不进口径块也不进调试档 —— 它不是「这个数怎么算的」,是「照这个数发货会超发」 |
| `找替代` | 这里更致命:结论会直接变成「不用采购了」 |
| `./周更.mjs` | 每周该看的就是「在途到了没有」,节奏本来就是周级的 |
**不按资产类型或型号过滤,全都报。** 这类键极少(实测全仓 1 个),而漏报的代价是超发;
**过滤逻辑本身会变成一个失效时是绿的点** —— 过滤错了就静默不报。几行噪音换零漏报。
和「筛选一条都没命中」同脾气:**一直报到有人处理为止**。
**日期跟着每一条走。** 人拿到「有 640 根在途」之后的动作完全由日期决定 ——
等它到,还是另想办法。没填就明说「**没填预计到货**,问一下什么时候能到」,
**不编一个出来**:编了的后果是有人照着一个不存在的到货日排工期。
`预计到货` 在 `读占用` 里是**软要求**(缺列只是少个日期),而 `物料键` / `占用中`
缺了就算不出可用量,是硬的 —— 两者不共用一个 throw。
### 人要做的只有一个动作
**到货后,把占用记录那行的「关联物料」改指到真键。** 改完空行变成 在库 0 / 占用 0,
警报自动消失,那 640 根在真键上被正确扣掉。
`闭环状态` 那个下拉框 **MCP 一个字都不看** —— 它在占用记录表上,而 MCP 读的是物料表。
改它是给飞书 `占用时长` 那一列用的(🟠等货中 / 🔴等货超期),为人自己看。
**两处没有判据、要知道**:
- **挪错了抓不出来。** 只检查「有没有挪」,不检查「挪得对不对」。挪到 `QSFP112`
而不是 `QSFPDD`、或者挪到错的库房,一声不吭 —— 因为挪完之后那个键在源表里确实有货。
- **分批或分散到货得拆行。** 一条占用记录只能指一个物料键。
### 讨论过、没做的三条
| | 为什么没做 |
|---|---|
| 拿 `闭环状态` 当开关(还是「采购在途」就不扣、改掉了就从同款货合计里扣) | 要多读一张表 + 约 50 行;人拍了先不做 |
| 在途单开一张表 | **承诺一旦分两张表存,就没有一个地方能回答「这张工单一共答应了什么」** |
| 扩占用记录表(加型号/单号/ETA 三列,`关联物料` 降级成选填) | 形式上最规范,但 `剩余占用` / `同工单同物料几行` / `数据检查` 四个公式都要分叉,**而飞书公式没法写测试** —— 这是整个项目里最验不动的一层 |
| 周更跳过「备注或预计到货非空」的行 | 量过了:399 行里符合的 1 行,其中在库非 0 的 **0 行** —— 这条规则今天是空的。它要成立必须靠人工填在库,而那会给 `在库数量` 这唯一单所有者的列引入第二个所有者 |
## 台账:每周导一次,只增不减
台账是「配件占用管理表」的**物料表**(多维表格)。MCP 读源表算在库,台账那侧算占用,
两边按物料键对上,`可用量 = 实时在库 − 台账占用中`。
**台账只收走领用流程的配件**(`台账范围`,定义在 `lib/ledger.mjs`):光模块 / 硬盘 / 网卡 / 内存。
随整机走、不按物料键单独领的东西不收 —— 进了台账只会在下拉框里多几个永远没人选的键。
导入时先过滤再校验,日志会打「挡掉 N 个不在台账范围内的物料键」。
这个集合和方案包(`plans.json`)现在**恰好**一样宽,但两者管的是两件事:方案包管
「MCP 读哪些源表」,`台账范围` 管「哪些资产该进占用台账」。**方案包加一类资产时要单独
判断它进不进台账**,别默认跟着走 —— 否则新加的那类会静默进台账,多出一批没人选的键。
「台账里没这个键」因此有两种,`挂可用量` 分开计数、口径块分开报。合成一个数的话,
只要存在任何一类「本来就不该进」的资产,它就永远大于 0,人看几周就习惯性忽略,
真有新货没导时也看不出来,还会照着「跑一次导台账」这条修不好的建议白跑:
| 信号 | 含义 | 怎么办 |
|---|---|---|
| `还没进台账的键` | 范围内、台账里却没有 | 跑 `./导台账.mjs` |
| `不走台账领用的键` | 范围外、随整机走的 | **什么都不用做**,它的「可用量」就是在库数 |
```
./导台账.mjs --dry # 先看一遍:新增几条、更新几条、置 0 几条
./导台账.mjs # 真写
./周更.mjs # 每周四:重读 → 和上周基线比 → 出报告 → 品牌待补清单 → 存基线 → 写台账
```
**`--dry` 里最该看的是「置 0 的」,而且要逐条问「这批货是没了,还是换了个键」。**
`--dry` 只报「原在库 → 0」,不告诉你那一格现在还有没有别的货 —— 后者才分得出
「真出库」和「改名/重分类」。判据是**拿键的「资产类型|品牌|库房」去这周的实时数据里查**:
那一格还有别的型号 = 大概率改名,那一格空了 = 这一格真的清了。
2026-08-14 那次实测:24 条置 0 里 19 条落在「硬盘 · 临港联通」,型号却是
`QSFP112-400G-DR4-SM1310` 这种光模块写法 —— 加起来**正好 926 根**,
和那天修的「临港联通光模块被读成硬盘」是同一批。这种情况置 0 是对的,
它正是这次修复要清掉的错键。
**写入规则一条不改,来自台账那侧的设计说明 §3.3**:按物料键 upsert,把「这次没出现在
导入里的物料」的在库数量**置 0,而不是删记录**。置 0 之后记录还在,占用记录的键仍然匹配
得上,级别自动变「待采购」——库里没货还欠着人,正是想要的语义。**代码里永远不调
`record-delete`**:删了占用记录就悬空,而物料那一侧是完全静默的(可用量会悄悄涨回去、
级别还写着「充足」、数据检查是空的)。
四道闸,任何一道不过就**整批不写**(不是「跳过坏的写好的」):
| 闸 | 挡什么 |
|---|---|
| 有源表没读到 | 那个库房会被当成「清零」全部置 0,而货都在 |
| 键不合法(四段有空/含竖线) | 空键会「吸走所有空键的占用」,且完全静默 |
| 键归属对不上多个老键 | 这周把两个老键的写法合成了一组,机器不猜留哪个 |
| 写完读回核对 | 「API 返回 ok 但值是空的」是这张表最贵的错误 |
### 键一旦发放就锁死(`lib/keyreg.mjs`)
占用记录存的是**键的字符串**,而键的第三段是归一「选」出来的标准写法 —— 实测 26 个合并组
里有 9 组是靠根数决出来的(`CX6-25G双口(287)` vs `CX6-25G*2(158)`,一次出库就翻盘)。
翻盘后老键归零、新键冒出来,占用记录还挂在老键上,那笔占用永远结不清。
锁法不靠槽位序列化(网卡根本解析不出槽位),靠 `normalize` 现成的 `原始` 字段:
**新算出的键只要和某个已发放的键共享任何一个原始写法,就判定是同一款货,沿用老键**。
判据是「共享写法」不是「键字符串相同」,标准写法怎么翻都不影响身份。沿用老键这件事会
报出来(台账里显示的还是老写法,人会觉得不对)。注册表在
`~/.cache/inventory-mcp/键注册表.json`,**写完台账才更新** —— 台账写失败的话键不该算发出去了。
**踩过的坑(都实测过)**:`+record-list` 默认分页 100、最大 200,不翻页的话台账超过 100 条
之后那些会被当成「不存在」重复新增,而 API 一路返回 ok;`+record-delete` 的 `--record-id`
是可重复参数,拿空格拼一串传会报「Record id must start with rec」而 ID 其实合法;
返回是**列式**的(`data.data` 二维数组 + `data.fields` 列名),不是每条一个 `fields` 对象。
## 品牌怎么补
源表品牌列空着的有三种补法,**可信度不同,所以分开放、分开报**:
| 来源 | 粒度 | 放在哪 | 口径块里报成 |
|---|---|---|---|
| `brandAliases` | 写法映射(Samsung→三星) | `plans.json`(油猴那边也读它) | 归一改动 |
| CMDB 导出 | **逐根 SN 查证** | `~/.cache/inventory-mcp/sn-品牌.tsv` | 按SN补的品牌 |
| 人工整批判断 | 库房 + 资产类型 | `lib/ledger.mjs` 的 `品牌补充` | 品牌是补的 |
补出来的值**还要再过一遍 `brandAliases`** —— CMDB 写 `CLT`、库存写 `海光芯创`,
不过一遍的话台账里就是同一家两个名字、两套键。归一完还有一条**会抛的检查**:
结果里但凡还留着「对照表里有标准写法」的品牌,直接报错不返回。
补不上的进「品牌待补」,每周四出一份 SN 清单(`~/.cache/inventory-mcp/周报/品牌待补-*.csv`)
拿去 CMDB 查。给 SN 而不是型号,因为 CMDB 里按 SN 才查得到一根货的品牌。
### 查的时候品牌传错了:工具知道答案,就别只回一个 0
`查库存` 的品牌是**精确匹配**(`kw(r.brand) === kw(条件.品牌)`)—— 数据侧读表时已经用
`brandAliases` 归一过,所以查询侧要求传标准名。但客户嘴里的牌子是英文、是简称、是打了一半,
模型没翻的时候拿到的是一个不带任何线索的 `0`,**和「这批货真的没有」长得一模一样**,
而后者会被原样报给人。零命中那套补救(放宽哪一项、差得最少的几个)只在给了型号或槽位时才算,
`{品牌:'Samsung'}` 这种查询压根进不去。
现在命中 0 且给了品牌时,返回体里多一条「品牌这一项」,四种情形分开答
(`品牌怎么救`,`lib/ledger.mjs`):
| 传进来的 | 回什么 |
|---|---|
| `三星`(名字本来就对) | **不是牌子的问题**,是其余条件的问题 —— 这条最容易被误读成「这个牌子没货」 |
| `Samsung`(别名) | 标准名是「三星」(全库 N 根),换成它重查一次 |
| `海光`(打了一半) | 名单里没有,但有「海光芯创」—— 候选来自库里真有的牌子,不是拼出来的 |
| `浪潮`(不认识) | 名单里没有,库里有货的是这几个。**0 根的不列** —— 列了等于把模型往一个空结果上引 |
**四种情形的顺序不能反**:`brandAliases` 允许自映射,标准名会出现在自己的别名列表里,
先判别名的话,传标准名会被答成「你填的是别名」,把人往错方向带。`tests/ledger.test.mjs` ㉙ 守这条。
## 判据只有一份,家在 `lib/slots.mjs`
判「两个写法是不是同一款货」的逻辑在 `lib/slots.mjs`,`lib/ledger.mjs` 直接 import。
**2026-08-16 之前它不在这儿** —— 住在 `userscripts/feishu-warehouse-composer/slots.mjs`,
MCP 运行时按 `export function 建槽位(` 这个字符串标记切一段出来 `new Function` 跑。
那一整套(环境变量覆盖路径、切片标记、切不出来就抛)都是为了「判据只有一份、家在油猴那边」
这个约束才存在的。
**约束没了,所以搬家**:那个油猴脚本(飞书仓库组合器)不再更新 —— 飞书那侧 2026-08-11 起
改走 `lark-cli` 官方接口,逆向那条路退休了。**依赖一个没人维护的路径,比多一份拷贝更危险**:
拷贝会漂,但漂了有判据能抓;而路径哪天不在了,报出来的是「切不出建槽位」,
没人看得懂那是什么意思。油猴那边那份留着给它自己用,两边不再有同步关系 ——
它不更新了,也就不会漂。
**判同款漂了不会报错**,只表现为「库存翻倍」或「同一款货查不到」。实测过:
`QSFP28-100G-SR4`(多模)和 `QSFP28-100G-LR4`(单模),缺了「标准推光纤类型和波长」
那一步就判成同款,发过去插上不亮。所以它值得有一份自己的家,而不是寄人篱下。
`run-tests.sh` 那条检查跟着反了方向 —— 原来查「必须去读油猴那份」,现在查三件事:
判据文件在且是本仓库的、`lib/ledger.mjs` import 的是它、`lib/slots.mjs` 之外没有第二份
槽位词表字面量。**查的是依赖机制不是「提到」**:第一版写成 grep 路径字符串,
把解释这段历史的注释也报红了 —— 判据写太宽和写太窄一样坏。
**品牌对照表还在外面**,家是 `plans.json` 的 `brandAliases`(油猴「品牌对照表」界面改的
那份,`INVENTORY_PLANS` 可覆盖)。这边只读、不留副本,读不到就抛;同一个写法在两个方案里
映射到不同品牌也直接抛,不静默取一个。**它和槽位判据的区别是:那份还有人在用界面改**,
所以它该留在被改的地方,而不是搬进来变成第二份。
## 跑测试
```bash
./run-tests.sh # 改的过程中跑:23 秒,305 条判据 + 61 个消融,不打网络
./run-tests.sh 全 # 收尾 / 提交前跑一次:89~190 秒(缓存热时 89),打网络的五条链并行
./新旧.sh # 跑着的那个 MCP 是不是最新代码 —— 它是 WorkBuddy 启动时拉起的单例,新开对话不换进程
./自检.mjs # 这套东西能不能跑起来:lark-cli / 登录态 / 方案包 / 真读源表 / 台账 / 三件静态检查
./自检.mjs --快 # 同上,跳过真读源表那步(不打网络)
```
**分两档是量出来的**:打网络的 5 个测试加它们的消融占全套 635 秒里的 630 秒,
而 15 个不打网络的测试加 14 组消融一共 5 秒。改一行代码等十分钟才知道有没有改坏,
**这个循环长到没人会在改的过程中跑它** —— 于是它只在收尾跑一次,而那正是最晚发现问题的时刻。
**`全` 那档五条链并行**(2026-08-17 实测 624 秒 → 195 秒,判据和消融一条不少)。
五个正跑单独测过:串行 275 秒、并行 92 秒,一个都没被频控挂 —— 当天做的退避重试兜住了突发。
**`写路径` 那条链必须串着,而且不许和自己并行**:它往生产 base 建同名前缀的表、
`finally` 按前缀清理,两个进程同时跑会互删对方的表,而失败方式是「表突然不见了」,
看起来像接口出错。它整条链(正跑 + 消融)在一个子进程里顺序跑,跟别的链并行。
输出按固定顺序印、不按完成顺序 —— **两次跑的输出要能直接 diff**。
每条链的秒数也收回来(并行之后它们从秒表里消失过一次,而「量不到就没法再优化」
正是这一档存在的理由:624→195 就是照着秒表找出来的)。
默认那档末尾会明说没验哪几项、它们各守什么。退出码照旧 0(它确实通过了它跑的那些),
但**只跑了一半不许印得像全跑了**:「判不了」和「通过」合并成一个无差别的绿,
是这套东西最不能出的那种错。
**shell 脚本交给 `shellcheck`,不自己写正则**(`brew install shellcheck`,没装就报红——静默跳过等于永远通过)。它认得出这个仓库真栽过的那种:`计时=""` → `SC2276 This is interpreted as a command name containing '='`,bash 会把它当命令执行、报 command not found **然后接着往下跑**,变量恒空而脚本照常绿着跑完(`bash -n` 查不出来,语法合法)。2026-08-17 一天栽了五次。
**接的时候防住第二件事:解析中途停了不许算数。** 中文函数名(`秒() {`)bash 认、shellcheck 不认,它在那一行报 `SC1088` 停止解析、**照样退非 0**——看起来在干活,实际 260 行只扫了 28 行,而前面那几条 `appears unused` 全是假的(它没看到用的地方)。所以 `run-tests.sh` 里的函数名是 `sec` / `run_one` / `teeth` / `chain` 而不是中文:**中文函数名合法,但它把这个仓库唯一的 shell linter 挡在门外。**
**判据编号是跑出来的**(`ok #37 …`),不是手编的圈字符 —— ㊱–㊿ 在 90 条判据那儿早用完了,手编到后来必然重号,`FAIL ㊹` 分不清是哪一条。加判据不用维护编号。
**消融个数是数出来的,不是写在 `run-tests.sh` 里的**(`tests/消融.mjs`)。每个测试文件用
`认消融(N)` 声明自己有几个,套件从 1 一直试到测试回退出码 2 为止。
退出码四档,**`tests/消融.mjs` 的文件头是唯一一份定义**,缺哪一档就分不出对应的两种情况:
| 码 | 什么意思 | 缺了它会把什么误当成什么 |
|---|---|---|
| 0 | 全绿(消融时 = 这个消融是恒绿的,必须报) | — |
| 1 | 有判据挂了 | — |
| 2 | 没有这个消融号,停 | 「压根没这个消融」当成「消融有效」 |
| 3 | 挂了,但有一条是撞飞书频控 → **这轮没验成** | 「网络那分钟太忙」当成「代码坏了」;消融那圈里更糟,**一个根本没被执行到的消融被数成一颗牙** |
**3 不是通过。** 它让整套退非 0、要求重跑一遍 —— 所以就算某轮里混了真失败被一起标成 ⏸,
干净的那一遍照样会把它印红,最坏是晚一个来回看到,不是看不到。
这条是踩出来的:`分段读` 加了第 3 个消融、`run-tests.sh` 里还写着 `for m in 1 2`,
**那个消融从来没跑过,而汇总栏照印「✓ 2/2 消融都变红」** —— 一条新判据加进去了、
从没被验证过会红,而整套测试全绿。顺带堵上另一个:`决定.test.mjs` 原来对认不出的消融号
什么都不做(`if…else if` 没有 else),`ABLATE=3` 既没跑正常路径也没跑消融路径,
四条判据一起挂,**从退出码看和「消融 3 起作用了」一模一样**。
| 测试 | 打不打网络 | 守什么 |
|---|---|---|
| `tests/ledger.test.mjs` | 不打 | 归一 + 坏件排除 + 字面规范化 + 缺表降级 + 品牌补充 + **型号写法不像这一类**(第五道检查)+ 品牌传错时说一句。33 条判据 |
| `tests/direct.test.mjs` | 不打 | 直读那条路。60 条判据:URL 解析、方案包认不出 URL 要抛、位置取「地区」不取「库房」、一条 SN 一根、跨方案剔重、聚合不守恒要抛、结构缓存校验、**缓存段长只许比当前策略小**、**lark-cli 两条报错路归一**、SN 逐单对比、筛选命中率、**撞频控从认出来到套件退出码整条链**(含「不是限流的失败一次都不重试」和「`\b429\b` 不匹配 `rec429xx`」) |
| `tests/substitute.test.mjs` | 不打 | 找替代 + 放宽代价。59 条判据:**死路上摆「你是不是想找」**(㊺㊻㊼,2026-08-24 加)—— 客户在厂商料号后面缀项目名(`MZQL23T8HCLS-00B7C-JD项目`)时,槽位解析不出、字面也对不上,这条路原来一个线索都不给,而答案就在库里。判据是**指纹包含**(库里那条的字面指纹整个包在你给的里面,或反过来),召回样本 23 条死路救回 21 条、每次候选中位 1 个。**不用编辑距离**:阈值 ≤4 才救得全,而 4 恰好是 `JD项目` 的指纹长度 —— 那是对测试自己那条变形规则的过拟合。**两边都要挡空指纹**(`fq.includes('')` 恒真,只挡一边的话型号写成 `/` 的脏行会出现在每一次候选里,实测一次摆 61 条)。摆出来的永远不叫「命中」、也不进召回率 —— 人挑一个再查一遍才算找回来。**「精确匹配」那一档里没约束的槽位混着什么,要点名**(㊸㊹,2026-08-24 加)—— 客户写「400G DR4」不提封装时,QSFP112 / OSFP-RHS / QSFP-DD 三种封装会一起进精确档,**而它们是物理上不同的笼子**,整档报成「和你要的一模一样」会让人发出插不进去的货。反过来也要报:没约束那项其实只有一个值时(库里 246 个真实型号里 27 个是这样)要明说「这次确实是一模一样」,否则人为一个已经收窄到底的结果白重查一遍。消融 12 把分布拿掉,㊸㊹ 两条都必须红;**厂商料号(一个核心槽位都解析不出来)三档全空 + 说清「没法判断什么算它的替代」**(2026-08-23 修的:原来它们把整类列进「精确匹配」,也就是一句「这些和你要的一模一样」,实测 30 种网卡里 20 种被这么列 —— 比查库存那个洞更糟,那边只是数字大,这边是一句会被照着发货的断言),同时要说一句「库里就有这个」免得人以为没货;其余:四档的边界、`≈` 那一档要带依据、只差一项的要点名其余哪几项相同、不许写成「能替代」、相近档分层(含按代价拆层)、**平铺列表和分层同序**、网卡「决定不做」和「还没定」不许混、占用读不到时不拿在库冒充;**放宽按代价排不按根数排**、自动放宽只挑「能放」、只有「不能放」的能捞到货时才准说「没有」、每个核心槽位都定过代价 |
| `tests/weekly.test.mjs` | 不打 | 周更:键校验、周差异三类、待补清单和快照对齐、**改名解释**(键级噪音摘不掉就变红)、**「键归零」和「台账会悬空」分开**(拿原始写法的消失去断言归一后的键会悬空,每周误报一次)。20 条判据 |
| `tests/源表.test.mjs` | 不打 | 看源表。13 条判据:列名不一样的必须点名、缺字段的表要点名、空映射当「没有」不当「叫空」、筛选规则和表头行原样给、名单截了要说还剩几张 |
| `tests/scope.test.mjs` | 不打 | 口径块瘦身。7 条判据:调试字段默认不发 / `INVENTORY_VERBOSE=1` 全回来 / **影响回答对错的一条都不许瘦掉** / 留下的值和详细档一致。探针是 `tests/scope-probe.mjs`(环境变量是模块加载时读的,只能起子进程验) |
| `tests/sn.test.mjs` | 不打 | 查SN。19 条判据:多种原始写法收回同一个键、`闵行库房` 要折成 `闵行`、认不出的 SN 不许静默丢、在库和有SN分开、一写法对两键要抛、CSV 带 BOM / 逗号转义 / 文件名去重、写盘读回一致 |
| `tests/资源.test.mjs` | 不打 | 资源 / 提示模板 / 参数补全,外加 `resources/list` 每类只列最近几个、截掉的仍然够得着。17 条判据 |
| `tests/通知.test.mjs` | 不打 | `list_changed` 通知。6 条判据 |
| `tests/召回.test.mjs` | 不打 | **召回率回归**。189 个真型号 × 9 种客户写法扰动,跑的是 `tests/fixtures/召回样本.json`(不打网络)。原样必须 100%、整体 ≥95%、不许低于上次基线。5 条判据。**新增的第 4 条守「问句不许混进召回率」**(2026-08-24):`带项目尾巴` 掉的那 23 条现在会摆「你是不是想找」候选(指纹包含,救出 21 条),它单独进 `问句` 那一列,**命中数必须还是 166** —— 混进去的话召回从 88% 跳到 99%,而一个人都没多拿到一根货。这条边界只能自己守:召回基线是 `>=` 比较,指标虚高它一声不吭。消融 2 就是把问句算成命中那种改法。**命中分「走槽位」和「走字面」两列**:库里 23 个厂商料号(`MZQL23T8HCLS-00B7C` 这类)一个核心槽位都解析不出来,走字面指纹那条,它照样算「查回了自己」,但不许靠「放宽一项」凑数。2026-08-23 `带项目尾巴` 的基线从 100 降到 88 —— **唯一一次降基线**,掉的 23 条逐条归因过、全是那批料号原来靠「命中整类」白拿的分,理由写在测试文件的基线注释里 |
| `tests/决定.test.mjs` | 不打 | 人拍过的决定。17 条判据:依据/谁拍的不许空、跑两遍状态一样、方向和写法都归一之后判重、改口留上一版、拍「不能顶」的不许消失、坏文件要抛但不许拖垮查询 |
| `tests/凑单.test.mjs` | 不打 | 收尾那一行。10 条判据:一处不写数量、多处各自带数量、用的处数是最少的、凑不够不写「满足」也不逐处铺开、没给「要几根」不许下结论、按可用量凑、次序确定 |
| `tests/发通知.test.mjs` | 不打 | 匹配完拼好通知文本、按收件人分段返回(不真发)。9 条判据:分满足/缺货/待定(命中键区分「匹配上但不够」和「没对上库存」、带机房)、资管段+采购段(缺货/待定两段、只有待定时不出缺货段)、归一型号和 normalize 字面指纹同口径(分隔符变体不漏配)|
| `tests/日更工具.test.mjs` | 不打 | 日更 MCP 工具的两步闸。3 条判据:第②步不写不执行、重放已消费票/伪造票都票据无效(①和真写要读写飞书台账,离线测不了,靠 日更.test.mjs 的算红 + 真站手验)|
| `tests/周更工具.test.mjs` | 不打 | 周更 MCP 工具的两步闸 + **SN 快照够不够新**。10 条判据:第②步不写不执行、重放/伪造票都票据无效;以及 `快照判据`——原来这儿是 `setTimeout(2000)` 干等落盘(写 10MB 实测几十毫秒,**白等约 1.95 秒**),而万一慢于 2 秒或落盘那条路把异常吞了(`.catch(() => {})`),读到的是**上一次的快照**:周环比照样算出一个数,「和上周比」悄悄变成「和上上周比」。最贵的是**存基线**——那时候 这周行 是空的,存下去下周一比全仓二十几万根全算「新增」,一路全绿。现在改成 await 真正的落盘 promise,没滚就停掉周环比/待补/存基线三件事并报红(①和真写要读写飞书台账+基线,离线测不了,靠 weekly.test.mjs 的差异算法 + 真站手验)|
| `tests/导出.test.mjs` | 不打 | CSV 转义 / BOM / 桌面和缓存两个去处分开 / **旧文件按时间戳淘汰**(按文件名排会让上周的 `分布-` 挤掉今天的 `SN-`)。31 条判据。**自动落盘的目录里同一组只留最新一份**(2026-08-22;实测 21 个文件里 16 个是同一组 `分布-全部`,只是时间戳不同);**桌面豁免**——桌面那份是人明确要的,同一组三份不许自己消失,只受「留几份」总量约束。这条默认值抽成 `按组收吗(目录)` 单独可验,因为它错了会删人桌面上的东西。**外加无主文件 7 条**(㉑~㉗):清理器只碰对得上格式的名字,副作用是别的文件永远清不掉、而且没有任何一处会提起它(实测残留 `导出/probe.csv`)。报出来但**一个都不删**;**桌面一律返回空** —— 桌面上全是人自己的东西,在那儿报无主离「顺手删掉」只差一步(这条拿掉闸消融验过必红)。 |
| `tests/不膨胀.test.mjs` | 不打 | 静态扫每个 `mkdirSync` 的目标,逐个对上清理函数登记表 —— 新开一个目录忘了配清理就红。外加:**起 server 子进程的测试必须把「会改别人答案的状态」指到临时目录**(SN 快照是 `看变动` 的基线、周报和导出会被 `列资源` 扫;不隔离的话生产状态被搅乱,而别的测试读它就变成「单跑绿、全档偶尔红」)。8 条判据 |
| 各自的消融 | 三个打(分段读 / 增量 / 写路径) | 每个拿掉一处承重逻辑,**都必须变红**,否则断言是恒绿的。**个数这里不写** —— 写死过一次就漂过一次,跑一遍看汇总栏那行 `✓ N/N` 才是准的 |
| `tests/分段读.test.mjs` | 打 | 超 10 MB 的表分段读 + 撞了 `90221` 自适应砍半。8 条判据 |
| `tests/增量.test.mjs` | **打** | 增量重读。6 条判据,只有一条要紧:**增量结果必须和全量逐个物料键一模一样**(不能拿「守恒过了」当判据 —— 复用了一本其实变了的文档,总数少一截而每步守恒照样绿)。造场景靠 `INVENTORY_FAKE_CHANGED`,指到临时目录跑、不碰真缓存 |
| `tests/自检.test.mjs` | 不打 | `./自检.mjs` 自己的回归 + 方案包路径怎么找 + 缓存里的无主文件。10 条判据:正常时退 0、缺方案包要红、**红的那条后面跟着「怎么办」**(只报「坏了」的检查等于没有)、一项坏了后面照样跑完、lark-cli 找不到时说清它跟 WorkBuddy 走;外加路径三条:按顺序找、**别处不再自己写一份默认路径**(原来四个文件各写一份)、**`INVENTORY_PLANS` 指到不存在的文件时不许静默降级**(人以为在用 A 实际在用 B,而两份方案包的差别不报错、只表现为数字对不上);⑨⑩ **缓存根目录里的无主文件要报、退非 0**,而 `缓存常住` 名单上的 `rows.json` 不许被算进去(报了它下一步就是被人顺手删掉全量行缓存)。判据①②跑之前把缓存指到临时目录(`INVENTORY_CACHE_DIR`)—— 不指的话「一切正常时全打勾」的红绿取决于跑测试的人机器上攒了什么,跟代码对不对没关系 |
| `tests/文档没漂.test.mjs` | 不打 | **README 里写死的数字有没有漂**。3 条判据:每个不打网络的测试表里都有一行、写的判据数和跑出来的一致、仓库根每个入口脚本文档里都提到了。加它是因为一次审计发现两个新测试压根没进表格 —— **这类漂移不报错,只会让文档变成一份看着像真的、对不上的东西** |
| `tests/分发.test.mjs` | 不打 | **`tools/call` 那条分发路径**,0 秒。8 条判据:分发通不通、返回体没被改坏、**分发处的钩子真的产生了副作用**(只验返回值的话钩子静默抛异常看不出来)、改过名的老参数当场顶回去、没有的工具名报错、`ping` 回空 result、返回体里不许出现台账那条写死的 wiki token。在它之前这一档里没有任何测试执行过 `tools/call` |
| `tests/问答日志.test.mjs` | 不打 | 记下模型实际问了什么。9 条判据:参数原样记不补默认值、**失败也记**(命中 0 和「压根跑不通」是两类问题)、不许拖慢查询、按月切只留最近几个月、关得掉 |
| `tests/keyreg.test.mjs` | 不打 | 键锁死 + 台账范围 + **占用的两个方向** + **源表整批改写法**。27 条判据:标准写法翻盘要沿用老键、跨库房不沿用、合成一组要报冲突;「该导没导」和「不走台账领用」分开计数(源表有、台账没有);**「台账有占用、源表没这个键」也要报**(在途落进去的地方,漏了就是超发),日期要跟着每一条走、没填不许编;`找前身` 只对「跑完在库归 0 的同类型同品牌同库房老键」出候选、槽位解析不出的不出候选,`认改名` 只许撤这次新发的键、并完之后 `对键` 真的不再发新键 |
| `tests/规格新鲜度.test.mjs` | 不打 | 「按 SN 校准型号」那份缓存旧了要能看出来(`lib/规格新鲜度.mjs`)。15 条判据。背景:源表的型号,硬盘写粗略容量(`3.84T`)、内存写厂商料号(`128GB 2Rx4 PC5-5600B-RA0-1010-XT`),人读不懂也匹配不准;CMDB 按 SN 逐根记着精确规格(`3.84T NVMe U.2 Gen4`、`128G DDR5 5600`),所以查询时按 SN 换成 CMDB 那份(`direct.mjs` 的 `sn规格()`)。**这份缓存离线生成、不会自己更新**,新到的货和 CMDB 改过的规格都不在里面,而**变旧的表现是「那些货的型号还是老样子」——不报错、不变红**。守四件:① **只算硬盘+内存**(光模块/网卡源表本来就写人读的规格串,一根都不许算进「该覆盖」);② 缓存不存在时报 0 且不抛(日更不能因为这个中止),并说清是「从来没生成过」;③ **新鲜的不报警**(天天报人就当噪音了),7 天才报;④ 报警里必须说清「这事不报错」和具体多少天 —— 不说人不会当回事。距今天数夹到 ≥0(刚写完的文件 mtime 可能比 `Date.now()` 晚几毫秒,`Math.floor` 会算出 -1) |
| `tests/查库存.test.mjs` | 不打 | **主路**(`lib/查库存.mjs`,九个工具里被叫得最多的那个)。2026-08-22 从 `server.mjs` 搬出来时只验到「行为没变」,一条自己的判据都没有 —— 在那之前它只在「起真服务器 + 打真网」那一档被间接跑到,改一行要等几分钟。35 条判据,依赖全从工厂注进去(`取数`/`相关缺表`/`取枚举`/`取源表数`),7 处消融验过必红。挑判据的口子是**错了会不会报错**,这个模块的错几乎全是静默的:①~⑥ 槽位值不在词表里要当场红(静默查 0 根 = 和「库里真没有」长得一样);⑦⑧ **品牌精确匹配不是包含**(`H3C` 捞走 `H3C-新华三` 会多算,不报错);⑨⑩ 完全读不到的库房根数是 `null` 不进合计(当 0 加进去 = 把「不知道」算成「没有」);⑪⑫ 明细**梯队在前量在后**(第一行就是人当成答案的那一行,而二梯队要跨库房协调);⑬~⑯ **2026-08-23 抓到的真 bug**:解析器碰上认不出的写法不报错、返回 `{}`,而空槽位集对每一行都逐项相等 —— 问 `中文型号` 回的是整个资产类型的全部库存,还附一句「按槽位匹配」。方向是最坏的那个(**多报**:少报人会追问,多报人直接许出去了)。修法是「解析出空集 = 没解析出来」,消融 5 重演旧行为必红;⑰ 槽位填错当场抛且**一次表都没读**;⑱⑲ 枚举和源表张数每次现取(热重载后的 `let`,按值快照会让新资产类型永远认不出);⑰~㉒ **匹配有多严要说清**(详见「槽位这条路的两个洞」那节):全解出来的说「这是精确匹配」,只解出一部分的明说「**这不是精确匹配**」并点名哪几项没参与比较,只给非核心槽位(`lanes`/`media` 在词表里、不在 core 里)当没约束处理、不许整类全回,厂商料号只按字面(比指纹)回它自己那条并标出来;㉖㉗ 参数回显是**解析后的槽位串**且能原样贴回去(多轮追问记漏一个槽位多查一大截、多带一个少一大截,两种都不报错);㉒ 缺表警告排在数据块前面;㉓ `明细几行:0` 真的一行不给;㉝㉞㉟ **明细按库房聚拢**(同一库房的行连成一段——排序键少了库房那一层,同梯队内两个库房会按根数交错,而模型要 group by 库房才写得出「一个库房一行」,它在脑子里合并的时候梯队顺序就散了,人看到二梯队冒在最前面。样本是对抗性的:二梯队那个库房的量是全场最大)|
| `tests/答法.test.mjs` | 不打 | 展示层(`lib/答法.mjs`:答法/答法差/口径差/明细行/落地行数)。**这一层错了全是静默的** —— 回包照常有、数字照常对,只是模型看到的指令变了或该转达的警告没转达。32 条判据,时间靠注入不靠 sleep。守四件:① 格式指令的形状(永远要求原样带 ⚠、禁流水账);② 去重不许吃掉警告 —— **⚠/读取时间/源表链接 值没变也必发**(一个警告只报一次然后消失,读起来就像已经解决了),去重掉的要明说「其余同前」,警告消失了要主动说「已解除」;③ **会话边界**(2026-08-22 修的真 bug:原来去重状态永不重置,而 MCP server 是长驻进程、实测跑过 23 小时 —— 同事 A 早上问过之后,同事 B 下午拿到的是「其余同前」,而 B 从没看过那个「前」。现在按「离上次调用 10 分钟」切会话);④ 明细行的分支(物料键只在落文件时带、占用字段不编、「随整机走不该在台账里」和「漏录了」不许混成一个)。11 处消融验过必红 |
| `tests/工具契约.test.mjs` | 不打 | 给模型看的那份契约(名字/说明/参数表/annotations)的快照 + 不变量。**回归里唯一真起一次服务器的地方** —— 2026-08-22 把工具表搬进 `lib/工具表.mjs` 时 `node --check` 过了、全套绿了,服务器却起不来(漏搬两个常量、运行时才 ReferenceError),就是因为之前没有一条会执行到「起得来吗」。7 条判据:真起服务器并报拿到几个工具(0 或偏小=没检查)、每个都有名字、说明非空(说明是模型唯一的使用手册)、参数表是 object schema、无重名、`required` 点名的键 properties 里真有、整份契约哈希对基线。**契约变了不一定是坏事**,但必须人看过 diff 之后 `node tests/工具契约.test.mjs 祝福`,不许悄悄漂。6 处消融验过必红(改一个字的说明 / 改 annotations.title / 删一个工具 / 服务器起不来 / 说明变空 / required 点空头承诺)|
| `tests/依赖方向.test.mjs` | 不打 | 依赖方向交给 linter 强制(`.dependency-cruiser.mjs`),不靠纪律。7 条判据:干净必绿且**要报它看见了几个模块几条依赖**(37/129;0 或偏小 = 没扫到、不是没问题,`tach` 就这么打过假勾);四条规则各造一个违规样本必红——禁循环(有一边会拿到半初始化的模块)、`lib/` 不许反向 import 入口(否则整张工具表被拖进每个用它的地方)、不许 import 伸出仓库(换机器断在运行时)、禁孤儿;样本清掉必须重新绿 |
| `tests/死代码.test.mjs` | 不打 | 死代码棘轮,`knip` 走真的 import 图(grep 数不准:中文标识符和注释里提到的名字分不开)。5 条判据,**只许降不许涨**:**每次跑都自己种一个未用导出、knip 必须逮到**(第一版拿"报了几个文件有发现"当自证,存量清零后那个数就是 0、在代码最干净时假红;样本还必须种进一个已被 import 的文件——种成新文件会被算进 unused **files**、和 exports 是两类);存量已清到 **0** 条(2026-08-21 清的:32 个全是多余的 export,摘掉 export 后 eslint 判定它们在本文件内仍在用,真死代码 0 个),基线在 `tests/死代码基线.json`,新增零消费者导出必红并点名是谁,清掉了也红、提醒把基线跟着降(不降的话余量会被下一个人悄悄用掉)。存量本身怎么处置归人拍板,这条只管别再长 |
| `tests/资源位置.test.mjs` | 不打 | 找文件不许从「代码放在哪」推(`lib/资源位置.mjs`)。17 条判据:**候选列表长得对 ≠ 真找得到**(2026-08-24 逮到的「失效时是绿的」样板)—— 原来只验「候选里有一条以 `lib/dedup.py` 结尾」,而从 `tests/` 里跑时那条是 `tests/lib/dedup.py`、压根不存在,判据照样绿;真炸的地方在 `全` 档(`增量`/`只读要用的表` 报 `python3: can't open file`)。现在直接验 `挑存在的()` 落到一个真存在的文件上,并验 `direct.mjs` 找不到 `dedup.py` 时抛的是「试过这些位置 + 该设哪个变量」那个自带修法的错,不是降级成候选第一条丢给 python3;入口目录跟着 `process.argv[1]` 走,**把模块拷到别的目录后结果不变**(这正是 `import.meta.url` 做不到的那点);候选顺序 = 自己的环境变量 → `INVENTORY_HOME` → 入口/产物旁边 → `~/.config/inventory-mcp` → 调用方给的已知位置;显式指定了却不存在要抛、不许降级;找不到的报错要列全试过的路径 + 点名该设哪个变量。**带棘轮**:非测试代码里出现一处 `import.meta.url` 就红(注释里写不算),并自报扫了几个 `.mjs`(0 或偏小 = 没检查)。守的是打包成单文件后还能找到 `plans.json`/`决定.json`/`dedup.py` —— 那类错打包时不报、运行时才炸 |
| `tests/版本协商.test.mjs` | 不打 | 握手时那一句协议版本谁说了算。12 条判据:客户端报的版本我方支持就原样回,**不支持就回我方最新、不许回声**(2026-08-22 修的真 bug:原来无条件把对面报的版本回过去,于是对面的兼容性检查「你回的和我要的一样吗」永远成立、永远发现不了不兼容 —— 这是典型的失效时是绿的);乱字符串、未来版本、空串、干脆不报,四种都归到我方最新;最后一条验**真起到了服务器并拿到了回复**(拿到 null 的话上面十一条全不作数)|
| `tests/新到.test.mjs` | 不打 | 「离上次写台账新进来了什么」+ SN 证据(`lib/台账基线.mjs` / `lib/新到.mjs`)。14 条判据:**没有基线 ≠ 没有新到**(读不到基线返回 null 不是空数组,否则第一次跑会把全库算成新到、而那个数看着完全正常);按物料归并 + CSV 一根一行(清单落文件,实测一次到货 2 万根,塞进上下文会冲掉);搬库房/补品牌不算改名;**SN 证据不受「老键归零」约束**——源表只改一部分记录时槽位那条路一个候选都出不来、SN 照样认得出,且 SN 候选排最前 |
| `tests/改名确认.test.mjs` | 不打 | 判改名那道闸,**日更周更共用一份**(`lib/改名确认.mjs`)。20 条判据:候选带老键原在库、**备选必须跟着走**(实测「最像的」有 3/28 是错的,正确前身在备选里);`认定的改名` 省略 ≠ 判过了都不是(省略拒写)、显式 `[]` 放行、编的配对拒写;命令行没有默认放行的口子(`--都不是改名` / `--改名 <json>`);最后同时跑日更和周更两个工具,验它们拿到的是同一套闸 |
| `tests/日更.test.mjs` | 不打 | `日更.mjs` 自检回执的红检查(`lib/日更检查.mjs` 的 `算红`)。13 条判据:注册表报了新发键但条目数没涨=红、更新于不是今天=红、在途读不到=红;dry 模式跳过注册表检查、在途读不到照红;新发 0 键条目数不变不误报;**占用挂在「被判成不是改名」的归零老键上=红**(2026-08-20 那次 6,144 根许出去的货结不清,当时全绿),两个条件缺一不红、干跑也照红 |
| `tests/只读要用的表.test.mjs` | **打** | 只读问到的那类资产的表 + 在途去重。11 条判据,最要紧的一条是**只读一类时一个基线都不许滚**(半份数据覆盖全仓 SN 快照,下次全量会把没读的二十几万根全算成「多了」,而每步守恒照样绿)。另外守着:口径要自报「只读了部分源表」、行缓存叠加不覆盖、「全部」那份缓存能答窄问题、全量**要**滚快照(和前一条互为反证)。消融靠两个逃生开关注入(`INVENTORY_NO_NARROW` / `INVENTORY_NO_INFLIGHT`)|
| `tests/写路径.test.mjs` | **打,而且真写** | 写多维表格那两条命令的**契约**。真建一张一次性的表(`ZZ-写路径测试-勿动` 前缀)、写、读回、删掉,不碰任何业务表。5 条判据,两条是 2026-08-16 那次事故直接教出来的:**「每条记录各自的新值真的写进去了」**(不是「命令没报错」)、**「拿不存在的记录 ID 更新接口照样回 ok」**(把「写完必须回读」变成会红的判据)。默认跑 —— 那条写路径从写出来到崩、回归里一次都没覆盖过,「只在改了写路径时手动跑」等于永远不跑。**请求体的形状从 `lib/ledger-write.mjs` 那两个函数拿,不在测试里手写一份** |
| `tests/protocol.test.mjs` | 打 | initialize / tools/list / tools/call 这条链能不能通,外加进度通知(带没带 progressToken 的两种行为互为反证)、查SN 走文件和走内联两条路、老参数名要当场报错、记下决定走真的写盘(指到临时文件)。99 条判据 |
消融不是摆设,抓到过三次:`brandMap` 原来在模块加载时建成常量,测试改 `BRAND` 影响不到它,消融 1 是恒绿的;直读那边第一版消融把对应判据自己豁免了(`ABLATE === '1' ? true : ...`),三个消融里两个不变红;字面规范化那条消融第一版**样本选错**——拿 `CX7-400G单口` vs `Cx7 400G 单口` 当样本,槽位解析器自己就能合,字面归一对它不承重,换成 `128g`(小写 g 解析不出容量)和 `SFP.25G-SR.LC`(点号解析不出规格)才真正只走字面这条路。
**「消融不红」有两种原因,第二种更隐蔽**:判据豁免了自己,或者样本根本不经过那条路径。
### 演一遍撞频控
```bash
INVENTORY_FAKE_RATELIMIT=99999 INVENTORY_BACKOFF_MS=1,1 ./run-tests.sh # 该印 ⏸,退 3
./run-tests.sh # 该全绿,退 0
```
反着跑这一遍不是走过场,2026-08-16 一次就抓出四个真问题,**三个是同一天刚写的那套频控机制自己的**:
1. `收尾()` 原来要求「挂的每一条都是频控」才算没验成 —— 而 `分段读` 挂 5 条只有 1 条带码,
其余是拿不到数据之后的连锁反应,于是**专门为频控做的机制在真频控面前不生效**。
2. `direct.test.mjs` 里验退出码的子进程直接 `{...process.env}` 传下去,
把注入用的变量也带了进去 —— **一个不打网络的测试因为别处的注入而变红**,测试的沙盒漏了。
3. `protocol.test.mjs` 十四处裸 `JSON.parse(…content[0].text)`,工具回人话时抛的是
`Unexpected token '查', "查不了:wiki 换"…` —— **原文只剩前十个字,而 `code=99991400` 在第 40 个字上**。
4. `写路径.test.mjs` 自己那份 `cli()` 是裸 `pexec`,lark-cli 非 0 退出时错误体在 stdout 里、
而那个对象根本没被读,报错只剩一句 `Command failed:`。它要真写生产 base,
恰恰是最容易撞上限额的那条。
四个的共同点:**都是"失效时是绿的/黄的"**,而且都只有把场景真造出来才看得见。
## 还没做的
- **登记占用**没做(写「占用记录」表 `tbl8GkzD4stgYcay`)。物料表的写入已经做了(`./导台账.mjs`),
源表一律只读。三条硬规矩先落地:agent 不能填「核验人」、写带请求 ID 且写前查重、
写前重读发现已被占就中止。这张表 2026-08-15 探过一遍,四件事和原来记的不一样:
**① 占用和出库是同一张表的两种「类型」**,不是两张表。靠 `带符号数量` 加减(占用 `+数量`、
出库 `-数量`),`剩余占用` 是公式算的净额。出库那行还要在 `关联工单` 里指回它冲销的那条占用行
(实测:29124 占用 +2 → 29154 出库 -2 指着它 → 剩余占用 0、状态 ⚪已结清)。
**② 物料已经是 link 字段(`关联物料`),不是 text。** `本行物料键` 是从它算出来的公式,
所以人是从下拉里选物料、不是手打 40 个字符 —— 原来记的「现在是 text」已经过期。
**③「link 走 API 会静默丢数据」不成立**(2026-08-15 实测写了一条再删掉):`+record-batch-create`
传 `关联物料: [{"id":"rec..."}]` 写得进去,回读拿到的 link 值一字不差,而且公式 `本行物料键`
真的算出了 `光模块|海光芯创|QSFP112-400G-DR4-SM1310|闵行` —— **关联是活的,不是存了个空壳**。
形状写错时接口是**显式报错**(`800010701 Cell value does not match any supported shape`),
还在 hint 里给出正确形状,不是悄悄吞掉。
**④ 但返回体不给 record_id**:`+record-batch-create` 回 `ok:true` 而 `records` 是空数组,
从返回值看不出写没写、写成了什么。**所以「写完必须回读校验、不认返回值」那条规矩在这张表上
不是保险是必需**。删记录要 `--yes`。
写之前的自检有现成的:这张表有个 `数据检查` 公式字段,把规则全写在里面了 ——
没填类型 / 没选物料(「这笔占用谁都扣不到」)/ 数量要大于 0 / 同一工单同一物料写了多行
(「剩余占用会偏小」)/ 冲销超过占用量 / 缺日期 / 缺成本归属 / 出库没选关联工单 /
出库未填闭环状态。**写完回读这个字段就知道对不对,别在代码里把规则再实现一遍。**
- **物料键的第三段还是型号字符串**,只做了字面层归一。纯打字差异(大小写/分隔符)已经挡住了,
但**语义相同、字面不同**的仍会各成一个键。根治是把键换成槽位序列化、型号字符串降级成显示名。
实测 26 个合并组里有 9 组的标准写法是靠根数决出来的(`CX6-25G双口(287)` vs `CX6-25G*2(158)`,
差 129 根,一次出库就能翻盘),翻了之后周对比会报出假的「消失 + 新增」。
- **网卡不做规则替代**(2026-08-15 定,不是待办)。`方向.网卡` 三项全是 `null`,所以它的
规则档必然是空的 —— 能不能拿 CX6 顶 CX5、双口能不能顶单口是硬件常识,槽位表算不出来,
拍这张表的成本高于收益。**空数组必须带解释**:`不做规则替代` 那张表命中时返回值里明说
「这类不做规则替代 + 为什么」,和「⚠ 这类的替代方向还没定」互斥 —— 后者读起来是个待办,
会让人一直等一个不会来的东西。网卡照常给精确匹配和相近分层。要开做就删掉那一项、
把 `方向` 填上,两处一起动(`lib/substitute.mjs`,判据 ㉟㊱㊲ + 消融 9)。
- **配额不是障碍,已查清**。飞书没有「月度额度」这回事 —— 官方 `frequency-control` 页给的是按 API × 应用 × 租户的**每分钟/每秒速率**,最窄一档 100 次/分,基础版和商业版的表几乎一样。触发了回 HTTP 429 + `code 99991400`,响应头 `x-ogw-ratelimit-reset` 直接告诉你等几秒。**但直读这条路够得着**:31 张源表,一次冷启动约 48 次(30 张各一趟 + 闵行光模块那张
1 次元数据 + 17 段),空缓存再多一趟表头。**一分钟内连开两次冷启动就撞线**,撞上后从
8 秒掉到 19~21 秒 —— 这条不是理论,2026-08-14 排查读表慢的时候踩到过,当时误以为是数据量。
热态只有 9 次(查 9 本文档的 revision),台账那条路 1~2 次。三个人各装一份、各自零星查够不着,
但别在一分钟内连跑两次全量。**段数越细调用次数越多**,改 `每段格子` 之前先算这笔账。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues