# baoyu-workbench-data-pipeline — details

### 共享模块

`脚本/erp_sales_core.py` — `find_latest_erp()`（按 mtime 扫 `hermas输入-工作台数据库/月度销售额数据` + `数据/每日上传`）+ `aggregate()`（口径过滤+人员映射+按月聚合）。`refresh_erp.py`（kpi_progress 当月）和 `mcp_kpi_pipeline.py` v5（kpi_dashboard H2 + three_year_monthly 同步）都复用它。


### ① 客户 Top5 把「周」当「月」取数（最严重）

旧代码用 `erpAmount.customer_monthly`（**月**粒度）比较 `_cm >= periodStart.slice(0,7) && _cm <= periodEnd.slice(0,7)` → 选「本周」时实际把**整 9 月**算进去。识别法：截图里「本周」的 Top5 与切到「本月」后**一模一样**就是它。

修复：`erp_sales_core.aggregate_daily()` 新增 **`customer_daily`**（`{日期: {客户: 金额}}`，209 天 / 全年勾稽相等），写入 `erp_amount.json`；前端改成遍历 `customer_daily` 按 `periodStart~periodEnd` 日界过滤。验收铁律：**各区间全体客户合计必须等于 `daily` 同区间合计**（实测本周 20.51万 / 本月 332.48万 / 本年 3343.75万 三者逐项相等）。
（`aggregate_daily` 返回值已变 8 元组，新增 `customer_daily` 在 `fname` 之前；调用方只有 `refresh_erp_amount.py`，已同步。）


### 通用教训

**面板里的每个数字都要能回答「什么区间、什么口径、跟表格同一套筛选吗」**。只写「某维度聚合」而不对齐三件事（区间粒度 / 业务口径 / 页面筛选），一定会出这类 bug；验收时**同一面板切不同期间必须给出不同结果**，相同就是取数粒度错了。

## 任意区间销售额查询（不用跑 pandas，直接读 erp_amount.json）

`数据/erp_amount.json`（每次 ERP 入库自动重跑）已按人/部门/日三维聚合，**查「某部门自某日至今」不用再解析 24557 行的合并件**：

| 键 | 粒度 | 用途 |
|---|---|---|
| `daily` | 全公司×日 | 单日总盘 |
| `daily_person` | 销售员×日 | 任意起止日的按人聚合（区间求和即可） |
| `monthly_person` | 销售员×月 | 单人月度合计 |
| `monthly_dept` | 部门×月 | 大客户部 / 销售拓展部 / **生命科学部** |
| `customer_monthly` | 客户×月 | 客户维度 |

口径：ERP 标准销售订单（已审核）价税合计，预测单(FO)/测试单不计（测试单金额恒为 0）。部门归属按销售员映射——生命科学部 = 吴云/韩远怀/叶信福/袁殿果/侯海瑜/刘斌/王诗香/陈彩红/黄建华（`数据/sales_team.json`），校验法：该 9 人 `monthly_person` 当月合计应 == `monthly_dept[月]['生命科学部']`（实测 2026-08 = 31826.3 元，一致）。

**跨月区间必做的一次勾稽**：区间值 = 首月部分日 + 中间整月 + 末月部分日；用「月末 `monthly_dept` − 区间外日 `daily_person` 合计」反推剩余部分，两边对上才发数（本次 8/20–9/22 生命科学部 = 18539.3 + 40847.9 = 59387.2，反推校验 31826.3 − 13287.0 = 18539.3 ✅）。

注意：`daily_person` 的键序不是严格时间序（月内会有「上旬块」落到文件末尾），按 key 取不要按行号假设；数据永远是「到某日为止」的快照，回答「至今」须注明实际覆盖日（如截至 9/22）。

## ⚠️ 数据刷新 cron 运维陷阱：subprocess.run 超时抛异常 + 硬编码日期（2026-09-07）

**症状**：daily_full_refresh.py（早9点全量刷新）连续 3 天 error，顺丰物流数据 12 小时不更新。

**根因（两层）**：
1. `subprocess.run(..., timeout=600)` 超时抛 `TimeoutExpired` 异常（非返回错误码），脚本未 try/except → 直接 crash，后续步骤（顺丰物流）全跳过。
2. `mcp_sales_pipeline.py` 里 `TODAY = datetime(2026, 7, 9)` 硬编码 7 月 9 日 → "近90天"窗口不滚动，订单累积，客户补全（逐条 list_related_workitem）量爆炸超时。

