# baoyu-workbench-data-pipeline — data pipeline

### mcp_kpi_pipeline 的"单月"bug

v4 只取第一个有效行的月份（`if date_val and not month`），全年文件倒序排列时第一个月=8月，会把全年金额全写进 h2_actual_months[1]（8月），严重虚增。**必须按月路由**：7月→[0]，8月→[1]。


### 人员映射（ERP销售员 → KPI people）

```python
黄明月 → 刘子研（大客户部，离职划转）
张立娅 → 刘子研（大客户部，离职划转）
刘新元 → 刘子研（若客户在刘子研名下23家；否则归部门不计个人）
其他在职 → 直接匹配；离职 → 归部门不计个人
⚠️ **生命科学部在职区域经理有个人 KPI，必须计个人**（2026-08-24 修正）：吴云（浙江科研经理，my_kpis 目标80万）、韩远怀（广州科研经理，目标100万）此前被误归部门。`KPI_PEOPLE` 已加入二人，`kpi_dashboard.people` 也补了条目。经理（龙友波/刘欣等已离职）才归部门。
```
23家客户名单从 `pricing/_key_customers.json` 取 `salesperson=='刘子研'`。


### cron 覆盖风险（务必同步修复）

`cb_sync.sh` 每2h 会跑 pipeline 覆盖手改数据，必须先修源头：
1. `update_kpi_progress.py`（MCP拉金额，违反ERP铁律）→ 已从 cb_sync.sh 移除
2. `b5c6c96b1eee`（KPI-MCP每日，MCP拉金额）→ 已暂停
3. `refresh_erp.py` 硬编码 month='2026-07' + 全行口径 → 已重写
4. cb_sync.sh FILES 缺 three_year_monthly.json → 已补


### 订单管理 Tab 的「完整明细」（2026-09-23 新增）

`工具/index.html` 订单管理 Tab 的数据源是 `shipment_status.json`（MCP/物流视角），**该文件没有货号/品名/数量/单价字段**（sku/product/spec/quantity/unitPrice 全为 0/缺失），金额也只有 368/1382 有值 —— 所以订单卡片上的货号/品名/数量永远是「—」。

修复链路：`脚本/gen_orders_detail.py` 从 ERP 生成逐单明细（key=单据编号，含 lines[]：货号/品名/规格/单位/数量/含税单价/税率/价税合计/赠品/批号），前端展开订单行时**懒加载** `../数据/orders_detail_2026.json` 并渲染表格。

- 对接键：`shipment.orderId` ∩ ERP `单据编号` = 1380/1382（其余为跨年或仅飞书侧单据，前端会显示「未找到」）
- 规模：6171 单 / 24557 明细行 / 8.28MB（单文件，展开时才 fetch，内存缓存）
- **一单多货号占 72%**（最多一单 29 个货号）—— 用卡片视图看这些单只能看到 1 个货号，必须走这个明细层
- ⚠️ 展开行的 DOM id 已从 `trackingNo` 改为 `trackingNo || orderId`：大量 MCP 订单无物流单号，用 trackingNo 当 id 会全部撞成 `ship-detail-`，**点击第二行会展开第一行**

两者都要「重跑 + 重传（仅 price_index 上 CDN）」。验证口径：`price_index.meta.sources[0].file` 应等于 `find_latest_erp()` 选中的合并件、`raw_rows` 等于合并件行数；`build_order_data` 产物的历史成交价最新月份应含当月。

**硬编码检查要连注释/docstring 一起看**：`build_price_index.py` 的 docstring 曾写死 `销售订单_20260101-20260918.xlsx`（代码本身是动态 glob 取最新，仅注释误导后来人）。改为 `销售订单_20260101-YYYYMMDD.xlsx（取 mtime 最新）` 并注明「禁止写死具体日期」。

## ⚠️ 订单管理三栏面板的取数口径（2026-09-23 修）

用户报「客户 Top5 这个板块没做到正确的筛选数据和区分」。三个独立缺陷，都属「面板口径 ≠ 页面口径」：


