# baoyu-workbench-frontend-pitfalls — data pipeline

## 113. 清单页新增统计维度：默认交叉、四处同步、页面端派生

用户说「把 X 类分单独类别统计」时（如给特殊要求加「NA 类（核酸残留）」）：

1. **先定互斥还是交叉，并写进页面**：默认做**交叉维度** —— 同一条记录既保留原类别、也计入新维度；擅自把记录从原类别挪走会让用户已记住的原类别分布凭空缩水。要互斥必须先跟用户确认。
2. **四处同步，少一处就是半成品**：① 顶部 KPI 卡新增一张（`涉及类别数` 同时 +1）② 类别芯片（带实时计数、点击即筛选）③ 类别下拉（与芯片同一 value 语义，常用哨兵值如 `__NA__`，别混进真实类目）④ 表格行内小标签（原类别后追加，让「它为什么算这一类」逐行可见）。
3. **新维度在页面端派生，不改主数据 JSON**：关键词规则与判定函数写死在页面 JS。JSON 的 `category` 是 ETL/模型产出，改了会被下次重建覆盖；派生维度还能对已上线数据立即生效，无需跑管道。
4. **规则要在页面上「自白」**：统计条写明命中数、占比、按原类别分布、识别依据（哪些关键词）、是否与既有类别重叠。用户一定会核对灰色地带条目（如「无核酸酶水」「背景菌残留」算不算 NA）—— 把命中明细逐条列给他定夺。
5. **上线前用真实 JSON 跑渲染断言**：命中数 / 按原类别分布 / 「筛选后行数 == 命中数」「行内标签数 == 行数」/ 切回「全部」恢复总数。规则的大小写与空格要归一（`NA级` vs `NA 级` vs `na级`），否则漏抓。



## 100. AI 助手文件上传：前端校验别比后端严（2026-09-04）

症状：AI 助手「只上传 PDF、不输入文字」点发送无反应，文件发不出去。

根因：`doChatSend` 里 `if (!q) return` 强制要求输入文字，但后端 `ai_chat.handle()` 的校验是 `if not question and not messages and not files`（files 非空就放行）。前后端校验口径不一致，纯文件上传被前端静默拦截。

修复：`if (!q && !chatFiles.length) return`（允许只传文件）；`q` 为空时不 push 空历史消息。

教训：前端入口校验（必填/格式）永远别比后端严——后端能处理的请求，前端不该拦。排查「上传/提交无反应」时，先对比前后端校验条件是否一致，再查数据链路（本次后端 pymupdf/OCR/funnel 均正常，问题只在前端这行 return）。

关联：AI 助手入口已从「右下角 🤖 气泡 + AI 助手 Tab」双入口，统一为「AI 助手 Tab」单入口（chatFab/chatPanel/toggleChat/sendChat/chatOpen 已删除，appendChatMsg 默认容器 chatBody→aiChatBody）。文件上传只在 Tab 的 📎 里。



## 102. 看板环比基准月错误：kpi.last 写死 6 月而非上月（2026-09-05）

**症状**：看板"环比X月"数值错，月初数据少时尤其扎眼（如 9月 7.94万 环比显示 -98.4%，实际应跟 8月 388万 比）。

**根因**：`getKPI()` 里 `kpi.last` 写死 `h1.total.actuals[5]`（=6月，H1 最后一个月），但卡片标签写的是"环比上个月"。7月碰巧对（环比基准正是6月），8月起所有月份环比基准都错。非 admin 分支同样 `last: monthlyActuals[5]`。

**修复**：last 改为动态取上月实际——
- admin 分支：上月≤6月用 `h1.total.actuals[上月-1]`，上月≥7月用 `threeYearMonthly['2026'][String(上月)]`（注意把 `d.threeYearMonthly` 读取提前到 last 之前，原本在 last 之后）。
- 非 admin 分支：上月≤6月用 `monthlyActuals[上月-1]`，上月≥7月用 `dashPerson.h2_actual_months[上月-7]`（新增 `last2` 变量，return 里 `last: last2`）。
- `index.html` + `app.html` 各改 admin+非admin 两个分支，改完 `tcb hosting deploy` 部署两个文件。

**验证**：`_prevM = _nowM===1?12:_nowM-1` 后，9月→last=8月数据。node 模拟 `_prevM=8 → last=388.76万 → 环比 -98%`（修复前跟6月506万比是 -98.4%）。

工作台使用 `main.innerHTML` 全量重建 DOM。oninput 顺序不可错：

```javascript
// ✅ 正确
oninput="var v=this.value,p=this.selectionStart;state.val=v;
debounce(function(){
  render();                                         // 1. 先重建 DOM
  var el=document.getElementById('searchId');       // 2. 找新元素
  if(el){el.focus();el.selectionStart=el.selectionEnd=p;}  // 3. 恢复
},300)()"

// ❌ 错误：focus 在 render 前，旧元素被销毁
debounce(function(){el.focus();render();},300)()
```



## 3. 表格空数据必须有提示

```javascript
filtered.map(renderRow).join('') || '<tr><td colspan="N"><div class="todo-empty">暂无数据</div></td></tr>'
```



## 4. MCP 字段映射（必读！字段不匹配是客户页全空/全0的根因）

MCP 用 `field_xxxxx`（如 `field_5ed7ab`=部门），前端代码用语义名（`dept`）。**不映射就死**——MCP 数据通过但字段名错配，页面会静默显示空值/0/—，**不会报 JS 错误**，极难排查。

### 完整字段映射表（客户工作项，type_key=65ae1e403c87b152f3365ca6，93个字段）

| 页面语义名 | MCP 字段 | 飞书显示名 | 类型 | 备注 |
|---|---|------|------|------|
| `name` | `name` | 客户名称 | text | 直接可用 |
| `group` | `field_c3224b` | 所属集团客户 | text | — |
| `dept` | `c.field_5ed7ab` | 所属人部门/区域 | select | **⚠️ 值是对象 `{key,label}`，必须取 `.label`！见#36** |
| `level` | `c.field_c8e80d` | 客户级别 | select | 大客户/重点客户 |
| `ctype` | `c.field_17186c` | 客户等级 | select | A/B/C/D/准A类 |
| `contacts` | `field_8c870a` | 联系人数量 | number | — |
| `opps` | `field_79aa0b` | 客户对应商机 | related_multi | 取 `.length`（MQL 可能返回空） |
| `location` | `c.field_6415cf` | 所在地 | text | — |
| `work_item_id` | `work_item_id` | 工作项ID | number | 用于飞书链接 |
| `amount` | **MCP 无此字段** | — | — | 从 `_key_customers.json` 按客户名交叉匹配 |

