# 应到数据「提交导出任务前」去重 — 实现总结
> 日期:2026-07-29
> 范围:顺心 / 中通 / 韵达 / 安能 4 站**应到(expected)**数据;百世与实到不在本次范围。
## 一、运行机制
在周期 / 手动触发下载时,于**提交导出任务之前**按交接单号判断该批应到数据是否已落库,已落库则跳过,从源头消除重复下载与重复落库。
```mermaid
flowchart TD
TRIG[周期调度 / 手动触发
task_spec: site, kind, force] --> DISP[dispatch_task → 站点 download_impl]
DISP --> LOAD{force 强制重下?}
LOAD -- 是 --> EMPTY[existing = 空集]
LOAD -- 否 --> QRY[查 PG expected_record.handover_no]
QRY -- cpolar 失败 --> EMPTY
QRY -- 成功 --> SET[existing = 已落库交接单号集合]
EMPTY --> LOOP[遍历本次查询到的班次/交接单号]
SET --> LOOP
LOOP --> JUDGE{交接单号 ∈ existing?}
JUDGE -- 是 → 已落库 --> SKIP[⏭️ 跳过:不提交导出
不 append export_times]
JUDGE -- 否 → 新单 --> EXP[提交导出任务 → 轮询下载 → 入库 UPSERT]
SKIP --> DONE{全部处理完}
EXP --> DONE
DONE --> FINAL{本次提交了新任务?}
FINAL -- 无 → 全跳过 --> BAIL[空兜底 return:不进下载轮询]
FINAL -- 有 --> POLL[轮询导出任务管理页 → 下载 → 入库]
```
**核心要点:**
- **去重数据源**:PostgreSQL `expected_record.handover_no`(已落库的权威记录),新增 `store.get_existing_handover_nos(site)` 查询。
- **判断时机**:提交导出任务**之前**(循环内逐单判断),而非下载之后。
- **安全降级**:PG 不可用 / `force=true` → `existing=空集` → 当作未落库 → 继续提交(**宁可重复、绝不漏**,UPSERT 兜底)。
- **空兜底**:全部跳过时 `export_times` 为空 → 直接 `return`,不进下载轮询(避免下载数校验失败 / 空转超时)。
- **force 开关**:前端 checkbox(默认关)→ `POST /tasks.force` → 一路透传到 impl;周期调度恒不 force。
## 二、force 强制重下透传链路
```mermaid
flowchart LR
UI[前端 checkbox
forceRedownload] --> POST["POST /api/tasks
{site,kind,force}"]
POST --> BFF[Next BFF 透传]
BFF --> TS["task_spec
{site,kind,force}"]
TS --> DISP[dispatch_task]
DISP --> HDR["handler(ctx, force)"]
HDR --> DL["download(pg, force)"]
DL --> IMPL["impl(pg, force)"]
IMPL --> DEC{force?}
DEC -- 是 --> EMPTY2["existing = 空集
强制重下,跳过去重"]
DEC -- 否 --> LOAD2[查 PG 加载 existing]
```
> 周期调度(`_enqueue_fetch`)投递任务时不带 `force` → 默认不强制。
## 三、4 站点标识获取
| 站点 | 提交前标识 | 来源 |
| --- | --- | --- |
| 中通 / 韵达 / 安能 | 交接单号(原有代码已读取) | DOM 列 / CDP 复选框 |
| 顺心 | 交接单号 `RTS\d{3}WJ\d+` | 点"运单列表"后从界面读取(方式1) |
> 顺心"班次号"业务上等同交接单号;4 站统一用交接单号(= DB `handover_no`)作去重键。
## 四、改动概览
**后端 InboundVerify:**
| 文件 | 改动 |
| --- | --- |
| `schema.sql` | +`idx_expected_handover` 索引 |
| `inbound_verify/store.py` | +`get_existing_handover_nos(site)`(含 cpolar 降级) |
| `inbound_verify/cli/server.py` | `TaskRequest.force` + 透传到 task_spec |
| `inbound_verify/runtime.py` | `dispatch_task` + 所有 handler 透传 `force` 到 `download(impl)` |
| `inbound_verify/sites/{zto,yunda,anneng,shunxin}.py` | `impl` 加 `force` + 提交导出前注入去重 + 空兜底 |
| `inbound_verify/sites/baishi.py` | `force` 形参兼容 |
**前端 dashboard:**
| 文件 | 改动 |
| --- | --- |
| `app/page.tsx` | `forceRedownload` state + checkbox + `trigger`/`triggerPrimary`/`onTrigger` 透传 force |
> BFF `app/api/tasks/route.ts` 是 generic 透传,无需改动。
## 五、验证结论
4 站去重 + force 开关均实测通过:
| 站点 | 二次触发行为 | 结果 |
| --- | --- | --- |
| 中通 | `...801` 已入库 → 跳过 → 空兜底 return | 20s(vs 首次 59s) ✅ |
| 顺心 | 双账号 4 班次全跳过(`RTS023WJ375478` 等) | ✅ |
| 韵达 | `...82001` 已入库 → 跳过 → 原有空兜底 | ✅ |
| 安能 | `4008242619171180544` 已入库 → 跳过 | **5s(vs 4 分钟)** ✅ |
| force | `[去重] 强制重下,跳过去重` + 已入库的重新导出 | ✅ |
实施过程中的两个问题均已解决:
1. **顺心 RTS 正则**:`RTS\d+` 遇字母 W 停(只抓 `RTS023`)→ 改 `RTS[A-Z0-9]+` 抓完整 `RTS023WJ375320`。
2. **韵达 force 偶发失败**:韵达站点自身 UI 不稳定(`section iframe` 匹配到 2 个 + 弹窗遮挡),与去重/force 无关;换安能验证 force 成功。
静态检查:Black(py310)+ compileall + tsc 全绿。