diff --git a/docs/2026-07-31-四站点差缺对比逻辑审查报告.md b/docs/2026-07-31-四站点差缺对比逻辑审查报告.md new file mode 100644 index 0000000..9c1e7ec --- /dev/null +++ b/docs/2026-07-31-四站点差缺对比逻辑审查报告.md @@ -0,0 +1,332 @@ +# 四站点差缺对比逻辑审查报告 + +> 审查日期:2026-07-31 +> 审查范围:顺心、中通、韵达、安能 四个站点的应到 vs 实到差缺对比逻辑 +> 排除:百世(站点直供未到明细,不参与四站比对) + +--- + +## 一、比对算法总览(四站共用) + +`compare.py:process()` 对四个站点执行**完全相同**的算法步骤。站点间的差异仅由 `domain.py:STATIONS` 配置注入——列名映射 + 实到单号解析器。 + +``` +步骤1: 读应到Excel → 按运单号去重keep-first → 构建 {运单号 → (交接单号, 交接件数=n)} +步骤2: 读实到Excel → 站点专用解析器 → 构建 {运单基号 → {已到单号集合}} +步骤3: 逐运单比对 + arrived_cnt >= n → 足额到货,跳过 + arrived_cnt == 0 → 完全未到 + 0 < arrived < n → 部分未到 +步骤4: 产出未到明细(交接单号 | 运单号 | 总件数 | 已到单号1 | 已到单号2 | ...) +``` + +### 核心口径 + +| 指标 | 口径 | +|------|------| +| 应到件数 | **交接件数**(非录单件数);按运单号去重 keep-first | +| 实到件数 | 单号去重计数(每扫描一件=一个单号) | +| 未到件数 | max(0, 应到件数 − 实到件数) | +| 未到率 | 未到件数 ÷ 应到件数 | + +### 未到明细输出约定 + +- 仅列出**短少运单**(实到 < 应到) +- 列出该运单**实际已到的单号**(已到单号1, 已到单号2, ...) +- **不编造缺件子单号**——实到扫描顺序号乱序,无法反推缺了哪个顺序号 + +### 统计指标 + +| 指标 | 含义 | +|------|------| +| 运单数 | 应到运单去重数 | +| 应到件 | Σ 交接件数 | +| 已到件 | Σ 实到单号去重数 | +| 未到件 | max(0, 应到件 − 已到件) | +| 涉及运单 | 存在短少的运单数 | +| 完全未到 | 整单零到货运单数 | +| 部分未到 | 部分缺件运单数 | + +--- + +## 二、四站配置对照 + +`domain.py:STATIONS` — 所有差异集中于此配置表,比对核心代码不感知站点差异。 + +| 维度 | 中通 | 顺心 | 韵达 | 安能 | +|------|------|------|------|------| +| 应到文件 | `中通-应到货物数据.xlsx` | `顺心-应到货物数据.xlsx` | `韵达-应到货物数据.xlsx` | `安能-应到货物数据.xlsx` | +| 实到文件 | `中通-实到货物数据.xlsx` | `顺心-实到货物数据.xlsx` | `韵达-实到货物数据.xlsx` | `安能-实到货物数据.xlsx` | +| 应到-运单号列 | `运单号` | `运单号` | `运单号` | `运单号` | +| 应到-件数列 | `交接件数` | `交接件数` | `交接件数` | `交接件数` | +| 应到-交接单号列 | `交接单号` | `交接单号` | `交接单号` | `交接单号` | +| 实到-基号列 | —(从复合串推导) | `运单号` | **`主单号`** | **`所属单号`** | +| 实到-单号列 | `运单号`(复合串) | `子单号` | `子单号` | `扫描单号` | +| 解析器 | `arrived_pieces_zhongtong` | `arrived_pieces_by_cols` | `arrived_pieces_by_cols` | `arrived_pieces_by_cols` | + +--- + +## 三、逐站点详细分析 + +### 3.1 中通(ZTO) + +#### 业务逻辑 + +实到货物数据中的「运单号」为复合串,由三部分构成: + +``` +┌──────────┬────────────┬──────────┐ +│ 运单号 │ 录单件数 │ 顺序号 │ +│ (12位) │ (4位) │ (4位) │ +└──────────┴────────────┴──────────┘ + 总长 20 位 + +示例: 330953527953 0001 0001 + ├─ 运单号 ─┤├录单┤├顺序┤ +``` + +- **运单号(12位)**: 与应到货物数据中的运单号对齐 +- **录单件数(4位)**: 该运单在系统中的录单总件数,0占位 +- **顺序号(4位)**: 0占位,如 `0001`, `0002`, `0003`, `0004` + +对比逻辑: +1. 从应到数据取运单号 + 交接件数(**非录单件数**) +2. 从实到数据取复合串,掐尾8位得运单基号,完整串为子运单号 +3. 按运单基号分组,子运单号去重得实到件数 +4. 实到件数 < 交接件数 → 差缺 + +> **重要**: 录单件数仅作参考。举例:某运单录单件数=4、交接件数=2,实到最多出现2条数据。如果只出现了1条,我们只知道差缺了,但**无法判断具体差缺了哪一件**(顺序号乱序)。 + +#### 代码实现 + +`domain.py:17-26` — 实到解析器: + +```python +def arrived_pieces_zhongtong(df): + res = defaultdict(set) + for v in df["运单号"]: + v = str(v).strip() + if len(v) > 8 and v[-4:].isdigit(): + res[v[:-8]].add(v) # 基号=前12位, 已到单号=完整20位复合串 + return res +``` + +`domain.py:48-56` — 站点配置: + +```python +{ + "name": "中通", + "exp_qty": "交接件数", # 应到件数口径:交接件数(非录单件数) + "exp_wb": "运单号", + "exp_jd": "交接单号", + "arrived_pieces": arrived_pieces_zhongtong, + "columns": ["交接单号", "运单号", "总件数"], +} +``` + +#### 对齐情况:✅ 对齐 + +代码实现与业务逻辑一致。`v[:-8]` 掐尾8位得12位运单基号,保留完整复合串作为已到单号——不解析、不推断录单件数和顺序号的具体含义。 + +--- + +### 3.2 安能(Anneng) + +#### 业务逻辑 + +与中通相同的差缺对比逻辑。 + +安能实到数据同样为复合串,结构:`运单号(12位) + 录单件数(4位) + 顺序号(4位)`(20位)。 + +与中通的关键区别:安能实到表有**独立的「所属单号」列**(干净运单基号),无需像中通那样从复合串掐尾8位推导基号。 + +#### 代码实现 + +`domain.py:76-86`: + +```python +{ + "name": "安能", + "arrived_pieces": arrived_pieces_by_cols("所属单号", "扫描单号"), + ... +} +``` + +安能使用 `arrived_pieces_by_cols` 而非 `arrived_pieces_zhongtong`——直接从「所属单号」列读基号、从「扫描单号」列读完整单号,效果等价。 + +| 差异点 | 中通 | 安能 | +|--------|------|------| +| 实到基号来源 | 从复合串解析(`v[:-8]`) | 直接读「所属单号」列 | +| 实到单号来源 | 复合串本身(「运单号」列) | 「扫描单号」列 | +| 解析器 | `arrived_pieces_zhongtong` | `arrived_pieces_by_cols` | +| 最终产出 | `{基号 → {完整单号集合}}` | 相同 | + +#### 数据库验证 + +``` +piece_no=61003282264500140014 → waybill_no=610032822645 (12位), total=0014, seq=0014 +``` + +#### 对齐情况:✅ 对齐 + +--- + +### 3.3 顺心(Shunxin)⚠️ + +#### 业务逻辑 + +顺心站点需区分两类运单: + +**A. 非SF开头运单(占 97%):** + +实到「子单号」结构为两部分: + +``` +┌──────────┬──────────┐ +│ 运单号 │ 顺序号 │ +│ (不定长) │ (3位) │ +└──────────┴──────────┘ + +示例: S71623721115 001 + ├─ 运单号 ──┤├顺序┤ + +注意:顺心子单号无录单件数部分(仅两部分) +``` + +对比时从实到取「子单号」列,按「运单号」分组,子单号去重得实到件数。 + +**B. SF开头运单(占 3%):** + +SF订单的「子单号」为**随机号码**(非由运单号衍生),不能用于差缺推导。 + +对比逻辑: +1. 在实到数据中按「运单号」字段查找,统计出现次数 +2. 出现次数 < 交接件数 → 差缺 +3. 将找到的子单号(虽随机但可以列出来)填入「已到单号」列 + +SF订单的差缺判定:**只基于交接件数与实到运单号出现次数的比较**,不依赖子单号的结构解析。 + +#### 代码实现 + +`domain.py:57-65`: + +```python +{ + "name": "顺心", + "arrived_pieces": arrived_pieces_by_cols("运单号", "子单号"), +} +``` + +**SF 与非 SF 没有任何区分处理。** 所有运单走同一条路径。 + +#### 数据库验证 + +**非SF(正常):** +``` +子单号=S71623721115001 → 运单号=S71623721115 + 后缀=001 ✅ +子单号=S71934073996002 → 运单号=S71934073996 + 后缀=002 ✅ +``` + +**SF(异常):** +``` +运单号=SF1225002296515 的两条实到记录: + 子单号=SF2025318183224 (随机SF号码) + 子单号=SF1225002296515 (与运单号相同) +``` +数据中有 10 个SF运单存在多条实到记录。 + +#### 对齐情况:⚠️ 部分对齐,SF特殊逻辑缺失 + +| 检查项 | 代码现状 | 业务要求 | +|--------|----------|----------| +| 非SF处理 | ✅ `arrived_pieces_by_cols("运单号", "子单号")` | 一致 | +| 非SF子单号结构 | ✅ 运单号 + 顺序号(两部分) | 一致 | +| SF处理 | ❌ 与非SF完全一致,使用子单号去重 | **不能**使用子单号,只按运单号行数计数 | +| 功能影响 | 子单号虽随机但值唯一,按目前逻辑也能正确去重计数 | 但语义不正确——SF子单号不由运单号衍生 | + +--- + +### 3.4 韵达(Yunda)❌ + +#### 业务逻辑 + +**去重规则:** 韵达实到数据存在重复行(同一子单号出现两次)。去重依据为「交接单号」字段: +- **保留**交接单号为**空**的行 +- **丢弃**交接单号**非空**的行 + +**子单号结构:** 两部分——单号 + 顺序号(无录单件数部分)。 + +``` +┌──────────┬──────────┐ +│ 主单号 │ 顺序号 │ +│ (不定长) │ (4位) │ +└──────────┴──────────┘ + +示例: 713326603 0003 + ├─主单号─┤├顺序┤ +``` + +**对比方式:** 与中通/安能同——按「主单号」分组,「子单号」去重得实到件数,与交接件数比对。 + +#### 代码实现 + +`store.py:316-320`(入库过滤): + +```python +if site == "韵达": + # 韵达业务清洗:抛弃「交接单号」为空的行(派件/签收等其他扫描无交接单号), + # 再按子单号去重(一件多扫只留一条;清洗后子单号已天然唯一,drop 为保险)。 + df = df[df["交接单号"].astype(str).str.strip() != ""] # ← 保留非空 + df = df.drop_duplicates(subset=[cm["piece"]], keep="last") +``` + +`domain.py:67-75`(比对配置): + +```python +{ + "name": "韵达", + "exp_wb": "运单号", + "arrived_pieces": arrived_pieces_by_cols("主单号", "子单号"), +} +``` + +#### 对齐情况:❌ 交接单号过滤逻辑完全相反 + +| 检查项 | 代码现状 | 业务要求 | +|--------|----------|----------| +| 交接单号过滤 | 保留 `!= ""`(**非空**) | 保留 `== ""`(**空**) | +| 子单号结构 | ✅ `7133266030003` = wb`713326603` + seq`0003` | 一致 | +| 实到解析 | ✅ `arrived_pieces_by_cols("主单号", "子单号")` | 一致 | +| compare.py 过滤 | ❌ **无过滤**,所有行参与比对 | 需要过滤 | + +**影响分析:** + +1. `store.py` 过滤反了——入库时留下了错误的数据集 +2. `compare.py` 完全没有交接单号过滤——如果原始 Excel 中同时存在空和非空行,比对阶段会全部读入导致重复计数 +3. 当前数据库中韵达 3483 条记录全部为非空交接单号——说明当前 Excel 数据中空交接单号行偏少或不存在,但这不改变逻辑错误 + +--- + +## 四、差异汇总 + +| # | 站点 | 问题 | 严重程度 | 影响范围 | +|---|------|------|----------|----------| +| 1 | **韵达** | 交接单号过滤反了:`!= ""` 应改为 `== ""` | ❌ 严重 | `store.py:319` + `compare.py` 需新增过滤 | +| 2 | **顺心** | SF运单无特殊处理,与非SF混用子单号 | ⚠️ 中等 | `domain.py` 需新增SF判断分支 | +| 3 | **中通** | 录单件数0占位描述与实际数据完全一致 | ✅ 无影响 | 代码不依赖此区分 | + +--- + +## 五、代码位置索引 + +| 逻辑 | 文件 | 行号 | +|------|------|------| +| 单站比对 `process()` | `compare.py` | 61-143 | +| 站点配置 `STATIONS` | `domain.py` | 46-87 | +| 中通实到解析器 | `domain.py` | 17-26 | +| 通用实到解析器 | `domain.py` | 29-42 | +| 单站未到文件写入 | `compare.py` | 258-273 | +| 全量汇总报告 | `compare.py` | 293-324 | +| 未到触发编排 | `runtime.py` | 478-497 | +| 韵达入库过滤(需修) | `store.py` | 316-320 | +| 顺心实到配置(需修) | `domain.py` | 57-65 |