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

12 KiB
Raw Permalink Blame History

未到(差缺)统计逻辑现状梳理

梳理日期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供前端下载。
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_nohandover_pieces(交接件数=应到口径)、order_pieces(录单件数)、business_date(属性,非唯一键)、raw JSONB
actual_record 扫描件级,一扫描一行 (site, piece_no) waybill_no(运单基号)、piece_no(扫描/子单号)、scan_timescan_site
undelivered_record 百世直供未到明细,子单级 (site, piece_no) biz_typelast_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_offset0=今天,最大回溯 30 天)。
  • fetch_schedule:周期抓取开关、激活时段、间隔(分钟)。
  • task_history:任务记录(triggertarget_dateforce)。
  • 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_noSF 开头):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 IntervalTriggerfetch_schedule 配置周期投递,激活时段内才投;任务空闲才投(create_task_if_idle)。
  • 手动POST /tasks {site, kind, force?, date?},前端「获取未到数据」主按钮触发各站 primary kind前端「跑比对」按钮触发 __compare__/compare

任务执行统一走 dispatch_taskruntime.py):登录态校验 → 调 handler → 成功后写业务日期(_record_business_date)→ 入库(_persist_to_db)。

4.3 目标日期target_date怎么定

state_store.resolve_target_date(site, kind, date)

  • 显式传 date → 用之(前端重试按钮会回放原 target_date)。
  • 未传 → today offsetexpectedexpected_offsetactualactual_offset、四站 undelivered 跟随 expected_offset、百世恒当天。

4.4 就绪态与业务日期ready / business_date

  • _ready_flags:直接查 PGexpected / 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_datestore._read_business_dates),可能与交接单实际生成日不同(详见 §6 疑点)。

五、站点差异与配置

5.1 比对配置(db_compare.SITE_COMPARE_CONFIG

站点 has_sf 备注
顺心 True SF 运单走行计数
中通 / 韵达 / 安能 False 子单号去重计数

5.2 入库列映射(store.ACTUAL_COLMAPdomain.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 前端触发任务、状态盘、下载报表