# 顺丰快递路由查询 API 接入方案

## 数据来源

飞书项目 MCP `销售管理` 空间 → `销售订单` 工作项（type_key=`662612aea6bb7089fea044ef`）：

| MCP字段 | field_key | 用途 | 示例 |
|--------|-----------|------|------|
| 物流单号 | `field_96a245` | API输入 | SF5150750372878 |
| 收货人联系电话# | `field_3f38c1` | 校验用（后4位） | 15858793770 → 3770 |
| 收货人姓名 | `field_0e6822` | 前端展示 | 陈博 |
| 收货地址 | `field_290c00` | 前端展示 | 浙江省杭州市余杭区… |
| 销售订单关联客户 | `field_e1001d` | 客户匹配 | 杭州师范大学-浙江 |

当前 3112 条记录有物流单号，全部为顺丰（SF开头）。

## 顺丰开放平台 API

### 基本信息

| 项目 | 详情 |
|------|------|
| 平台入口 | https://open.sf-express.com |
| 接口名 | EXP_RECE_SEARCH_ROUTES（路由查询） |
| 费用 | **免费** |
| 配额 | 50万次/月，30次/秒 |
| 查询范围 | 3个月内运单 |
| 环境 | 沙箱 → 生产（已上线 ✅ 2026-07-24） |

### 已配置凭证

| 项目 | 值 |
|------|-----|
| 生产 partnerID | BRSWK5CQ08HV |
| 生产 secret | V7JDT0azGX93pnIXRyZe9zvlzY6fk1rC |
| 状态 | ✅ 生产环境可用 |

### 环境地址

| 环境 | 鉴权地址 | API地址 |
|------|---------|---------|
| 生产 | `https://sfapi.sf-express.com/oauth2/accessToken` | `https://bspgw.sf-express.com/std/service` |
| 沙箱 | `https://sfapi-sbox.sf-express.com/oauth2/accessToken` | `https://sfapi-sbox.sf-express.com/std/service` |

**注意：生产环境的 partnerID/secret 不能在沙箱使用**，沙箱需单独注册应用。

### 鉴权流程（生产环境）

```
步骤1: POST https://sfapi.sf-express.com/oauth2/accessToken
   Body: partnerID={顾客编码}&secret={校验码}&grantType=password
   返回: { "accessToken": "xxx", "expiresIn": 7200 }

步骤2: POST https://bspgw.sf-express.com/std/service
   Headers: Content-Type: application/x-www-form-urlencoded;charset=utf-8
   Body:
     partnerID={顾客编码}
     requestID={UUID}
     serviceCode=EXP_RECE_SEARCH_ROUTES
     timestamp={Unix秒}
     accessToken={步骤1的token}
     msgData={"language":"zh-CN","trackingType":1,"trackingNumber":["SF5150750372878"],"methodType":1,"checkPhoneNo":"3770"}
```

**关键：`trackingNumber` 必须是数组格式 `["SFxxx"]`，不能是字符串 `"SFxxx"`。**

**鉴权方式：必须用 accessToken，不要用 msgDigest。生产环境数字签名返回 A1006。**

### 真实返回示例（2026-07-24 实测 SF5112860018518）

```json
{
  "apiResultCode": "A1000",
  "apiResultData": "{\"success\":true,\"errorCode\":\"S0000\",\"msgData\":{\"routeResps\":[{\"mailNo\":\"SF5112860018518\",\"routes\":[{\"acceptTime\":\"2026-07-24 20:18:04\",\"acceptAddress\":\"珠海市\",\"remark\":\"顺丰速运 已收取快件，您的期待，我们定竭诚守护，不负所托。感谢您使用顺丰特快\",\"opCode\":\"54\"},{\"acceptTime\":\"2026-07-24 20:34:08\",\"acceptAddress\":\"珠海市\",\"remark\":\"快件在【珠海香洲屏东二路营业店】完成分拣，准备发往 【珠海转运中心】\",\"opCode\":\"30\"},{\"acceptTime\":\"2026-07-24 20:38:21\",\"acceptAddress\":\"珠海市\",\"remark\":\"快件离开 【珠海香洲屏东二路营业店】，已在发往  【珠海转运中心】 的路上\",\"opCode\":\"36\"},{\"acceptTime\":\"2026-07-24 21:31:31\",\"acceptAddress\":\"中山市\",\"remark\":\"快件到达 【珠海转运中心】\",\"opCode\":\"31\"}]}]}}"
}
```

### 关键状态码（opCode）— 2026-07-24 实测

| opCode | 含义 | 实测示例 |
|--------|------|---------|
| 54 | 已揽收 | "顺丰速运 已收取快件" |
| 30 | 分拣/派送中 | "快件在【珠海香洲屏东二路营业店】完成分拣" |
| 31 | 到达转运中心 | "快件到达 【珠海转运中心】" |
| 36 | 离开营业点，在途 | "快件离开 【珠海香洲屏东二路营业店】" |
| 80 + remark含"签收" | 已签收 | （暂未实测到签收单） |

