autotrain/scripts/annotate_roster_invoices.py
xinxin6623 7a01ed4db9 feat: add invoice batch wrap-up (name extraction + roster annotation) and web ui automation improvements
- add scripts/annotate_roster_invoices.py: match passenger names to invoice PDFs, write status + relative encoded hyperlinks into roster xlsx
- add scripts/add_invoice_passenger_name.py, scripts/calibrate.py
- add .opencode/skills/invoice-wrapup skill
- improve invoice_tool.py / web_ui.py / vision_fallback.py automation
- add device/screen/windows adaptation docs
- ignore out/ (delivered invoice PDFs contain sensitive data)
2026-07-10 00:11:45 +08:00

124 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Annotate a roster xlsx with invoice status + PDF hyperlink by matching passenger names.
Scans one or more directories for invoice PDFs whose filenames already carry the
real passenger name (produced by add_invoice_passenger_name.py or the timestamped
android flow), builds a name -> pdf map, then writes two columns into the roster:
`开票状态` (已开票 / 未开票) and `发票PDF` (filename + file:// hyperlink).
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from urllib.parse import quote
import openpyxl
def rel_hyperlink(pdf: Path, base: Path) -> str:
rel = os.path.relpath(pdf, base)
return "/".join(quote(seg) for seg in rel.split(os.sep))
def extract_name(fname: str) -> str | None:
stem = fname[:-4] if fname.lower().endswith(".pdf") else fname
parts = stem.split("_")
# timestamped android flow: YYYYMMDD_HHMMSS_realname_masked
if len(parts) >= 4 and parts[0].isdigit() and len(parts[0]) == 8:
return parts[2]
# add_invoice_passenger_name output: <digits>-_realname_电子发票...
if len(parts) >= 2:
return parts[1]
return None
def norm(name: str) -> str:
return str(name).replace(" ", "").replace("\u3000", "").strip()
def build_map(dirs: list[Path]) -> dict[str, Path]:
mapping: dict[str, Path] = {}
for d in dirs:
for p in sorted(d.glob("*.pdf")):
nm = extract_name(p.name)
if nm:
mapping.setdefault(norm(nm), p)
return mapping
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("xlsx", type=Path, help="Roster xlsx to annotate (edited in place)")
ap.add_argument("dirs", type=Path, nargs="+", help="Directories containing named invoice PDFs")
ap.add_argument("--sheet", default=None, help="Sheet name (default: first sheet)")
ap.add_argument("--name-col", type=int, default=2, help="1-based column of passenger name (default 2)")
ap.add_argument("--status-header", default="开票状态")
ap.add_argument("--link-header", default="发票PDF")
ap.add_argument("--done-label", default="已开票")
ap.add_argument("--missing-label", default="未开票")
args = ap.parse_args()
xlsx = args.xlsx.expanduser().resolve()
if not xlsx.is_file():
raise SystemExit(f"not a file: {xlsx}")
dirs = [d.expanduser().resolve() for d in args.dirs]
for d in dirs:
if not d.is_dir():
raise SystemExit(f"not a directory: {d}")
name_to_pdf = build_map(dirs)
print(f"mapped PDFs: {len(name_to_pdf)}")
wb = openpyxl.load_workbook(xlsx)
ws = wb[args.sheet] if args.sheet else wb.worksheets[0]
def find_or_append_col(header: str) -> int:
for c in range(1, ws.max_column + 1):
if ws.cell(row=1, column=c).value == header:
return c
col = ws.max_column + 1
ws.cell(row=1, column=col, value=header)
return col
status_col = find_or_append_col(args.status_header)
link_col = find_or_append_col(args.link_header)
xlsx_dir = xlsx.parent
matched = 0
roster: set[str] = set()
for r in range(2, ws.max_row + 1):
name = ws.cell(row=r, column=args.name_col).value
if not name:
continue
key = norm(name)
roster.add(key)
cell = ws.cell(row=r, column=link_col)
cell.hyperlink = None
pdf = name_to_pdf.get(key)
if pdf:
ws.cell(row=r, column=status_col, value=args.done_label)
rel = rel_hyperlink(pdf, xlsx_dir)
cell.value = pdf.name
cell.hyperlink = rel
cell.style = "Hyperlink"
matched += 1
else:
ws.cell(row=r, column=status_col, value=args.missing_label)
cell.value = None
wb.save(xlsx)
missing = sorted(roster - set(name_to_pdf))
extra = sorted(set(name_to_pdf) - roster)
print(f"saved: {xlsx}")
print(f"matched rows: {matched}")
print(f"roster without pdf ({len(missing)}): {missing}")
print(f"pdf without roster ({len(extra)}): {extra}")
return 0
if __name__ == "__main__":
raise SystemExit(main())