修复安能实到日期设定 + 站点统计口径重构
- site_anneng.py: set_scan_date 改用 execCommand 全选替换+回车(修复 Ant DatePicker 受控组件未写入导致实到下成当天); wait_scan_form_ready 增加 settle 与 DatePicker 预热,消除首设竞态 - expected_undelivered.py: 应到口径改交接件数、实到改单号去重、未到明细列已到单号(不再编子单号),百世排除 - docs: 补充站点统计逻辑审查报告与韵达/安能计算逻辑梳理
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user