**修复**：
1. 抽 `run_step()` 统一 try/except `TimeoutExpired` + Exception，单步失败继续跑后续。
2. `TODAY = datetime.now()`（滚动窗口）。
3. 顺丰物流独立成步，即使前面全失败也照常执行。

**教训**：`subprocess.run(timeout=N)` 超时是抛异常不是返回非零码，必须 try/except；任何"近N天"窗口禁止硬编码日期，用 `datetime.now()` 滚动。

## 手动触发

在 Hermes 中说"更新工作台数据"，或：
```bash
bash ~/Desktop/Hermes输出-工作类/数据/每日上传/process_daily.sh
```

## ⚠️ 工作台金额铁律：KPI 数据源 ≠ shipment 订单 taxTotal（2026-07-28）

**症状**：看板"累计达成"显示 62.5 万，实际应为 2,353.7 万（H1 2,164 万 + H2 189.6 万）。

**根因**：`fullYearTax`/`yearTax` 从 shipment 订单 `taxTotal` 求和。MCP 订单该字段几乎全为 0（138/187 条），累加严重偏低。

**铁律**：金额 KPI 卡片必须用 `kpi_dashboard.json.total_h1 + kpi_progress.json.total`，禁止从订单求和。

```javascript
var dash = state.data.kpiDashboard || {};
var prog = state.data.kpiProgress || {};
var fullYearTax = (dash.total_h1 || 0) + (prog.total || 0);
```

影响：`renderDashboard()` 累计达成 + `renderOrders()` 本年累计。两处都必须改。


### 前端日期 toISOString() UTC 偏移（2026-09-07）

**症状**：订单管理日期范围显示偏 -1 天（本月显示 8/31~9/7，正确 9/1~9/7）。

**根因**：`new Date('2026-09-01').toISOString().slice(0,10)` 里 `toISOString()` 返回 UTC（中国 UTC+8），本地 9/1 00:00 → UTC 8/31 16:00 → slice 得 8/31。

**修复**：本地日期格式化用 `getFullYear() + '-' + String(getMonth()+1).padStart(2,'0') + '-' + String(getDate()).padStart(2,'0')`。新增 `fmtDate(d)` 函数，`new Date().toISOString().slice(0,10)` 全部替换为 `today()`/`fmtDate()`。存时间戳的完整 `toISOString()`（无 slice）是 UTC 标准做法保留。index.html 和 app.html 两个文件都要改。

## 看板数字不同步诊断：KPI 三文件数据源地图（2026-08-14）

**症状**：看板两个组件显示同一月份的金额不一致（实测：「📅 8月当月进展」=171.1万 vs「📊 H2月度目标vs实际」8月=115.7万）。

**根因**：三个 KPI 文件是**不同的源、不同的时间快照**，不是同一个数算错：

| 文件 | 语义 | 来源 | 更新 |
|------|------|------|------|
| `kpi_progress.json` | 当月实时 | **MCP MQL**（KPI cron `b5c6c96b1eee`，每日00:00，逐单 get_workitem_brief 拿销售员） | 常超时→停更 |
| `three_year_monthly.json` | 逐月快照 | **ERP 导出**（标准销售订单） | 用户上传后更新 |
| `kpi_dashboard.json` | 累计 H1+H2 | `mcp_kpi_pipeline.py` v4（读 ERP Excel） | 随 cb_sync 每2h |

**诊断法（任何"数字不同步"先做这步，别急着改计算）**：grep 每个组件读的 JSON 文件+字段 → 查各文件 mtime 和 source 字段。不同源/不同快照 = 数据新鲜度问题，不是计算 bug。


### 错误链路

```
ERP真实销售 ¥353.6万
   ↓ 飞书录入
MCP「销售订单」金额字段 → 几乎全为 0（系统级限制）
   ↓ daily_sales_mcp.json 汇总
MCP总额仅 ¥251.2万（比真实少29%）
   ↓ mcp_kpi_pipeline.py v3 致命操作：
   拿错误的总额 → 按每个人年度目标比例分摊
   ↓
kpi_dashboard.json = 每人一个"算出来的"数字
   路要峰：KPI表 8.3万 vs ERP真实 47.0万（差5.7倍）
   黄建华：KPI表 12.5万 vs ERP真实 0.4万（高估31倍）
```


