# feishu-project-mcp — data pipeline

### 价格单位提取（ERP→产品级）

ERP的"销售单位"列含单位信息（ml, U, 包, 只, 瓶等）。全量扫描3年ERP数据→SKU→单位映射，注入定价JSON和档案。

```python
# 见 references/customer-profile-page-pattern.md 完整模式
```


### 客户概况提炼（客诉+订单+测试单+活动，2026-08-30 验证）

按客户名单提炼「客户概况」时的数据源形态与查询法：

- **单据类型三值**：销售订单「单据类型」字段 MQL key = `field_a6e501`（SELECT 必须用 key，中文 label `单据类型` 直接 SELECT 返回空；用 `list_workitem_field_config(field_query='单据类型')` 可查得 key）。字段枚举 = 测试销售订单(key `khm3zkd9a`) / 标准销售订单(key `7skbo_qss`) / **预测订单(key `dcm48do4j`，单据编号 FO 开头、金额为 0、不计销售须排除)**。`WHERE 单据类型 = '测试销售订单'`（label 过滤）实测可用，可单独 count 测试单规模。零销售但有测试单的客户 = 「测试中待转正」潜力客户。
- **⚠️ KPI 口径核实（2026-09-05 实测）**：MCP 当月订单按 `field_a6e501` 拆解——标准销售订单承载全部金额，测试销售订单和预测订单**金额均为 0**（例：9月 304 行货号 = 标准 92 行 ¥67.8万 + 测试 208 行 ¥0 + 预测 4 行 ¥0）。即用 `refresh_kpi_mcp.py` 拉 MCP KPI 时**无需额外过滤单据类型**，total 天然等于标准单金额，不会被测试单/预测单污染。
- **按客户拉订单**：`WHERE `销售订单关联客户` = '客户名'` 可用（中文 WHERE 对 varchar/link 字段 OK）；`in (...)` 也支持多客户合并查。`list[0].count` 是**分录行数**（同单多行重复），非独立订单数，仅作活跃度参考。
- **本地 JSON 形态差异**：`mcp_complaints.json` 是分行格式化，可按 `"customer"` 字段 ripgrep 定位；`mcp_orders_input.json` / `daily_activities_mcp.json` 是**单行大 JSON**（1~1.8MB），grep count 恒为 1、无法按客户 grep → 改用 live MQL 按客户 WHERE。
- **客诉 grep 陷阱（全名 vs 简称）**：`mcp_complaints.json` 的 `customer` 字段存**完整公司名**（如「珠海丽珠试剂股份有限公司」），`name` 字段存简称（如「丽珠」）。前缀锚定 grep `"customer": "丽珠"` 会漏——完整名以「珠海」开头。正确做法：按完整名（或非锚定关键词）grep；按简称统计会命中 `name` 字段造成双计（本次实测简称关键词 89 命中 vs 完整名 17 命中），务必用完整名定客诉数。
- **活动无法按客户统计**：活动类型无「关联客户」字段（关系字段 MQL 不暴露），客户概况里「拜访次数」维度缺失属正常，如实说明即可。
- **客户分类速判**：标准单+测试单并进 = 活跃成交；仅测试单无标准单 = 测试待转正；2025 有单 2026 无 = 沉睡/流失风险；全无 = 名义客户。

## MQL 查询语法


### 已验证字段（按工作项类型）

**story/商机:**
| 字段 | MQL SELECT名 | 备注 |
|------|-------------|------|
| 名称 | `name` 或 `名称` | 返回字段名为中文"名称" |
| 描述 | `description` | 返回字段名"反馈结果(销售)"或"描述" |
| 创建时间 | `创建时间` | |
| 创建者 | `创建者` | 返回user_key ID，非姓名 |
| 工作项ID | `work_item_id` | 返回字段名"工作项id" |
| 活动关联客户 | ❌ | 关系字段，MQL不暴露 |
| 活动对应销售订单 | ❌ | 关系字段 |

**完整商机自定义字段映射见 `references/opportunity-field-mapping.md`**：含意向产品(`field_35fb26`)、竞对信息(`field_a1d6e8`/`field_110e12`)、痛点(`field_49e4c7`)、可用产品(`field_7da0cf`)、项目阶段(`field_b71dc1`)、项目分类(`field_83e106`)等73个字段的完整对照表。

