1e3a4a7f3e
- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号) - Web 处理完成后自动生成出库单并提供下载 - 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统 - 补充 API.md、README(含 Mermaid 数据流)及 config.example.json Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
6.9 KiB
Python
244 lines
6.9 KiB
Python
"""
|
|
将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。
|
|
|
|
仅写入表格数据单元格,保留原模板字体、边框与版式。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from . import get_logger
|
|
from .bot import load_invoice_data
|
|
from .config import load_config
|
|
|
|
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 _format_cn_date(date_str: str) -> str:
|
|
if not date_str:
|
|
return ""
|
|
parts = date_str.replace("-", "/").split("/")
|
|
if len(parts) != 3:
|
|
return date_str
|
|
y, m, d = parts[0], str(int(parts[1])), str(int(parts[2]))
|
|
return f"{y}年{m}月{d}日"
|
|
|
|
|
|
def _apply_font(rng) -> 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, 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) -> 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, 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 | 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_data(str(csv_path), config)
|
|
|
|
if backup:
|
|
bak = doc_path.with_suffix(doc_path.suffix + ".bak")
|
|
shutil.copy2(doc_path, bak)
|
|
|
|
import win32com.client
|
|
|
|
word = win32com.client.Dispatch("Word.Application")
|
|
word.Visible = False
|
|
word.DisplayAlerts = 0
|
|
doc = word.Documents.Open(str(doc_path.resolve()))
|
|
|
|
try:
|
|
if invoices:
|
|
cn_date = _format_cn_date(invoices[0].get("invoice_date", ""))
|
|
_replace_date_in_doc(doc, 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(inv.get("spec_model", ""))
|
|
amount = _format_money(inv.get("total_amount", ""))
|
|
unit_price = _format_money(parsed["unit_price"])
|
|
qty = parsed["qty"]
|
|
if qty and qty.isdigit():
|
|
qty = str(int(qty))
|
|
|
|
values = [
|
|
str(inv.get("seq", 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), val)
|
|
|
|
_normalize_table_font(tbl)
|
|
|
|
doc.Save()
|
|
finally:
|
|
doc.Close()
|
|
word.Quit()
|
|
|
|
return doc_path
|
|
|
|
|
|
def fill_consumable_from_template(
|
|
csv_path: str | Path,
|
|
template_path: str | Path,
|
|
output_path: str | Path,
|
|
config: dict | 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 / "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()
|