### 两个断点

| 断点 | 位置 | 问题 |
|------|------|------|
| ① MCP金额=0 | 飞书项目MCP | `销售订单`不返回财务金额，这是系统级限制（见 `references/mcp-sales-amount-limitation.md`） |
| ② 比例分摊 | `mcp_kpi_pipeline.py` v3 第40-60行 | `dept_actual = total_july * dept_ratio.get(dept, 0)` + `person_actual = dept_actual * person_ratio` — 完全按目标比分配，产生的数字与真实销售无关 |


### 症状识别

当发现以下模式时，说明kpi_dashboard.json中了比例分摊陷阱：
- 每个人的"达成率"都差不多（因为按目标比分配）
- 所有20人都有数据，但有人ERP实际为0
- 部门合计=个人合计，但和ERP对不上（本例差+101.5万，+40%）


### 验证命令

```bash
# 对比 KPI 仪表盘 vs ERP 真实数据
python3 -c "
import json
with open('数据/kpi_dashboard.json') as f: kpi=json.load(f)
# 如果每个人h2_actual_months[0]都正好等于其目标比例，就是分摊的
for p in kpi['people']:
    ratio = p['target']/sum(x['target'] for x in kpi['people'] if x['dept']==p['dept'])
    print(f\"{p['name']}: actual={p['h2_actual_months'][0]:,.0f}  ratio={ratio:.3f}\")
"
```

## 📦 前端 14→1 JSON 合并（2026-07-28）

`merge_workbench_data.py` 合并 14 个 JSON → `workbench_data.json`（~2.3MB）。`loadData()` 单次 fetch 替代 14 次并发。

**管线**：`cb_sync.sh` 管线完成 → `merge_workbench_data.py` → `workbench_data.json` → 上传 CloudBase。静态文件（h1/h2/3year/my_kpis）从 cb_sync 移除。

## 📤 文件上传管道（2026-08-07）

完整的上传→质检→月份路由→处理→归档管道见 `references/upload-pipeline.md`。

核心改进：
- **智能月份检测**：从单据编号前缀 `ZHBR2607`/`ZHBR2608` 识别数据月份
- **路由**：当前月→`kpi_progress.json`，历史月→`three_year_monthly.json`+`kpi_dashboard.json`
- **合计行过滤**：`str(None)='None'` 必须显式过滤，单纯 `not bn` 不够
- **Admin-only 上传Tab**：拖拽上传，10条分页列表，一键处理


### ⚠️ openpyxl read_only=True 列数陷阱

`read_only=True` + `iter_rows()` 无 `values_only` → 第一行只返回第一个Cell → 质检报"1列"。必须去掉 `read_only`，用 `values_only=True`。

## 📊 无物流单号订单备货状态（2026-07-28；2026-09-23 升级）

按创建天数标注：≤3天🟢备货中 / 4-5天🟡备货预警 / 6-7天🟠备货超期 / >7天🔴严重超期。

> ⚠️ **旧结论已推翻**：过去写「MCP 无计划发货日期字段，只能基于创建时间估算」。**实测 MCP 侧确实没有，但 ERP 导出有** —— 见下节。订单管理页「预计发货」列已改用真数据，不要再回到「创建时间估算」那套。

## ✅ 订单级「预计发货日期」= ERP `发货日期`（2026-09-23 上线）

**结论：能抓到，且 ERP 导出第 20 列 `发货日期` 就是它。** 三条实测依据：

1. **订单级字段**：6171 单里「单内多行日期不一致」= **0 单**（同一单据所有分录行同值）→ 挂订单维度是对的。
2. **未出库订单也有值**：2455 行/601 单未出库订单 100% 有日期 → 有「预计」语义，不是发货后才回填。
3. **滚动计划**：跨两次导出比对同 5295 单，99.9% 不变；仅 4 单变更，**全部是当时未出库的单**（改期往后推）→ 未发货时=计划日，发货后≈实际发货日。另有 62 行日期晚于当天（未来日期）。


### CRM活动导出

两种列结构（见 `references/crm-format-2026.md`）：
- **旧格式**（20260328导出）：K列有值（活动参与人员），A=跟进部门，M=日期，G=商机
- **新格式**（20260701导出）：K列不存在，A=客户，H=日期，无商机列

