260 lines
7.9 KiB
Python
260 lines
7.9 KiB
Python
"""
|
||
将 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()
|
||
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:
|
||
doc.Close()
|
||
word.Quit()
|
||
finally:
|
||
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()
|