### 订单管理 renderOrders 金额统计彻底改用 ERP（2026-09-07）

**实测 MCP 订单金额缺失程度**（mcp_orders_input.json 2104 条）：金额字段 74% 为 0，非"恒 0"。按部门：生命科学部（浙江 90%、广东 90% 几乎全 0）> 诊断原料部（约 70% 为 0）。所以订单管理页 `ps.taxTotal`/客户Top5 从 shipment 订单 `taxTotal`/`amount` 求和必然严重偏低，且之前 kpiProgress 兑底只覆盖「本月」、算不出「本周/本季」。

**修复**：新增 `erp_amount.json`（`脚本/refresh_erp_amount.py` → `erp_sales_core.aggregate_daily()`），按天 + 按客户按月聚合 ERP 金额（口径=标准订单+已审核+非赠品+剔宝锐系）。前端 `renderOrders()` 金额统计改：
```javascript
var _erpDaily = (state.data.erpAmount || {}).daily || {};
var _periodTax = 0;
for (var _d in _erpDaily) {
  if (_d >= periodStart && _d <= periodEnd) _periodTax += (parseFloat(_erpDaily[_d]) || 0);
}
ps.taxTotal = _periodTax;  // YYYY-MM-DD 字符串字典序比较，安全无时区问题
```
客户Top5 用 `customer_monthly` 按月聚合。`refresh_erp_amount.py` 已纳入 cb_sync.sh（FILES 加 erp_amount.json）。

**数据时效提醒**：ERP 文件只覆盖到文件名截止日（如 `_20260901` 只到 9/1），最新几天订单 ERP 金额缺失 → 本周/本月金额偏低。需用户上传最新 ERP 导出补全，是数据时效问题非代码 bug。

**⚠️ 铁律：ERP 文件必须是「全年到最新日期」导出，禁止增量文件**。`find_latest_erp()` 按 mtime 扫 `销售订单*.xlsx` 选最新，若用户上传增量文件（如 `销售订单_20260901-07.xlsx` 只含 9/1-9/7，行数几百），会被误选为最新 → 聚合结果只剩增量那几天，1-8 月历史金额全部丢失。收到用户上传 ERP 文件先读 `df['合同日期'].min()/max()` 判断是否覆盖全年。若只含几天的**增量文件，直接与最新全年文件合并，不要退回要求重传**（用户会反复传增量文件，合并比退回更快且实测可靠）：

```python
# 增量文件合并：旧全年文件(1-8月) + 新增量文件(9月) → 全年命名新文件
old = pd.read_excel(old_fp, dtype=str)   # 旧全年文件（如 销售订单_20260101-20260907.xlsx）
new = pd.read_excel(new_fp, dtype=str)   # 新增量文件（如 销售订单_20260901-0909.xlsx）
assert list(old.columns) == list(new.columns), '新旧列结构不一致，先对齐'
old['_d'] = old['合同日期'].apply(ser2date)
new['_d'] = new['合同日期'].apply(ser2date)
old_keep = old[old['_d'] < '2026-09-01'].drop(columns=['_d'])  # 旧文件保留增量起始日之前的行
new_keep = new[(new['_d'] >= '2026-09-01') & (new['_d'] <= '2026-09-30')].drop(columns=['_d'])  # 新文件保留增量区间
merged = pd.concat([old_keep, new_keep], ignore_index=True)
merged.to_excel('月度销售额数据/销售订单_20260101-20260909.xlsx', index=False)
```

关键点：合并文件必须存到 `月度销售额数据/` 目录、文件名用全年命名（`销售订单_20260101-YYYYMMDD.xlsx`），find_latest_erp 按 mtime 会选中它；ERP 导出含「合计」汇总行（合同日期='合计'），用日期区间过滤自然剔除，无需单独处理。


### mcp_kpi_pipeline.py 的 DEFAULT_ERP 陷阱