> 金额（价税合计 `field_51d592`）在**销售订单**类型（type_key=`662612aea6bb7089fea044ef`）中，不在客户类型。短期从 ERP `_key_customers.json` 交叉匹配，长期可从销售订单按客户关联聚合。

### `adaptMcpFields()` 标准实现（不可变模式）

在 `loadData()` 的 `Promise.all` 回调中调用，**返回新对象不修改原始数据**：

```javascript
function adaptMcpFields(mcpData) {
  var custs = (mcpData || {}).customers || [];
  // ERP 金额字典（从 _key_customers.json 按客户名索引）
  var erpAmount = {};
  var erpCusts = state.data.customers || [];
  erpCusts.forEach(function(c) { if (c.amount) erpAmount[c.name] = c.amount; });
  return custs.map(function(c) {
    // ⚠️ select 类型字段值可能是对象 {key,label}，必须提取 label
    var dept = c.field_5ed7ab;
    if (typeof dept === 'object' && dept !== null) dept = dept.label || '';
    var level = c.field_c8e80d;
    if (typeof level === 'object' && level !== null) level = level.label || '';
    var ctype = c.field_17186c;
    if (typeof ctype === 'object' && ctype !== null) ctype = ctype.label || '';
    return {
      name: c.name,
      group: c.field_c3224b || '',
      dept: dept,
      level: level,
      ctype: ctype,
      contacts: c.field_8c870a || 0,
      opps: (c.field_79aa0b || []).length,
      location: c.field_6415cf || '',
      work_item_id: c.work_item_id,
      amount: erpAmount[c.name] || 0,
      _raw: c
    };
  });
}
```



## 36. select 字段值在 adaptMcpFields 中是对象不是字符串（2026-07-27 已修复）

**症状**：部门下拉显示 `[object Object]`，部门筛选全部失效。控制台无报错。

**根因**：`c.field_5ed7ab`（以及 `field_c8e80d`、`field_17186c` 等 select 类型字段）在 pull_customers.py 的 `parse_items` 中被写成 `v['key_label_value']`，这是一个字典 `{key: "xxx", label: "诊断原料大客户销售部"}`。但 `adaptMcpFields` 直接取 `c.field_5ed7ab || ''`，得到的是对象自身 → 渲染成 `[object Object]`。

**影响字段**：`field_5ed7ab`(部门)、`field_c8e80d`(客户级别)、`field_17186c`(客户等级)——所有 `select` 和 `key_label_value` 类型的字段。

**修复**：在 `adaptMcpFields` 中使用 `extractLabel()` 辅助函数：
```javascript
function extractLabel(v) {
  if (!v) return '';
  if (typeof v === 'object' && v.label) return v.label;
  return String(v);
}
// 在 adaptMcpFields 中：
dept: extractLabel(c.field_5ed7ab),
level: extractLabel(c.field_c8e80d),
ctype: extractLabel(c.field_17186c),
```

> ⚠️ 订单数据中 `field_5ed7ab`（销售部门）也有同样的问题——`pull_orders.py` 的 Python 侧需用 lambda 提取 label，不能用 JS 语法。



## 5. JSON 缓存 key 名匹配前端

```python
json.dump({..., 'customers' if cache_key=='customers' else 'items': data}, f)
```



## 46. `kpi` 变量在其定义前使用——看板崩溃（2026-07-27）

**症状**：看板显示"⚠️ 加载失败"，控制台报 `Cannot read properties of undefined (reading 'ytd')`。

**根因**：`renderDashboard` 中：
```javascript
var fullYearTax = kpi.ytd;  // ← kpi 未定义！hoisting 只提升声明不提升赋值
// ... 中间若干行 ...
var kpi = getKPI();         // ← 定义在这里
```

**修复**：将 `var kpi = getKPI() || fallback` 移到 `fullYearTax` 之前，并加 fallback 对象防止 getKPI 返回 undefined。

**ironclad rule**：任何变量必须在**所有使用它的语句之前**完成赋值。JavaScript 的 `var` hoisting 只提升声明不提升赋值，调试极其困难。

### getKPI() 对非标准角色返回 undefined

`getKPI()` 在 `my.people` 和 `dash.people` 中按 `name` 查找，找不到时返回 undefined（而非 fallback 对象）。`personnel.json` 中赵云浩的 role 是"销售工程师"而非"sales"，`dash.people` 可能不包含"销售工程师" → `getKPI()` 返回 undefined → `kpi.ytd` 报 `Cannot read properties of undefined`。

**修复**：调用方加 fallback：`var kpi = getKPI() || {annual:0, ytd:0, pct:0, label:'?', current:0, last:0, h2Targets:[], monthly:{targets:[],actuals:[]}};`

### 订单管理"本年"税合计被 fullTax 覆写为 0

`renderOrders` 中对于 `analysisPeriod === 'year'` 会执行 `ps.taxTotal = fullTax`（即 `data.fullYearTax`）。当前 `fullYearTax` 在 shipment_status.json 中始终为 0 → 年税合计恒 0。

**修复**：去掉 `ps.taxTotal = fullTax;`，让 `ps.taxTotal` 保持 `periodOrders` 的汇总值。全年数据已在 MCP 拉取中覆盖。



## 46b. 看板 KPI 累计达成数据源——必须用 KPI 数据而非 orders 汇总（2026-07-28 修正）

**2026-07-28 重大修正**：此前铁律要求用 `orders.reduce()` 做 `fullYearTax`，但 shipment orders 只包含近期订单（~187条），且金额字段多为0。**累计达成必须用 KPI 数据**：`dash.total_h1`（H1实际）+ `prog.total`（H2当月）。