检测：`bool(cells.get('K'))` → True=旧格式

注意：新格式第1行可能是汇总行（如"「杨天雄」共19个"），检测前先跳过汇总行。


### XLSX Sheet选择

新旧导出可能使用sheet1.xml或sheet2.xml，需自动检测：
```python
sheet_names = [f for f in zf.namelist() if f.startswith('xl/worksheets/sheet') and f.endswith('.xml')]
sheet_names.sort()
sheet_file = 'xl/worksheets/sheet2.xml' if 'xl/worksheets/sheet2.xml' in sheet_names else sheet_names[0]
```

## 销售目标KPI解析

三部门文件结构见 `references/kpi-target-file-format.md`。关键pitfalls：

- **部门名检测**：必须用 `'销售' in a`，不可用 `'销售部' in a`（"销售拓展部"不含"销售部"子串）
- **合并单元格**："实际完成"行A列为空，需跟踪`current_person`状态
- **姓名清理**：`re.sub(r'[（(][^)）]*[)）]', '', name).strip()` 去后缀
- **重复条目**：同Sheet后半有过程KPI区（小数值），保留annual最高的记录
- **跳过行**：离职人员、含"承接"后缀
- **达成率 = H1实际 / 全年目标**（非半年目标）

## 类型判定规则

| 分类 | 单据类型 | ERP列 |
|------|---------|-------|
| 测试单 | = "测试销售订单" | I |
| 销售单 | = "标准销售订单" | I |

## 输出JSON

| 文件 | 内容 | 前端Tab |
|------|------|---------|
| `daily_activities.json` | 最近30天活动，按日聚合 | 📋 每日活动 |
| `daily_tests.json` | 最近30天测试单明细 | 🧪 测试单 |
| `daily_sales.json` | 最近30天销售单明细 | 📦 销售单 |
| `kpi_dashboard.json` | H1/H2部门+个人月表 | 📊 KPI |

KPI JSON注意：部门条目用`dept`字段，个人条目用`name`字段。两者都有`dept`。
前端渲染时用`data[0].name`区分部门/个人表，行标签用`d.name || d.dept`。

## H2 目标计算

采用**阶梯递增权重**（均分不符合业务节奏）：

| 月份 | 权重 | 逻辑 |
|------|------|------|
| 7月 | 12% | H1刚结束，缓冲启动 |
| 8月 | 15% | 加速追赶 |
| 9月 | 19% | Q3末冲刺 |
| 10月 | 20% | Q4开局高峰 |
| 11月 | 19% | 维持高位 |
| 12月 | 15% | 年末收尾回落 |

**剩余目标 = 年度目标 - H1已达成**，每月目标 = 剩余 × 权重。

H2表的"合计"列显示"剩余XXXw"，"7月"列出7月ERP实际达成。


### ⚠️ API 服务器健康检查（2026-07-10）
工作台页面通过 `fetch('http://127.0.0.1:8765/...')` 加载 JSON，依赖 `api_server.py` 在后台运行。**该进程可能无声死亡**（lsof 显示进程存在但不响应，curl 返回空或超时）。

**启动前必检**：
```bash
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8765/api/activities?limit=1
# 返回 200 → OK；返回 000 或超时 → 需重启
```

**重启**：
```bash
lsof -ti :8765 | xargs kill 2>/dev/null  # 杀僵尸进程
cd ~/Desktop/Hermes输出-工作类 && python3 api_server.py &
sleep 2 && curl -s http://127.0.0.1:8765/api/activities?limit=1  # 验证
```