无参数运行时读硬编码 `数据/每日上传/销售订单导出-202607.xlsx`（**旧文件，8/3 的导出**），且 `source` 字段 stamp 的是 `datetime.now()`（当天日期）。结果 kpi_dashboard 的 source 显示"ERP销售订单导出(20260814)"但实际读的是 8/3 旧数据，极具误导性。cb_sync.sh 每2h 重跑，等于反复用旧文件覆盖新数据。


### KPI cron `b5c6c96b1eee` 双重问题

1. **超时**：每个订单单独调 `get_workitem_brief` 拿销售员（无缓存），订单一多就爆 600s 上限（实测 925s）。应复用 `order_role_cache.json`（见"下单人字段取数"）。
2. **违反 ERP 铁律**：从 MCP MQL 拉金额（`field_51d592`）写 kpi_progress，而 KPI 铁律是"金额唯一可靠源是 ERP，严禁从 MCP 拉金额"。当月金额应走 ERP 上传 / refresh_erp.py，而不是这个 cron。


### kpi_progress.json 多生产者互相覆盖

`kpi_progress.json` 有 4 个生产者，输出 shape 各不相同，谁最后跑谁覆盖谁：
- KPI cron `b5c6c96b1eee`（MCP MQL）→ `total` + `by_salesperson`（前端 admin 读 total，non-admin 读 by_salesperson）
- `update_kpi_progress.py`（MCP）→ `month_total` + `by_creator`（**无 total/by_salesperson，前端会显示 0**）
- `refresh_erp.py`（ERP）→ `total` + `by_salesperson`，但 `month` 硬编码 `'2026-07'`
- `销售工作台服务.py` 上传 handler（ERP，当前月）→ `total` + `people`（**无 by_salesperson**）

> 附带：本地 8765 服务（销售工作台服务.py）挂掉时，`tmux new-session -d -s workbench '~/.hermes/hermes-agent/venv/bin/python3 脚本/销售工作台服务.py 8765'` 重启，验证 `curl -o /dev/null -w "%{http_code}" http://127.0.0.1:8765/` 返回 200。

## ⚠️ mcp_kpi_pipeline.py 比例分摊陷阱（2026-08-03 根因修复）

**这是整个KPI数据链最致命的bug。** `mcp_kpi_pipeline.py` v3 的两个断点叠加导致kpi_dashboard.json全线虚假：


### 修复：v4 直接用ERP

`mcp_kpi_pipeline.py` v4 改为直接从ERP导出Excel读取真实销售额：

```python
# v4 正确做法：读ERP Excel → 按销售员汇总
by_sp = defaultdict(float)
for row in ws.iter_rows(min_row=2):
    if doc_type == '标准销售订单' and status == '已审核':
        by_sp[sp] += amt  # 真实金额，不做任何分摊

# 直接写入 kpi_dashboard.json
person['h2_actual_months'][MONTH_IDX] = round(by_sp.get(pname, 0), 2)
```

**铁律升级**：不仅禁止估算，而且禁止任何"比例分摊"——即使总额来自MCP也不行。ERP导出是金额数据的唯一可靠来源。


### 数据链路（四段，缺一段页面就没数）

```
ERP 销售订单*.xlsx 第20列「发货日期」
  ↓ 脚本/build_erp_ship_dates.py        # 订单级索引，6171 单 / 471KB
数据/erp_ship_dates.json                 # {单据编号: {d:日期, out:累计出库, notify, st}}
  ↓ shipment_pipeline.py attach_erp_ship_dates()
数据/shipment_status.json  orders[].planShipDate / shipped / shippedQty   # 命中 1380/1381
  ↓ 工具/index.html  订单管理 Tab
planShipCell(o) → 「预计发货」列
```