### 常见错误码

| 错误码 | 含义 | 原因 | 解决 |
|--------|------|------|------|
| A1000 | 成功 | — | — |
| A1004 | 无对应服务权限 | 应用未订阅该 API | 控制台 → API管理 → 申请"路由查询"(EXP_RECE_SEARCH_ROUTES) |
| A1011 | 认证失败 | 生产Token用于沙箱 / 密钥错误 | 沙箱需单独注册；生产环境确认partnerID+secret正确 |

### 注意事项

1. **沙箱测试**：新应用必须先沙箱调试，但沙箱需单独注册（生产环境的 partnerID/secret 在沙箱不可用）。通过后切生产环境
2. **API订阅**：创建应用后必须在控制台手动申请/订阅 `EXP_RECE_SEARCH_ROUTES`，否则返回 A1004
3. **trackingNumber格式**：必须是数组 `["SFxxx"]`，不能是字符串 `"SFxxx"`
4. **Token过期**：accessToken 有效期约2小时（7199秒），需实现自动刷新
5. **手机号校验**：checkPhoneNo **必须传**（文档标注"可选"但实测不传则路由为空）。传收件人手机号后4位（MCP已有完整号码，截取即可）。
   - 批量查询时：多个号码后4位用英文逗号连接，顺序与 trackingNumber 一一对应
   - 无手机号时传 `"0000"` 兜底
6. **月结绑定**：如果宝锐与顺丰有月结账号，绑定后可用月结免手机号查询
7. **仅顺丰**：当前全部为顺丰单号。未来如有京东/EMS需另接对应API

### 物流状态判断规则（⚠️ 易错）

顺丰的 remark 措辞与直觉不同，必须按以下优先级判断：

```python
remark = last_route.get("remark", "")  # 最后一条路由的remark

DELIVERED_KW = [
    "派送至本人", "派送成功", "签收", "已通知客户取件",
    "已取件", "客户已取", "本人已取", "已签", "代签",
    "放入快递柜", "放入丰巢", "放入驿站", "已投递",
    "前台签收", "门卫代收", "保安代收", "物业代收",
    "快递柜签收", "驿站签收"
]
DELIVERING_KW = ["正在派", "派送中", "派件中"]

if any(kw in remark for kw in DELIVERED_KW):
    status = "delivered"
elif any(kw in remark for kw in DELIVERING_KW):
    status = "delivering"
elif last_route.get("opCode") == "54":
    status = "picked_up"
elif routes:
    status = "in_transit"
else:
    status = "no_data"
```

**Pitfall（2026-07-24 实测）**：顺丰"已签收"的 remark 是 `"您的快件已派送至本人"` 或 `"您的快件已派送成功"`，不含"签收"二字。如果先用 `"派送" in remark` 判断，435条已签收会被误判为"派送中"。**必须按 `派送至本人/派送成功/签收` → `正在派` 的顺序判断。**

**Pitfall 2（2026-07-24 批次回归）**：/tmp 下的早期临时脚本（process_orders.py、gen_shipment.py、merge_and_query.py）仍携带旧规则（`"签收" in remark` 优先、其次 `"派送" in remark`），照抄即回归误判——典型症状：整批 0 条"已签收"、数百条"派送中"。复制状态判断逻辑时以本节修正规则为准，勿从临时脚本搬。补救：批次 JSON 已存完整 `routes`，可离线重判 statusLabel，无需重调 API。

## 落地步骤

| 步骤 | 内容 | 执行 | 状态 |
|------|------|------|------|
| 1 | 在 open.sf-express.com 注册企业账号，完成认证 | 用户 | ✅ 已完成 |
| 2 | 创建应用，获取 partnerID + secret | 用户 | ✅ 已完成 (BRSWK5CQ08HV) |
| 3 | 控制台 → API管理 → 订阅 EXP_RECE_SEARCH_ROUTES | 用户 | ✅ 已完成 |
| 4 | 沙箱测试（网页控制台）+ 申请上线 | 用户 | ✅ 已完成 |
| 5 | 生产环境 API 调试验证 | Agent | ✅ 已完成 |
| 6 | 数据管道脚本 `shipment_pipeline.py` | Agent | ✅ 已完成 |
| 7 | 嵌入销售工作台订单管理 Tab | Agent | ✅ 已完成 |
| 8 | 设置 cron 每日更新物流状态 | Agent | ⏳ 待配置 |

## 数据管道（已实现）

```bash
python3 脚本/shipment_pipeline.py          # 生产模式（从MCP数据文件读取）
python3 脚本/shipment_pipeline.py --mock   # 测试模式
```

数据流：
```
MCP 销售订单(分页拉取) → 去重+提取手机号后4位 → 顺丰API(10个/批,30qps内) → 数据/shipment_status.json
```

前端接入：`../数据/shipment_status.json`，已在销售工作台 ENDPOINTS 中注册为 `['shipments', '../数据/shipment_status.json']`。

MCP分页拉取模式见 `references/mcp-pagination-patterns.md`。
