保留填表模板样式

改为直接更新 xlsx 单元格 XML,避免 openpyxl 保存时改变模板样式,并在输出校验中检查样式是否保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
copper-xw 2026-05-29 13:43:21 +08:00
parent 327b745055
commit 240d8534fd

View File

@ -14,8 +14,11 @@ import argparse
import json
import re
import shutil
import zipfile
from datetime import date
from pathlib import Path
from tempfile import NamedTemporaryFile
from xml.etree import ElementTree as ET
import pdfplumber
@ -306,35 +309,88 @@ def ensure_fill_dependencies():
def fill_template(result, output_dir):
output_dir.mkdir(exist_ok=True)
import openpyxl
meta = result["meta"]
expenses = result["expenses"]
output_file = output_dir / build_output_filename(meta)
shutil.copy(TEMPLATE_FILE, output_file)
wb = openpyxl.load_workbook(output_file)
ws = wb.active
ws["E3"] = meta["出差事由"]
ws["A6"] = f'{meta["出发日期"]}-{meta["结束日期"]}'
ws["B6"] = f'{meta["出发地"]}-{meta["目的地"]}'
ws["C6"] = f'{meta["出差天数"]}天'
ws["D6"] = meta["出行方式"]
ws["H6"] = expenses["住宿"]["单据张数"]
ws["I6"] = expenses["住宿"]["金额"]
ws["H7"] = expenses["饮食"]["单据张数"]
ws["I7"] = expenses["饮食"]["金额"]
ws["H8"] = expenses["交通"]["单据张数"]
ws["I8"] = expenses["交通"]["金额"]
ws["H9"] = expenses["其他"]["单据张数"]
ws["I9"] = expenses["其他"]["金额"]
wb.save(output_file)
update_xlsx_cells(output_file, build_cell_values(result))
return output_file
def build_cell_values(result):
meta = result["meta"]
expenses = result["expenses"]
return {
"E3": meta["出差事由"],
"A6": f'{meta["出发日期"]}-{meta["结束日期"]}',
"B6": f'{meta["出发地"]}-{meta["目的地"]}',
"C6": f'{meta["出差天数"]}天',
"D6": meta["出行方式"],
"H6": expenses["住宿"]["单据张数"],
"I6": expenses["住宿"]["金额"],
"H7": expenses["饮食"]["单据张数"],
"I7": expenses["饮食"]["金额"],
"H8": expenses["交通"]["单据张数"],
"I8": expenses["交通"]["金额"],
"H9": expenses["其他"]["单据张数"],
"I9": expenses["其他"]["金额"],
}
def update_xlsx_cells(xlsx_path, cell_values):
temp_file = NamedTemporaryFile(delete=False, suffix=".xlsx", dir=xlsx_path.parent)
temp_path = Path(temp_file.name)
temp_file.close()
try:
with zipfile.ZipFile(xlsx_path, "r") as source:
with zipfile.ZipFile(temp_path, "w", compression=zipfile.ZIP_DEFLATED) as target:
for item in source.infolist():
data = source.read(item.filename)
if item.filename == "xl/worksheets/sheet1.xml":
data = update_sheet_xml(data, cell_values)
target.writestr(item, data)
temp_path.replace(xlsx_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def update_sheet_xml(sheet_xml, cell_values):
namespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
ET.register_namespace("", namespace)
root = ET.fromstring(sheet_xml)
for cell_ref, value in cell_values.items():
cell = root.find(f".//{{{namespace}}}c[@r='{cell_ref}']")
if cell is None:
raise SystemExit(f"模板缺少单元格:{cell_ref}")
set_cell_xml_value(cell, value, namespace)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def set_cell_xml_value(cell, value, namespace):
for child in list(cell):
if child.tag in {f"{{{namespace}}}v", f"{{{namespace}}}f", f"{{{namespace}}}is"}:
cell.remove(child)
if isinstance(value, (int, float)):
cell.attrib.pop("t", None)
value_node = ET.SubElement(cell, f"{{{namespace}}}v")
value_node.text = format_number_for_excel(value)
else:
cell.set("t", "inlineStr")
inline_string = ET.SubElement(cell, f"{{{namespace}}}is")
text_node = ET.SubElement(inline_string, f"{{{namespace}}}t")
text_node.text = str(value)
def format_number_for_excel(value):
if isinstance(value, int):
return str(value)
return f"{float(value):.10f}".rstrip("0").rstrip(".")
def finalize_archive_dir(archive_dir, result):
if archive_dir == Path("."):
return archive_dir
@ -428,6 +484,37 @@ def verify_output(output_file, result):
if not isinstance(value, str) or not value.startswith("="):
raise SystemExit(f"输出校验失败:{label} 单元格 {cell} 公式丢失,当前值={value!r}")
verify_xlsx_styles_preserved(output_file)
def verify_xlsx_styles_preserved(output_file):
with zipfile.ZipFile(TEMPLATE_FILE, "r") as template, zipfile.ZipFile(output_file, "r") as output:
if template.read("xl/styles.xml") != output.read("xl/styles.xml"):
raise SystemExit("输出校验失败:样式表 xl/styles.xml 与模板不一致")
template_styles = read_sheet_cell_style_ids(template.read("xl/worksheets/sheet1.xml"))
output_styles = read_sheet_cell_style_ids(output.read("xl/worksheets/sheet1.xml"))
for cell_ref in filled_cell_refs():
if template_styles.get(cell_ref) != output_styles.get(cell_ref):
raise SystemExit(
f"输出校验失败:单元格 {cell_ref} 样式编号变化,"
f"模板={template_styles.get(cell_ref)!r} 输出={output_styles.get(cell_ref)!r}"
)
def read_sheet_cell_style_ids(sheet_xml):
namespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
root = ET.fromstring(sheet_xml)
return {
cell.attrib["r"]: cell.attrib.get("s")
for cell in root.findall(f".//{{{namespace}}}c")
if "r" in cell.attrib
}
def filled_cell_refs():
return ["E3", "A6", "B6", "C6", "D6", "H6", "I6", "H7", "I7", "H8", "I8", "H9", "I9"]
def build_parser():
parser = argparse.ArgumentParser(description="差旅报销自动化脚本")