- **回填不用重查顺丰**：`python3 脚本/shipment_pipeline.py --enrich-only`（秒级，只补日期）
- **已挂钩自动链路**：`refresh_erp.py` 末尾会依次跑 `build_erp_ship_dates.py` + `shipment_pipeline.py --enrich-only`（ERP 入库即刷新，不用等顺丰那 4 次）；顺丰 4 次/天刷新走正常 `shipment_pipeline.py`，已内置同一 attach
- **增量刷新不会丢字段**：`shipment_api.py` / `sf_bulk_refresh.py` 都只改 `o['shipment']` 子对象后写回整个对象（不重建 orders）→ `planShipDate` 一直保留
- **关联键**：`shipment.orderId` ∩ ERP `单据编号`（MCP 1399 单里 1385 单能对上，99.0%）


### 双数据源：ERP `发货日期` + 飞书 `field_98f9e8`（2026-09-23 补）

ERP 导出有截止日（如 `_20260922`），**当天新建的单不在里面** → 这些单显示「待同步」。而**飞书销售订单工作项自带 `发货日期` 字段（MQL `field_98f9e8`，date 类型，实测近 50 单 100% 有值）**，可实时补位。

- 字段位置：空间 **`销售管理`(simpleName `xsguanli`)**，工作项 `销售订单`。查法：`list_workitem_field_config(project_key='xsguanli', work_item_type='销售订单', field_query='发货')`。⚠️ `br-shgl` 空间**没有**销售订单类型（那里只有客诉/反馈/售后下单-国际专用），别在错空间找。
- 取数：`数据/pull_orders.py` 的 `COLUMNS` 加 `field_98f9e8`，输出 `'发货日期'`；`shipment_pipeline.process_orders` 带出 `mcpShipDate`。
- 合并优先级：**ERP 命中用 ERP**（有出库量，能判「未发货/超期」）→ ERP 未命中且飞书有值 → 用飞书值并标 `planShipFrom='mcp'`、`shipped=None`（**未知不参与「未发货/超期」判定**，否则会把已发货的单误报成超期）→ 两者都无 → `待同步`。
- 页面文案：`待同步`（浅灰）而不是 `—`；title 里带来源（`（来源：飞书销售订单，ERP 导出未含此单）`），避免用户以为没数据。


### 🔴 事故教训：改完数据管道必须重推一次，否则会被定时任务用旧代码写回（2026-09-23）

症状：页面「预计发货」列**全空**，但本地 JSON 明明有 `planShipDate`。

根因链（两段）：
1. 顺丰 12 点 cron 的管道**子进程在 12:02 启动**，而我 12:04:16 才把 `attach_erp_ship_dates` 补进 `main()` → 子进程跑的是旧代码，写出 1399 条**无字段**的文件；
2. `sf_delivery_watchdog.py` 随后把这份加载后**原样写回**（它是最终写入方）→ 字段全丢，且页面用的是 **`数据/shipment_status.json`**（不是 `workbench_data.json` 合并包——后者虽然也内嵌 shipments，但 `工具/index.html` 的 `loadData()` 逐个读 15 个 ENDPOINT）。

三条铁律：
- **改完管道立即手动重跑一次**（`--enrich-only` 或完整跑）并核对字段，不能改完就走——下一次 cron 覆盖需要几个小时，期间页面是错的。
- **字段要挂在 `process_orders()` 出口，不要只挂在 `main()`**：cron/watchdog/其他脚本都是从 `process_orders` 或子进程进入的，挂 `main()` 迟早漏。
- **所有写回 `shipment_status.json` 的路径都要兜一层**：`shipment_pipeline.process_orders` 出口、`shipment_api._refresh_locked`（写回前 `_ensure_plan_dates`）、`sf_delivery_watchdog.py` 的 dump 后补跑 `--enrich-only`。
- **兜底源必须由兜底函数自己读文件，不能依赖上游把字段带进 JSON**：`attach_erp_ship_dates` 一开始只认订单对象上的 `mcpShipDate`，而 `--enrich-only` 与 watchdog 走的是**文件路径**（订单对象从 JSON 反序列化，根本没有这个键）→ 兜底静默失效、命中 0 单且不报错。改成兜底函数内部**懒加载一次源文件建「单号→值」表**（`数据/mcp_orders_input.json`），两条调用路径才能都吃到。判据：兜底命中的单数如果恒为 0 而源文件里明明有值，就是这条。

