Files
InboundVerify/docs/2026-08-02-未到差缺统计逻辑现状梳理.md
Misaka 36b204abe5 refactor(db_compare): 差缺统计改为应到驱动,以出库日为批次归属日
将未到统计从实到驱动(实到锚点反推批次)改为应到驱动(batch_out_date 归属日直接取应到批次),以吸收应到任务提前 1~2 天提交的扰动(韵达固定 +1、中通偶发 +1)。

- expected_record 新增 out_date / batch_out_date + 索引;入库解析出库时间并聚合批次归属日,支持历史回填

- 新增 compare_site_outdate 应到驱动入口,保留 compare_site_date 实到驱动作对照

- 未到任务 / POST /compare / 全站汇总切换到应到驱动

- 附现状梳理、设计、实现总结三篇文档
2026-08-03 22:52:28 +08:00

233 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 未到(差缺)统计逻辑现状梳理
> 梳理日期2026-08-02
> 分支:`refactor/undelivered-by-expected`
> 范围:顺心、中通、韵达、安能四站的"应到 / 实到 / 差缺"统计链路(百世为站点直供未到,单独处理)
> 目的:固定当前真实逻辑,为"改为基于应到数据统计差缺"的重构提供基线
---
## 一、系统总览与数据流
系统分三层:
1. **下载层**`inbound_verify/sites/*.py`):用 Playwright / CDP 登录各物流站点按目标日期导出「应到货物数据」「实到货物数据」Excel 到 `downloads/`
2. **持久化层**`store.py` + PostgreSQL把 Excel 幂等 UPSERT 进 `expected_record`(应到,运单级)/ `actual_record`(实到,扫描件级)/ `undelivered_record`(百世直供未到,子单级)/ `baishi_daily_stats`(百世日聚合基数)。
3. **统计层**`db_compare.py`):直接查 PostgreSQL以**实到扫描日期为锚点**反推交接批次,展开批次全量应到后逐运单比对,产出差缺统计与明细,落盘 `output/` 下 Excel供前端下载。
```mermaid
flowchart LR
subgraph 下载层["下载层 sites/*.py"]
A1[定时任务<br/>fetch_schedule] --> D[Playwright/CDP 登录站点]
A2[手动任务<br/>POST /tasks] --> D
D --> E1[应到货物数据.xlsx]
D --> E2[实到货物数据.xlsx]
end
subgraph 持久化层["持久化层 store.py + PG"]
E1 --> F[ingest_task<br/>expected_record]
E2 --> G[ingest_task<br/>actual_record]
end
subgraph 统计层["统计层 db_compare.py"]
F --> H[compare_site_date<br/>实到锚点→反推批次→展开应到]
G --> H
H --> I[output/*-未到数据.xlsx]
H --> J[output/应到未到数据.xlsx<br/>全站汇总]
end
I --> K[前端下载]
J --> K
```
---
## 二、数据模型
### 2.1 PostgreSQL 业务表schema.sql
| 表 | 粒度 | 业务唯一键 | 关键列 |
|----|------|-----------|--------|
| `expected_record` | 运单级,一运单一行 | `(site, waybill_no)` | `handover_no``handover_pieces`(交接件数=应到口径)、`order_pieces`(录单件数)、`business_date`(属性,非唯一键)、`raw` JSONB |
| `actual_record` | 扫描件级,一扫描一行 | `(site, piece_no)` | `waybill_no`(运单基号)、`piece_no`(扫描/子单号)、`scan_time``scan_site` |
| `undelivered_record` | 百世直供未到明细,子单级 | `(site, piece_no)` | `biz_type``last_scan` |
| `baishi_daily_stats` | 百世站级日聚合 | `(site, business_date)` | `expected_pieces`(应扫)、`arrived_pieces`(已扫)、`undelivered_pieces`(未扫) |
> 索引:`expected_record` 有 `(site, business_date)` 与 `(site, handover_no)` 索引;`actual_record` 有 `(site, waybill_no)` 与 `(scan_time)` 索引——差缺比对的主查询路径均命中。
### 2.2 SQLite 状态库state.db
- `site_config`:各站 `expected_offset` / `actual_offset`0=今天,最大回溯 30 天)。
- `fetch_schedule`:周期抓取开关、激活时段、间隔(分钟)。
- `task_history`:任务记录(`trigger``target_date``force`)。
- `ingest_state`:最近一次入库健康状态(`ok` / `count` / `error`)。
- `site_status`:登录态 + 各 kind 的 `*_ready``*_business_date`(由 PG 派生,见 §4.4)。
---
## 三、当前差缺统计算法(核心)
### 3.1 一句话概括
> **以目标日期的实到扫描为锚点 → 反推这些运单所属的交接批次 → 展开批次全量应到 → 逐运单比对差缺。**
即:**先有实到,再找应到**。这是本次重构要推翻的核心假设。
### 3.2 算法步骤(`db_compare.compare_site_date`
```mermaid
flowchart TD
S[compare_site_date site, target_date] --> S1
S1["Step1 实到锚点<br/>SELECT DISTINCT waybill_no<br/>FROM actual_record<br/>WHERE site=? AND scan_time::date = target_date"]
S1 -->|当天无实到| X[返回 None<br/>“当天无实到数据,无法比对”]
S1 -->|有实到运单| S2
S2["Step2 反推交接批次<br/>SELECT DISTINCT handover_no<br/>FROM expected_record<br/>WHERE waybill_no = ANY(锚点运单)"]
S2 --> S3
S3["Step3 展开批次全量应到<br/>SELECT waybill_no, handover_no, handover_pieces<br/>FROM expected_record<br/>WHERE handover_no = ANY(批次)"]
S3 -->|无应到| X2[返回 None]
S3 --> S4
S4["Step4 取批次全量实到<br/>SELECT waybill_no, piece_no<br/>FROM actual_record<br/>WHERE waybill_no = ANY(展开的全部运单)"]
S4 --> S5
S5["Step5 逐运单比对 _do_compare"]
S5 --> R[CompareResult<br/>stats + 差缺明细 rows]
```
### 3.3 逐运单比对口径(`_do_compare`
对展开出的每一条应到运单:
| 判断 | 结论 |
|------|------|
| `handover_pieces <= 0` | 跳过,不计入应到 |
| 实到件数 `>=` 应到件数(`handover_pieces` | **足额到货**(含溢到),不进差缺 |
| 实到件数 `== 0` | **完全未到**`full_miss++` |
| `0 <` 实到件数 `<` 应到件数 | **部分未到**`part_miss++` |
实到件数口径SF / 非 SF 分支):
- **非 SF中通/韵达/安能)**`COUNT(DISTINCT piece_no)`,子单号去重。
- **顺心 SF 运单**`waybill_no``SF` 开头):`COUNT(*)` 行计数不去重SF 子单号为随机号码,不能去重计数)。
### 3.4 统计指标定义(`CompareStats`
| 指标 | 定义 |
|------|------|
| `waybill_count` | 应到运单数(`handover_pieces>0` 的展开运单) |
| `expected_pieces` | Σ `handover_pieces`(交接件数,非录单件数) |
| `arrived_pieces` | Σ 各运单实到件数(按上节口径) |
| `undelivered_pieces` | `max(0, expected_pieces arrived_pieces)` |
| `undelivered_wb` | `full_miss + part_miss`(差缺运单数) |
| `full_miss` / `part_miss` | 完全未到 / 部分未到运单数 |
| `sf_wb_count` / `sf_undelivered` | 顺心 SF 运单总数 / 其中差缺数 |
### 3.5 差缺明细(`UndeliveredRow`
仅含短少运单:`交接单号 | 运单号 | 总件数(交接件数) | 已到单号1 | 已到单号2 | ...`。已到单号按实到记录顺序列出,**不编造缺件子单号**(扫描顺序号乱序,无法反推缺了哪个)。
### 3.6 另一个入口:按批次比对(`compare_site_batch`
已知交接单号时可直接按 `handover_no` 精确比对,**不依赖实到锚点**。展开该批次全量应到 → 取全量实到 → 走同一 `_do_compare`。此入口当前未接入任务链路,主要用于调试/复核。
---
## 四、任务触发与执行链路
### 4.1 任务种类
| kind | 含义 | 四站行为 | 百世行为 |
|------|------|---------|---------|
| `expected` | 应到下载 | 下载应到 Excel → 入库 | 不支持 |
| `actual` | 实到下载 | 下载实到 Excel → 入库 | 不支持 |
| `undelivered` | 未到(差缺) | 应到+实到 → 入库 → DB 比对 → 写单站未到 Excel | 站点直供未到 → 入库 |
| `compare``__compare__` | 全站跑比对 | 4 站 DB 比对 + 百世 PG → 全站汇总 Excel | 同上 |
### 4.2 触发方式
- **定时**APScheduler `IntervalTrigger``fetch_schedule` 配置周期投递,激活时段内才投;任务空闲才投(`create_task_if_idle`)。
- **手动**`POST /tasks {site, kind, force?, date?}`,前端「获取未到数据」主按钮触发各站 primary kind前端「跑比对」按钮触发 `__compare__/compare`
任务执行统一走 `dispatch_task``runtime.py`):登录态校验 → 调 handler → 成功后写业务日期(`_record_business_date`)→ 入库(`_persist_to_db`)。
### 4.3 目标日期target_date怎么定
`state_store.resolve_target_date(site, kind, date)`
- 显式传 `date` → 用之(前端重试按钮会回放原 `target_date`)。
- 未传 → `today offset``expected``expected_offset``actual``actual_offset`、四站 `undelivered` 跟随 `expected_offset`、百世恒当天。
### 4.4 就绪态与业务日期ready / business_date
- `_ready_flags`:直接查 PG`expected` / `actual` 看对应 `today offset` 日期是否有数据;四站 `undelivered = expected ∧ actual`;百世看 `baishi_daily_stats` 当天。
- `_record_business_date`:任务成功后在 SQLite 快照本次数据日期,供前端状态盘与报表「数据日期」列展示。
- **注意**`expected_business_date` / `actual_business_date` 是**按偏移派生的目标日期**,不是下载文件内的真实业务日期——`expected_record.business_date` 入库时取自状态库的 `expected_business_date``store._read_business_dates`),可能与交接单实际生成日不同(详见 §6 疑点)。
---
## 五、站点差异与配置
### 5.1 比对配置(`db_compare.SITE_COMPARE_CONFIG`
| 站点 | has_sf | 备注 |
|------|--------|------|
| 顺心 | `True` | SF 运单走行计数 |
| 中通 / 韵达 / 安能 | `False` | 子单号去重计数 |
### 5.2 入库列映射(`store.ACTUAL_COLMAP` 与 `domain.STATIONS`
| 站点 | 应到运单号列 | 实到运单基号列 | 实到单件列 | 扫描时间列 |
|------|-------------|---------------|-----------|-----------|
| 顺心 | 运单号 | 运单号 | 子单号 | 操作时间 |
| 中通 | 运单号 | 由子单号复合串 `v[:-8]` 推导 | 运单号(复合串) | 扫描时间 |
| 韵达 | 运单号 | 主单号 | 子单号 | 扫描时间 |
| 安能 | 运单号 | 所属单号 | 扫描单号 | 扫描时间 |
### 5.3 入库清洗特例
- **韵达实到**:只保留「交接单号为空」的行(到/接件扫描),丢弃非空行(派件/签收等重复数据);再按子单号去重 keep-last。
- **中通实到**`piece_no` 为复合串 `H+运单号(12)+总数(4)+顺序(4)`,基号 = `v[:-8]`,每串计一件。
---
## 六、当前逻辑的特征与疑点(重构输入)
### 6.1 特征
1. **锚点=实到扫描日**:某天实到为空 → 比对直接返回 None不产出任何差缺"先有实到才有结论")。
2. **批次是反推出来的**:只要当天有 1 个运单扫到,其所属整个交接批次都会被展开,把该批次的历史未到也一起统计进来(跨日旧账混入当天报表)。
3. **应到=批次全量**`expected_pieces` 是"被命中批次的全部应到",不是"当天的应到交接单"`business_date` 标签也可能滞后/漂移。
4. **溢到不抵消**`undelivered_pieces = max(0, Σ应到 Σ实到)` 是全局差;单运单溢到(实到>应到)只会让该运单不进差缺,不会抵消他单未到。
### 6.2 疑点(来自 08-02 韵达实测)
- 08-02 实到 130 条/58 运单全部命中应到且件数一致,但差缺 9 单里有 7 单是 07-21 批次的历史未到——因为当天有 1 个运单(`295468511`)属于该批次被扫到,整批被展开。
- `task_history` 无记录却有 18:28 入库 130 条实到:入库链路与常规任务链路不一致(`ingest_state` 也未刷新),说明存在绕过任务系统的直入路径,需在重构时统一入口。
---
## 七、重构目标对照(待细化)
| 维度 | 现状(实到驱动) | 重构方向(应到驱动) |
|------|----------------|--------------------|
| 统计起点 | 目标日期实到扫描运单 | 目标日期/批次的应到数据(`expected_record` |
| 批次来源 | 实到锚点反推 | 应到自身携带的 `handover_no` / `business_date` |
| 无数据表现 | 实到空 → 不产出 | 应到空 → 明确"无应到";应到有、实到空 → 全部记差缺 |
| 历史批次混入 | 会1 单命中即整批展开) | 应到锚定,天然按应到口径隔离 |
| 报表口径 | 批次全量 | 按应到日期/批次统计 |
> 重构后仍需保持:应到=交接件数、实到=子单号去重SF 行计数)、未到=`max(0, 应到−实到)`、差缺明细含已到单号。
---
## 八、涉及文件清单
| 文件 | 职责 |
|------|------|
| `inbound_verify/db_compare.py` | DB 差缺比对引擎 + 全站汇总(重构主战场) |
| `inbound_verify/runtime.py` | 任务派发、未到 handler、入库钩子、就绪派生 |
| `inbound_verify/store.py` | Excel → PG 入库expected/actual/百世) |
| `inbound_verify/state_store.py` | SQLite 状态/配置/任务/偏移/目标日期 |
| `inbound_verify/domain.py` | 站点/文件/列映射单一配置源 |
| `inbound_verify/schema.sql` | PG 表结构 |
| `inbound_verify/cli/server.py` | FastAPI 端点(/tasks /compare /status /config /report |
| `dashboard/app/page.tsx` | 前端触发任务、状态盘、下载报表 |