- `.xls` 文件（旧版二进制）需用 `xlrd`，不能用 `zipfile`
- `execute_code` 沙箱可能缺 `xlrd`，改用 `terminal` 执行
- 处理脚本更新后需同步到 `~/.hermes/scripts/update_workbench_data.sh`
- 文件名支持中文括号，shell脚本中需正确引用
- ⚠️ Patch工具 `\\\\\\\"` 转义：修改JS中带 `style=\\\"...\\\"` 等内容时，用Python直接替换代替patch。详见 `references/patch-quote-escaping.md`
- ⚠️ 图片上传：切忌 innerHTML 拼接 onclick（中文键+引号转义必断裂）。用 DOM API + 数字索引 + data-key 属性。详见 `references/html-event-handler-anti-pattern.md`
- ⚠️ `});}` 孤儿括号：工作台HTML页面的常见JS语法错误模式。详见 `references/js-orphan-brace-pattern.md`
- ⚠️ `'销售部' in a` vs `'销售' in a`：\"诊断原料销售拓展部\"中间有\"拓展\"二字，`'销售部' in '诊断原料销售拓展部'` 为 False。KPI解析时必须用 `'销售' in a` 检测部门行。
- MCP 销售订单金额字段限制：飞书 MCP 的 `销售订单` 工作项不返回财务金额（`金额`/`价税合计`/`含税单价` 均为 0），ERP 导出是金额数据的唯一可靠来源。详见 `references/mcp-sales-amount-limitation.md`。
- 顺丰路由查询管道（2026-07-24）：MCP销售订单(物流单号字段) → 按单号去重(分录级数据，500行→~187唯一单号，必须先去重再查) → 顺丰API `EXP_RECE_SEARCH_ROUTES` 批量查路由(每批10单+手机后4位checkPhoneNo校验，批间0.4s) → shipment JSON。完整API规格+状态分类逻辑见 `references/sf-express-tracking-pipeline.md`，可直接运行 `scripts/sf_route_query.py`。
- KA客户3年对比页：独立HTML页面 `工具/KA客户3年对比.html`，数据源 `数据/ka_3year.json`。详见 `references/ka-3year-data-generation.md`。
  - 支持月/季/年三种视图切换，集团筛选，搜索，热力图着色（全局最大值基准）
  - **数据生成（v2）**：从3个ERP年度订单Excel（2024全年/2025全年/2026H1）中提取原始订单行，用pandas读取指定Sheet，按客户/月份聚合为12月数组
  - **KA客户匹配**：用大客户部客户清单（82家，赵云浩+张立娅），精确匹配ERP客户名列
  - **列映射**：2024=Sheet2(客户[4]价税合计[12]), 2025=Sheet1(客户[3]价税合计[12]), 2026=Sheet2(客户[4]价税合计[13])
  - **未来**：增量管道每日从飞书MCP拉新订单追加到月度数据
- 科研测试反馈页：`工具/科研测试反馈.html`，数据源 `数据/test_feedback.json`。详见 `references/test-feedback-workflow.md`。
- ERP跨年快速查询：跨3年ERP文件按客户+货号查询采购记录，区分免费/付费，查市场价。详见 `references/erp-quick-lookup.md`。
- 客户概况提炼（"XX名下客户概况/按客户汇总客诉订单测试活动"）：本地JSON按客户维度的数据源地图 + 无terminal时 ripgrep 锚定字段提取 + _key_customers.json 360结构 + 客诉customer字段vs name字段归属坑。详见 `references/customer-profile-synthesis.md`。
- **客户360归属权威化 + MCP 数据源格式漂移（2026-09-10）**：`数据/pricing/_key_customers.json` 是销售归属**唯一真相源**（`build_customer_360.py` 已改用它，勿回退 detail 的 salesperson）。⚠️ `mcp_activities.json` 已被上游换成纯汇总形态（无 items），活动明细改读 `数据/daily_activities_mcp.json`；`mcp_complaints.json` 字段已扁平化为 `customer`/`status`/`created`/`id`；字段值可能是 `{key,label}` dict，需 `norm_name()` 归一化。含前端双写登录态（`bior_sw_user_v2` + `bior_auth_token`）、`/api/auth/token` 用 `email`、�customer_360 字段名 `total`。详见 `references/customer-360-ownership-authority.md`。

## MCP实时数据集成（飞书项目→工作台）

> **迁移路线图**：`references/mcp-realtime-migration-plan.md` — 4 个 Phase 逐步实现全 MCP 实时数据。

当从飞书项目 MCP 拉取实时数据（活动、客诉、阻塞）需要同步到工作台时，遵循**独立面板模式**，不直接修改已有的复杂作战室 HTML。


### Cron 定时任务

```bash
# 每2小时自动同步
cronjob create --name "CloudBase数据同步" --schedule "0 */2 * * *" --script cb_sync.sh --no_agent
```


### 手动触发

```bash
bash ~/Desktop/Hermes输出-工作类/脚本/cb_sync.sh
# 日志
tail -f ~/Desktop/Hermes输出-工作类/数据/cb_sync.log
```