排查手法（下次直接用）：① `stat -f "%Sm %N" -t "%H:%M:%S"` 把「脚本 mtime」与「数据文件 mtime + cron last_run_at」摆在同一时间轴上，一眼看出是不是竞态；② 浏览器里读 `state.data.shipments.orders[0]` 的 **key 列表**——若新字段的 key 根本不存在（而不是值为空），说明写入方压根没跑到那段代码，别去查数据源。

## 格式版本检测


### ERP销售订单

两种列结构（见 `references/erp-format-2026.md`）。

**⚠️ 不同时期ERP导出列布局可能不同**（如H1文件多了"区域"列导致销售员从Col5变为Col6），详查 `references/erp-column-layout-variants.md`。每次处理前必须打印列头。
- **旧格式**：A=客户, B=日期(序列号), C=销售员, D=金额, F=单据类型, N=是否赠品
- **新格式**：A=单据编号, B=销售员, C=金额, D=客户, G=日期, I=单据类型, AD=是否赠品

检测：检查**首个数据行**（跳过表头和汇总行）的G列是否含`-`。
⚠️  不能用表头行检测（G=合同日期不含`-`），否则新格式会被误判为旧格式。
必须跳过汇总行（如"「杨天雄」共19个"）再检测。

**订单「备注」字段（客户特殊要求库的权威源）**：只有「销售管理视图」导出（`销售订单导出-销售管理*.xlsx`，含 `备注(内部)#` / `备注(客户)#`）有备注列，主表 `销售订单_YYYYMMDD-*.xlsx` 没有；该导出还有三个坑——分组表头 `「X」共N个` 里既有部门也有**客户名**（照它推归属人会把客户名写成销售员）、无日期列（日期藏在单据编号 `ZHBRYYMMDD…`）、无销售员列（归属只能从主表「客户+销售员」补）。
取数口径、预筛、提示词与上线前的判权覆盖体检见 `llm-batch-fact-extraction` 技能的 `references/erp-order-remark-field.md`。


### ⚠️ H2 目标被「实际值」污染：检测 + 反推恢复（2026-08-31）

**症状**：某部门/某人的 `h2_months[0]`（7月目标）恰好等于 `my_kpis.json` 的 7月实际（精确到分），且不符合「T×权重」规律。

**检测法：只认「7月目标 ≈ 该部门/该人的 7月 ERP 实际」这一个信号，不要用权重比例判。** `h2_months` 是逐月累进舍入算出来的，`round(T×权重)`（T 由 `h2_months[1]/0.15` 反推）对三个部门**全部不成立**（实测一次跑出 3 个假阳性，差额几十到几百万不等）—— 其中大客户部的大缺口源自「岗位离职后无人承接」的目标缺口，是结构性的，**不是**污染。正确做法：把 `h2_months[0]` 与该部门/该人 7 月的 ERP 实际（基线快照 / `my_kpis.json`）比，**精确吻合**才判定被实际值污染。

**反推恢复法**（在 `hm[1]/0.15` 附近 ±200 搜整数 T，使 `round(T×权重[i]) == h2_months[i]` 对 8-12月全成立，再算 `7月 = round(T×0.12)`）：

```python
weights = [0.12, 0.15, 0.19, 0.20, 0.19, 0.15]
def recover_july(hm):
    approx = int(hm[1] / 0.15)
    for T in range(approx-200, approx+200):
        if all(round(T*weights[i]) == int(hm[i]) for i in range(1, 6)):
            return round(T*weights[0])  # 正确的7月目标
    return None
```

⚠️ `h2_months` 的 T 可能 ≠ `target`（刘子研 target=入职后8-12月目标751万，但 T=793.4万是7-12月目标），不能直接用 target 反推，必须用 `h2_months` 自身反推。修改 `h2_months[0]` 后 `h2_actual`/`h2_remain` 不受影响（它们只依赖 target/h1_total/h2_actual）。

