Compare commits

2 Commits

Author SHA1 Message Date
Misaka_Company
95597fbb0c docs: add four-site comparison logic review report
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 14:48:24 +08:00
Misaka_Company
f66e6dd39e fix(yunda): invert handover number filter to keep empty rows
The previous filter kept rows with non-empty handover numbers
(派件/签收 scans), which were duplicate rows. The correct logic
is to keep rows with empty handover numbers (到/接件 scans).

- store.py: change != "" to == "" in ingest filter
- compare.py: add same filter before comparison (previously missing)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 14:44:50 +08:00
3 changed files with 341 additions and 2 deletions

View File

@@ -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 |

View File

@@ -75,6 +75,12 @@ def process(name):
df_exp = pd.read_excel(exp_path, dtype=str).fillna("") df_exp = pd.read_excel(exp_path, dtype=str).fillna("")
df_act = pd.read_excel(act_path, dtype=str).fillna("") df_act = pd.read_excel(act_path, dtype=str).fillna("")
if name == "韵达":
# 韵达实到数据有重复行(同子单号出现两次),保留交接单号为空的(到/接件扫描),
# 丢弃交接单号不为空的(派件/签收等),再按子单号去重。
df_act = df_act[df_act["交接单号"].astype(str).str.strip() == ""]
df_act = df_act.drop_duplicates(subset=["子单号"], keep="last")
# 同一运单可能有多条交接记录,按运单号去重、保留首条 # 同一运单可能有多条交接记录,按运单号去重、保留首条
dup = int(df_exp[cfg["exp_wb"]].duplicated().sum()) dup = int(df_exp[cfg["exp_wb"]].duplicated().sum())
df_exp = df_exp.drop_duplicates(subset=[cfg["exp_wb"]], keep="first") df_exp = df_exp.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")

View File

@@ -314,9 +314,10 @@ def _ingest_actual(cur, site):
cm = ACTUAL_COLMAP[site] cm = ACTUAL_COLMAP[site]
df = pd.read_excel(path, dtype=str).fillna("") df = pd.read_excel(path, dtype=str).fillna("")
if site == "韵达": if site == "韵达":
# 韵达业务清洗:抛弃「交接单号」为空的行(派件/签收等其他扫描无交接单号 # 韵达业务清洗:保留「交接单号」为空的行(到/接件扫描
# 抛弃「交接单号」不为空的行(派件/签收等,属重复数据)。
# 再按子单号去重一件多扫只留一条清洗后子单号已天然唯一drop 为保险)。 # 再按子单号去重一件多扫只留一条清洗后子单号已天然唯一drop 为保险)。
df = df[df["交接单号"].astype(str).str.strip() != ""] df = df[df["交接单号"].astype(str).str.strip() == ""]
df = df.drop_duplicates(subset=[cm["piece"]], keep="last") df = df.drop_duplicates(subset=[cm["piece"]], keep="last")
rows = [] rows = []
for r in df.to_dict("records"): for r in df.to_dict("records"):