修复安能实到日期设定 + 站点统计口径重构

- site_anneng.py: set_scan_date 改用 execCommand 全选替换+回车(修复 Ant DatePicker 受控组件未写入导致实到下成当天); wait_scan_form_ready 增加 settle 与 DatePicker 预热,消除首设竞态
- expected_undelivered.py: 应到口径改交接件数、实到改单号去重、未到明细列已到单号(不再编子单号),百世排除
- docs: 补充站点统计逻辑审查报告与韵达/安能计算逻辑梳理
This commit is contained in:
Misaka
2026-07-19 13:16:01 +08:00
parent f3ff188302
commit 8f79cbc5d7
4 changed files with 430 additions and 122 deletions

View File

@@ -1,25 +1,29 @@
# -*- coding: utf-8 -*-
"""
应到未到数据比对
应到未到数据比对(重构版)
目的:对中通 / 顺心 / 韵达 / 安能四个站点,比对各自的「应到货物数据」与
「实到货物数据」,逐件找出应到却未到的单,汇总到 output/应到未到数据.xlsx。
工作簿结构:
· 汇总报表 —— 首页,跨站点统计与可视化(不含明细)。
· 中通 / 顺心 / 韵达 / 安能 —— 各站未到明细,每行一件。
目的:对中通 / 顺心 / 韵达 / 安能 四个站点,比对各自的「应到货物数据」与
「实到货物数据」,找出应到却未到的单,汇总到 output/应到未到数据.xlsx。
(百世为站点直供未到明细,不参与本模块比对,见 process_baishi。
核心逻辑(四站点统一):
1. 每个运单的应到件数 N = 应到数据中的「录单件数」(输出列名为「总件数」)。
2. 该运单应到的子单序号集合 = {1, 2, …, N}。
3. 从实到数据中解析出该运单实际已到的序号集合
4. 应到未到 = {1…N} 已到序号
—— 缺件按定义不在实到里,故其子单号 / 扫描单号由程序按各站格式现拼生成。
核心口径(四站点统一,重构后
1. 应到件数 = 应到表「交接件数」之和(按运单号去重 keep-first)。
—— 录单件数 只是该单号的总录单量,实际只有“交接件数”会真正到站,
故应到必须按交接件数统计,不能用录单件数
2. 实到件数 = 实到表「单号」的去重数量(直接数,不再由“应到−未到”倒推)
—— 每扫描一件,系统生成该件的单号(一个单号=一件);后缀含总数/顺序号,
但计数时无视后缀,仅对单号去重即得实到件数。
3. 未到件数 = max(0, 应到件数 实到件数)。
4. 未到明细downloads/<站>-未到数据.xlsx仅列“短少”运单实到 < 应到),
每行:交接单号 | 运单号 | 总件数(=应到/交接件数) | 已到单号1 | 已到单号2 | …。
—— 实到扫描的顺序号是乱序的,缺件的“顺序号”无法反推,故不再编造子单号;
改为把该运单“实际扫到的单号”依次填到后续单元格,便于核对到了哪几件。
各站差异(已据真实数据核定)
中通:实到「运单号」列即复合串 = 运单号 + 总数(4位) + 顺序号(4位),从右解析。
顺心:实到有干净「运单号」列 +「子单号」= 运单号 + 顺序号(3位)。
韵达:实到「主单号」对应运单号,「子单号」= 主单号 + 顺序号(4位)。
安能:实到「所属单号」对应运单号,「扫描单号」= 所属单号 + 总数(4位) + 顺序号(4位)。
各站实到单号列 / 运单基号
中通:单号列=运单号(复合串 H+运单号+总数+顺序),基号=v[:-8]
顺心:单号列=子单号,基号=运单号
韵达:单号列=子单号,基号=主单号
安能:单号列=扫描单号,基号=所属单号
目录约定:
源数据放在脚本同级目录的 downloads/ 下;结果写入 output/(不存在则自动创建)。
@@ -52,91 +56,72 @@ BAISHI_COLUMNS = ["类型", "子单号", "运单号", "最新扫描记录"]
# ============================ 比对逻辑 ============================
def arrived_zhongtong(df):
"""中通:运单号列即复合串,右侧 8 位 = 总数(4)+顺序(4),其余为运单号。"""
def arrived_pieces_zhongtong(df):
"""中通:实到「运单号」为复合串H + 运单号(12) + 总数(4) + 顺序(4))。
基号 = v[:-8](与应到表运单号对齐),单件 = 整串(每串即一件)。"""
res = defaultdict(set)
for v in df["运单号"]:
v = str(v).strip()
if len(v) > 8 and v[-4:].isdigit():
res[v[:-8]].add(int(v[-4:]))
res[v[:-8]].add(v) # 以完整复合串作为“已到单号”存入
return res
def arrived_by_prefix(main_col, sub_col, has_total=False):
"""顺心 / 韵达 / 安能:子单号以主单号为前缀,后缀含顺序号。
has_total=True 时后缀为 总数(4)+顺序(4),取末 4 位为顺序号。"""
def arrived_pieces_by_cols(wb_col, piece_col):
"""顺心 / 韵达 / 安能:按干净运单列分组,单件 = 子单号 / 扫描单号。
wb_col实到表中与应到运单号对齐的干净列
(顺心=运单号 / 韵达=主单号 / 安能=所属单号)
piece_col实到表中每件货物的单号列子单号 / 扫描单号)"""
def parse(df):
res = defaultdict(set)
for m, s in zip(df[main_col], df[sub_col]):
for m, s in zip(df[wb_col], df[piece_col]):
m, s = str(m).strip(), str(s).strip()
if not m or not s:
continue
if s == m: # 无后缀的单件,记为第 1 件
res[m].add(1)
continue
if not s.startswith(m):
continue
seq = s[len(m) :][-4:] if has_total else s[len(m) :]
if seq.isdigit():
res[m].add(int(seq))
if m and s:
res[m].add(s)
return res
return parse
def code_zhongtong(wb, seq, n):
return f"{seq:04d}" # 中通:仅顺序号
def code_shunxin(wb, seq, n):
return f"{wb}{seq:03d}" # 顺心:运单号 + 3 位顺序
def code_yunda(wb, seq, n):
return f"{wb}{seq:04d}" # 韵达:主单号 + 4 位顺序
def code_anneng(wb, seq, n):
return f"{wb}{n:04d}{seq:04d}" # 安能:运单号 + 总数 + 顺序
STATIONS = [
{
"name": "中通",
"exp": "中通-应到货物数据.xlsx",
"act": "中通-实到货物数据.xlsx",
"arrived": arrived_zhongtong,
"code": code_zhongtong,
"code_col": "单号",
"columns": ["交接单号", "运单号", "子单号", "总件数"],
"exp_qty": "交接件数", # 应到件数口径:交接件数(非录单件数)
"exp_wb": "运单号", # 应到表运单号列(兼作去重键)
"exp_jd": "交接单号", # 未到数据需展示的交接单号
"arrived_pieces": arrived_pieces_zhongtong,
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "顺心",
"exp": "顺心-应到货物数据.xlsx",
"act": "顺心-实到货物数据.xlsx",
"arrived": arrived_by_prefix("运单号", "子单号"),
"code": code_shunxin,
"code_col": "单号",
"columns": ["班次号", "交接单号", "运单号", "子单号", "总件数"],
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("运单号", "子单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "韵达",
"exp": "韵达-应到货物数据.xlsx",
"act": "韵达-实到货物数据.xlsx",
"arrived": arrived_by_prefix("主单号", "子单号"),
"code": code_yunda,
"code_col": "单号",
"columns": ["交接单号", "单号", "子单号", "总件数"],
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("单号", "子单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "安能",
"exp": "安能-应到货物数据.xlsx",
"act": "安能-实到货物数据.xlsx",
"arrived": arrived_by_prefix("所属单号", "扫描单号", has_total=True),
"code": code_anneng,
"code_col": "扫描单号",
"columns": ["交接单号", "单号", "扫描单号", "总件数"],
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("所属单号", "扫描单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
]
@@ -147,7 +132,10 @@ def _site_cfg(name):
def process(name):
"""4 站单站比对:返回 (列名list, 明细行list[dict], 统计dict);源文件缺失或非 4 站返回 None。"""
"""4 站单站比对(重构版):返回 (列名list, 明细行list[dict], 统计dict)
源文件缺失或非 4 站返回 None。
新口径:应到=交接件数;实到=直接数单号去重;未到=应到−实到;
未到明细行仅含「交接单号|运单号|总件数|+已到单号…」,不再编造子单号。"""
cfg = _site_cfg(name)
if cfg is None:
return None
@@ -160,58 +148,72 @@ def process(name):
df_exp = pd.read_excel(exp_path, dtype=str).fillna("")
df_act = pd.read_excel(act_path, dtype=str).fillna("")
# 同一运单可能有多条交接记录(录单件数一致),按运单号去重、保留首条
dup = int(df_exp["运单号"].duplicated().sum())
df_exp = df_exp.drop_duplicates(subset=["运单号"], keep="first")
# 同一运单可能有多条交接记录,按运单号去重、保留首条
dup = int(df_exp[cfg["exp_wb"]].duplicated().sum())
df_exp = df_exp.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")
arrived = cfg["arrived"](df_act)
rows = []
exp_pieces = full_miss = part_miss = 0
# 应到件数(新口径)= 交接件数 之和;记录 运单 -> (交接单号, 应到件数)
exp_by_wb = {}
exp_pieces = 0
for _, r in df_exp.iterrows():
wb = str(r["运单号"]).strip()
wb = str(r[cfg["exp_wb"]]).strip()
if not wb:
continue
try:
n = int(float(r["总件数"] if "总件数" in r else r["录单件数"]))
n = int(float(r[cfg["exp_qty"]]))
except (TypeError, ValueError, KeyError):
try:
n = int(float(r["录单件数"]))
except (TypeError, ValueError, KeyError):
continue
n = 0
if n <= 0:
continue
exp_pieces += n
missing = sorted(set(range(1, n + 1)) - arrived.get(wb, set()))
if missing:
if len(missing) == n:
full_miss += 1
else:
part_miss += 1
for seq in missing:
row = {}
for col in cfg["columns"]:
if col == "运单号":
row[col] = wb
elif col == cfg["code_col"]:
row[col] = cfg["code"](wb, seq, n)
elif col == "总件数":
row[col] = n
else:
row[col] = str(r.get(col, "")).strip()
rows.append(row)
if wb not in exp_by_wb:
exp_by_wb[wb] = {
"jd": str(r.get(cfg["exp_jd"], "")).strip(),
"n": n,
}
# 实到件数(新口径)= 实到表单号去重数量(分组 运单->已到单号集合)
arrived = cfg["arrived_pieces"](df_act)
act_pieces = sum(len(s) for s in arrived.values()) # 全局去重单号数
# 未到:逐运单比较,列出实际已到的单号(顺序号乱序,无法反推缺件序号)
rows = []
full_miss = part_miss = 0
max_arrived = 0
for wb, info in exp_by_wb.items():
n = info["n"]
arrived_set = arrived.get(wb, set())
arrived_cnt = len(arrived_set)
if arrived_cnt >= n:
continue # 足额或溢到,不进未到表
if arrived_cnt == 0:
full_miss += 1
else:
part_miss += 1
max_arrived = max(max_arrived, arrived_cnt)
row = {
cfg["exp_jd"]: info["jd"],
cfg["exp_wb"]: wb,
"总件数": n,
}
for i, piece in enumerate(sorted(arrived_set, key=lambda x: str(x))):
row[f"已到单号{i+1}"] = piece
rows.append(row)
# 动态列:基础 3 列 + 已到单号1..max_arrived
columns = list(cfg["columns"]) + [f"已到单号{i+1}" for i in range(max_arrived)]
stats = {
"运单数": len(df_exp),
"运单数": len(exp_by_wb),
"应到件": exp_pieces,
"已到件": exp_pieces - len(rows),
"未到件": len(rows),
"已到件": act_pieces,
"未到件": max(0, exp_pieces - act_pieces),
"涉及运单": full_miss + part_miss,
"完全未到": full_miss,
"部分未到": part_miss,
"重复运单": dup,
}
return cfg["columns"], rows, stats
return columns, rows, stats
# ============================ 样式常量 ============================
@@ -258,7 +260,7 @@ def write_station(ws, columns, rows):
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = BORDER
for row in rows:
ws.append([row[c] for c in columns])
ws.append([row.get(c, "") for c in columns])
for r in range(2, ws.max_row + 1):
for c, col in enumerate(columns, start=1):
cell = ws.cell(row=r, column=c)
@@ -270,7 +272,7 @@ def write_station(ws, columns, rows):
else:
cell.number_format = "@" # 文本,避免长单号被转科学计数
for c, col in enumerate(columns, start=1):
body = [len(str(row[col])) for row in rows] if rows else []
body = [len(str(row.get(col, ""))) for row in rows] if rows else []
width = min(max([len(str(col))] + body) + 4, 36)
ws.column_dimensions[ws.cell(row=1, column=c).column_letter].width = max(
width, 12
@@ -594,7 +596,7 @@ def build_summary(ws, results, generated_at, dates=None):
"指标口径:未到率 未到件数 ÷ 应到件数;完全未到运单 整单零到货;部分未到运单 部分到货、部分缺件。",
"合计 / 图表仅含 4 站(顺心/中通/韵达/安能,应到−实到口径);百世为站点直供未到、无应到基数,单列不计入合计。",
"本次下载失败的站点标注为(无数据)并计 0不影响其余站点统计。",
"明细见各站点工作表;4 站缺件的子单号 / 扫描单号按各站编号规则生成,并非实到原始记录",
"明细见各站点工作表;未到明细仅列短少运单并列出该运单实际扫到的单号已到单号1…缺件不再编造子单号",
"数据日期:各站本次纳入数据对应的业务日期(=应到数据下载日 日期偏移;韵达偏移 1 为前一日);合计为多站混合、不标注。",
]
for k, text in enumerate(notes):