## 销售单/测试单字段

新格式ERP导出包含扩展字段，处理时需提取：

| 字段 | 新格式列 | 旧格式列 | 前端显示 |
|------|---------|---------|----------|
| 规格型号 | W | U | 规格列 |
| 销售单位 | X | J | 规格列（拼接） |
| 数量 | Z或Y | K | 数量列 |
| 含税单价 | AE或AF | E | 单价列 |

JSON detail记录结构：`{client, product, amount, sales, spec, unit, qty, price}`

## 工作台设计原则

- 零依赖：纯HTML+CSS+JS，不依赖Chart.js/HermesTable
- **数据来源标签**：每个Tab顶部info-bar显示源徽章 🟢实时(飞书API) / 🟡批量(飞书cron) / 🔴ERP(批量导出)。顶栏右侧动态切换徽章
- **进度条**：每次fetch自动显示蓝色脉冲进度条，数据加载完自动消失
- **筛选栏**：每个Tab含文本搜索+下拉筛选+计数显示

## 输出JSON


### 数据流

```
飞书项目 MCP（实时查询）
    ↓  Python脚本（分页拉取 + 字段解析）
数据/mcp_activities.json    ← 活动数据（含部门名+创建者名）
数据/mcp_complaints.json    ← 客诉数据（含阻塞节点+负责人）
数据/mcp_blockers.json      ← 审批阻塞数据
    ↓  fetch()
工具/飞书实时数据面板.html   ← 自包含三Tab面板
```


### 数据刷新

MCP 数据通过 cron 定时刷新（如每 8 小时拉取一次飞书活动+客诉）。管道脚本直接调 MCP curl（Token 在 `~/.hermes/config.yaml`），写入 JSON 文件。前端无需 websocket，刷新页面即获取最新数据。


### 同步脚本

`脚本/cb_sync.sh` — 依次运行 MCP 刷新脚本，然后 `tcb hosting deploy` 上传所有 JSON。

```bash
#!/bin/bash
WORKDIR="$HOME/Desktop/Hermes输出-工作类"
CB_ENV="bier-sales-d0gatbvlx288724e9"
cd "$WORKDIR/数据"
python3 mcp_refresh.py       # 客诉+活动
python3 mcp_kpi_pipeline.py  # KPI v4：从ERP Excel直读真实销售额，严禁比例分摊。详见 references/kpi-proportional-allocation-pitfall.md
cd "$WORKDIR/脚本"
python3 refresh_erp.py       # ERP
python3 update_kpi_progress.py # KPI进度
# 上传
for f in 数据/mcp_customers.json 数据/mcp_complaints.json ...; do
  tcb hosting deploy "$f" "$f" -e "$CB_ENV"
done
```


### 关键文件列表（需同步到 CloudBase）

| 本地路径 | 类型 | 更新频率 |
|---------|------|---------|
| `数据/mcp_customers.json` | MCP | 随每2h cron（含 `adaptMcpFields` 使用的所有字段） |
| `数据/mcp_complaints.json` | MCP | 随每2h cron（`pull_complaints.py` MQL全量拉取，替代旧 list_todo） |
| `数据/customer_contacts.json` | MCP | 随每2h cron（`pull_contacts.py` 联系人反向聚合） |
| `数据/opportunity_sandbox.json` | MCP | 随每2h cron |
| `数据/shipment_status.json` | ERP+MCP | 随每2h cron（由顺丰监控 cron 生成，cb_sync 上传） |
| `数据/kpi_dashboard.json` | ERP | 随每2h cron（`mcp_kpi_pipeline.py` v4：直读ERP Excel，严禁比例分摊） |
| `数据/kpi_progress.json` | MCP+ERP | 多生产者互相覆盖（见「KPI三文件数据源地图」）；KPI cron `b5c6c96b1eee` 从 MCP 拉金额，违反 ERP 铁律 |
| `数据/h1_monthly.json` | ERP | 手动（归档） |
| `数据/h2_monthly_targets.json` | ERP | 手动（目标不变） |
| `数据/three_year_monthly.json` | ERP | 手动（逐年累加） |
| `数据/my_kpis.json` | ERP+MCP | 随每2h cron |
| `数据/my_activities.json` | ERP+MCP | 随每2h cron |
| `数据/mcp_orders_input.json` | MCP | pull_orders.py → shipment_pipeline.py 前置步骤 |
| `数据/customer_contacts.json` | MCP | pull_contacts.py 联系人反向聚合 |
| `数据/pricing/_key_customers.json` | 静态 | 手动 |
| `数据/pricing/_personnel.json` | 静态 | 手动 |