**renderDashboard 中的正确计算**：
```javascript
// ✅ 正确：KPI 数据（H1实际 2164万 + H2当月 189.6万 = 2353.7万）
var fullYearTax = (dash.total_h1 || 0) + (prog.total || 0);

// ❌ 错误：shipment orders 汇总（近期187条，金额多为0 → 62.5万）
var fullYearTax = orders.reduce(function(s,o){return s+(parseFloat(o.taxTotal||o.amount||0)||0)},0);
```

**验证**：看板"年度目标"卡片的"累计达成"应与 kpi_dashboard.json 的 total_h1 + kpi_progress.json 的 total 一致。



## 70. 合同编号字段 `field_4a1b47` 遗漏（2026-07-28）

**症状**：订单管理表格合同编号列全空。

**根因**：`pull_orders.py` 的 MQL COLUMNS 未包含 `field_4a1b47`（合同编号#），`shipment_pipeline.py` 也未映射 `contractNo` 字段。

**修复**（三处）：
```python
# pull_orders.py: COLUMNS 加上 field_4a1b47
COLUMNS = '..., field_4a1b47'

# pull_orders.py: 输出加字段
'合同编号#': o.get('field_4a1b47', ''),

# shipment_pipeline.py: 透传
"contractNo": o.get("合同编号#", ""),
```



## 73. cb_sync.sh 动态文件精简 + 数据目录归档（2026-07-28）

**原则**：只上传管线产出的动态 JSON，静态文件（H1月度/三年对比/年度KPI）一次性部署。归档不用的分析结果到 `_archive/`。

**精简**：13→9 个动态 JSON + HTML（去掉 h1_monthly/h2_targets/three_year_monthly/my_kpis）。

**数据归档**：41 个未引用 JSON → `数据/_archive/`，78→37 个文件，释放 ~12MB。

**铁律**：每次新增 JSON 产出后检查是否需加入 cb_sync FILES 数组。HTML 部署是独立步骤。



## 48. 订单管理 KPI 卡片：按周期切换（本周/本月/本季/本年）

**最终方案（2026-07-27 终版）**：KPI 卡片使用 `ps.taxTotal`/`ps.total`（period stats），随周期按钮动态切换，**不是始终显示全年**。用户需要分别看各周期数据。

**前提**：`periodOrders` 按 `createDate` 过滤（allOrders 已按 creator 隔离）。全年数据必须已拉取（`pull_orders --days 365`），否则"本年"选项显示的只是 90 天范围。

**关键修复**：`ps.total` 必须统计**全部订单**（含无物流单号的），不只是有物流单号的：
```javascript
periodOrders.forEach(function(o) {
  var s = (o.shipment || {}).status || 'unknown';
  if (ps[s] !== undefined) ps[s]++; 
  ps.total++;  // ← 所有订单都计数，不只是有物流的
  ps.taxTotal += parseFloat(o.taxTotal || o.amount || 0) || 0;
});
```

**Ironclad**：**不要**在"本年"分析中执行 `ps.taxTotal = fullTax`（即 `data.fullYearTax`）——`fullYearTax` 始终为 0，会覆写正确值。



## 49. 订单明细去重——同一 orderId 出现多行

**症状**：订单管理明细表中同一订单号出现 2-12 行（如 `ZHBR260727020` 出现 10 次）。

**根因**：飞书中一个订单对应多个工作项（不同产品行 = 不同 work_item_id）。`pull_orders.py` 只按 `work_item_id` 去重，同单号的不同工作项全保留。

**根因修复（2026-07-28）**：在 `pull_orders.py` 输出时按 `单据编号` 去重：
```python
seen_oid = set()
for o in all_items:
    oid = o.get('name', '')
    if oid in seen_oid: continue
    seen_oid.add(oid)
    # ... 构建记录
```
效果：384→186 条，103 个重复单号归零。

**前端兜底（仍有冗余时）**：
```javascript
var seenOids = {};
var deduped = filtered.filter(function(o){
  if(seenOids[o.orderId]) return false;
  seenOids[o.orderId] = true;
  return true;
});
```



## 67. 无物流订单备货状态（2026-07-28）

**症状**：订单管理表格中无物流单号的订单状态列为空白/`?`，无法区分"刚创建未发货"和"超期未发货"。

**方案**：前端根据创建时间计算天数，分段标记备货状态（备货中/预警/超期/严重超期）。MCP 销售订单**没有**「计划发货日期」字段。

详见 `references/preparation-status-pattern.md`。



## 68. 合同编号字段遗漏（2026-07-28）

**症状**：`cb_sync.sh` 执行成功，但工作台页面仍是旧版。

**根因**：`cb_sync.sh` 的 FILES 数组只包含数据 JSON（`mcp_customers.json`、`shipment_status.json` 等），**不包含 HTML 文件**。修改 `工具/index.html` 后必须单独部署：

> ⚠️ **但 cb_sync.sh 末尾（第 60 行）会单独部署 index.html**：`tcb hosting deploy "$WORKDIR/工具/index.html" /workbench/index.html`，把**本地源文件** `工具/index.html` 自动覆盖到线上 `/workbench/index.html`（每 2 小时）。**所以改工作台 HTML 必须改本地 Desktop 源文件**（`工具/index.html`、`工具/app.html`），改 `/tmp` 或 `tcb hosting download` 下来的副本部署后会被 cb_sync 覆盖回旧版（2026-08-30 血案：改了 /tmp 副本部署成功，2 小时内被覆盖，用户反馈"没有 tab"）。app.html 走 `工具/app.html`→`/工具/app.html`，cb_sync 不部署它，但改本地源文件仍是最稳做法。
```bash
tcb hosting deploy 工具/index.html /workbench/index.html -e bier-sales-d0gatbvlx288724e9
```

**验证**：部署后用 `curl -H "Cache-Control: no-cache"` 检查新代码特征是否存在：
```bash
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/workbench/index.html" | grep -c "multiSelect"
```

> 注意：CloudBase CDN 有 5-10 分钟缓存延迟，部署后 curl 验证可能仍显示旧版。用 `tcb hosting download` 验证 Origin 文件已更新。



## 50. shipment_pipeline 必须输出所有订单（含无物流单号的）

**症状**：赵云浩只有 109 条订单（仅含物流单号），无物流单号的订单不显示 → 价税合计被大量低估。

