一份「源数据」Excel,几十个需求单号,采购单号用斜杠拼接——仓库同事原本要花一整个下午手工拆表。这个脚本双击运行,3 秒出结果,自动处理 PO 拆分、箱数均分、城市提取,并保留原 Excel 模板格式。
580
行 Python 代码
3s
平均处理耗时
2
自动生成子表
0
依赖安装(exe 版)
一、核心功能架构
🎯
main()
入口 & 流程控制
入口 & 流程控制
📂
pick_file()
文件选择弹窗
文件选择弹窗
🔍
find_source_sheet()
自动定位源数据
自动定位源数据
🔄
load_workbook_with_retry()
带重试的加载
带重试的加载
📖
read_source()
iter_rows 批量读取
iter_rows 批量读取
🗺️
build_po_city_map()
跨子表扫描映射
跨子表扫描映射
📋
build_pickup_data()
揽收单数据构建
揽收单数据构建
🏷️
build_label_data()
标签数据展开
标签数据展开
✍️
write_pickup()
模板写入 & 格式保留
模板写入 & 格式保留
💾
save_workbook_with_retry()
带重试的保存
带重试的保存
二、数据流处理流程
📂
选择文件
tkinter 弹窗
tkinter 弹窗
📖
读取源数据
data_only=True
data_only=True
🗺️
构建 PO→城市
全子表交叉扫描
全子表交叉扫描
🔀
拆分 & 均分
按需求单号分组
按需求单号分组
📋
生成揽收单
按 PO 汇总
按 PO 汇总
🏷️
生成标签
按箱展开
按箱展开
💾
写入模板
*_已生成.xlsx
*_已生成.xlsx
三、核心函数详解
3.1 地址解析 — extract_city_from_address()
从表格中库房地址字符串中提取城市名,处理省-市-区、直辖市、自治区等多种格式:
def extract_city_from_address(addr: str) -> str:
"""从表格中库房地址中提取城市名(处理省/自治区/直辖市)"""
if not addr:
return ""
addr = str(addr).strip()
# 第一级:匹配到县区级别
m = re.match(r"(.+?[市县区])", addr)
if not m:
return addr[:4]
city = m.group(1)
# 第二级:处理带省份前缀的地址
prov = re.match(r".+?(?:省|自治区)", addr)
if prov:
rest = addr[prov.end():]
dm = re.match(r"(.+?[市县区])", rest)
if dm:
city = dm.group(1)
# 第三级:处理直辖市(如北京市朝阳区)
elif city.endswith("市") and len(addr) > len(m.group(1)):
rest = addr[len(m.group(1)):]
dm = re.match(r"(.+?[市区县])", rest)
if dm:
city = dm.group(1)
# 清理后缀:广州**市** → 广州
return re.sub(r"[市区县]$", "", city)
✅ 覆盖场景:
广东省广州市天河区xxx → 天河 ·
北京市朝阳区xxx → 朝阳 ·
新疆维吾尔自治区乌鲁木齐市xxx → 乌鲁木齐
3.2 箱数处理 — safe_int() & distribute_evenly()
处理发货箱数的小数向上取整,以及在多个 PO 之间均分箱数(余数补前):
def safe_int(v) -> int:
"""安全转换为整数,小数向上取整"""
try:
return math.ceil(float(v)) if v else 0
except (ValueError, TypeError):
return 0
def distribute_evenly(total: int, n: int) -> list[int]:
"""均分箱数,前 remainder 份各+1
示例: 10箱÷3 → [4,3,3];7箱÷2 → [4,3]"""
if n <= 0:
return []
base = total // n
remainder = total % n
return [base + (1 if i < remainder else 0)
for i in range(n)]
3.3 揽收单数据构建 — build_pickup_data()
按需求单号分组汇总,去重 PO,均分箱数:
def build_pickup_data(rows: list[dict],
po_city_map: dict[str, str] | None = None) -> list[dict]:
"""构建揽收单数据:按需求单号分组,PO去重保序,箱数均分"""
by_req = defaultdict(list)
for r in rows:
req = str(r.get("需求单号", ""))
by_req[req].append(r)
pickup_rows = []
seq = 0
for req_no in sorted(by_req.keys()):
group = by_req[req_no]
# 收集所有 PO(去重保序)
all_pos, seen = [], set()
for r in group:
for po in split_po(r.get("采购单号", "")):
if po not in seen:
all_pos.append(po)
seen.add(po)
total_boxes = sum(safe_int(r.get("发货箱数")) for r in group)
addr = str(group[0].get("库房地址", "") or "")
boxes_list = distribute_evenly(total_boxes, len(all_pos))
for i, po in enumerate(all_pos):
seq += 1
city = po_city_map.get(po) if po_city_map else None
if not city:
city = extract_city_from_address(addr)
pickup_rows.append({
"seq": seq, "po": po,
"boxes": boxes_list[i],
"city": city, "center": center,
"warehouse": warehouse, "addr": addr
})
return pickup_rows
3.4 标签模板数据构建 — build_label_data()
按最细粒度展开:每行源数据 → 拆分 PO → 每箱一行标签:
def build_label_data(rows: list[dict],
po_city_map: dict[str, str] | None = None) -> list[dict]:
"""构建标签数据:SKU × PO × 箱号 逐行展开"""
labels = []
for r in rows:
pos = split_po(r.get("采购单号", ""))
boxes = safe_int(r.get("发货箱数"))
if boxes <= 0 or not pos:
continue
supplier = str(r.get("供应商简码", "") or "")
addr = str(r.get("库房地址", "") or "")
warehouse = str(r.get("送货仓库", "") or "")
upc = str(r.get("UPC码", "") or "")
product = str(r.get("产品名称", "") or "")
spec = str(r.get("箱规", "") or "")
boxes_list = distribute_evenly(boxes, len(pos))
for i, po in enumerate(pos):
po_boxes = boxes_list[i]
if po_boxes <= 0:
logger.warning(f"采购单号 {po} 被分配 0 箱,已跳过")
continue
if po_city_map and po in po_city_map:
city = po_city_map[po]
else:
city = extract_city_from_address(addr)
# 按箱展开:第 1 箱、第 2 箱...
for box_no in range(1, po_boxes + 1):
labels.append({
"供应商名称": supplier,
"采购单号": po,
"目的城市": city,
"目的库房": warehouse,
"SKU": upc,
"商号名称": product,
"箱规": spec,
"第几箱": box_no,
"共多少箱": po_boxes
})
return labels
📊 展开示例:一行源数据
PO001/PO002, 6箱 → 拆成 2 个 PO 各 3 箱 → 展开为 6 行标签(PO001 有 1-3 箱,PO002 有 1-3 箱)。
四、模板写入与格式保留
4.1 揽收单写入 — write_pickup()
在已有模板上动态调整行数,保留格式,处理合并单元格:
def write_pickup(ws, data: list[dict]) -> None:
"""写入揽收单:动态插入/删除行,保留模板格式"""
start_row = find_pickup_start_row(ws) # 自动定位"序号"行+1
data_count = len(data)
# 定位汇总行("提货总箱数"或"签字确认")
summary_row = None
for r in range(start_row, ws.max_row + 1):
v = ws.cell(row=r, column=2).value
if v and "提货总箱数" in str(v):
summary_row = r
break
current_data_rows = summary_row - start_row
diff = data_count - current_data_rows
# 动态调整行数:插入或删除
if diff > 0:
# 需要插入行
_shift_merged_cells_down(ws, summary_row, diff)
ws.insert_rows(summary_row, diff)
# 复制格式...
elif diff < 0:
# 需要删除行
del_count = -diff
del_start = summary_row - del_count
_shift_merged_cells_up(ws, del_start + del_count, del_count)
ws.delete_rows(del_start, del_count)
# 写入数据(处理 MergedCell)
for idx, d in enumerate(data):
r = start_row + idx
_safe_write_cell(ws, r, 1, d["seq"])
_safe_write_cell(ws, r, 2, d["po"])
_safe_write_cell(ws, r, 3, d["boxes"])
_safe_write_cell(ws, r, 4, d["city"])
# 写入汇总
total = sum(d["boxes"] for d in data)
ws.cell(row=summary_row, column=2).value = "提货总箱数*:"
ws.cell(row=summary_row, column=3).value = total
⚠️ 关键处理:模板中 H-J 列是合并单元格(TC 收货说明)。写入前需先
shift_merged_cells 移动合并区域,再写入数据。遇到 MergedCell 时,通过坐标查找找到主单元格写入。
4.2 标签模板写入 — write_labels()
标签模板是完全覆盖写入,保留表头样式:
def write_labels(ws, data: list[dict]) -> None:
"""写入标签模板:清空旧数据,重写表头+数据"""
headers = ["供应商名称", "采购单号", "目的城市", "目的库房",
"SKU", "商号名称", "箱规", "第几箱", "共多少箱"]
# 清空旧数据(保留第 1 行表头)
if ws.max_row > 1:
ws.delete_rows(2, ws.max_row)
# 写入数据行
for ri, label in enumerate(data, 2):
for ci, h in enumerate(headers, 1):
apply_style(ws.cell(row=ri, column=ci),
font=FONT_NORMAL, border=BORDER_THIN,
align=ALIGN_CENTER, value=label[h])
# 重写表头样式
for ci, h in enumerate(headers, 1):
apply_style(ws.cell(row=1, column=ci),
font=FONT_HEADER, fill=FILL_HEADER,
border=BORDER_THIN, align=ALIGN_CENTER)
# 调整列宽
widths = [14, 18, 10, 30, 16, 35, 8, 8, 10]
for i, w in enumerate(widths, 1):
ws.column_dimensions[get_column_letter(i)].width = w
五、文件占用处理 — 重试/取消对话框
针对 Excel 文件被占用的情况,实现了带 GUI 对话框的重试机制:
def popup_retry_cancel(title: str, message: str) -> bool:
"""弹出重试/取消对话框,返回 True 表示重试"""
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
result = messagebox.askretrycancel(title, message,
parent=root, icon="warning")
root.destroy()
return result
except Exception:
# 回退到命令行交互
while True:
choice = input("\n[重试(r)/取消(c)]: ").strip().lower()
if choice in ("r", "重试"): return True
if choice in ("c", "取消"): return False
def load_workbook_with_retry(filepath: str, data_only: bool = False):
"""加载工作簿,文件被占用时弹出重试对话框"""
while True:
try:
return load_workbook(filepath, data_only=data_only)
except PermissionError:
msg = f"文件被占用:{filepath}\n\n请先关闭该文件的 Excel 窗口,然后点击「重试」。"
if not popup_retry_cancel("文件被占用", msg):
raise PermissionError(f"用户取消操作: {filepath}")
time.sleep(0.5)
✅ 用户体验:当 Excel 文件被占用时,弹出模态对话框提示用户关闭文件,提供「重试」和「取消」按钮,而不是直接报错退出。
六、源数据子表智能定位
支持多种子表命名情况,策略是精确优先,唯一模糊自动选,多个模糊弹窗选:
def find_source_sheet(sheetnames: list[str]) -> Optional[str]:
"""查找源数据子表:精确匹配 → 唯一模糊 → 弹窗选择"""
# 1. 精确匹配优先
for name in ("源数据-1", "源数据"):
if name in sheetnames:
return name
# 2. 唯一模糊候选 → 自动选
candidates = [n for n in sheetnames if "源数据" in n]
if len(candidates) == 1:
return candidates[0]
# 3. 多个候选 → 返回 None,触发弹窗让用户选
return None
def pick_sheet(sheetnames: list[str]) -> Optional[str]:
"""弹窗让用户选择数据源子表(tkinter Listbox)"""
try:
import tkinter as tk
root = tk.Tk()
root.title("选择数据源子表")
root.attributes("-topmost", True)
selected = {"name": None}
label = tk.Label(root, text="找不到源数据子表\n请选择要作为数据源的子表:",
font=("微软雅黑", 11))
label.pack(pady=(15, 10), padx=20)
listbox = tk.Listbox(root, font=("微软雅黑", 10),
width=40, height=min(len(sheetnames), 15),
selectmode=tk.SINGLE, activestyle="dotbox")
for name in sheetnames:
listbox.insert(tk.END, name)
listbox.selection_set(0)
listbox.pack(padx=20, pady=5)
def on_confirm():
sel = listbox.curselection()
if sel:
selected["name"] = sheetnames[sel[0]]
root.destroy()
btn = tk.Button(root, text="确 定", command=on_confirm)
btn.pack(pady=(5, 15))
root.mainloop()
return selected["name"]
except (ImportError, Exception) as e:
# 回退到命令行选择...
七、数据读取优化 — iter_rows
使用 iter_rows(values_only=True) 批量读取,比逐个 cell() 调用快 3-5 倍:
def read_source(ws) -> list[dict]:
"""批量读取源数据,按列名建索引"""
rows_iter = ws.iter_rows(min_row=1, values_only=True)
headers_raw = next(rows_iter, None)
if not headers_raw:
return []
# 列名 → 索引映射(不依赖列顺序)
col_map = {name: i for i, name in enumerate(headers_raw) if name}
req_idx = col_map.get("需求单号")
rows = []
for row in rows_iter:
# 跳过空行(以需求单号判断)
if req_idx is not None and not row[req_idx]:
continue
rows.append({name: row[i] for name, i in col_map.items()})
return rows
八、依赖与使用方式
| 依赖 | 用途 | 类型 |
|---|---|---|
| openpyxl | Excel 读写(保留格式) | 第三方 |
| tkinter | 文件选择弹窗、对话框 | 标准库 |
| re / math / collections | 地址解析、箱数计算、分组 | 标准库 |
| logging / time / copy | 日志、重试等待、样式复制 | 标准库 |
使用方式
# 方式 1:双击运行(推荐)
generate_sheets.exe
→ 弹出文件选择对话框 → 选择 .xlsx → 自动生成 *_已生成.xlsx
# 方式 2:命令行传参
python generate_sheets.py input.xlsx [output.xlsx]
# 方式 3:拖放文件到 exe
→ 自动生成同目录下的 *_已生成.xlsx
九、函数索引
apply_style(cell, **kwargs)
样式应用工具
统一应用字体、边框、填充、对齐方式
extract_city_from_address(addr)
城市提取
从表格库房地址解析城市名,处理省/直辖市/自治区
safe_int(v) → int
安全整数转换
小数向上取整,异常返回 0
split_po(po_str) → list
PO 拆分
按斜杠分隔采购单号,去空去重
distribute_evenly(total, n) → list
箱数均分
前 remainder 份各+1,确保总和一致
build_po_city_map(rows) → dict
PO→城市映射
从源数据构建采购单号到城市的映射
find_source_sheet(names) → str|None
源表定位
精确匹配 → 唯一模糊 → 返回 None 触发弹窗
read_source(ws) → list[dict]
批量读取
iter_rows 高效读取,按列名索引
build_pickup_data(rows, map) → list
揽收单数据构建
按需求单号分组,PO 去重保序,箱数均分
build_label_data(rows, map) → list
标签数据构建
SKU × PO × 箱号 逐行展开
write_pickup(ws, data)
揽收单写入
动态调整行数,保留模板格式,处理合并单元格
write_labels(ws, data)
标签写入
完全覆盖写入,重写表头样式
popup_retry_cancel(title, msg) → bool
重试对话框
文件被占用时弹出,支持 GUI 和命令行回退
pick_file() → str|None
文件选择
tkinter 文件对话框,失败回退 input()
pick_sheet(names) → str|None
子表选择
Listbox 弹窗选择数据源子表
main()
主流程
文件选择 → 读取 → 构建映射 → 生成 → 写入 → 保存
十、设计亮点总结
- 格式保留:揽收单在现有模板上覆盖,不动边框和合并单元格;标签完全重写但保留表头样式
- 智能定位:源数据子表支持精确匹配、模糊匹配、弹窗选择三级策略
- 跨表扫描:构建 PO→城市映射时扫描所有子表,解决数据粒度不一致问题
- 健壮写入:处理 MergedCell、动态插入删除行、复制行高和格式
- 用户友好:文件被占用时弹出重试/取消对话框,而非直接报错
- 零依赖分发:Nuitka 打包成单文件 exe,同事无需安装 Python
🎯 效果:从「一个下午的手工拆表」到「双击 3 秒出结果」,揽收单和标签模板自动生成,箱数均分零误差,城市提取全覆盖。