### 新增 MCP 数据管道脚本（2026-07-27）

| 脚本 | 用途 | 触发 |
|------|------|------|
| `数据/pull_complaints.py` | MQL 全量拉取客诉（替代旧 list_todo），直接输出 mcp_complaints.json | cb_sync → mcp_refresh.py |
| `数据/pull_customers.py` | MQL 全量拉取客户数据（含 field_c8e80d/field_8c870a 等新字段） | cb_sync → mcp_refresh.py |
| `数据/pull_contacts.py` | MQL 全量拉取 8804 条联系人，按 field_6b3789 反向聚合到 customer_contacts.json | cb_sync → mcp_refresh.py |
| `数据/pull_orders.py` | MQL 拉取近期销售订单（含物流单号、联系电话、价税合计），**输出时按单据编号去重**（同一订单多个工作项→只保留一条） | sf_delivery_watchdog.py 前置步骤 |


### ⚠️ MCP 订单去重：必须按单据编号，不能只按 work_item_id（2026-07-28）

飞书中一个订单对应多个工作项（不同产品行 = 不同 work_item_id）。`pull_orders.py` 如果只按 `work_item_id` 去重，同单号的不同工作项全保留，`shipment_status.json` 中同一订单重复 2-12 次，订单管理页面严重冗余。

**修复**：`pull_orders.py` 输出时按 `单据编号`（`o.get('name')`）去重：
```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 条（去除 198 条冗余），103 个重复单号归零。

**⚠️ 但「保留第一条」会丢只在部分工作项上有值的字段。** 同一订单的多个工作项字段并不同构：某些字段（实测「发货日期」`field_98f9e8`）只在部分分录行上有值，去重时撞到空值行就会整单丢值（实测 1399 单里只有 712 单有值，9-23 那批新单全被丢掉）。**修法：字段级合并 + 取非空**，不要只按行取第一条：

```python
# 按单号汇集「非空」值，再用于输出（对任何「只在部分工作项上有值」的字段都适用）
val_map = {}
for o in all_items:
    oid = o.get('name', '')
    v = str(o.get('<field_key>', '') or '')[:10]
    if oid and v and not val_map.get(oid):
        val_map[oid] = v
```

诊断信号：**同一批数据里该字段命中率明显低于 100%，且缺失集中在某一批单上** → 先怀疑去重撞空行，不要急着怪数据源。


### ⚠️ 顺丰轨迹状态判断：opCode 优先，文本仅兜底（2026-07-28）

顺丰 API 返回的 opCode 是最可靠的状态信号，文本匹配易出错。典型案例："已派送成功"(opCode=80)含"派送"→文本匹配误判为"派送中"。**必须 opCode 优先**：

| opCode | 含义 | 映射 |
|--------|------|------|
| `80` | 已签收 | `delivered` |
| `204` / `30` | 派送中 | `delivering` |
| `54` | 已揽收 | `picked_up` |

```python
def fine_status(routes, reason=''):
    if not routes: return ('no_data' if reason else 'unknown'), None
    last = routes[-1]; op = last.get('opCode', '')
    if op == '80':  return 'delivered', last
    if op == '204' or op == '30': return 'delivering', last
    if op == '54':  return 'picked_up', last
    # 文本兜底（opCode 未命中时）
    remark = last.get('remark', '')
    if '签收' in remark or '派送成功' in remark: return 'delivered', last
    if '派送' in remark: return 'delivering', last
    if '揽收' in remark: return 'picked_up', last
    return 'in_transit', last
