# customer_360 归属权威化

> 补充（同日）：重点表只有「集团聚合条目」时，集团下的**具体客户**仍拿不到归属。
> 例：「英和领源集团」在 `_key_customers.json` 里有一条，但「北京英和领源生物技术有限公司」
> 没有独立条目 → 档案页显示空白。修法＝加载器再读 `月报库/report_store.json`（`customers[名].salesperson`）
> **只补空缺、绝不覆盖重点表**（保证已确认归属不被改写）。
> 必须同时输出 `sales_source` 字段（`key_customers` / `report_store` / `detail`），
> 让回填数据可审计、可按来源过滤——否则引入 800 条未经逐条确认的归属却无法追溯来源，
> 等于把错误藏进"100% 覆盖"里，比留空更危险。

## 补归属的正确顺序

1. `pricing/_key_customers.json`（重点客户表，业务已确认）—— 命中即用
2. `月报库/report_store.json`（月报入库口径，覆盖长尾/集团成员）—— 仅补空缺
3. 客户 detail 文件的 `salesperson` —— 兜底（多为陈旧值，含离职人员，慎用）

## 不要用订单 creator 验证归属

订单 `creator` 是**下单操作人**，不等于客户归属销售：内勤代下单、manager 代下单、
离职人员的历史订单都会污染。用它交叉验证月报库归属只有 56% 一致率——
**这个低一致率不代表数据错**，是验证源选错了。真正的权威源是飞书 CRM 的
`current_status_operator`。

## 原归属权威化记录 + 数据源格式漂移（2026-09-10）

## 一、销售归属唯一真相源 = `数据/pricing/_key_customers.json`

**症状**：客户360档案列表显示 `👤张立娅`（5 月已离职），特殊要求模块却按正确归属鉴权 —— 同一客户在两处归属不一致。

**根因**：`脚本/build_customer_360.py` 第 107 行原本取 detail 文件的陈旧值：

```python
'sales': norm(detail.get('salesperson')),   # ❌ detail 里 1205 个文件仅 75 个有值，且含离职人员
```

而 `pricing/_index.json`（1197 客户，脚本的 `idx`）**根本没有 salesperson 字段**——权威归属只存在于 `pricing/_key_customers.json`（444 条，其中 39 条是集团条目）。

**修复**（已应用到 `build_customer_360.py`）：新增模块级权威表 + 3 处取值改造。

```python
# norm/strip_sfx 之后插入
_AUTH_SALES = {}

def load_auth_sales():
    """权威归属表：客户名 → 销售员（唯一真相源）"""
    global _AUTH_SALES
    kc = load('pricing/_key_customers.json', []) or []
    if isinstance(kc, dict):
        kc = list(kc.values())
    m = {}
    for e in kc:
        nm, sp = norm(e.get('name')), norm(e.get('salesperson'))
        if not nm or not sp:
            continue
        m[nm] = sp
        m.setdefault(strip_sfx(nm), sp)   # 去地区/后缀兜底匹配
    _AUTH_SALES = m
    return m

def auth_sales(name):
    return _AUTH_SALES.get(name) or _AUTH_SALES.get(strip_sfx(name)) or ''
```

三处改造：main() 里 `auth = load_auth_sales()`；客户条目 `'sales': auth_sales(name) or norm(detail.get('salesperson'))`；占位卡 `'sales': auth_sales(nm)`。

**效果**：有归属客户 75 → 401；张立娅残留清零；与 `_key_customers.json` 逐条对照 **0 不一致**（差的 43 条是集团条目，非客户）。

**验证口径**（改完必做）：
```python
kmap = {k['name']: (k.get('salesperson') or '').strip() for k in kc}
cmap = {c['name']: c.get('sales') for c in customers}
mism = [n for n, s in kmap.items() if s and n in cmap and cmap[n] != s]  # 必须为 0
```
注意 `customer_360.json` 客户条目金额字段是 **`total`**（不是 `amt_total`），年份字段 `y2024/y2025/y2026`。

## 二、⚠️ MCP 数据源格式漂移（同一路径，形态被换掉）

重新生成时曾造成回归：活动 26→0、客诉 44→0。根因是**上游刷新脚本把 JSON 换了形态，路径没变**：

