将未到统计从实到驱动(实到锚点反推批次)改为应到驱动(batch_out_date 归属日直接取应到批次),以吸收应到任务提前 1~2 天提交的扰动(韵达固定 +1、中通偶发 +1)。 - expected_record 新增 out_date / batch_out_date + 索引;入库解析出库时间并聚合批次归属日,支持历史回填 - 新增 compare_site_outdate 应到驱动入口,保留 compare_site_date 实到驱动作对照 - 未到任务 / POST /compare / 全站汇总切换到应到驱动 - 附现状梳理、设计、实现总结三篇文档
12 KiB
未到(差缺)统计逻辑现状梳理
梳理日期:2026-08-02 分支:
refactor/undelivered-by-expected范围:顺心、中通、韵达、安能四站的"应到 / 实到 / 差缺"统计链路(百世为站点直供未到,单独处理) 目的:固定当前真实逻辑,为"改为基于应到数据统计差缺"的重构提供基线
一、系统总览与数据流
系统分三层:
- 下载层(
inbound_verify/sites/*.py):用 Playwright / CDP 登录各物流站点,按目标日期导出「应到货物数据」「实到货物数据」Excel 到downloads/。 - 持久化层(
store.py+ PostgreSQL):把 Excel 幂等 UPSERT 进expected_record(应到,运单级)/actual_record(实到,扫描件级)/undelivered_record(百世直供未到,子单级)/baishi_daily_stats(百世日聚合基数)。 - 统计层(
db_compare.py):直接查 PostgreSQL,以实到扫描日期为锚点反推交接批次,展开批次全量应到后逐运单比对,产出差缺统计与明细,落盘output/下 Excel,供前端下载。
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)
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 特征
- 锚点=实到扫描日:某天实到为空 → 比对直接返回 None,不产出任何差缺("先有实到才有结论")。
- 批次是反推出来的:只要当天有 1 个运单扫到,其所属整个交接批次都会被展开,把该批次的历史未到也一起统计进来(跨日旧账混入当天报表)。
- 应到=批次全量:
expected_pieces是"被命中批次的全部应到",不是"当天的应到交接单";business_date标签也可能滞后/漂移。 - 溢到不抵消:
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 |
前端触发任务、状态盘、下载报表 |