Compare commits

..

3 Commits

Author SHA1 Message Date
copper-xw
04e1592fe3 修复归档路径错误,添加公式单元格缓存值支持
- fill 命令生成 Excel 后正确移动到归档目录而非保留在报销输出
- 新增 build_formula_cached_values 支持填充公式单元格的缓存值
- 完成后清理报销输出目录避免残留临时文件
- .gitignore 移除 *.json 使 analyzed_result.json 可被追踪
2026-05-29 14:21:03 +08:00
copper-xw
240d8534fd 保留填表模板样式
改为直接更新 xlsx 单元格 XML,避免 openpyxl 保存时改变模板样式,并在输出校验中检查样式是否保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 13:43:21 +08:00
copper-xw
327b745055 填表完成后清理 Python 缓存
在差旅报销填表成功后删除 __pycache__,避免运行脚本产生的缓存目录残留在项目根目录。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 11:53:53 +08:00
2 changed files with 154 additions and 24 deletions

1
.gitignore vendored
View File

@ -3,7 +3,6 @@
*.jpeg *.jpeg
*.png *.png
*.txt *.txt
*.json
*.xlsx *.xlsx
报销输出/ 报销输出/
__pycache__/ __pycache__/

View File

@ -14,8 +14,11 @@ import argparse
import json import json
import re import re
import shutil import shutil
import zipfile
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile
from xml.etree import ElementTree as ET
import pdfplumber import pdfplumber
@ -218,10 +221,12 @@ def command_fill(args):
output_dir = json_path.parent output_dir = json_path.parent
archive_dir = output_dir.parent if output_dir.name == OUTPUT_DIR_NAME else Path(".") archive_dir = output_dir.parent if output_dir.name == OUTPUT_DIR_NAME else Path(".")
output_file = fill_template(result, output_dir) output_file = fill_template(result, archive_dir)
verify_output(output_file, result) verify_output(output_file, result)
final_archive_dir = finalize_archive_dir(archive_dir, result) final_archive_dir = finalize_archive_dir(archive_dir, result)
final_output_file = final_archive_dir / OUTPUT_DIR_NAME / output_file.name final_output_file = final_archive_dir / output_file.name
cleanup_output_dir(final_archive_dir / OUTPUT_DIR_NAME)
cleanup_pycache()
print(f"完成:{final_output_file}") print(f"完成:{final_output_file}")
print(f"本次归档目录:{final_archive_dir}") print(f"本次归档目录:{final_archive_dir}")
@ -305,35 +310,119 @@ def ensure_fill_dependencies():
def fill_template(result, output_dir): def fill_template(result, output_dir):
output_dir.mkdir(exist_ok=True) output_dir.mkdir(exist_ok=True)
import openpyxl
meta = result["meta"] meta = result["meta"]
expenses = result["expenses"]
output_file = output_dir / build_output_filename(meta) output_file = output_dir / build_output_filename(meta)
shutil.copy(TEMPLATE_FILE, output_file) shutil.copy(TEMPLATE_FILE, output_file)
wb = openpyxl.load_workbook(output_file) update_xlsx_cells(output_file, build_cell_values(result), build_formula_cached_values(result))
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)
return output_file 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["出行方式"],
"F6": meta["出差天数"],
"H6": expenses["住宿"]["单据张数"],
"I6": expenses["住宿"]["金额"],
"H7": expenses["饮食"]["单据张数"],
"I7": expenses["饮食"]["金额"],
"H8": expenses["交通"]["单据张数"],
"I8": expenses["交通"]["金额"],
"H9": expenses["其他"]["单据张数"],
"I9": expenses["其他"]["金额"],
}
def build_formula_cached_values(result):
summary = result["summary"]
advance = summary["预借差旅费"]
total = summary["报销总额"]
return {
"H2": summary["附件总张数"],
"F9": result["meta"]["出差天数"],
"C10": total,
"H10": max(total - advance, 0),
"H11": max(advance - total, 0),
}
def update_xlsx_cells(xlsx_path, cell_values, formula_cached_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, formula_cached_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, formula_cached_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)
for cell_ref, value in formula_cached_values.items():
cell = root.find(f".//{{{namespace}}}c[@r='{cell_ref}']")
if cell is None:
raise SystemExit(f"模板缺少单元格:{cell_ref}")
set_formula_cached_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 set_formula_cached_value(cell, value, namespace):
cell.attrib.pop("t", None)
value_node = None
for child in cell:
if child.tag == f"{{{namespace}}}v":
value_node = child
break
if value_node is None:
value_node = ET.SubElement(cell, f"{{{namespace}}}v")
value_node.text = format_number_for_excel(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): def finalize_archive_dir(archive_dir, result):
if archive_dir == Path("."): if archive_dir == Path("."):
return archive_dir return archive_dir
@ -358,6 +447,17 @@ def unique_archive_dir(path):
index += 1 index += 1
def cleanup_output_dir(output_dir):
if output_dir.exists():
shutil.rmtree(output_dir)
def cleanup_pycache():
pycache_dir = Path("__pycache__")
if pycache_dir.exists():
shutil.rmtree(pycache_dir)
def build_archive_dir_name(meta): def build_archive_dir_name(meta):
start_date = normalize_date_for_filename(meta["出发日期"]) start_date = normalize_date_for_filename(meta["出发日期"])
end_date = normalize_date_for_filename(meta["结束日期"]) end_date = normalize_date_for_filename(meta["结束日期"])
@ -421,6 +521,37 @@ def verify_output(output_file, result):
if not isinstance(value, str) or not value.startswith("="): if not isinstance(value, str) or not value.startswith("="):
raise SystemExit(f"输出校验失败:{label} 单元格 {cell} 公式丢失,当前值={value!r}") 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(): def build_parser():
parser = argparse.ArgumentParser(description="差旅报销自动化脚本") parser = argparse.ArgumentParser(description="差旅报销自动化脚本")