| 文件 | 旧形态 | 现形态 | 后果 |
|---|---|---|---|
| `mcp_activities.json` | 含 `items` 明细 | **仅汇总**（`by_month`/`by_dept`，9 条目） | `.get('items', [])` → 0 |
| `mcp_complaints.json` | `field_ac5caf`+`work_item_status` | 扁平 `customer`/`status`/`created`/`id` | 字段取空 → 0 |

**活动明细正确源 = `数据/daily_activities_mcp.json`**（4543 条，字段 `field_5f20fc`=客户、`field_b99055`=内容、`field_76654e`=模式、`start_time`、`owner`）。

**客诉字段兼容**（新旧都能跑）：客户名 `customer`(新) → `field_ac5caf` 标签(旧)；状态 `status`(新) → `work_item_status[0].label`(旧)；日期 `created`(新) → `start_time`(旧)；ID `id`(新) → `work_item_id`(旧)。**判定"进行中"的关键词必须加 `结束`**，否则 `status='结束'` 会被当成进行中。

**字段值可能是 `{key,label}` dict**：`field_5f20fc` 有 4239 条是 dict、304 条是空串；`field_76654e` 全 None。统一加归一化 helper，勿直接 `norm()`：

```python

def norm_name(v):
    """字段值可能是 str 或 {key,label} 形态，统一取名称。"""
    if isinstance(v, dict):
        for k in ('label', 'name_cn', 'name', 'value'):
            if norm(v.get(k)):
                return norm(v.get(k))
        return ''
    if isinstance(v, (list, tuple)):
        return norm_name(v[0]) if v else ''
    return norm(v)
```

修完效果：活动 26 → **1444**、客诉 44 → **307**（远超旧版，因新源更全）。

**教训**：重新生成任何聚合 JSON 前，先 diff 新旧产物的**各字段覆盖数**，别只看总量；`0` 往往意味着上游换了形态而不是真的没数据。

## 三、重新生成后的收尾（必做）

```bash
tcb hosting deploy 数据/customer_360.json 数据/customer_360.json -e bier-sales-d0gatbvlx288724e9
bash ~/.hermes/scripts/regenerate_manifest.sh && tcb hosting deploy 数据/file_manifest.json 数据/file_manifest.json -e <env>
launchctl kickstart -k gui/$(id -u)/ai.hermes.workbench   # 后端缓存
```
CDN 校验（中文路径必须 URL 编码）：`curl -s -H "Cache-Control: no-cache" <CDN>/%E6%95%B0%E6%8D%AE/customer_360.json`

## 四、前端页面测试的登录套路（本地/无头必踩）

- **档案页有飞书 UA 门**（显示"请在飞书内打开"）：CDP `Emulation.setUserAgentOverride` 设 UA 含 `Lark/7.20.0` 绕过。
- **登录态是双写**：`localStorage['bior_sw_user_v2']`（ROLE_KEY，`{name,role,...}` 的**完整用户对象 JSON**）决定页面是否渲染；`localStorage['bior_auth_token']` 只用于 API 鉴权。只写后者 → 页面仍显示"🔐 请先登录销售工作台"。
- **`/api/auth/token` 用 `email` 不是 `username`**（`{email, password}`），`password` 对应 `user_roles.json` 的 `pwd` 字段。
- **档案列表是展开式**：点 `.cust-head`（不是 `.cust-card`）才展开详情，`data-name` 属性是客户全名定位符。
- CloudBase 测试域名首访拦"🐴 风险提醒"：CDP `Accessibility.getFullAXTree` 找 `确定访问` 的 `backendDOMNodeId` → `DOM.getBoxModel` 取中心坐标 → `click_at_xy`（直接 `.click()` 无效，是倒计时按钮）。

## 五、已知覆盖缺口（非 bug，待业务决策）

803/1204 客户（2302 万，19.1% 金额）无归属，因为不在 444 条重点客户表里。其中不少是**集团子客户**（如"深圳生科原技术有限公司"316 万），而集团的 `salesperson` 有值（深圳生科原集团=陈溪子）——**子客户 `group` 字段为空导致归属没继承**。如需提升覆盖率，可加一条：客户名去掉地区/后缀后模糊命中集团名 → 继承集团归属。改动属业务判断，需用户确认后再做。
