diff --git a/差旅报销.py b/差旅报销.py index 4541692..0d3317f 100644 --- a/差旅报销.py +++ b/差旅报销.py @@ -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="差旅报销自动化脚本")