""" 将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。 仅写入表格数据单元格,保留原模板字体、边框与版式。 """ from __future__ import annotations import argparse import re import shutil from datetime import date from pathlib import Path from typing import Any from .. import get_logger from ..config import load_config from ..doc.invoice import load_invoice_csv log = get_logger("fill_consumable_doc") CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc" # Word COM 常量 WD_CHARACTER = 1 # 表格统一字体:宋体、五号(10.5 磅) TABLE_FONT_NAME = "宋体" TABLE_FONT_SIZE = 10.5 def _split_name_spec(left: str) -> tuple[str, str]: m = re.search(r"(\S+一批)\s*$", left) if m: return m.group(1), left[: m.start()].strip() parts = left.split(" ", 1) if len(parts) == 2: return parts[0], parts[1] return left, "" def parse_spec_model(spec: str) -> dict[str, str]: spec = (spec or "").strip() if " 个 " not in spec: return { "product_name": spec, "spec": "", "unit": "", "qty": "", "unit_price": "", } left, right = spec.split(" 个 ", 1) product_name, model_spec = _split_name_spec(left.strip()) tokens = right.split() qty = "" unit_price = "" if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]): qty, unit_price = tokens[0], tokens[1] elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]): qty, unit_price = "1", tokens[0] return { "product_name": product_name, "spec": model_spec, "unit": "个", "qty": qty, "unit_price": unit_price, } def _format_money(value: str | float) -> str: """单价、金额:固定保留两位小数。""" if value is None or value == "": return "" try: num = float(value) except (TypeError, ValueError): return str(value) return f"{num:.2f}" def _today_cn_date() -> str: """当前日期,格式:2026年5月26日""" today = date.today() return f"{today.year}年{today.month}月{today.day}日" def _apply_font(rng: Any) -> None: """将范围字体设为宋体五号(含数字与英文)。""" font = rng.Font font.Name = TABLE_FONT_NAME font.NameFarEast = TABLE_FONT_NAME font.NameAscii = TABLE_FONT_NAME font.NameOther = TABLE_FONT_NAME font.NameBi = TABLE_FONT_NAME font.Size = TABLE_FONT_SIZE def _set_cell_value(cell: Any, text: str) -> None: """写入单元格正文(不含末尾单元格标记)。""" rng = cell.Range rng.MoveEnd(WD_CHARACTER, -1) rng.Text = "" if text is None else str(text) _apply_font(rng) def _normalize_table_font(tbl: Any) -> None: """填写完成后统一整张表的字体。""" for row in tbl.Rows: for cell in row.Cells: rng = cell.Range rng.MoveEnd(WD_CHARACTER, -1) _apply_font(rng) def _replace_date_in_doc(doc: Any, new_date: str) -> None: """仅替换表头段落中的日期文字,不改动段落其余部分。""" if not new_date: return try: para = doc.Paragraphs(3) except Exception: return rng = para.Range text = rng.Text.replace("\r", "").replace("\x07", "") m = re.search(r"\d{4}年\d{1,2}月\d{1,2}日", text) if not m: return start = rng.Start + m.start() end = rng.Start + m.end() doc.Range(Start=start, End=end).Text = new_date def fill_consumable_doc( csv_path: str | Path, doc_path: str | Path, config: dict[str, Any] | None = None, backup: bool = True, ) -> Path: csv_path = Path(csv_path) doc_path = Path(doc_path) if config is None: config = load_config() invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or [] if backup: bak = doc_path.with_suffix(doc_path.suffix + ".bak") shutil.copy2(doc_path, bak) import pythoncom import win32com.client pythoncom.CoInitialize() word = None doc = None try: word = win32com.client.Dispatch("Word.Application") word.Visible = False word.DisplayAlerts = 0 doc = word.Documents.Open(str(doc_path.resolve())) try: _replace_date_in_doc(doc, _today_cn_date()) tbl = doc.Tables(1) storage = config.get("consumable_storage", "躬行楼 C205") for i, inv in enumerate(invoices): row_idx = i + 2 if row_idx > tbl.Rows.Count: break parsed = parse_spec_model(str(inv.get("spec_model", ""))) # 当规格型号为空时,从项目名称提取产品信息 if not parsed["product_name"]: item_name = str(inv.get("item_name", "")) # 去除 "*分类*" 前缀(如 "*电子工业设备*元件盒" -> "元件盒") if "*" in item_name: item_name = item_name.split("*")[-1].strip() parsed["product_name"] = item_name card_amount_raw = inv.get("card_amount") or 0 card_amount: float = float(str(card_amount_raw).replace(",", "")) qty_str = parsed["qty"] qty_val = int(qty_str) if qty_str and qty_str.isdigit() else 0 # 金额填写刷卡金额,单价由刷卡金额反算 amount = _format_money(card_amount) unit_price = _format_money(card_amount / qty_val) if qty_val > 0 else _format_money(card_amount) # 数量:去掉前导零;若无数量则默认为 1 qty = str(qty_val) if qty_val > 0 else "1" values = [ str(inv.get("index", i + 1)), parsed["product_name"], parsed["spec"], parsed["unit"], qty, unit_price, amount, "", # 购货人签字 — 保持空白 storage, "", # 领用人签字 — 保持空白 "", # 备注 — 保持空白,避免撑破版式 ] for col_idx, val in enumerate(values, start=1): _set_cell_value(tbl.Cell(row_idx, col_idx), str(val)) _normalize_table_font(tbl) doc.Save() finally: if doc is not None: doc.Close() finally: if word is not None: word.Quit() pythoncom.CoUninitialize() return doc_path def fill_consumable_from_template( csv_path: str | Path, template_path: str | Path, output_path: str | Path, config: dict[str, Any] | None = None, ) -> Path: """从模板复制并填写出库单(Web 会话每次从模板重新生成)。""" template_path = Path(template_path) output_path = Path(output_path) if not template_path.exists(): raise FileNotFoundError(f"出库单模板不存在: {template_path}") shutil.copy2(template_path, output_path) return fill_consumable_doc(csv_path, output_path, config=config, backup=False) def main() -> None: root = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单") parser.add_argument("--csv", default=str(root / "invoice_summary.csv")) parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc")) parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json")) parser.add_argument("--no-backup", action="store_true") args = parser.parse_args() cfg = None if Path(args.config).exists(): import json with open(args.config, encoding="utf-8") as f: cfg = {**load_config(), **json.load(f)} out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup) print(f"已填写并保存: {out}") if __name__ == "__main__": main()