feat: Web 表格编辑、易耗品出库单自动生成与文档完善
- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号) - Web 处理完成后自动生成出库单并提供下载 - 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统 - 补充 API.md、README(含 Mermaid 数据流)及 config.example.json Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
20
app/bot.py
20
app/bot.py
@@ -297,23 +297,13 @@ class ReimburseBot:
|
||||
|
||||
for i, inv in enumerate(invoices):
|
||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
||||
|
||||
self.page.click("#insertAcc", timeout=5000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现
|
||||
self.page.click("#insertAcc", timeout=5000) #点击增加按钮出现
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.page.select_option("#fjlx", "1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
self.page.fill("#fpsmxx", explanation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -323,16 +313,14 @@ class ReimburseBot:
|
||||
self.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"文件上传失败: {e}")
|
||||
|
||||
try:
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
self.page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
try:
|
||||
self.page.press("body", "Escape")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log.info("上传附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
self._screenshot("step6_error")
|
||||
|
||||
@@ -29,5 +29,6 @@ def load_config() -> dict:
|
||||
"default_name": raw.get("default_name", ""),
|
||||
"default_card_no": raw.get("default_card_no", ""),
|
||||
"default_person_id": raw.get("default_person_id", ""),
|
||||
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
|
||||
"attachment_dir": project_root / "attachments",
|
||||
}
|
||||
243
app/fill_consumable_doc.py
Normal file
243
app/fill_consumable_doc.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
将 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()
|
||||
Reference in New Issue
Block a user