**根因**：`process_orders()` 在 step 1 就 `if not tn: continue` 跳过了无物流单号的订单。

**修复**：改为收集全量订单，SF 查询仅对有物流单号的部分执行，最终输出全部订单（无物流的 `shipment` 字段为空 `{}`）：

```python
all_orders = []
tracking_orders = []
for o in orders_data:
    all_orders.append(o)
    if tn and tn not in seen_tn:
        tracking_orders.append(o)
# SF 查询仅对 tracking_orders 执行
# 输出 all_orders（含无物流的）
```

**效果**：赵云浩 109 → 488 条，税合计 ¥142 万。



## 79. MCP field_5ed7ab 选项与 HR 部门名不一致（2026-07-31 发现）

**症状**：按 `field_5ed7ab = '生命科学销售部'` 查询客户返回 0 条，但实际有 111 家生命科学客户。

**根因**：MCP `field_5ed7ab` 的选项是**飞书项目配置的固定列表**，与 HR 体系（`_personnel.json`）的部门名不一致：

| 业务线 | MCP field_5ed7ab 实际选项 | HR dept | 客户数 |
|------|------|------|:--:|
| 诊断原料 | `诊断原料销售拓展部` | 销售拓展部 | — |
| 诊断原料 | `诊断原料大客户销售部` | 大客户部 | — |
| **生命科学** | **`浙江`** | 生命科学销售部 | ~70 |
| **生命科学** | **`广东`** | 生命科学销售部 | ~41 |
| 其他 | `南区`,`北区`,`华中区`,`国际业务部`,`生物医药销售部`,`北京区域` | — | — |

> MCP 中**没有**"生命科学销售部"选项。生命科学客户以地域标签标识。查询 MQL：
> ```sql
> SELECT `name`, `field_5ed7ab` FROM `销售管理`.`客户` WHERE `field_5ed7ab` in ('浙江','广东')
> ```

### 业务线双映射模式

所有涉及业务线隔离的页面应使用双映射，分开处理数据源过滤（MCP 实际值）和用户筛选（HR 体系）：

```javascript
var LINE_CONFIG = {
  da: {
    mcpDepts: ['诊断原料销售拓展部','诊断原料大客户销售部'],  // 数据过滤用 MCP 值
    hrDepts: ['大客户部','销售拓展部','诊断原料销售部'],        // 用户筛选用 HR 值
  },
  ls: {
    mcpDepts: ['浙江','广东'],                                    // 数据过滤用 MCP 值
    hrDepts: ['生命科学销售部','生命科学-浙江','生命科学-广东'],  // 用户筛选用 HR 值
  }
};
```

**关键**：永远先用 `list_workitem_field_config` 查字段选项，不要假设 HR 部门名在 MCP 中存在。



## 9. 客诉数据管道：三个致命缺陷（2026-07-27 诊断）
三个缺陷（字段错位 / 去重基准错 / 状态机漏态）的完整诊断与修法见 `references/complaints-pipeline-three-bugs.md`。


## 9b. 客诉数据在另一个项目中

客诉不是 `销售管理` 的 `issue` 表，在 `售后管理`(658288abfb8bd616b17025f1) 的 `khts`(客户反馈单, key=6669433056a98249604376de)。发现方法：`search_project_info` 传空查全部 → 逐个 `list_workitem_types`。



## 10. MQL 字段值类型陷阱

`moql_field_list` 的值有多种格式，`v` 可能为 `None` 必须先判空：
- 字符串：`v['string_value']`
- 整数：`v['long_value']`
- 浮点：`v['double_value']`（如 `field_51d592` 价税合计，订单金额）
- 用户：`v['user_value']` / `v['user_value_list']`
- 选项：`v['key_label_value']` / `v['key_label_value_list']`

**完整解析参考**：`references/mql-value-type-parsing.md` — 包含所有 6 种 value_type 的处理模板和 None 防护。

**`double_value` vs `long_value`**：数字字段在 MCP 中可能是 `long_value`（整数，如 work_item_id）或 `double_value`（浮点，如金额）。两者需要分开处理——`long_value` 丢 `int()`，`double_value` 丢 `float()`。

不处理数组/None 会导致 `field_79aa0b`(商机关联)、`work_item_status`、`current_status_operator` 等字段丢失。



## 75. H1 数据存档 + 前端单文件合并（2026-07-28）

### 问题

订单历史数据（H1）不在 `shipment_status.json` 中，导致"本年"/"本季"视图数据不准。MCP 全量拉取超时（365天数据量过大）。

### 方案：按月拉取 → 存档 → 合并

**月度拉取**：`pull_h1_archive.py --m01` ~ `--m06`（1-6月各一次），输出到 `h1_archive/`。使用客户→销售员映射（不逐条查角色，省时间）。

**合并输出**：`merge_workbench_data.py` 将 H1 存档(4063条) + H2 动态(187条) 合并为单一 `workbench_data.json`（4249条），H1 订单标记 `shipment.status = 'archived'`。

**前端单请求**：`loadData()` 只 fetch `workbench_data.json`（14→1 请求），不再逐文件拉取。

### 数据链路
```
pull_h1_archive.py --m01~m06 → h1_archive/h1_all.json ─┐
                                                         ├→ merge_workbench_data.py → workbench_data.json
pull_orders.py --days 90 → shipment_status.json ────────┘
```

### merge_workbench_data.py 核心逻辑
```python
# 读取 H1 存档 + shipment_status.json
h1_orders = json.load(open('h1_archive/h1_all.json')).get('orders', [])
h2_orders = result['shipments'].get('orders', [])

# 补默认 shipment（H1 无顺丰轨迹）
for o in h1_orders:
    if not o.get('shipment'): o['shipment'] = {'status': 'archived', 'statusLabel': 'H1 归档'}

# 合并去重（H2 同单号覆盖 H1）
seen = set(o.get('orderId','') for o in h1_orders)
merged = h1_orders + [o for o in h2_orders if o.get('orderId','') not in seen]
result['shipments']['orders'] = sorted(merged, key=lambda o: o.get('createDate',''), reverse=True)
```

### 月度校对（每月3号 9:00）
`reconcile_monthly.py` 对比 H1 存档 + H2 动态 vs KPI 目标，生成报告 `h1_archive/reconcile_YYYY-MM.md`。

