未到数据按站独立 + 百世并入汇总 + 全量跑比对(失败容错)

- state_store:site_status 加 undelivered_ready 字段(旧库 ALTER 迁移);init_db 提早到启动最前(server lifespan + launch_and_prepare 第0步),避免 /api/status 早于迁移报错
- expected_undelivered:重构为 process(name)/process_baishi/write_site_file/build_full_report;build_summary 支持百世(仅未到件、无基数,不计入合计/图表)与失败容错(未成功站保留行无数据)
- runtime:4 站 ("站","undelivered") = 下应到+实到 → 比对写 <站>-未到数据.xlsx;("__compare__","compare") 改 run_all(顺序跑5站、记成功清单 → build_full_report,未登录/失败跳过);DATA_FILENAMES 加 undelivered、心跳探测之;_site_undelivered_handler 用 is not False 与 dispatch 一致
- site_shunxin:shunxin_expected/actual_download 改为 return with_retry 结果(修复返回 None 致调用方误判失败、以及 with_retry 失败被当成功的潜在 bug)
- server:lifespan 启动时 init_db

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-17 23:19:21 +08:00
parent 26a94999dc
commit 82c80fc859
5 changed files with 272 additions and 82 deletions

View File

@@ -41,6 +41,13 @@ DOWNLOADS = os.path.join(BASE, "downloads")
OUTPUT = os.path.join(BASE, "output")
OUTFILE = os.path.join(OUTPUT, "应到未到数据.xlsx")
# 汇总报表覆盖的全部站点4 站在前、百世在末;汇总页图表只取 4 站)
ALL_REPORT_SITES = ["顺心", "中通", "韵达", "安能", "百世"]
# 4 站单站未到明细文件名(百世未到文件由站点直接产出,名为 BAISHI_FILE
SITE_UNDELIVERED_FILE = "{name}-未到数据.xlsx"
BAISHI_FILE = "百世-应到未到货物数据.xlsx"
BAISHI_COLUMNS = ["类型", "子单号", "运单号", "最新扫描记录"]
# ============================ 比对逻辑 ============================
@@ -134,8 +141,16 @@ STATIONS = [
]
def process(cfg):
"""返回 (列名list, 明细行list[dict], 统计dict);源文件缺失时返回 None。"""
def _site_cfg(name):
"""按名称取 4 站配置(百世不在 STATIONS返回 None"""
return next((c for c in STATIONS if c["name"] == name), None)
def process(name):
"""4 站单站比对:返回 (列名list, 明细行list[dict], 统计dict);源文件缺失或非 4 站返回 None。"""
cfg = _site_cfg(name)
if cfg is None:
return None
exp_path = os.path.join(DOWNLOADS, cfg["exp"])
act_path = os.path.join(DOWNLOADS, cfg["act"])
if not os.path.exists(exp_path) or not os.path.exists(act_path):
@@ -268,6 +283,78 @@ def write_station(ws, columns, rows):
ws.print_title_rows = "1:1"
# ============================ 单站 / 全量产出 ============================
def process_baishi():
"""百世:读站点直供的未到明细,返回 (columns, rows, stats);文件缺失返回 None。
百世文件本身即未到结果(无应到/已到基数),统计只能给出未到件数。"""
path = os.path.join(DOWNLOADS, BAISHI_FILE)
if not os.path.exists(path):
return None
df = pd.read_excel(path, dtype=str).fillna("")
rows = df.to_dict("records")
wb_count = df["运单号"].nunique() if "运单号" in df.columns else len(rows)
stats = {
"运单数": wb_count,
"应到件": None,
"已到件": None,
"未到件": len(rows),
"涉及运单": wb_count,
"完全未到": None,
"部分未到": None,
"重复运单": 0,
}
return (BAISHI_COLUMNS, rows, stats)
def write_site_file(name):
"""4 站:把该站未到明细写到 downloads/<站>-未到数据.xlsx。
应到/实到缺process 返回 None→ 删旧文件、返回 False成功返回 True。"""
path = os.path.join(DOWNLOADS, SITE_UNDELIVERED_FILE.format(name=name))
out = process(name)
if out is None:
if os.path.exists(path):
os.remove(path)
return False
columns, rows, _stats = out
wb = Workbook()
wb.remove(wb.active)
ws = wb.create_sheet(name)
write_station(ws, columns, rows)
wb.save(path)
return True
def build_full_report(include):
"""生成全站汇总报表 output/应到未到数据.xlsx。
include: 本次成功的站点集合;未成功站点在汇总里保留行、无数据(不影响他站)。
返回 {站点: 未到件或None} 供日志。"""
os.makedirs(OUTPUT, exist_ok=True)
wb = Workbook()
wb.remove(wb.active)
summary_ws = wb.create_sheet("汇总报表") # 首页占位
summary = [] # (name, stats_or_None)顺序4 站 + 百世
for name in ALL_REPORT_SITES:
if name == "百世":
out = process_baishi() if "百世" in include else None
columns = BAISHI_COLUMNS
else:
out = process(name) if name in include else None
cfg = _site_cfg(name)
columns = cfg["columns"] if cfg else []
stats = out[2] if out is not None else None
rows = out[1] if out is not None else []
summary.append((name, stats))
ws = wb.create_sheet(name)
write_station(ws, columns, rows)
build_summary(summary_ws, summary, datetime.now().strftime("%Y-%m-%d %H:%M"))
wb.save(OUTFILE)
return {n: (s["未到件"] if s else None) for (n, s) in summary}
# ============================ 写汇总报表 ============================
@@ -275,12 +362,15 @@ def build_summary(ws, results, generated_at):
center = Alignment(horizontal="center", vertical="center")
left = Alignment(horizontal="left", vertical="center", indent=1)
t_wb = sum(s["运单数"] for _, s in results)
t_exp = sum(s["应到件"] for _, s in results)
t_arr = sum(s["已到件"] for _, s in results)
t_miss = sum(s["未到件"] for _, s in results)
t_full = sum(s["完全未到"] for _, s in results)
t_part = sum(s["部分未到"] for _, s in results)
# 合计/KPI 只算 4 站中本次成功的(百世无应到基数、失败站无数据,均不计入)
four = [(n, s) for (n, s) in results if n != "百世"]
ok = [s for _, s in four if s]
t_wb = sum(s["运单数"] for s in ok)
t_exp = sum(s["应到件"] for s in ok)
t_arr = sum(s["已到件"] for s in ok)
t_miss = sum(s["未到件"] for s in ok)
t_full = sum(s["完全未到"] for s in ok)
t_part = sum(s["部分未到"] for s in ok)
rate = (t_miss / t_exp) if t_exp else 0
ws.sheet_view.showGridLines = False
@@ -387,20 +477,27 @@ def build_summary(ws, results, generated_at):
cell.border = BORDER
ws.row_dimensions[9].height = 22
# —— 各站数据行 ——
# —— 各站数据行4 站 + 百世)——
r = 10
for idx, (name, s) in enumerate(results):
srate = (s["未到件"] / s["应到件"]) if s["应到件"] else 0
vals = [
name,
s["运单数"],
s["应到件"],
s["已到件"],
s["未到件"],
srate,
s["完全未到"],
s["部分未到"],
]
is_baishi = name == "百世"
srate = 0
if s is None:
vals = [f"{name}(无数据)", 0, 0, 0, 0, 0, 0, 0]
elif is_baishi:
vals = [name, s["运单数"], "", "", s["未到件"], "", "", ""]
else:
srate = (s["未到件"] / s["应到件"]) if s["应到件"] else 0
vals = [
name,
s["运单数"],
s["应到件"],
s["已到件"],
s["未到件"],
srate,
s["完全未到"],
s["部分未到"],
]
for i, v in enumerate(vals):
col = chr(ord("B") + i)
cell = ws[f"{col}{r}"]
@@ -408,12 +505,13 @@ def build_summary(ws, results, generated_at):
cell.font = BODY_FONT
cell.border = BORDER
cell.alignment = left if i == 0 else center
if idx % 2 == 1 and i != 5:
if s is None:
cell.fill = PatternFill("solid", fgColor="EFEFEF")
elif not is_baishi and idx % 2 == 1 and i != 5:
cell.fill = PatternFill("solid", fgColor=ZEBRA)
if i in (1, 2, 3, 4, 6, 7):
cell.number_format = "#,##0"
if i == 5:
cell.number_format = "0.0%"
if isinstance(v, (int, float)):
cell.number_format = "0.0%" if i == 5 else "#,##0"
if i == 5 and s is not None and not is_baishi:
cell.fill = PatternFill("solid", fgColor=heat(srate))
ws.row_dimensions[r].height = 19
r += 1
@@ -435,7 +533,7 @@ def build_summary(ws, results, generated_at):
if i == 5:
cell.number_format = "0.0%"
ws.row_dimensions[r].height = 20
last_data_row = 9 + len(results)
last_data_row = 9 + len(four) # 图表只取 4 站(百世无应到/已到基数,不绘图)
chart_anchor = r + 2
# —— 堆叠柱状图:各站已到 / 未到 ——
@@ -464,7 +562,9 @@ def build_summary(ws, results, generated_at):
note_row = chart_anchor + 19
notes = [
"指标口径:未到率 未到件数 ÷ 应到件数;完全未到运单 整单零到货;部分未到运单 部分到货、部分缺件。",
"明细见各站点工作表;缺件的子单号 / 扫描单号按各站编号规则生成,并非实到原始记录",
"合计 / 图表仅含 4 站(顺心/中通/韵达/安能,应到−实到口径);百世为站点直供未到、无应到基数,单列不计入合计",
"本次下载失败的站点标注为(无数据)并计 0不影响其余站点统计。",
"明细见各站点工作表4 站缺件的子单号 / 扫描单号按各站编号规则生成,并非实到原始记录。",
]
for k, text in enumerate(notes):
rr = note_row + k
@@ -486,37 +586,32 @@ def build_summary(ws, results, generated_at):
def main():
os.makedirs(OUTPUT, exist_ok=True)
wb = Workbook()
wb.remove(wb.active)
summary_ws = wb.create_sheet("汇总报表") # 首页占位
print("应到未到比对结果")
"""菜单 [9] / 离线入口:用 downloads/ 下现有文件生成全站汇总报告(有文件的站即纳入)。"""
print("应到未到比对(全站汇总)")
print("-" * 56)
results = []
for cfg in STATIONS:
out = process(cfg)
if out is None:
continue
columns, rows, stats = out
ws = wb.create_sheet(cfg["name"])
write_station(ws, columns, rows)
results.append((cfg["name"], stats))
extra = f",应到重复运单 {stats['重复运单']}" if stats["重复运单"] else ""
print(
f"{cfg['name']}:应到运单 {stats['运单数']},应到件 {stats['应到件']}"
f"已到 {stats['已到件']},未到 {stats['未到件']}"
f"(涉及运单 {stats['涉及运单']}:完全未到 {stats['完全未到']} / 部分未到 {stats['部分未到']}{extra}"
)
if not results:
include = set()
for name in ALL_REPORT_SITES:
if name == "百世":
if os.path.exists(os.path.join(DOWNLOADS, BAISHI_FILE)):
include.add(name)
else:
cfg = _site_cfg(name)
if (
cfg
and os.path.exists(os.path.join(DOWNLOADS, cfg["exp"]))
and os.path.exists(os.path.join(DOWNLOADS, cfg["act"]))
):
include.add(name)
if not include:
print("未处理任何站点:请确认 downloads/ 下存在源数据文件。")
return
build_summary(summary_ws, results, datetime.now().strftime("%Y-%m-%d %H:%M"))
undel = build_full_report(include)
print("-" * 56)
wb.save(OUTFILE)
for name in ALL_REPORT_SITES:
if name in include:
print(f"{name}:未到 {undel.get(name)}")
else:
print(f"{name}:无数据,跳过")
print(f"已输出:{OUTFILE}")