**活动（65ae1e5d44338dbe7c39a29a）:**
| 字段 | MQL名 | 备注 |
|------|-------|------|
| 名称 | `name` | 活动名称 |
| 跟进内容 | `field_b99055` | **活动描述字段，跟进内容真实落点**。`description` 是内建字段恒空，查它必然误判为空 |
| 创建时间 | `创建时间` | ISO日期格式，WHERE 支持 `>= "2026-04-01"` |
| 创建者 | `创建者` | user_key ID（数字），非姓名 |
| 工作项ID | `work_item_id` | ⚠️ 实测常为空字符串！不可用作去重键 |
| 跟进部门 | `跟进部门` | ⚠️ 返回option_id编码（如`2o467stav`），非可读名 |
| 活动关联客户 | ❌ | 关系字段 |

**URL格式**: `https://project.feishu.cn/xsguanli/65ae1e5d44338dbe7c39a29a/detail/{id}`


### ⚠️ 活动数据质量三大坑（2026-07-10 实测）

详见 `references/activity-mql-pitfalls.md` — 完整查询实录、数据质量问题清单、去重策略。

**坑1：`跟进部门` 返回编码，非可读名**
MQL SELECT `跟进部门` 返回的是飞书字段的 option_id（如 `2o467stav`、`sp46mkhje`、`591s4k2uz`），不是"诊断原料销售部"等中文名。

**方案A：meta-fields 查 options**（理论可行，实测偶有JSON解析失败）：
```python
fields = mcp("meta_fields", {"project_key": SALES_PK, "work_item_type_key": "65ae1e5d44338dbe7c39a29a"})
for f in fields.get('list', []):
    if f.get('name') == '跟进部门':
        for opt in f.get('options', []):
            dept_map[opt['option_id']] = opt['name']
```

**方案B：创建者反推部门**（实测可靠，推荐）：
```python
# 1. 每个dept code取样几条活动的 work_item_id + 创建者
# 2. get_workitem_brief 拿创建者姓名
# 3. 用已知的"人名→部门"映射反推编码含义
# 示例：591s4k2uz → 创建者王诗香 → 生命科学-广东
```
实测映射结果（2026-07-10 初版，2026-09-07 用 key_label_value.label 校正）：

`跟进部门` 是枚举字段（MQL key = `field_5ed7ab`），SELECT 返回 `key_label_value`，**label 才是中文名**。正确映射：

| key（option_id） | label（中文名） | 归属部门 |
|------|------|------|
| `591s4k2uz` | 广东 | 生命科学-广东（广州区） |
| `i4l86_tn6` | 浙江 | 生命科学-浙江 |
| `sp46mkhje` | 诊断原料大客户销售部 | 大客户部 |
| `2o467stav` | 诊断原料销售拓展部 | 销售拓展部（12人） |

✅ **MQL WHERE `跟进部门` = 'label' 可用**（2026-09-07 实测）：按中文 label 过滤可直接统计各部门活动量，**不要用 option_id key**：
```sql
-- ✅ 正确：按 label 过滤
SELECT `名称`, `创建时间`, `跟进部门` FROM `销售管理`.`65ae1e5d44338dbe7c39a29a`
WHERE `创建时间` >= "2026-08-01" AND `创建时间` < "2026-09-01" AND `跟进部门` = '广东'
-- 返回 list[0].count = 该部门活动总数（实测 8月广东 580 / 浙江 478 / 大客户部 87）
```
- 旧结论「WHERE 跟进部门 = 编码不工作」只在按 **option_id key** 过滤时成立；按 **label** 过滤实测可用。
- 四个 label 名：`'广东'`、`'浙江'`、`'诊断原料大客户销售部'`、`'诊断原料销售拓展部'`。

**人员→部门映射（2026-09-07 用活动 跟进部门 字段实测校正，含邮箱）**：
- 大客户部（诊断原料大客户销售部）：赵云浩 zhaoyunhao、刘子研 liuziyan、黄明月（2026-05 已离职）
- 生命科学-广东（label「广东」）：韩远怀 hanyuanhuai（区域经理，广州100万）、刘斌 liubin、王诗香 wangshixiang、陈彩红 chencaihong、黄建华、梁莹莹、段玉
- 生命科学-浙江（label「浙江」）：叶信福 yexinfu、袁殿果 yuandianguo、侯海瑜、王慧洁、曾琪、吴云（区域经理，浙江80万）
- 诊断原料销售拓展部：李江辉、杨天雄、黄文才、宋学伟、张文蝶、刘虹晶、林潇、冯贤平、陈溪子（华南区）、路要峰、徐卓凡、张立娅

⚠️ 旧映射「刘斌属诊断原料」是错的——刘斌实际在生命科学-广东（liubin@biori.com，跟进部门=广东）。按 跟进部门 字段为准，不要按人名猜部门。