```

此 bug 曾导致 138 条已签收订单被误判为"运输中"。完整 opCode 映射见 `references/sf-opcode-reference.md`。


### 顺丰物流 + 飞书弹窗推送全链路（2026-07-27）

`~/.hermes/scripts/sf_delivery_watchdog.py` — 四步统一管道：

```
① pull_orders.py    → MCP 拉 7 天内订单（含物流单号+收货人电话+销售员）
② shipment_pipeline.py → 顺丰 API 查询轨迹（批量10单，checkPhoneNo 必传）
③ 状态 diff         → 与上次 snap 比对，检测 新签收/新派送
④ 飞书卡片推送      → 按销售员分组，POST /im/v1/messages（receive_id_type=email）
```

**销售员识别**：订单角色（下单人）→ `get_workitem_brief` → 缓存兜底。详见上方 "⚠️ 下单人字段取数"。

**飞书 API 要点**：token=tenant_access_token（app_id+secret），消息用 email 作 receive_id（无需转 open_id）。详见 `references/sf-feishu-push-pipeline.md`。

**⚠️ 推送 open_id 匹配坑（2026-09-07）**：实际脚本推送前用 `batch_get_id`（email→open_id）查 open_id，查不到 open_id 的销售员会被**静默跳过**（日志 `⚠️ 某某 (xx@biori.com): 无open_id,跳过`）。根因是该员工邮箱在飞书通讯录里不存在（已离职未销/未开飞书账号/邮箱与拼音@biori.com不一致）。诊断：`batch_get_id` 对比已知有效邮箱（如 zhaoyunhao@biori.com 返回 user_id）vs 被跳过邮箱（只返回 email 无 user_id）。2026-09 实测 6 人匹配不到：袁殿果/林潇/刘斌/吴云/黄文才/陈溪子。


### 验证云上数据已更新

```bash
# 检查 shipment_status.json 的 updated_at 时间戳
# -H "Cache-Control: no-cache" 用于绕过 CDN 缓存
curl -s -H "Cache-Control: no-cache" \
  "https://bier-sales-d0gatbvlx288724e9-1458438710.tcloudbaseapp.com/数据/shipment_status.json" \
  | head -c 200
```

## 月度经营报告生成（ECharts自包含HTML）

触发：用户说"X月月报"、"月度报告"、"经营月报"。

生成暗色主题自包含HTML，ECharts CDN图表，六大板块（KPI/个人排名/客户TOP/产品结构/活动/洞察）。

**⚠️ ECharts formatter只能用function()不能用箭头函数**，否则大量"NaN万"。

完整规范见 `references/echarts-monthly-report-pattern.md`。

报告路径：`~/Desktop/Hermes输出-工作类/月报/2026年X月月报.html`

## 图灵(Turing)辅助数据更新工作流

当有新 Excel 数据需要更新仪表盘时，使用图灵子 Agent 分担工作：

```
1. 主 Agent 提取：Python pandas 读 Excel → 汇总 JSON → 数据/ 目录
2. 发送图灵：JSON路径 + 目标HTML + 具体更新要求
3. 图灵执行：读JSON → 更新HTML + 底层JSON → 浏览器验证
4. 主 Agent 验证：node --check + 控制台检查 + 登录测试
```


### H2月度目标数据管道

源文件：`2026H2月度目标-20260721.xlsx`（三部门7-12月目标）
输出：`数据/h2_monthly_targets.json`
仪表盘：销售工作台.html 新增 SVG 分组柱状图（目标/实际）


### 三年逐月对比数据管道

源文件：`2024全年订单.xlsx` + `2025全年订单.xlsx`（Sheet1，26K/30K行）
输出：`数据/three_year_monthly.json`（按合同月份聚合，排除赠品）
仪表盘：销售工作台.html 三年汇总重构为月份×年份矩阵