`node --check file.html` 报 `ERR_UNKNOWN_FILE_EXTENSION`。改用 Python 验证括号平衡：

```bash
python3 -c "
import re
with open('销售工作台.html') as f:
    html = f.read()
scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL)
for i, s in enumerate(scripts):
    if not s.strip() or 'cloudbase' in s[:30]: continue  # 跳过外部 SDK
    o, c = s.count('{'), s.count('}')
    if o != c: print(f'⚠ Script block {i}: {o} {{ vs {c} }}')
"
# 无输出 = 通过
```

**完整验证清单**（建议每次改 HTML 后跑一遍）：
```bash
# 1. 括号平衡
python3 -c "..."  # 如上

# 2. 无重复 var 声明
for v in API_BASE_URLS ROLE_KEY TODO_PREFIX AUTO_REFRESH_MS refreshTimer; do
  c=$(grep -c "var $v\s*=" 销售工作台.html)
  [ $c -gt 1 ] && echo "⚠ $v: $c duplicates"
done

# 3. loadData 是 JSON 优先版本
grep -c "fallbackPath" 销售工作台.html  # ≥ 2

# 4. 函数无重复声明
grep -c "function loadData" 销售工作台.html  # = 1
```

- `references/email-login-migration.md` — 邮箱登录迁移全记录（CloudBase SDK 失效→后端 API 过程）
- `references/complaints-data-reference.md` — 客诉数据字段与 MQL 查询
- `references/feishu-customer-link.md` — 飞书客户链接路由（诊断原料 vs 生命科学）
- `references/mcp-field-mappings.md` — MCP 字段映射表
- references/pipeline-scripts.md — 客诉+客户数据管道脚本（pull_complaints.py / pull_customers.py）
### 商机沙盘隔离 + 混合类型筛选 + 可编辑字段（2026-07-27）

**隔离**：`renderOppSandbox` 对非 admin/manager 按 `o.sales === user.name` 过滤。赵云浩 339→29 条。

**混合类型筛选**：约 60% 商机的 `type` 字段是"增量/存量"，既不能归入纯存量也不能归入纯增量。筛选按钮从 `===` 改为 `indexOf` 匹配：

```javascript
// 聚合时：混合类型同时计入存量和增量
if ((o.type||'').indexOf('存量')>=0) s['存量'] += toWanSmart(o.target, o.sales);
if ((o.type||'').indexOf('增量')>=0) s['增量'] += toWanSmart(o.target, o.sales);

// 筛选时
var filteredOpps = s.opps.filter(function(o) {
  return oppSandboxFilter === 'all' || (o.type||'').indexOf(oppSandboxFilter) >= 0;
});
```

**可编辑字段**：type 标签改为 `contenteditable="true"`，blur 时 `updateOppField(el)` 存 localStorage 并重渲染。Q1-Q4 达成已有 number input。

`renderOppSandbox` 数据来自 `opportunity_sandbox.json`（`d.opportunities`），`o.sales` 字段即销售员名。非 admin/manager 按 `state.user.name` 精确过滤：

```javascript
// 数据隔离：非admin只看自己的商机
var user = state.user || {};
if (user.role !== 'admin' && user.role !== 'manager') {
  opps = opps.filter(function(o) { return o.sales === user.name; });
}
```

商户数据中 `dept` 字段可能为 NaN（数据源问题），不影响按 sales 名称的过滤准确度。每个商机展开后有 Q1-Q4 达成输入框，blur 自动存 localStorage。验证结果：赵云浩 339→29 条。



## 40. MCP `double_value` 0 值被 Python falsy 吞掉（2026-07-27 血案）

**症状**：`shipment_status.json` 中所有订单 `taxTotal`/`amount` 均为 None/0，但 MQL 原始数据中 `field_51d592` 有非零值（如 `double_value: 162000`）。拉取 1442 条订单，价税合计全 0。

**根因链**：
1. `pull_orders.py` 中 `parse_items` → `fields['field_51d592'] = v['double_value']` ✅ 正确存为数字 0
2. 输出映射：`'金额': o.get('field_51d592', '')` → 若值为 0，`o.get('field_51d592', '')` 返回 **`0`（整数）**
3. `shipment_pipeline.py` 中 `o.get('金额', '') or ''` → **Python 中 `0 or ''` = `''`**（0 是 falsy！），金额被吞掉

**同时还有另一个 bug**：MCP 有时把 `double_value` 包在数组里：`[{"double_value": 371396.45}]`。`parse_items` 只处理了 `v['double_value']`，未处理 `v` 是列表的情况。

**修复**（两处）：

```python
# 修复 1：parse_items 加数组解包（pull_orders.py）
if isinstance(v, list) and len(v) > 0: v = v[0]

# 修复 2：输出时用 != '' 替代 or ''（pull_orders.py）
# ❌ '价税合计': o.get('field_51d592', ''),     ← 0 被吞
# ✅ 使用 o['field_51d592'] if 'field_51d592' in o and o['field_51d592'] != '' else ''
'金额': o.get('field_51d592') if o.get('field_51d592') is not None else '',

# 修复 3：shipment_pipeline 中 0 值检查
# ❌ tax_total = o.get('金额', '') or ''
# ✅ tax_total = o.get('金额') if o.get('金额') is not None else ''
```

**铁律**：处理 `double_value` 时必须意识到 **`0` 是合法金额**（免费样品/0 元单），不能用 `or ''` 作默认值判断。同样适用于所有 numeric MCP 字段。



## 62. 存档区客户名模糊匹配——文件名简称 vs MCP 全称（2026-07-27）

**症状**：赵云浩的万孚拜访卡不显示，但清单中有。

**根因**：文件名客户名是简称 `广州万孚`，`getVisibleCustomers()` 返回全称 `广州万孚生物技术股份有限公司`。`indexOf` 严格匹配失败。

**修复**：双向包含 + 逐字重叠匹配：
```javascript
// 双向包含
if (fullName.indexOf(shortName) >= 0 || shortName.indexOf(fullName) >= 0) matched = true;

// 逐字重叠兜底（2字滑动窗口）
if (!matched && shortName.length >= 3) {
  for (var ci = 0; ci < shortName.length - 1; ci++) {
    if (fullName.indexOf(shortName.slice(ci, ci+2)) >= 0) overlap++;
  }
  if (overlap >= 2) matched = true;
}
```



