diff --git a/.gitignore b/.gitignore index 3276a10..6f4ddd3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ desktop.ini # Project specific downloads/ +output/ *.xlsx *.xls *.log diff --git a/expected_undelivered.py b/expected_undelivered.py new file mode 100644 index 0000000..c637f52 --- /dev/null +++ b/expected_undelivered.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- +""" +应到未到数据比对 + +目的:对中通 / 顺心 / 韵达 / 安能四个站点,比对各自的「应到货物数据」与 +「实到货物数据」,逐件找出应到却未到的子单,汇总到 output/应到未到数据.xlsx。 +工作簿结构: + · 汇总报表 —— 首页,跨站点统计与可视化(不含明细)。 + · 中通 / 顺心 / 韵达 / 安能 —— 各站未到明细,每行一件。 + +核心逻辑(四站点统一): + 1. 每个运单的应到件数 N = 应到数据中的「录单件数」(输出列名为「总件数」)。 + 2. 该运单应到的子单序号集合 = {1, 2, …, N}。 + 3. 从实到数据中解析出该运单实际已到的序号集合。 + 4. 应到未到 = {1…N} − 已到序号。 + —— 缺件按定义不在实到里,故其子单号 / 扫描单号由程序按各站格式现拼生成。 + +各站差异(已据真实数据核定): + 中通:实到「运单号」列即复合串 = 运单号 + 总数(4位) + 顺序号(4位),从右解析。 + 顺心:实到有干净「运单号」列 +「子单号」= 运单号 + 顺序号(3位)。 + 韵达:实到「主单号」对应运单号,「子单号」= 主单号 + 顺序号(4位)。 + 安能:实到「所属单号」对应运单号,「扫描单号」= 所属单号 + 总数(4位) + 顺序号(4位)。 + +目录约定: + 源数据放在脚本同级目录的 downloads/ 下;结果写入 output/(不存在则自动创建)。 +""" + +import os +from datetime import datetime +from collections import defaultdict + +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.chart import BarChart, Reference +from openpyxl.worksheet.page import PageMargins +from openpyxl.worksheet.properties import PageSetupProperties + +BASE = os.path.dirname(os.path.abspath(__file__)) +DOWNLOADS = os.path.join(BASE, "downloads") +OUTPUT = os.path.join(BASE, "output") +OUTFILE = os.path.join(OUTPUT, "应到未到数据.xlsx") + + +# ============================ 比对逻辑 ============================ + + +def arrived_zhongtong(df): + """中通:运单号列即复合串,右侧 8 位 = 总数(4)+顺序(4),其余为运单号。""" + 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:])) + return res + + +def arrived_by_prefix(main_col, sub_col, has_total=False): + """顺心 / 韵达 / 安能:子单号以主单号为前缀,后缀含顺序号。 + has_total=True 时后缀为 总数(4)+顺序(4),取末 4 位为顺序号。""" + + def parse(df): + res = defaultdict(set) + for m, s in zip(df[main_col], df[sub_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)) + 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": ["交接单号", "运单号", "子单号", "总件数"], + }, + { + "name": "顺心", + "exp": "顺心-应到货物数据.xlsx", + "act": "顺心-实到货物数据.xlsx", + "arrived": arrived_by_prefix("运单号", "子单号"), + "code": code_shunxin, + "code_col": "子单号", + "columns": ["班次号", "交接单号", "运单号", "子单号", "总件数"], + }, + { + "name": "韵达", + "exp": "韵达-应到货物数据.xlsx", + "act": "韵达-实到货物数据.xlsx", + "arrived": arrived_by_prefix("主单号", "子单号"), + "code": code_yunda, + "code_col": "子单号", + "columns": ["交接单号", "运单号", "子单号", "总件数"], + }, + { + "name": "安能", + "exp": "安能-应到货物数据.xlsx", + "act": "安能-实到货物数据.xlsx", + "arrived": arrived_by_prefix("所属单号", "扫描单号", has_total=True), + "code": code_anneng, + "code_col": "扫描单号", + "columns": ["交接单号", "运单号", "扫描单号", "总件数"], + }, +] + + +def process(cfg): + """返回 (列名list, 明细行list[dict], 统计dict);源文件缺失时返回 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): + print(f"[跳过] {cfg['name']}:downloads 下缺少 {cfg['exp']} 或 {cfg['act']}") + return None + + 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") + + arrived = cfg["arrived"](df_act) + + rows = [] + exp_pieces = full_miss = part_miss = 0 + for _, r in df_exp.iterrows(): + wb = str(r["运单号"]).strip() + if not wb: + continue + try: + n = int(float(r["总件数"] if "总件数" in r else r["录单件数"])) + except (TypeError, ValueError, KeyError): + try: + n = int(float(r["录单件数"])) + except (TypeError, ValueError, KeyError): + continue + 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) + + stats = { + "运单数": len(df_exp), + "应到件": exp_pieces, + "已到件": exp_pieces - len(rows), + "未到件": len(rows), + "涉及运单": full_miss + part_miss, + "完全未到": full_miss, + "部分未到": part_miss, + "重复运单": dup, + } + return cfg["columns"], rows, stats + + +# ============================ 样式常量 ============================ + +FONT = "微软雅黑" +NAVY = "1F3864" # 标题栏 +BLUE = "305496" # 表头 +LIGHTBLUE = "D6DCE5" # 合计行 +CARD_BG = "F2F6FC" # 指标卡底 +RED = "C00000" # 未到 +GREEN = "548235" # 已到 +GRAY = "808080" +ZEBRA = "F4F7FC" +LINE = "D9D9D9" +TILE = "BFBFBF" + +THIN = Side(style="thin", color=LINE) +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + + +def heat(rate): + """未到率热力底色:绿(低) / 黄(中) / 红(高)。""" + if rate >= 0.50: + return "FFC7CE" + if rate >= 0.15: + return "FFEB9C" + return "C6EFCE" + + +# ============================ 写明细表 ============================ + +HEADER_FILL = PatternFill("solid", fgColor=BLUE) +HEADER_FONT = Font(name=FONT, bold=True, color="FFFFFF", size=11) +BODY_FONT = Font(name=FONT, size=10) + + +def write_station(ws, columns, rows): + ws.sheet_view.showGridLines = False + ws.append(columns) + for c in range(1, len(columns) + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = BORDER + for row in rows: + ws.append([row[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) + cell.font = BODY_FONT + cell.border = BORDER + if col == "总件数": + cell.number_format = "#,##0" + cell.alignment = Alignment(horizontal="right", vertical="center") + else: + cell.number_format = "@" # 文本,避免长单号被转科学计数 + for c, col in enumerate(columns, start=1): + body = [len(str(row[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 + ) + ws.freeze_panes = "A2" + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) + ws.print_title_rows = "1:1" + + +# ============================ 写汇总报表 ============================ + + +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) + rate = (t_miss / t_exp) if t_exp else 0 + + ws.sheet_view.showGridLines = False + ws.column_dimensions["A"].width = 2.5 + for col, w in { + "B": 11, + "C": 12, + "D": 12, + "E": 12, + "F": 12, + "G": 11, + "H": 14, + "I": 14, + }.items(): + ws.column_dimensions[col].width = w + ws.row_dimensions[1].height = 6 + + # —— 标题栏 —— + ws.merge_cells("B2:I2") + t = ws["B2"] + t.value = "应到未到比对 · 汇总报表" + t.fill = PatternFill("solid", fgColor=NAVY) + t.font = Font(name=FONT, bold=True, size=18, color="FFFFFF") + t.alignment = center + for row in ws["B2:I2"]: + for c in row: + c.fill = PatternFill("solid", fgColor=NAVY) + ws.row_dimensions[2].height = 34 + ws.merge_cells("B3:I3") + sub = ws["B3"] + sub.value = f"数据快照 · 生成于 {generated_at}" + sub.font = Font(name=FONT, size=10, color=GRAY) + sub.alignment = Alignment(horizontal="right", vertical="center") + ws.row_dimensions[3].height = 18 + + # —— KPI 指标卡 —— + cards = [ + ("应到总件数", t_exp, NAVY, "#,##0"), + ("已到总件数", t_arr, GREEN, "#,##0"), + ("未到总件数", t_miss, RED, "#,##0"), + ("总体未到率", rate, RED, "0.0%"), + ] + spans = [ + ("B5:C5", "B6:C6"), + ("D5:E5", "D6:E6"), + ("F5:G5", "F6:G6"), + ("H5:I5", "H6:I6"), + ] + card_bg = PatternFill("solid", fgColor=CARD_BG) + thin = Side(style="thin", color=TILE) + for (lab, val, acc, fmt), (lrng, vrng) in zip(cards, spans): + ws.merge_cells(lrng) + ws.merge_cells(vrng) + acctop = Side(style="medium", color=acc) + for row in ws[lrng]: + for c in row: + c.fill = card_bg + c.font = Font(name=FONT, size=10, color=GRAY) + c.alignment = center + c.border = Border(left=thin, right=thin, top=acctop, bottom=thin) + for row in ws[vrng]: + for c in row: + c.fill = card_bg + c.font = Font(name=FONT, bold=True, size=20, color=acc) + c.alignment = center + c.border = Border(left=thin, right=thin, top=thin, bottom=thin) + ws[lrng.split(":")[0]].value = lab + vc = ws[vrng.split(":")[0]] + vc.value = val + vc.number_format = fmt + ws.row_dimensions[5].height = 18 + ws.row_dimensions[6].height = 38 + ws.row_dimensions[7].height = 8 + + # —— 小节标题 —— + ws.merge_cells("B8:I8") + sec = ws["B8"] + sec.value = "各站点明细统计" + sec.font = Font(name=FONT, bold=True, size=12, color=NAVY) + sec.alignment = Alignment(horizontal="left", vertical="center") + for row in ws["B8:I8"]: + for c in row: + c.border = Border(bottom=Side(style="medium", color=BLUE)) + ws.row_dimensions[8].height = 22 + + # —— 统计表头 —— + headers = [ + "站点", + "应到运单数", + "应到件数", + "已到件数", + "未到件数", + "未到率", + "完全未到运单", + "部分未到运单", + ] + for i, h in enumerate(headers): + col = chr(ord("B") + i) + cell = ws[f"{col}9"] + cell.value = h + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = center + cell.border = BORDER + ws.row_dimensions[9].height = 22 + + # —— 各站数据行 —— + 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["部分未到"], + ] + for i, v in enumerate(vals): + col = chr(ord("B") + i) + cell = ws[f"{col}{r}"] + cell.value = v + cell.font = BODY_FONT + cell.border = BORDER + cell.alignment = left if i == 0 else center + if 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%" + cell.fill = PatternFill("solid", fgColor=heat(srate)) + ws.row_dimensions[r].height = 19 + r += 1 + + # —— 合计行 —— + tot_fill = PatternFill("solid", fgColor=LIGHTBLUE) + tot_font = Font(name=FONT, bold=True, size=10) + totals = ["合计", t_wb, t_exp, t_arr, t_miss, rate, t_full, t_part] + for i, v in enumerate(totals): + col = chr(ord("B") + i) + cell = ws[f"{col}{r}"] + cell.value = v + cell.fill = tot_fill + cell.font = tot_font + cell.border = BORDER + cell.alignment = left if i == 0 else center + if i in (1, 2, 3, 4, 6, 7): + cell.number_format = "#,##0" + if i == 5: + cell.number_format = "0.0%" + ws.row_dimensions[r].height = 20 + last_data_row = 9 + len(results) + chart_anchor = r + 2 + + # —— 堆叠柱状图:各站已到 / 未到 —— + chart = BarChart() + chart.type = "col" + chart.grouping = "stacked" + chart.overlap = 100 + chart.title = "各站点到货构成(已到 / 未到 件数)" + data = Reference( + ws, min_col=5, max_col=6, min_row=9, max_row=last_data_row + ) # E已到 F未到 + chart.add_data(data, titles_from_data=True) + cats = Reference(ws, min_col=2, min_row=10, max_row=last_data_row) + chart.set_categories(cats) + chart.series[0].graphicalProperties.solidFill = GREEN + chart.series[1].graphicalProperties.solidFill = RED + chart.y_axis.title = "件数" + chart.x_axis.delete = False + chart.y_axis.delete = False + chart.legend.position = "b" + chart.height = 9 + chart.width = 20 + ws.add_chart(chart, f"B{chart_anchor}") + + # —— 口径说明 —— + note_row = chart_anchor + 19 + notes = [ + "指标口径:未到率 = 未到件数 ÷ 应到件数;完全未到运单 = 整单零到货;部分未到运单 = 部分到货、部分缺件。", + "明细见各站点工作表;缺件的子单号 / 扫描单号按各站编号规则生成,并非实到原始记录。", + ] + for k, text in enumerate(notes): + rr = note_row + k + ws.merge_cells(f"B{rr}:I{rr}") + cell = ws[f"B{rr}"] + cell.value = text + cell.font = Font(name=FONT, size=9, color=GRAY) + cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) + + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) + ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.5, bottom=0.5) + ws.print_area = f"A1:I{note_row + 1}" + + +# ============================ 主流程 ============================ + + +def main(): + os.makedirs(OUTPUT, exist_ok=True) + wb = Workbook() + wb.remove(wb.active) + summary_ws = wb.create_sheet("汇总报表") # 首页占位 + + 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: + print("未处理任何站点:请确认 downloads/ 下存在源数据文件。") + return + + build_summary(summary_ws, results, datetime.now().strftime("%Y-%m-%d %H:%M")) + + print("-" * 56) + wb.save(OUTFILE) + print(f"已输出:{OUTFILE}") + + +if __name__ == "__main__": + main() diff --git a/main_router.py b/main_router.py index ec6b12c..f9c7765 100644 --- a/main_router.py +++ b/main_router.py @@ -6,7 +6,6 @@ import subprocess import time import urllib.request import yaml -import pandas as pd from playwright.sync_api import sync_playwright from paths import DOWNLOAD_DIR, CONFIG_PATH @@ -18,6 +17,9 @@ import site_zto import site_yunda import site_anneng +# 应到未到比对(全站点,离线处理 downloads/ 下的应到/实到数据) +import expected_undelivered + # 定义网点及对应的初始登录 URL SITES_CONFIG = { "顺心": "https://sxne.sxjdfreight.com", @@ -80,62 +82,11 @@ def launch_anneng(app_path): return proc -def task_process_undelivered_data(site_name="顺心"): - """应到未到比对:找出应到但未实到的运单 (按站点前缀)""" - print(f"\n▶ 开始执行【{site_name} - 应到未到数据处理】任务...") - - download_dir = DOWNLOAD_DIR - expected_path = os.path.join(download_dir, f"{site_name}-应到货物数据.xlsx") - actual_path = os.path.join(download_dir, f"{site_name}-实到货物数据.xlsx") - output_path = os.path.join(download_dir, f"{site_name}-应到未到货物数据.xlsx") - - if not os.path.exists(expected_path): - print(f"❌ 错误:找不到【{site_name}-应到货物数据】主文档:{expected_path}") - return - - if not os.path.exists(actual_path): - print(f"❌ 错误:找不到【{site_name}-实到货物数据】主文档:{actual_path}") - return - - try: - print(">> 正在载入本地 Excel 文档...") - # 全部按字符串读取并保留空串:避免 18 位运单号被当成浮点数丢精度,也避免空单元格变成 NaN。 - df_expected = pd.read_excel(expected_path, dtype=str, keep_default_na=False) - df_actual = pd.read_excel(actual_path, dtype=str, keep_default_na=False) - - if "运单号" not in df_expected.columns or "运单号" not in df_actual.columns: - print("❌ 校验失败:数据源缺少【运单号】字段,请检查导出配置。") - return - - print(">> 正在比对应到与实到数据...") - - # 统一运单号为去空白字符串,消除 int/float 与 str 混读导致 isin 永不命中的隐患 - df_expected["运单号"] = df_expected["运单号"].astype(str).str.strip() - df_actual["运单号"] = df_actual["运单号"].astype(str).str.strip() - - # 剔除空白运单号,避免空值被误判为“应到未到” - df_expected = df_expected[df_expected["运单号"] != ""] - actual_set = set(df_actual["运单号"]) - {""} - - # Left Anti-Join:在应到中找出不存在于实到里的运单号 - df_undelivered = df_expected[~df_expected["运单号"].isin(actual_set)] - - target_columns = ["班次号", "交接单号", "运单号"] - available_columns = [ - col for col in target_columns if col in df_undelivered.columns - ] - df_output = df_undelivered[available_columns] - - print(f">> 比对完成,共筛选出【应到未到】运单: {len(df_output)} 条。") - - df_output.to_excel(output_path, index=False) - print(f"====================================================") - print(f" 比对完成,结果已输出。") - print(f" 📁 输出路径: {output_path}") - print(f"====================================================") - - except Exception as e: - print(f"❌ 比对过程中发生异常: {e}") +def run_undelivered_compare(): + """应到未到比对(全站点):调用 expected_undelivered,读 downloads/ 下的应到/实到 + 数据,生成 output/应到未到数据.xlsx(汇总报表 + 各站明细)。""" + print("\n▶ 开始执行【应到未到比对(全站点)】任务 ...") + expected_undelivered.main() # ==================================================================== @@ -457,7 +408,9 @@ def run_multi_site_daemon(): print(" [8] 执行 - 全站点下载流程自动化测试 (交叉跑通校验)") print("-" * 52) print(" 全局离线数据处理") - print(" [9] 执行 - 异常数据清洗比对 (Left Anti-Join)") + print( + " [9] 执行 - 应到未到比对(全站点汇总,输出 output/应到未到数据.xlsx)" + ) print("-" * 52) print(" [0] 退出系统") print("====================================================") @@ -493,12 +446,7 @@ def run_multi_site_daemon(): elif choice == "8": run_automation_test(pages_map) elif choice == "9": - site_name = input( - "请输入要比对的网点名称 (如 顺心/中通/韵达): " - ).strip() - if not site_name: - site_name = "顺心" - task_process_undelivered_data(site_name) + run_undelivered_compare() elif choice == "0": print("\n正在关闭浏览器并退出...") break