> 按部门做月度经营分析（活动量 + 客诉进度 + 人员归属）的完整流程见 `references/department-analysis.md`。

**坑2：`work_item_id` 常为空**
活动数据的 `work_item_id` 字段实测大量返回空字符串。**不能用它做去重键**。改用 `name + 创建时间` 组合去重，或用 MQL 返回的隐式分组 key（`data` dict 的顶层 key）。

**坑3：`description`（跟进内容）常为空**
大部分活动的跟进内容为空，仅活动标题（`name`）有值。活动名称通常为"活动跟进"等通用名，信息量有限。不要假设 description 有实质内容。


### 客户（kh）MQL 字段

| 字段 | MQL名 | 备注 |
|------|-------|------|
| 客户名称 | `客户名称` | |
| 决策链URL | `决策链URL链接` | ✅ MQL可读！需WHERE精确匹配客户名。⚠️ `update_field` 对表单模板字段返回成功但不实际写入，回填需飞书Open API或手动操作 |
| 客户级别 | `客户级别` | |
| 工作项ID | `work_item_id` | | |

**⚠️ `update_field` 对表单模板字段无效**（2026-07-11确认）：`决策链URL链接` 是客户类型的表单模板字段（模板："默认客户清单类型"，id=700371）。`update_field` 返回 `mcp_result: ""` 看似成功，但实际不持久化。回填需飞书Open API（需 App ID + Secret 凭证）或手动操作。MQL SELECT WHERE 精确匹配可正常读取该字段。

客户类型为"状态流"（`work_item_mod: 状态流`），`get_node_detail` 不可用。
`get_view_detail` 返回的 `work_item_fields` 仅含：关注人、完成日期、当前负责人、描述。决策链URL需用MQL WHERE精确匹配读取（`get_view_detail` 不暴露此字段）。


### 关键发现：字段名映射
MQL SELECT 返回的字段名是**中文显示名**而非 API key。需在 parse_items() 中标准化：
- `工作项id` → `work_item_id`
- `名称` → `name`
- `单据编号` → `order_no`


### 用户姓名解析（MCP的BUG）

`search_user_info` 对销售管理项目的user_key返回空数组。活动数据中"创建者"字段显示为数字ID(7612739891322981320)而非姓名。解决方案：
```python
# API Server端缓存：采样前5条活动调用 get_workitem_brief 拿 create_by.name
brief = mcp("get_workitem_brief", {project_key, work_item_id})
user_name = brief['work_item_attribute']['create_by']['name']  # → "韩远怀"
USER_CACHE[user_key] = user_name  # 缓存在内存中，后续API调用命中
```


### ⚠️ Python 后端直连 MCP：`search_by_mql` 响应解析（2026-07-26 实战）

当在 Python 后端（非 Hermes MCP 工具）中通过 HTTP POST 直连 MCP 调用 `search_by_mql` 时，响应结构需三层展开：

**第一层 JSON-RPC → 第二层 content[0].text → 第三层 data dict**

```python
def mcp_call(method, args, timeout=20):
    req = urllib.request.Request(MCP_API, data=json.dumps({
        'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
        'params': {'name': method, 'arguments': args}
    }).encode('utf-8'),
    headers={'X-Mcp-Token': MCP_TOKEN, 'Content-Type': 'application/json'})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        d = json.loads(r.read())
    # content 数组两条：第一条数据，第二条 log_id
    for c in d['result']['content']:
        t = c.get('text', '')
        if 'log_id' in t: continue
        return json.loads(t)
```

**第三层：`moql_field_list` → 扁平 dict**

`search_by_mql` 的 item 不是 `{key: value}`，而是：
```json
{"moql_field_list": [
  {"key": "name", "value": "上海美吉"},
  {"key": "field_17186c", "value": {"key_label_value": {"key": "gero4tln3", "label": "C类"}}}
]}
```

展平代码：
```python
for f in item.get('moql_field_list', []):
    k = f['key']
    v = f.get('value', None)
    if isinstance(v, dict) and 'key_label_value' in v:
        flat[k] = v['key_label_value']['label']  # 取 label 非 key
    elif isinstance(v, dict) and len(v) == 1:
        flat[k] = list(v.values())[0]
    else:
        flat[k] = v
```

**翻页：`data` 是 dict，每页一个 group_id key**

```python
for page in range(1, max_pages + 1):
    result = mcp_call('search_by_mql', {
        'project_key': PK, 'mql': mql,
        'session_id': session_id or '',
        'group_pagination_list': [{'page_num': page, 'group_id': 'default'}]
    })
    items = result['data'].get('1', [])  # key 是 group_id
    total = result['list'][0]['count']   # 总行数
    if len(items) < 50: break
    session_id = result['session_id']
```