## 43. `moql_field_list` 为 None 导致管道崩溃（2026-07-27）

**症状**：`pull_orders.py --days 365` 在 page 45（2250 条）崩溃：`TypeError: 'NoneType' object is not iterable`。全年数据拉取中断，只有前 2250 条入库。

**根因**：某些 MQL 返回的 items 中 `moql_field_list` 是 `null`。`item.get('moql_field_list', [])` 无法拦截 None（只有 key 不存在才走 default）。

**修复**：
```python
# ❌ 错误：None 不走 default
for f in item.get('moql_field_list', []):

# ✅ 正确：显式处理 None
for f in (item.get('moql_field_list') or []):
```

**铁律**：所有 MQL 解析循环都必须用 `or []` 而不是 `.get(key, [])` 防护 None。



## 35. 联系人数据通过联系人类型反向聚合（2026-07-27）

客户类型的 `field_8c870a`（联系人数量）在 MCP 中全部为空。需要通过联系人类型反向聚合：

1. 拉取全部联系人（`65b8a6cacde27fd415d769ca`，8804条）
2. 每个联系人有 `field_6b3789`（联系人关联客户）→ 取 label（客户名）
3. 按客户名聚合 `name`（联系人名称）→ 列表
4. 输出的 `customer_contacts.json` 被 `adaptMcpFields` 引用，计算 `contacts.length`

```python
# pull_contacts.py 核心: SELECT name, field_6b3789, work_item_id FROM 销售管理.联系人
by_customer[field_6b3789.label].append(name)
```



## 32. 销售订单 → 顺丰物流数据管道（2026-07-27）

订单管理 Tab 的数据源是 `shipment_status.json`，由 `shipment_pipeline.py` 从 `mcp_orders_input.json` 读取 → 调顺丰 API 查轨迹。但 `mcp_orders_input.json` 之前依赖手动 ERP 导出，已断流。

### 修复：MQL 直拉销售订单

`pull_orders.py`（在 `数据/` 目录）用 MQL 从飞书拉取销售订单，输出到 `mcp_orders_input.json`，再触发 `shipment_pipeline.py`。

**销售订单关键字段**（type_key=`662612aea6bb7089fea044ef`，48459 条）：

| MCP 字段 | 飞书显示名 | 取值方式 | 用途 |
|---------|----------|---------|------|
| `name` | 单据编号 | string_value | 订单号 |
| `start_time` | 创建时间 | string_value | 日期 |
| `field_96a245` | 物流单号 | string_value | 顺丰查询 key |
| `field_e1001d` | 销售订单关联客户 | key_label_value.label | 客户名 |
| `field_51d592` | 价税合计 | **double_value** | 金额 |
| `work_item_id` | 工作项ID | long_value | 飞书链接 |

**MQL 查询**：`SELECT name, start_time, field_96a245, field_e1001d, field_51d592, work_item_id FROM 销售管理.销售订单 WHERE start_time >= "YYYY-MM-DD"`

### 管道流程

```
pull_orders.py (--days 90)
  → mcp_orders_input.json
  → shipment_pipeline.py (顺丰 API 查轨迹)
  → shipment_status.json (→ 订单管理 Tab)
```

**已知现象**：
- 旧单号（几个月前）顺丰返回"暂无数据"（正常，顺丰不保留历史轨迹）
- 今天创建的订单物流单号为空（正常，未发货）
- 重复单号自动去重（同一订单多个工作项行）

### 集成到定时刷新

当前顺丰 cron（9:20/15:20）运行 `sf_delivery_watchdog.py`，但未前置拉取订单。长期应把 `pull_orders.py` 作为 `cb_sync.sh` 的前置步骤，或在 `sf_delivery_watchdog.py` 中集成。

Hermes cron 的 `script` 字段**必须是 `~/.hermes/scripts/` 下的相对文件名**，不接受绝对路径或家目录相对路径。

**错误配置**（导致 Silent failure，`Script not found`）：
```
script: /Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本/cb_sync.sh  ❌
script: 脚本/cb_sync.sh                                                 ❌
```

**修复**：在 `~/.hermes/scripts/` 创建**包装脚本**用 `exec` 转发到真实位置：
```bash
#!/bin/bash
# ~/.hermes/scripts/cb_sync.sh
exec bash "/Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本/cb_sync.sh"
```
然后 cron 配置 `script: cb_sync.sh`。

**检测**：`hermes cron list` → 查看上次执行状态是否为 `Script not found`。即使脚本在别处存在且可执行，cron 也只会搜 `~/.hermes/scripts/`。



## 85. KPI 税合计数据源全局修复（2026-08-01）

**核心原则**：shipment_status.json 中 75% 订单 taxTotal=0（MCP pipeline pitfall #40），**所有税合计计算必须改用 KPI 数据源**。

### 三个修复点

| 函数 | 之前 | 之后 |
|------|------|------|
| `renderDashboard` fullYearTax | `orders.reduce()` | `dash.total_h1 + prog.total` |
| `getShipmentOrders` fullYearTax | `shipments.full_year_tax \|\| 0` (始终0) | `dash.total_h1 + prog.total` |
| `renderOrders` 本月/本季/本年 | `ps.taxTotal` (orders汇总) | KPI覆盖（本月=prog.total, 本年=fullTax, 本季=prog.total） |

### 验证命令

```bash
curl -s "<url>/数据/kpi_dashboard.json" | python3 -c "import json,sys;d=json.load(sys.stdin);print(f'H1: {d[\"total_h1\"]/1e4:.1f}万')"
curl -s "<url>/数据/kpi_progress.json" | python3 -c "import json,sys;d=json.load(sys.stdin);print(f'7月: {d[\"total\"]/1e4:.1f}万')"
```

详见 `references/kpi-tax-fix-all-modules.md`。



## 91. app.html 缺失 `var TODO_PREFIX` → 待办 Tab 数据不更新（2026-08-23）

**症状**：用户报"工作台待办 Tab 还没更新"（数据已写入但前端看不到）。切待办 Tab 控制台报 `ReferenceError: TODO_PREFIX is not defined`（getTodoKey 内）。

