# 未到(差缺)统计逻辑现状梳理 > 梳理日期: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[定时任务
fetch_schedule] --> D[Playwright/CDP 登录站点] A2[手动任务
POST /tasks] --> D D --> E1[应到货物数据.xlsx] D --> E2[实到货物数据.xlsx] end subgraph 持久化层["持久化层 store.py + PG"] E1 --> F[ingest_task
expected_record] E2 --> G[ingest_task
actual_record] end subgraph 统计层["统计层 db_compare.py"] F --> H[compare_site_date
实到锚点→反推批次→展开应到] G --> H H --> I[output/*-未到数据.xlsx] H --> J[output/应到未到数据.xlsx
全站汇总] 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 实到锚点
SELECT DISTINCT waybill_no
FROM actual_record
WHERE site=? AND scan_time::date = target_date"] S1 -->|当天无实到| X[返回 None
“当天无实到数据,无法比对”] S1 -->|有实到运单| S2 S2["Step2 反推交接批次
SELECT DISTINCT handover_no
FROM expected_record
WHERE waybill_no = ANY(锚点运单)"] S2 --> S3 S3["Step3 展开批次全量应到
SELECT waybill_no, handover_no, handover_pieces
FROM expected_record
WHERE handover_no = ANY(批次)"] S3 -->|无应到| X2[返回 None] S3 --> S4 S4["Step4 取批次全量实到
SELECT waybill_no, piece_no
FROM actual_record
WHERE waybill_no = ANY(展开的全部运单)"] S4 --> S5 S5["Step5 逐运单比对 _do_compare"] S5 --> R[CompareResult
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` | 前端触发任务、状态盘、下载报表 |