### ⚠️ Python 后端直连 MCP：`get_workitem_brief` 响应解析（2026-07-30 实战）

`get_workitem_brief` 的 `content[0].text` 也是带 `\nlog_id:...` 尾部的 JSON，**必须先 strip 再解析**。

**响应结构**：
```json
{
  "work_item_attribute": {
    "role_members": [
      {"key": "role_7dd6e0", "name": "销售员", "members": [{"key": "...", "name": "路要峰"}]},
      {"key": "role_00a9b7", "name": "下单人", "members": [...]}
    ]
  }
}
```

⚠️ **`role_members` 是数组，不是 dict**——不能按 key 直接索引，必须遍历查找：
```python
for role in attr.get("role_members", []):
    if role.get("key") == "role_7dd6e0":
        sp = role.get("members", [{}])[0].get("name", "")
```

> 完整解析模式、角色 key 参考表、常见陷阱见 `references/direct-http-get-workitem-brief.md`

## 关系字段遍历（获取MQL不暴露的字段）


### ⚠️ MCP 写操作需要用户手动开启（2026-07-25 实测）

MCP Token 默认只有**读权限**。以下写操作全部返回 `"user has not enabled this MCP feature. Please configure it in your personal preferences"`：

- `create_workitem` — 创建活动/商机/客户等工作项
- `add_comment` — 添加评论
- `update_field` — 更新字段

**该权限是飞书项目内的个人设置**（非空间管理员设置），无法通过 API 或 Token 配置开启。每个用户需在飞书项目网页版中手动开启。

**替代方案（推荐）**：不依赖 MCP 写操作。
- 创建活动：跳转 `https://project.feishu.cn/xsguanli/sj/homepage`（见工作项类型主页 URL）
- 创建/编辑其他工作项：跳转对应类型的 homepage URL
- 评论/字段更新：引导用户到飞书项目页面手动操作
- Token过期后重新用同Token连接即可，无需重新配置


### 客户交接：改 `current_status_operator`（multi-user 字段）（2026-08-30）

客户归属/隔离靠 `current_status_operator`（「当前负责人」字段），前端按它做非 admin 数据隔离。销售离职/转岗交接客户 = 改这个字段。

- **字段类型**：`multi-user`（`list_workitem_field_config` 传 `field_query="负责人"` 查得 `field_key=current_status_operator, field_type=multi-user`）。MQL 读出来是 `user_value_list`（数组，含 `user_key`/`name_cn`）；`owner` 字段是 `user_value`（创建者，隔离**不用**它）。
- **改法**：`update_field` 的 `field_key=current_status_operator`，`field_value` 传 **userkey 数组 JSON 字符串**——multi-user 是**整体覆盖**，不是增量 add/remove：
  ```python
  new_keys = [k for k in current_keys if k != 离职人_userkey]   # 移除旧负责人
  if 新人_userkey not in new_keys: new_keys.append(新人_userkey)  # 确保新负责人在
  mcp("update_field", {"project_key": PK, "work_item_id": wid,
       "fields": [{"field_key": "current_status_operator",
                   "field_value": json.dumps(new_keys, ensure_ascii=False)}]})
  ```
- ⚠️ **保留其他共管人**：一个客户可能有多个负责人（如 `['刘子研','张立娅','路要峰']`），整体覆盖时**必须把其他人的 userkey 一起保留**，不能只传新人一个。userkey 从 MQL 拉到的 `current_status_operator[].user_key` 提取。
- ⚠️ **需要 MCP 写权限**：无权限时返回 `MCPPersonalPreferenceDisabled`（见上文）。写权限开启前，改客户负责人只能引导用户到飞书项目页面手动操作。
- **交接后**：重跑 `pull_customers.py` + 部署 `mcp_customers.json`，前端隔离（按 operators 匹配）才会反映新归属。


### 批量管道（cron定时运行）
管道脚本统一放 `~/Desktop/Hermes输出-工作类/数据/`：
- `mcp_pipeline.py` — 客诉+审批阻塞JSON
- `mcp_activities_pipeline.py` — 每日活动JSON (双线分类)
- `mcp_sales_pipeline.py` — 销售订单JSON (含客户名补全)
- `mcp_kpi_pipeline.py` — KPI从sales JSON读取真实数据


### 实时API服务器（页面直连MCP）
见 `references/api-server-pattern.md`


### ERP → KA 对比数据管道
见 `references/erp-ka-pipeline.md` — 逐年ERP Excel提取+KA客户匹配+月度聚合