**根因**：app.html 是双线分拆重构版，从 index.html 拆分时**遗漏了顶层变量 `var TODO_PREFIX = 'bior_sw_todos_v2_'`**（index.html L542 有，app.html 无），但 `getTodoKey()`、`renderClosedFeed()` 等 3 处都在用它。这是 #89（debounce 缺失）同类的「分拆遗漏顶层声明」bug，只是这次是**变量**不是函数。

**主动排查方法（通用，比逐个猜高效）**：对比两个文件的顶层 `var X =` / `function X()` 定义差集，再对每个「app 缺失」的 X 检查是否被使用：

```python
def extract_defs(path):
    js = '\n'.join(re.findall(r'<script[^>]*>(.*?)</script>', open(path, encoding='utf-8').read(), re.DOTALL))
    return (set(re.findall(r'\bvar\s+(\w+)\s*=', js)),
            set(re.findall(r'function\s+(\w+)\s*\(', js)))
app_v, app_f = extract_defs('工具/app.html')
idx_v, idx_f = extract_defs('工具/index.html')
missing = (idx_v - app_v) | (idx_f - app_f)
# 对每个 missing 的 X，re.search(r'\bX\b', app_js) 判断是否被使用；被使用才可能是真 bug
```

**⚠️ 误报过滤（关键）**：差集会混入大量误报，必须逐个看「首处上下文」`js[max(0,u-40):u+40]` 才能区分：
- CSS 属性：`font-size` 里的 `size`、`node-time` 里的 `time`
- HTML 标签：`<table>`、`<svg>`
- 对象方法/属性：`cbApp.auth()` 里的 `auth`、`serverStore.users` 里的 `users`
- 声明方式差异：index 用 `var today = function(){}`，app 用 `function today(){}`（`today` 不是缺失）

判断真缺失的铁律：**既无 `var X =` 定义、又无 `function X(` 定义、且作为独立标识符使用**——本例经此过滤后唯一真缺失就是 `TODO_PREFIX`。

**修复**：app.html 的 `var ROLE_KEY = 'bior_test_user_v1';` 后补 `var TODO_PREFIX = 'bior_sw_todos_v2_';`（值必须与 index.html 一致，否则 localStorage 待办 key 跨文件不共享）。

**附：待办数据双写机制（排查「写了数据前端看不到」的必读）**：
- 后端 `数据/todos.json`（`POST /api/todos`）+ CloudBase `todos` 集合（前端 `cbDb.collection('todos')`）是**两套独立数据**，`cb_sync.sh` 只同步静态 JSON 文件、不碰 CloudBase 集合。
- 前端 `pullTodos()` 里 `if (cbDb) return cbDb.collection('todos')...` —— **CloudBase 可用时直接 return，根本不读后端 todos.json**（`tryPull` 兜底不执行）。
- 所以「只写后端 todos.json」在 CloudBase 可用时前端看不到；必须同时写 CloudBase `todos` 集合：`{user: 用户名, todos: [...], updated: ISO}`（通过浏览器 `cbDb.collection('todos').where({user}).get()` 判存在后 `add`/`doc(id).update`）。



## 97. 飞书 MCP 写操作被禁用（MCPPersonalPreferenceDisabled）（2026-08-30）

**症状**：用 MCP `update_field`（改客户负责人等写操作）返回 `MCPPersonalPreferenceDisabled, message=user has not enabled this MCP feature. Please configure it in your personal preferences`。curl 直连 tools/call 也返回非 JSON（JSON 解析失败）。

**根因**：飞书项目 MCP token（`m-abfb29e8-...`）**只有查询权限**（search_by_mql/get_workitem_brief 等），写操作（update_field/create_workitem/transition_state）被禁用，需用户在飞书项目后台「个人偏好/MCP 设置」启用。

**判别**：查询类 MCP 调用成功、写类全失败 → 就是权限未启用，不是代码/参数问题。

**处理**：告知用户在飞书项目后台启用 MCP 写权限后再批量执行；或用户手动在飞书界面改；或先做「数据层修正 + 过渡映射」等权限开了再改源头。不要反复重试写调用（同一 token 永远被拒）。



## 99. Q3 当季进展卡片三处计算 bug + 目标数据污染检测（2026-08-31）

用户报「刘子研客户的 Q3 当季进展(7-9月)数据不对」。根因不在数据源（`kpi_dashboard.json` 的 `h2_actual_months` 是对的），在 `getKPI()` 非 admin 分支的 Q3 计算有三处 bug，`app.html` 与 `index.html` **双文件同步修**：

**bug1 个人 Q3 实际漏月（核心）**：`h2Actuals2 = [0,0,0,0,0,0]` 只填当月 `h2Actuals2[nowMonth-7]=current`，没从 `dashPerson.h2_actual_months` 取历史月。8 月底看 Q3，7 月实际整月被漏（赵云浩 Q3 实际只显示 8 月 55.8万，漏 7 月 46.6万）。修复：
```javascript
var h2Actuals2 = (dashPerson && dashPerson.h2_actual_months) ? dashPerson.h2_actual_months.slice() : [0,0,0,0,0,0];
if (nowMonth >= 7 && current) h2Actuals2[nowMonth - 7] = current;
```
> 注意 #90 修的是 admin 分支缺 `current` 注入，本条是非 admin 分支根本没取历史月——两者是不同 bug。

**bug2 入职拆分没应用到 Q3 目标**：split 员工（#95 JOIN_SPLIT）的 Q3 目标仍算 7+8+9 月（把入职前的 7 月也算进去），与年度卡片口径不一致。修复：Q3 从入职月算起，`split` 声明须提前到 `q3StartIdx` 之前：
```javascript
var split = JOIN_SPLIT[name] || null;
var q3StartIdx = split ? Math.max(0, (split.join_month || 8) - 7) : 0;
var q3Target2 = (h2Targets || []).slice(q3StartIdx, 3).reduce(function(s,v){return s+(v||0);},0);
var q3Actual2 = h2Actuals2.slice(q3StartIdx, 3).reduce(function(s,v){return s+(v||0);},0);
```
卡片标题同步动态化：`'📊 Q3 当季进展' + (kpi.split ? ' (入职后)' : ' (7-9月)')`。

**bug3 部门拆分条 Q3 实际重复计算**：三部门 `q3A = dp.h2_actual（累计）+ 各人 h2_actual_months[1]（8月）`，把 8 月重复加一次（大客户部 159.8万 被虚增到 230.8万）。修复：`var q3A = ((dp.h2_actual_months||[])[0]||0) + ((dp.h2_actual_months||[])[1]||0) + ((dp.h2_actual_months||[])[2]||0);`

### 目标数据污染检测（h2_weights 权重规律）

`h2_months`（H2 各月目标）按固定权重分配：`基数 × [0.12, 0.15, 0.19, 0.2, 0.19, 0.15]`（`h2_weights`，kpi_dashboard.json 顶层）。**用 `h2_months[1]/0.15` 反推基数，再 `×0.12` 得期望 7 月目标，与实际 `h2_months[0]` 对比**：偏差≈0 = 正常，偏差巨大 = 目标字段被污染。

本次发现：赵云浩/刘子研的 `h2_months[0]` 恰好等于 `my_kpis.json` 的 7 月实际（266787.4 / 222749.8，**精确到分**）——这是「目标字段被实际值污染」的铁证（正常员工如路要峰/刘虹晶/林潇偏差都是 0）。污染导致赵云浩 Q3 目标被低估约 101.6万、大客户部 Q3 目标低估约 174.6万（达成率虚高）。

验证脚本（改完必跑，Python 模拟 JS 计算逻辑）：
```python
import json
kd = json.load(open('数据/kpi_dashboard.json', encoding='utf-8'))
for p in kd['people']:
    hm = p.get('h2_months', [0]*6)
    if hm[1]:
        base = hm[1]/0.15
        print(p['name'], '7月目标', hm[0], '期望', round(base*0.12,1), '偏差', round(hm[0]-base*0.12,1))
```

### 修复值精度：反推基数 T 有 ±1 歧义（必须精确搜索 T，不能只用 hm[1]/0.15）

`base = hm[1]/0.15` 只是**估计**——`hm[1]` 本身是 `round(T×0.15)` 的结果，反推不精确。用 `round(base×0.12)` 算修复值会偏差 ±1（本次实测：刘子研 naive 算得 952082，精确搜索得 **952081**，T=7934010）。检测污染时偏差都是几十万，±1 无关紧要；但**修复（写回正确目标值）时必须搜索整数 T 使全 6 月都满足 `round(T×weight[i])==int(hm[i])`**，再取 `round(T×0.12)`：

```python
weights = [0.12, 0.15, 0.19, 0.20, 0.19, 0.15]
def exact_T(hm):
    approx = int(hm[1] / 0.15)
    for T in range(approx - 100, approx + 100):
        if all(round(T * weights[i]) == int(hm[i]) for i in range(6)):
            return T
    return None
T = exact_T(hm)          # 本次：赵云浩 10686193 / 刘子研 7934010 / 大客户部 18634153
correct_july = round(T * 0.12)
```

修复后写回 `kpi_dashboard.json` 前先备份（`数据/_backup/kpi_dashboard_before_*.json`），并 `tcb hosting deploy 数据/kpi_dashboard.json` 后 `curl -H "Cache-Control: no-cache"` 读回线上确认。h2_months 只影响目标，`h2_actual`/`h2_remain` 不依赖它、无需同步改。

### node --check 验证内联 JS（修正 #25）

#25 说 `node --check file.html` 报 `ERR_UNKNOWN_FILE_EXTENSION` 改用括号平衡。更准做法是**提取 `<script>` 内联块到临时 .js 再 node --check**（抓真语法错误，不止括号）：
```python
import re, subprocess, tempfile, os
html = open('工具/app.html', encoding='utf-8').read()
for i, s in enumerate(re.findall(r'<script(?![^>]*src)[^>]*>(.*?)</script>', html, re.DOTALL)):
    if not s.strip(): continue
    tmp = tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8')
    tmp.write(s); tmp.close()
    r = subprocess.run(['node', '--check', tmp.name], capture_output=True, text=True)
    print(i, 'OK' if r.returncode==0 else r.stderr[:200])
    os.unlink(tmp.name)
```



## 100. 月初当月进展误显上月数据——prog.month 必须校验当前月（2026-09-01）

**症状**：9月1日看板「📅 9月 当月进展」显示 8 月的 292.5万、达成率 32.2%（292.5/909.3），用户报「这个数字有问题」。

**根因**：`getKPI()` 里 `var current = prog.total`（`kpi_progress.json` 的 total，即当月 MCP 实时累计）**没校验 `prog.month` 是否等于当前月**。月初时 `kpi_progress.json` 还停留在上月（`month:"2026-08"`、`total:2925169.28`），被直接当成 9 月进展。9月目标 909.3万（`h2_monthly_targets.json`）是对的，错的是分子。

**修复**（app.html 与 index.html 双文件，共 4 处，全部加「prog.month 匹配当前月才采用」校验）：
1. admin 分支 `current`
2. 非 admin 分支 `current`（个人当月进展）
3. `filteredCurrent`（筛选视图当月进展）
4. `getShipmentOrders` 的 `fullYearTax`（物流「全年税合计」）

统一模式（把 `prog.month` 解析成月份数字与当前月比对）：
```javascript
var _nowM = new Date().getMonth() + 1;
var _progM = 0;
if (typeof prog.month === 'string') { var _pm = prog.month.match(/-(\d{2})$/); if (_pm) _progM = parseInt(_pm[1], 10); }
var current = (_progM === _nowM) ? (prog.total || 0) : 0;
```

**铁律**：任何取 `prog.total`/`prog.by_salesperson` 当「当月进展」的地方，必须校验 `prog.month == 当前月`。否则月初（数据还没刷新到新月）必然误显上月数据。`prog.month` 格式 `"2026-08"`，用正则 `/-(\d{2})$/` 提取月份。

**数据层真相**：`kpi_progress.json` 停在旧月是「正常」的——refresh 脚本的「当月」取 ERP 数据里最大的月份，新月没订单就还停在上月。所以**前端必须靠月份校验兜底**，不能指望数据源自动归零。
