commit f754386695f42969e3d033fe99502432905f63f2 Author: wandering Date: Mon May 25 11:18:12 2026 +0800 Initial commit: Auto-Finance 财务报销自动化系统 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..614187f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +.eggs/ + +# Playwright MCP snapshots +.playwright-mcp/ + +# Uploads (user data) +web/uploads/ + +# Logs +pipeline.log +*.log + +# Debug images +images/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/1. 电容一批.pdf b/1. 电容一批.pdf new file mode 100644 index 0000000..0f145c4 Binary files /dev/null and b/1. 电容一批.pdf differ diff --git a/1. 电容一批.png b/1. 电容一批.png new file mode 100644 index 0000000..b896129 Binary files /dev/null and b/1. 电容一批.png differ diff --git a/2. 电阻一批.pdf b/2. 电阻一批.pdf new file mode 100644 index 0000000..5411f45 Binary files /dev/null and b/2. 电阻一批.pdf differ diff --git a/2. 电阻一批.png b/2. 电阻一批.png new file mode 100644 index 0000000..b29b64e Binary files /dev/null and b/2. 电阻一批.png differ diff --git a/3. LED一批.pdf b/3. LED一批.pdf new file mode 100644 index 0000000..1759a7d Binary files /dev/null and b/3. LED一批.pdf differ diff --git a/3. LED一批.png b/3. LED一批.png new file mode 100644 index 0000000..aeafa38 Binary files /dev/null and b/3. LED一批.png differ diff --git a/4. 元器件盒.pdf b/4. 元器件盒.pdf new file mode 100644 index 0000000..f492665 Binary files /dev/null and b/4. 元器件盒.pdf differ diff --git a/4. 元器件盒.png b/4. 元器件盒.png new file mode 100644 index 0000000..129c550 Binary files /dev/null and b/4. 元器件盒.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..35a851b --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# 财务报销自动化 + +自动从 PDF 发票提取信息,OCR 识别支付记录截图,然后在财务系统中自动填报报销单。 + +## 项目结构 + +``` +├── run.py # CLI 入口 +├── config.json # 配置文件(登录凭据、系统 URL 等) +├── app/ +│ ├── config.py # 配置加载 +│ ├── extractor.py # PDF 发票信息提取 +│ ├── ocr.py # OCR 刷卡信息识别 +│ ├── bot.py # 浏览器自动填报 +│ └── pipeline.py # 流程编排(数据在内存中流转) +├── web/ +│ ├── app.py # Web 服务入口 +│ ├── templates/ +│ │ └── index.html # Web 前端页面 +│ └── uploads/ # 用户上传文件目录 +├── *.pdf # 发票 PDF(按需放置) +├── *.png / *.jpg # 与 PDF 同名的支付截图 +├── invoice_summary.csv # 中间产物 — 发票汇总表 +└── images/ # 调试截图 +``` + +## 数据流 + +``` +PDF 文件 ──► extractor 提取 ──► 发票列表 + │ +支付截图 ──► OCR 识别 ────────────► 回填刷卡信息 + │ + invoice_summary.csv + │ + bot 打开浏览器 ──► 自动填报 +``` + +## 环境要求 + +- Python 3.10+ +- 依赖见下方安装步骤 + +## 快速开始 + +### 1. 安装依赖 + +> 以下依赖需在 MinerU 虚拟环境中安装: +> ```bash +> conda activate MinerU +> ``` + +```bash +pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 +playwright install chromium +``` + +### 2. 准备数据 + +将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图: + +1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`) +2. **金额近邻匹配** — 文件名不同时,自动提取 PDF 的价税合计和截图的刷卡金额进行配对 + +截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。 +``` + +### 3. 配置 + +编辑 `config.json`,填写登录凭据和默认值: + +```json +{ + "username": "你的工号", + "password": "你的密码", + "default_name": "默认报销人姓名", + "default_card_no": "默认公务卡号", + "default_person_id": "默认人员编号" +} +``` + +未填写的字段将使用默认值,URL 类配置一般无需修改。 + +### 4. 运行 + +```bash +# 全流程(发票提取 → OCR 识别 → 浏览器填报) +python run.py + +# 仅执行某一步 +python run.py --step invoice # 仅发票提取 +python run.py --step ocr # 仅 OCR 识别 +python run.py --step submit # 仅浏览器填报 + +# 覆盖配置中的登录凭据 +python run.py -u 工号 -p 密码 +``` + +## 执行步骤说明 + +| 步骤 | 命令 | 说明 | +|------|------|------| +| 发票提取 | `--step invoice` | 扫描根目录 PDF,提取发票号码、金额、销售方等信息,生成 `invoice_summary.csv` 和 `.md` | +| OCR 识别 | `--step ocr` | 对支付截图执行 OCR,识别刷卡日期、刷卡金额、持卡人姓名,回填到 CSV | +| 浏览器填报 | `--step submit` | 打开浏览器,登录信息门户 → 进入报销系统 → 自动填单、录入明细、上传附件 | + +> 分步执行时,上一步的 CSV 产物会自动成为下一步的输入。 + +## Web 服务 + +提供浏览器界面,上传文件即可自动处理: + +```bash +python web/app.py +``` + +访问 `http://localhost:5000`,上传文件并填写配置后点击「开始处理」。 + +### 两种处理模式 + +| 模式 | 入口 | 说明 | +|------|------|------| +| **PDF 模式** | 上传 PDF + 截图 | 自动提取发票信息 → OCR 识别刷卡记录 → 生成 CSV → 可选浏览器填报 | +| **CSV 快捷模式** | 上传已有 CSV 文件 | 跳过提取和 OCR,直接使用 CSV 数据进行浏览器填报 | + +### 功能说明 + +| 功能 | 说明 | +|------|------| +| 发票提取 + OCR | 上传 PDF 后自动完成,无需手动操作 | +| CSV 快捷上传 | 已有发票数据 CSV 可直接上传,跳过前面的步骤 | +| 浏览器填报 | 勾选「同时提交到财务系统」后自动运行 | +| 实时日志 | 处理进度通过 SSE 实时推送 | +| 下载 CSV | 处理后下载发票汇总表 | +| 配置上传 | 可上传 `config.json` 自动填充表单 | + +> Web 模式下浏览器以无头模式运行,不会弹出窗口。 +> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。 + +## 注意事项 + +- 第三步会打开浏览器窗口,请勿关闭或切换标签页 +- 首次运行可能需要手动处理 SSO 登录(如已保存会话则跳过) +- 调试截图保存在 `images/` 目录,出错时可查看 +- `invoice_summary.csv` 中空白的字段会在 OCR 步骤自动回填,不会覆盖已有数据 +- 提交按钮默认未启用,确认数据无误后可在 `app/bot.py` 中取消注释 `bot.submit()` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8c1e837 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,35 @@ +"""财务报销自动化工具包""" + +import io +import logging +import sys +from pathlib import Path + +_LOG_FMT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s" +_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S" +_LOG_FILE = Path(__file__).resolve().parent.parent / "pipeline.log" + + +def get_logger(name: str) -> logging.Logger: + """获取带时间戳的日志记录器 + + 输出格式: 2026-05-24 12:34:56 [INFO ] extractor: 扫描目录: ... + 日志同时输出到终端和项目根目录的 pipeline.log + """ + logger = logging.getLogger(name) + if not logger.handlers: + logger.setLevel(logging.INFO) + formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT) + + # 终端输出 + utf8_stream = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + stream_handler = logging.StreamHandler(utf8_stream) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + # 文件输出 + file_handler = logging.FileHandler(str(_LOG_FILE), encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger \ No newline at end of file diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..e71a861 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,435 @@ +""" +浏览器自动化填报 + +使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。 + +对外接口: + load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值 + run_bot(config, invoices) 启动浏览器并执行填报流程 +""" + +import csv +from pathlib import Path + +from . import get_logger + +log = get_logger("bot") + + +# ------------------------------------------------------------------ +# 日期格式化 +# ------------------------------------------------------------------ + +def _format_date(date_str: str) -> str: + """将 '2026/5/13' 或 '2026-5-13' 转为 '2026-05-13'""" + if not date_str: + return "" + parts = date_str.replace("-", "/").split("/") + if len(parts) == 3: + return f"{parts[0].zfill(4)}-{parts[1].zfill(2)}-{parts[2].zfill(2)}" + return date_str + + +# ------------------------------------------------------------------ +# CSV 数据加载 +# ------------------------------------------------------------------ + +def load_invoice_data(csv_path: str, config: dict) -> list[dict]: + """从 CSV 加载发票数据,自动补全空白字段的默认值""" + invoices = [] + with open(csv_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + invoices.append({ + "seq": row.get("序号", ""), + "invoice_no": row.get("发票号码", ""), + "invoice_date": row.get("开票日期", ""), + "item_name": row.get("项目名称", ""), + "spec_model": row.get("规格型号", ""), + "total_amount": float(row.get("价税合计", 0)), + "seller_name": row.get("销售方名称", ""), + "person_name": row.get("人员姓名") or config.get("default_name", ""), + "card_date": _format_date(row.get("刷卡日期") or ""), + "card_no": row.get("公务卡号") or config.get("default_card_no", ""), + "card_amount": float(row.get("刷卡金额") or "0"), + "remark": row.get("备注") or "", + "person_id": row.get("工号") or config.get("default_person_id", ""), + }) + return invoices + + +# ------------------------------------------------------------------ +# 报销机器人 +# ------------------------------------------------------------------ + +class ReimburseBot: + """财务报销自动化机器人""" + + def __init__(self, config: dict, headless: bool = False): + self.config = config + self.headless = headless + self.work_dir: Path | None = None + self.browser = None + self.context = None + self.page = None + + from playwright.sync_api import sync_playwright + self._pw_ctx = sync_playwright() + self.pw = self._pw_ctx.__enter__() + + def launch(self): + """启动浏览器""" + self.browser = self.pw.chromium.launch(headless=self.headless) + self.context = self.browser.new_context(viewport={"width": 1920, "height": 1080}) + self.page = self.context.new_page() + self.page.set_default_timeout(30000) + + def login_portal(self): + """登录信息门户""" + log.info("登录信息门户...") + + self.page.goto(self.config["sso_login_url"], wait_until="domcontentloaded") + self._wait_for('text="微信扫码登录"', timeout=5000) + + try: + self.page.fill('input[placeholder*="工号"], input[placeholder*="学号"]', self.config["username"]) + self.page.fill('input[placeholder*="密码"]', self.config["password"]) + except Exception: + log.warning("未找到登录输入框,可能已登录") + + try: + checkbox = self.page.query_selector('input[type="checkbox"]') + if checkbox and not checkbox.is_checked(): + checkbox.click() + except Exception: + pass + + for selector in ['button:has-text("登录")', 'input[value="登录"]', 'text="登录"']: + try: + self.page.click(selector, timeout=3000) + break + except Exception: + continue + + self._wait_for_portal() + + def _wait_for_portal(self): + """等待跳转到统一信息平台""" + for _ in range(30): + self.page.wait_for_timeout(1000) + url = self.page.url + if any(kw in url for kw in ("tyrz.fynu.edu.cn/zs-uip", "tyrz.fynu.edu.cn/oshall", "portal")): + self._screenshot("portal_loaded") + return + log.error("等待门户跳转超时") + self._screenshot("portal_timeout") + raise TimeoutError("登录超时,未跳转到信息门户") + + def navigate_to_reimburse(self): + """从统一信息平台进入报销系统""" + log.info("进入报销系统...") + self._wait_for('text="快捷入口"', timeout=5000) + + try: + self.page.click('text="财务系统"', timeout=5000) + except Exception: + log.warning("未找到财务系统入口") + + new_tab = None + for _ in range(15): + self.page.wait_for_timeout(1000) + for p in self.context.pages: + if "dddl" in p.url or "210.45.32.214" in p.url: + new_tab = p + break + if new_tab: + break + + if new_tab: + self.page = new_tab + self._wait_for('text="网络报销"', timeout=5000) + else: + log.warning(f"未找到单点登录页面,当前 URL: {self.page.url}") + + for p in self.context.pages[:-1]: + try: + p.close() + except Exception: + pass + + try: + link = self.page.query_selector('a:has(img[src*="wlbx"])') + if link: + reimburse_url = link.get_attribute("href") + self.page.goto(reimburse_url, wait_until="domcontentloaded", timeout=15000) + except Exception: + pass + + self._wait_for('text="报销录入"', timeout=5000) + common_url = self.config["reimburse_url"] + self.config["reimburse_page"] + self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000) + self._wait_for('text="单据状态:"', timeout=5000) + + def open_reimburse_menu(self): + """点击「新增」创建新报销单""" + log.info("创建新报销单...") + self.page.wait_for_timeout(2000) + + try: + self.page.click('button:has-text("新增")', timeout=5000) + except Exception: + try: + self.page.click("text=新增", timeout=3000) + except Exception: + self._screenshot("no_add_button") + raise RuntimeError("无法点击新增按钮") + + self.page.wait_for_timeout(3000) + self._screenshot("after_add_click") + + def fill_basic_info(self, description: str = "元器件采购报销"): + """填写基本信息""" + log.info("填写基本信息...") + + try: + self.page.fill("#EXPENEXPLAIN", description) + except Exception: + pass + + try: + self.page.click("#PROJECTCODE", timeout=10000) + self.page.wait_for_timeout(1000) + except Exception: + pass + + self._screenshot("step3_project_modal") + + try: + self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000) + first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr") + if first_row: + first_row.click() + self.page.wait_for_timeout(1000) + except Exception: + pass + + self._screenshot("step3_project_selected") + + try: + self.page.click("#saveAndNext", timeout=5000) + self.page.wait_for_timeout(2000) + except Exception: + pass + + self._screenshot("step3_done") + + def add_reimburse_items(self, invoices: list[dict]): + """录入报销明细(一条总明细)""" + card_amount = sum(inv["card_amount"] for inv in invoices) + log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...") + + try: + self.page.click("#insertDetail", timeout=5000) + self.page.wait_for_timeout(1000) + self._wait_for('text="经济事项名称"', timeout=5000) + self.page.click("#economicscode2") + self.page.wait_for_timeout(1000) + + try: + self.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000) + rows = self.page.query_selector_all("#econmodal .fixed-table-body tbody tr") + if len(rows) >= 3: + rows[2].click() + self.page.wait_for_timeout(1000) + except Exception: + pass + + self.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', f"{len(invoices)}") + self.page.fill("#je_zwzcdz", f"{card_amount:.2f}") + self.page.click("#detailAdd", timeout=3000) + self.page.wait_for_timeout(1000) + + self._screenshot("item_total") + except Exception as e: + log.error(f"录入总明细失败: {e}") + self._screenshot("item_total_error") + raise + + def fill_payment(self, invoices: list[dict]): + """录入支付信息""" + log.info("录入支付信息...") + try: + self.page.click('text="下一步(支付方式)"', timeout=5000) + self._wait_for('text="下一步(附件清单)"', timeout=5000) + + for inv in invoices: + self.page.click("#insertPay", timeout=5000) + self.page.wait_for_timeout(1000) + self.page.fill("#personid2", inv["person_id"]) + self.page.fill("#accountname2", inv["person_name"]) + self.page.fill("#receiptdate2", inv["card_date"]) + self.page.fill("#localaccount2", inv["card_no"]) + self.page.fill("#receiptmoney2", str(inv["card_amount"])) + self.page.fill("#money2", str(inv["card_amount"])) + self.page.fill("#merchant2", inv["seller_name"]) + self.page.fill("#smark2", inv["remark"]) + self.page.click("#payAdd", timeout=3000) + self.page.wait_for_timeout(1000) + except Exception as e: + log.error(f"支付方式录入失败: {e}") + self._screenshot("step5_error") + raise + + self._screenshot("step5_done") + + def upload_attachments(self, invoices: list[dict]): + """上传发票附件""" + log.info("上传附件...") + + try: + self.page.click("#next3", timeout=5000) + self._wait_for("#submit2", timeout=5000) + + attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf")) + if not attachment_files: + log.warning("未找到附件 PDF,跳过附件上传") + return + + 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("#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) + except Exception: + pass + + if file_path and file_path.exists(): + try: + self.page.set_input_files("#file", str(file_path)) + 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 + + except Exception as e: + log.error(f"附件上传失败: {e}") + self._screenshot("step6_error") + raise + + self._screenshot("step6_done") + + def submit(self): + """提交报销单""" + log.info("提交报销单...") + try: + self.page.click("#submit", timeout=5000) + self.page.wait_for_timeout(1000) + self._screenshot("submitted") + except Exception as e: + log.error(f"提交失败: {e}") + self._screenshot("submit_error") + raise + + def close(self): + """关闭浏览器""" + if self.context: + self.context.close() + if self.browser: + self.browser.close() + try: + self._pw_ctx.__exit__(None, None, None) + except Exception: + pass + + # -------------------------------------------------------- + # 辅助方法 + # -------------------------------------------------------- + + def _wait_for(self, selector: str, timeout: int = None): + self.page.wait_for_selector(selector, timeout=timeout) + + def _screenshot(self, name: str): + img_dir = Path(__file__).parent.parent / "images" + img_dir.mkdir(exist_ok=True) + self.page.screenshot(path=str(img_dir / f"debug_{name}.png")) + + +# ------------------------------------------------------------------ +# 对外入口 +# ------------------------------------------------------------------ + +def run_bot(config: dict, invoices: list[dict], headless: bool = False, work_dir: Path | None = None): + """执行完整的浏览器填报流程""" + if not config["username"] or not config["password"]: + raise ValueError("缺少用户名或密码") + + bot = ReimburseBot(config, headless=headless) + bot.work_dir = work_dir + try: + bot.launch() + bot.login_portal() + bot.navigate_to_reimburse() + bot.open_reimburse_menu() + bot.fill_basic_info() + bot.add_reimburse_items(invoices) + bot.fill_payment(invoices) + bot.upload_attachments(invoices) + # bot.submit() # 确认无误后再取消注释 + except Exception as e: + log.error(f"操作失败: {e}") + try: + bot._screenshot("error") + except Exception: + pass + raise + finally: + bot.close() + + +def run_bot_web(config: dict, invoices: list[dict], work_dir: Path): + """Web 模式填报 — headless,附件从指定目录读取""" + if not config["username"] or not config["password"]: + raise ValueError("缺少用户名或密码") + + bot = ReimburseBot(config, headless=True) + bot.work_dir = work_dir + try: + bot.launch() + bot.login_portal() + bot.navigate_to_reimburse() + bot.open_reimburse_menu() + bot.fill_basic_info() + bot.add_reimburse_items(invoices) + bot.fill_payment(invoices) + bot.upload_attachments(invoices) + except Exception as e: + log.error(f"操作失败: {e}") + try: + bot._screenshot("error") + except Exception: + pass + raise + finally: + bot.close() \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a24f0ad --- /dev/null +++ b/app/config.py @@ -0,0 +1,33 @@ +""" +配置加载 + +从项目根目录的 config.json 读取配置,返回结构化的配置字典。 +""" + +import json +from pathlib import Path + +_CONFIG_PATH = Path(__file__).parent.parent / "config.json" + + +def load_config() -> dict: + """加载并合并配置,缺失字段使用默认值""" + raw = {} + if _CONFIG_PATH.exists(): + with open(_CONFIG_PATH, encoding="utf-8") as f: + raw = json.load(f) + + project_root = _CONFIG_PATH.parent + + return { + "sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"), + "portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"), + "reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"), + "reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"), + "username": raw.get("username", ""), + "password": raw.get("password", ""), + "default_name": raw.get("default_name", ""), + "default_card_no": raw.get("default_card_no", ""), + "default_person_id": raw.get("default_person_id", ""), + "attachment_dir": project_root / "attachments", + } \ No newline at end of file diff --git a/app/extractor.py b/app/extractor.py new file mode 100644 index 0000000..bd97383 --- /dev/null +++ b/app/extractor.py @@ -0,0 +1,256 @@ +""" +PDF 发票信息提取 + +从 PDF 发票文件中提取关键字段,输出为标准化的发票数据列表。 + +对外接口: + extract_invoices(directory) -> list[dict] 扫描目录下所有 PDF 并提取 + save_csv(invoices, path) 保存为 CSV + save_markdown(invoices, path) 保存为 Markdown 汇总 +""" + +import csv +import re +from pathlib import Path + +from . import get_logger + +log = get_logger("extractor") + +CSV_COLUMNS = [ + "序号", "发票号码", "开票日期", "项目名称", "规格型号", + "价税合计", "销售方名称", "人员姓名", "刷卡日期", + "公务卡号", "刷卡金额", "备注", "工号", +] + + +# ------------------------------------------------------------------ +# PDF 文件发现与文本提取 +# ------------------------------------------------------------------ + +def find_pdf_files(directory: str = ".") -> list[Path]: + """查找目录下所有 PDF 文件(非递归)""" + pdf_dir = Path(directory) + if not pdf_dir.exists(): + return [] + return sorted(pdf_dir.glob("*.pdf")) + + +def extract_text_from_pdf(filepath: Path) -> str: + """从单个 PDF 中提取全部文本""" + try: + import pdfplumber + except ImportError: + raise ImportError("缺少 pdfplumber,请执行: pip install pdfplumber") + + try: + parts = [] + with pdfplumber.open(filepath) as pdf: + for page in pdf.pages: + text = page.extract_text() + if text: + parts.append(text) + return "\n".join(parts) + except Exception as e: + log.error(f"无法读取 {filepath.name}: {e}") + return "" + + +# ------------------------------------------------------------------ +# 字段解析 +# ------------------------------------------------------------------ + +def _first(regexes: list[str], text: str) -> str | None: + """尝试多个正则,返回第一个匹配组的文本""" + for pattern in regexes: + m = re.search(pattern, text) + if m: + return m.group(1).strip() + return None + + +def _parse_line_item(line: str) -> dict | None: + """解析单行明细(*分类*具体名称 格式)""" + m = re.match(r"\*([^*]+)\*\s*(.+)", line) + if m: + return { + "项目名称": f"*{m.group(1).strip()}*{m.group(2).strip()}", + "规格型号": m.group(2).strip(), + } + return None + + +def _extract_line_items(text: str) -> list[dict]: + """从发票文本中提取所有明细行""" + items = [] + skip_keywords = ["项目名称", "合 计", "价税合计", "备注", "开票人"] + + for line in text.split("\n"): + line = line.strip() + if not line: + continue + if any(kw in line for kw in skip_keywords): + continue + if "*" in line: + item = _parse_line_item(line) + if item: + items.append(item) + + return items + + +def _format_date(date_raw: str) -> str: + """将「2026年5月18日」转为「2026/5/18」""" + m = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", date_raw) + if m: + return f"{m.group(1)}/{m.group(2)}/{m.group(3)}" + return date_raw + + +def parse_invoice(text: str) -> dict: + """从发票文本中提取关键字段,返回 dict + + 返回字段: + 发票号码, 开票日期, 销售方名称, 价税合计, _items (明细列表) + 其他字段(人员姓名等)留空,后续由 OCR 步骤填充 + """ + invoice: dict[str, str] = {} + + invoice["发票号码"] = _first([r"发票号码[::]?\s*(\d+)"], text) or "" + + date_raw = _first([r"开票日期[::]?\s*(\d{4}年\d{1,2}月\d{1,2}日)"], text) or "" + invoice["开票日期"] = _format_date(date_raw) if date_raw else "" + + invoice["销售方名称"] = _first( + [ + r"销\s*售?\s*方?\s*名称[::]?\s*(.+?)(?:\n|$)", + r"销\s*名称[::]?\s*(.+?)(?:\n|$)", + ], + text, + ) or "" + + invoice["价税合计"] = _first( + [r"价税合计.*?(小写)[¥¥]?\s*(\d+\.?\d*)"], text + ) or "" + + invoice["_items"] = _extract_line_items(text) + + # 以下字段无法从 PDF 提取,留空由 OCR 步骤填充 + for key in ("项目名称", "规格型号", "人员姓名", "刷卡日期", + "公务卡号", "刷卡金额", "备注", "工号"): + if key not in invoice: + invoice[key] = "" + + return invoice + + +# ------------------------------------------------------------------ +# CSV / Markdown 输出 +# ------------------------------------------------------------------ + +def save_csv(invoices: list[dict], output_path: str | Path = "invoice_summary.csv"): + """将发票列表保存为 CSV""" + csv_path = Path(output_path) + + with open(csv_path, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(CSV_COLUMNS) + + for idx, inv in enumerate(invoices, 1): + items = inv.get("_items", []) + first_item = items[0] if items else {} + writer.writerow([ + idx, + inv.get("发票号码", ""), + inv.get("开票日期", ""), + first_item.get("项目名称", inv.get("项目名称", "")), + first_item.get("规格型号", inv.get("规格型号", "")), + inv.get("价税合计", ""), + inv.get("销售方名称", ""), + inv.get("人员姓名", ""), + inv.get("刷卡日期", ""), + inv.get("公务卡号", ""), + inv.get("刷卡金额", ""), + inv.get("备注", ""), + inv.get("工号", ""), + ]) + + log.info(f"CSV 已保存: {csv_path.name}") + + +def save_markdown(invoices: list[dict], output_path: str | Path = "invoice_summary.md"): + """将发票列表保存为 Markdown 汇总表""" + md_path = Path(output_path) + lines = [ + "# 发票信息汇总表", + "", + "| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 |", + "|------|---------|---------|---------|---------|---------|-----------|", + ] + + total = 0.0 + for idx, inv in enumerate(invoices, 1): + amount = 0.0 + try: + amount = float(inv.get("价税合计", "0")) + except (ValueError, TypeError): + pass + total += amount + + items = inv.get("_items", []) + first_item = items[0] if items else {} + project = first_item.get("项目名称", inv.get("项目名称", "-")) + spec = first_item.get("规格型号", inv.get("规格型号", "-")) + + lines.append( + f"| {idx} " + f"| {inv.get('发票号码', '')} " + f"| {inv.get('开票日期', '')} " + f"| {project} | {spec} " + f"| ¥{amount:,.2f} " + f"| {inv.get('销售方名称', '')} |" + ) + + lines.append("") + lines.append(f"**总计: ¥{total:,.2f}**") + lines.append("") + + with open(md_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + log.info(f"Markdown 已保存: {md_path.name}") + + +# ------------------------------------------------------------------ +# 主入口 +# ------------------------------------------------------------------ + +def extract_invoices(directory: str = ".") -> list[dict]: + """扫描目录下所有 PDF,提取发票信息并返回列表""" + target_dir = Path(directory).absolute() + + pdf_files = find_pdf_files(directory) + if not pdf_files: + log.warning("未找到 PDF 文件") + return [] + + log.info(f"发现 {len(pdf_files)} 个 PDF 文件") + + all_invoices = [] + for pdf_path in pdf_files: + text = extract_text_from_pdf(pdf_path) + if text: + invoice = parse_invoice(text) + if invoice: + all_invoices.append(invoice) + else: + log.warning(f"未能解析: {pdf_path.name}") + else: + log.warning(f"未能提取文本: {pdf_path.name}") + + if all_invoices: + log.info(f"共处理 {len(all_invoices)} 张发票") + else: + log.warning("未成功解析任何发票") + + return all_invoices \ No newline at end of file diff --git a/app/ocr.py b/app/ocr.py new file mode 100644 index 0000000..aea5a57 --- /dev/null +++ b/app/ocr.py @@ -0,0 +1,454 @@ +""" +OCR 刷卡信息提取 + +从支付截图中识别刷卡记录(姓名、日期、金额),回填到发票数据中。 + +匹配策略: + 1. 先按文件名匹配(PDF 和图片同名) + 2. 未匹配的通过金额近邻匹配 + +对外接口: + enrich_with_ocr(rows, directory) -> list[dict] 用 OCR 识别结果丰富发票数据 +""" + +import csv +import os +import re +from pathlib import Path + +from . import get_logger + +log = get_logger("ocr") + + +# ------------------------------------------------------------------ +# 懒加载 OCR +# ------------------------------------------------------------------ + +_ocr_instance = None + + +def _get_ocr(): + """懒加载 PaddleOCR 实例(兼容 2.x / 3.x)""" + global _ocr_instance + if _ocr_instance is not None: + return _ocr_instance + + os.environ.setdefault("FLAGS_use_mkldnn", "0") + os.environ.setdefault("FLAGS_mkldnn_cache_enabled", "0") + + from paddleocr import PaddleOCR + + try: + _ocr_instance = PaddleOCR(use_textline_orientation=True, lang="ch") + except TypeError: + try: + _ocr_instance = PaddleOCR(lang="ch") + except TypeError: + _ocr_instance = PaddleOCR() + + return _ocr_instance + + +# ------------------------------------------------------------------ +# OCR 识别 +# ------------------------------------------------------------------ + +def ocr_image(image_path: Path) -> list[dict]: + """对单张图片执行 OCR,返回 [{"text": str, "confidence": float}, ...]""" + ocr = _get_ocr() + texts = [] + + try: + results = ocr.ocr(str(image_path), cls=True) + if results and isinstance(results, list): + for page_result in results: + if not page_result: + continue + for line in page_result: + if isinstance(line, (list, tuple)) and len(line) >= 2: + _, text_info = line[0], line[1] + if isinstance(text_info, (list, tuple)) and len(text_info) >= 2: + texts.append({ + "text": str(text_info[0]), + "confidence": float(text_info[1]), + }) + except Exception: + try: + if hasattr(ocr, "predict"): + results = ocr.predict(str(image_path)) + if results: + for result in results: + if hasattr(result, "rec_result_list"): + for line in result.rec_result_list: + t = getattr(line, "text", "") or "" + s = getattr(line, "score", 0.0) or 0.0 + texts.append({"text": str(t), "confidence": float(s)}) + elif isinstance(result, list): + for line in result: + if isinstance(line, (list, tuple)) and len(line) >= 2: + t = line[1][0] if isinstance(line[1], (list, tuple)) else str(line[1]) + s = line[1][1] if isinstance(line[1], (list, tuple)) and len(line[1]) > 1 else 0.0 + texts.append({"text": str(t), "confidence": float(s)}) + except Exception as e: + log.error(f"OCR 识别失败: {e}") + + return texts + + +def extract_card_info(texts: list[dict]) -> dict: + """从 OCR 文本中提取刷卡信息(日期 / 金额 / 姓名)""" + info = {"刷卡日期": "", "刷卡金额": "", "人员姓名": ""} + + valid = [t for t in texts if t["confidence"] > 0.5] + full_text = " ".join(t["text"] for t in valid) + if not full_text: + return info + + # 日期(优先级匹配,避免误抓发票开票日期) + date_candidates = [] + for pattern, priority in [ + (r"记账时间[::\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 10), + (r"交易时间[::\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 9), + (r"刷卡日期[::\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 9), + (r"日期[::\s]*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", 5), + ]: + for m in re.finditer(pattern, full_text): + date_candidates.append((priority, m.start(), m.group(1).replace("-", "/"))) + if date_candidates: + date_candidates.sort(key=lambda x: (-x[0], x[1])) + info["刷卡日期"] = date_candidates[0][2] + + # 金额 + amount_candidates = [] + for pattern, priority in [ + (r"交易金额[::\s]*([+-]?[\d,]+\.?\d*)", 10), + (r"刷卡金额[::\s]*([+-]?[\d,]+\.?\d*)", 10), + (r"金额[::\s]*([+-]?[\d,]+\.?\d*)", 5), + ]: + for m in re.finditer(pattern, full_text): + amt_str = m.group(1).replace(",", "").replace("+", "") + try: + val = float(amt_str) + if 0 < val < 999999: + amount_candidates.append((priority, m.start(), amt_str)) + except ValueError: + continue + if amount_candidates: + amount_candidates.sort(key=lambda x: (-x[0], x[1])) + info["刷卡金额"] = amount_candidates[0][2] + + # 姓名(排除公司/机构后缀) + EXCLUDE_SUFFIXES = ("公司", "银行", "中心", "支行", "商户", "网点", "有限", "责任") + name_candidates = [] + for pattern, priority in [ + (r"交易户名[::\s]*([\u4e00-\u9fff]{2,6})", 10), + (r"户名[::\s]*([\u4e00-\u9fff]{2,6})", 8), + (r"持卡人[::\s]*([\u4e00-\u9fff]{2,6})", 8), + (r"姓名[::\s]*([\u4e00-\u9fff]{2,6})", 8), + ]: + for m in re.finditer(pattern, full_text): + name = m.group(1) + if not any(name.endswith(s) for s in EXCLUDE_SUFFIXES): + name_candidates.append((priority, m.start(), name)) + if name_candidates: + name_candidates.sort(key=lambda x: (-x[0], x[1])) + info["人员姓名"] = name_candidates[0][2] + + return info + + +# ------------------------------------------------------------------ +# PDF 发票号提取 +# ------------------------------------------------------------------ + +def extract_invoice_number(pdf_path: Path) -> str: + """从 PDF 中提取发票号码""" + try: + import pdfplumber + except ImportError: + log.warning("缺少 pdfplumber,跳过发票号提取") + return "" + + try: + with pdfplumber.open(str(pdf_path)) as pdf_file: + page_text = "" + for page in pdf_file.pages: + page_text += page.extract_text() or "" + + for pattern in [ + r"发票号码[::\s]*([A-Za-z0-9]{8,20})", + r"发票代码[::\s]*([A-Za-z0-9]{10,12})", + r"号码[::\s]*([A-Za-z0-9]{8,20})", + ]: + m = re.search(pattern, page_text) + if m: + return m.group(1) + except Exception as e: + log.warning(f"PDF 读取失败 ({pdf_path.name}): {e}") + + return "" + + +# ------------------------------------------------------------------ +# 图片配对 +# ------------------------------------------------------------------ + +def _extract_amount_from_pdf(pdf_path: Path) -> float | None: + """从 PDF 中提取价税合计金额""" + try: + import pdfplumber + with pdfplumber.open(str(pdf_path)) as pdf: + text = "" + for page in pdf.pages: + t = page.extract_text() + if t: + text += t + "\n" + m = re.search(r"价税合计.*?(小写)[¥¥]?\s*(\d+\.?\d*)", text) + if m: + return float(m.group(1)) + except Exception: + pass + return None + + +def _extract_amount_from_image(img_path: Path) -> float | None: + """从图片 OCR 中提取刷卡金额""" + texts = ocr_image(img_path) + if not texts: + return None + info = extract_card_info(texts) + amt_str = info.get("刷卡金额", "") + if amt_str: + try: + return float(amt_str) + except ValueError: + pass + return None + + +def find_image_pairs(directory: str = ".") -> list[tuple[Path, Path]]: + """查找 PDF 和对应图片的配对 + + 1. 先按文件名匹配(PDF 和图片同名) + 2. 未匹配的通过金额近邻匹配 + """ + base = Path(directory) + pdfs = sorted(base.glob("*.pdf")) + image_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} + + all_images = sorted( + f for ext in image_exts for f in base.glob(f"*{ext}") + ) + + # ---- Phase 1: 文件名匹配 ---- + pairs: list[tuple[Path, Path]] = [] + matched_pdfs: set[Path] = set() + matched_imgs: set[Path] = set() + + for pdf in pdfs: + for ext in image_exts: + img = base / f"{pdf.stem}{ext}" + if img.exists(): + pairs.append((pdf, img)) + matched_pdfs.add(pdf) + matched_imgs.add(img) + break + + unmatched_pdfs = [p for p in pdfs if p not in matched_pdfs] + unmatched_imgs = [i for i in all_images if i not in matched_imgs] + + if not unmatched_pdfs or not unmatched_imgs: + return pairs + + # ---- Phase 2: 金额近邻匹配 ---- + if len(unmatched_pdfs) > 0 and len(unmatched_imgs) > 0: + log.info(f"文件名匹配 {len(pairs)} 组,剩余 {len(unmatched_pdfs)} 个 PDF、{len(unmatched_imgs)} 张图片,尝试金额匹配...") + + pdf_amounts: dict[Path, float] = {} + for pdf in unmatched_pdfs: + amt = _extract_amount_from_pdf(pdf) + if amt is not None: + pdf_amounts[pdf] = amt + + img_amounts: dict[Path, float] = {} + for img in unmatched_imgs: + amt = _extract_amount_from_image(img) + if amt is not None: + img_amounts[img] = amt + + # 贪婪匹配:每张图片找金额差最小的 PDF + used_pdfs: set[Path] = set() + for img, img_amt in sorted(img_amounts.items(), key=lambda x: x[0].name): + best_pdf: Path | None = None + best_diff: float = float("inf") + + for pdf, pdf_amt in pdf_amounts.items(): + if pdf in used_pdfs: + continue + diff = abs(pdf_amt - img_amt) + if diff < best_diff: + best_diff = diff + best_pdf = pdf + + if best_pdf is not None: + pairs.append((best_pdf, img)) + used_pdfs.add(best_pdf) + + log.info(f"金额匹配完成,共 {len(pairs)} 组配对") + + return pairs + + +# ------------------------------------------------------------------ +# CSV 读写 +# ------------------------------------------------------------------ + +from .extractor import CSV_COLUMNS + + +def _load_csv(csv_path: Path) -> list[dict] | None: + """读取现有 CSV 为 dict 列表,失败返回 None""" + try: + with open(csv_path, encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames or [] + missing = [c for c in CSV_COLUMNS if c not in fieldnames] + if missing: + log.error(f"CSV 缺少必要列: {missing}") + return None + return [row for row in reader] + except FileNotFoundError: + log.error(f"CSV 文件不存在: {csv_path.name}") + return None + except Exception as e: + log.error(f"CSV 读取失败: {e}") + return None + + +def _save_csv(csv_path: Path, rows: list[dict]): + """保存 CSV""" + with open(csv_path, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS) + writer.writeheader() + writer.writerows(rows) + + +# ------------------------------------------------------------------ +# Markdown 同步 +# ------------------------------------------------------------------ + +def save_markdown_from_csv(csv_path: Path, rows: list[dict]): + """根据最新 CSV 数据生成 Markdown 汇总表""" + md_path = csv_path.with_suffix(".md") + columns = [ + ("序号", "序号"), ("发票号码", "发票号码"), ("开票日期", "开票日期"), + ("项目名称", "项目名称"), ("规格型号", "规格型号"), ("价税合计", "价税合计"), + ("销售方名称", "销售方名称"), ("人员姓名", "人员姓名"), + ("刷卡日期", "刷卡日期"), ("公务卡号", "公务卡号"), + ("刷卡金额", "刷卡金额"), ("备注", "备注"), ("工号", "工号"), + ] + + lines = ["# 发票信息汇总表", ""] + header = " | ".join(col[1] for col in columns) + separator = "|".join(["------" for _ in columns]) + lines.append(f"| {header} |") + lines.append(f"|{separator}|") + + total_price = 0.0 + total_card = 0.0 + + for row in rows: + cells = [] + for key, _ in columns: + value = row.get(key, "").strip() + + if key == "价税合计" and value: + try: + total_price += float(value.replace(",", "")) + cells.append(f"¥{float(value.replace(',', '')):,.2f}") + except (ValueError, TypeError): + cells.append(value) + elif key == "刷卡金额" and value: + try: + total_card += float(value.replace(",", "")) + cells.append(f"¥{float(value.replace(',', '')):,.2f}") + except (ValueError, TypeError): + cells.append(value) + else: + cells.append(value if value else "") + + lines.append("| " + " | ".join(cells) + " |") + + lines.append("") + lines.append(f"**价税合计总计: ¥{total_price:,.2f}**") + lines.append(f"**刷卡金额总计: ¥{total_card:,.2f}**") + lines.append("") + + with open(md_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + log.info(f"Markdown 已同步: {md_path.name}") + + +# ------------------------------------------------------------------ +# 主入口 +# ------------------------------------------------------------------ + +def enrich_with_ocr(rows: list[dict], directory: str = ".") -> list[dict]: + """用 OCR 识别结果丰富发票数据,返回更新后的行列表 + + rows 应包含「发票号码」列,已存在的字段不会覆盖。 + """ + pairs = find_image_pairs(directory) + if not pairs: + log.warning("未找到 PDF-图片配对文件,跳过 OCR") + return rows + + log.info(f"找到 {len(pairs)} 组 PDF-图片配对") + + ocr_by_invoice: dict[str, dict] = {} + + for idx, (pdf, img) in enumerate(pairs, 1): + inv_num = extract_invoice_number(pdf) + if not inv_num: + inv_num = pdf.stem + + texts = ocr_image(img) + if not texts: + log.warning(f"OCR 未识别到文本: {img.name}") + continue + + info = extract_card_info(texts) + ocr_by_invoice[inv_num] = info + + # 更新行数据 + updated = 0 + matched = 0 + + for i, row in enumerate(rows): + inv_num = row.get("发票号码", "").strip() + ocr_info = ocr_by_invoice.get(inv_num) + + if not ocr_info: + for key, val in ocr_by_invoice.items(): + if inv_num in key or key in inv_num: + ocr_info = val + break + + if ocr_info: + matched += 1 + + if not row.get("人员姓名", "").strip() and ocr_info["人员姓名"]: + row["人员姓名"] = ocr_info["人员姓名"] + updated += 1 + if not row.get("刷卡日期", "").strip() and ocr_info["刷卡日期"]: + row["刷卡日期"] = ocr_info["刷卡日期"] + updated += 1 + if not row.get("刷卡金额", "").strip() and ocr_info["刷卡金额"]: + row["刷卡金额"] = ocr_info["刷卡金额"] + updated += 1 + + log.info(f"OCR 完成: 匹配 {matched}/{len(rows)} 行,更新 {updated} 个字段") + + return rows \ No newline at end of file diff --git a/app/pipeline.py b/app/pipeline.py new file mode 100644 index 0000000..0822560 --- /dev/null +++ b/app/pipeline.py @@ -0,0 +1,128 @@ +""" +报销全流程编排 + +将发票提取 → OCR 识别 → 浏览器填报串联为一条管道, +数据在内存中流转,同时生成 CSV / Markdown 中间产物。 +""" + +import sys +from pathlib import Path + +from . import get_logger +from .config import load_config +from .extractor import extract_invoices, save_csv as save_invoice_csv, save_markdown as save_invoice_md +from .ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv + +log = get_logger("pipeline") + + +def run_pipeline(step: str = "all", username: str = None, password: str = None): + """执行报销流程 + + Args: + step: all | invoice | ocr | submit + username: 覆盖 config.json 中的用户名 + password: 覆盖 config.json 中的密码 + """ + config = load_config() + if username: + config["username"] = username + if password: + config["password"] = password + + # 工作目录(项目根目录) + project_dir = Path(__file__).parent.parent + + # -------------------------------------------------- + # Step 1: 发票提取 + # -------------------------------------------------- + invoices = None + + if step in ("all", "invoice"): + log.info("=" * 60) + log.info("[1/3] 发票提取") + log.info("=" * 60) + + invoices = extract_invoices(str(project_dir)) + if not invoices: + log.error("未提取到任何发票数据") + return 1 + + save_invoice_csv(invoices, project_dir / "invoice_summary.csv") + save_invoice_md(invoices, project_dir / "invoice_summary.md") + + if step == "invoice": + log.info("[1/3] 发票提取 完成") + return 0 + + # -------------------------------------------------- + # Step 2: OCR 识别 + # -------------------------------------------------- + if step in ("all", "ocr"): + log.info("=" * 60) + log.info("[2/3] OCR 识别") + log.info("=" * 60) + + csv_path = project_dir / "invoice_summary.csv" + + if invoices is None: + rows = _load_csv(csv_path) + if rows is None: + return 1 + else: + # 将 dict 列表转为 CSV 风格的 dict(对齐列名) + from .extractor import CSV_COLUMNS + rows = [] + for idx, inv in enumerate(invoices, 1): + items = inv.get("_items", []) + first_item = items[0] if items else {} + rows.append({ + "序号": str(idx), + "发票号码": inv.get("发票号码", ""), + "开票日期": inv.get("开票日期", ""), + "项目名称": first_item.get("项目名称", inv.get("项目名称", "")), + "规格型号": first_item.get("规格型号", inv.get("规格型号", "")), + "价税合计": inv.get("价税合计", ""), + "销售方名称": inv.get("销售方名称", ""), + "人员姓名": inv.get("人员姓名", ""), + "刷卡日期": inv.get("刷卡日期", ""), + "公务卡号": inv.get("公务卡号", ""), + "刷卡金额": inv.get("刷卡金额", ""), + "备注": inv.get("备注", ""), + "工号": inv.get("工号", ""), + }) + + rows = enrich_with_ocr(rows, str(project_dir)) + save_ocr_csv(csv_path, rows) + save_markdown_from_csv(csv_path, rows) + invoices = rows + + if step == "ocr": + log.info("[2/3] OCR 识别 完成") + return 0 + + # -------------------------------------------------- + # Step 3: 浏览器填报 + # -------------------------------------------------- + if step in ("all", "submit"): + log.info("=" * 60) + log.info("[3/3] 报销提交") + log.info("=" * 60) + + from .bot import load_invoice_data, run_bot + + csv_path = project_dir / "invoice_summary.csv" + bot_invoices = load_invoice_data(str(csv_path), config) + run_bot(config, bot_invoices) + + if step == "submit": + log.info("[3/3] 报销提交 完成") + return 0 + + # -------------------------------------------------- + # 全流程完成 + # -------------------------------------------------- + log.info("=" * 60) + log.info("全流程执行完毕") + log.info("=" * 60) + return 0 \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..ad81679 --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "username": "202407021", + "password": "wang!1624155937", + "sso_login_url": "https://tyrz.fynu.edu.cn/sso/login", + "portal_url": "https://tyrz.fynu.edu.cn/oshall", + "reimburse_url": "http://210.45.32.214:8081", + "reimburse_page": "/expen/common/common?v=4.0", + "default_name": "王建锋", + "default_card_no": "6282880139161682", + "default_person_id": "202407021" +} \ No newline at end of file diff --git a/invoice_summary.csv b/invoice_summary.csv new file mode 100644 index 0000000..3321c6a --- /dev/null +++ b/invoice_summary.csv @@ -0,0 +1,5 @@ +序号,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号 +1,26442000005432755951,2026/05/18,*电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2850.00,, +2,26442000005432652421,2026/05/18,*电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2880.00,, +3,26442000005432937661,2026/05/18,*集成电路*集成电路 LED一批 个 2638.92 1% 26.39,集成电路 LED一批 个 2638.92 1% 26.39,2665.31,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2661.61,, +4,26442000005468940571,2026/05/18,*电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,96.00,东莞市长安顺淘电子工具经营部,陈陈,2026/05/07,,96.00,, diff --git a/invoice_summary.md b/invoice_summary.md new file mode 100644 index 0000000..9fabb9b --- /dev/null +++ b/invoice_summary.md @@ -0,0 +1,11 @@ +# 发票信息汇总表 + +| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 | 人员姓名 | 刷卡日期 | 公务卡号 | 刷卡金额 | 备注 | 工号 | +|------|------|------|------|------|------|------|------|------|------|------|------|------| +| 1 | 26442000005432755951 | 2026/05/18 | *电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,850.00 | | | +| 2 | 26442000005432652421 | 2026/05/18 | *电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,880.00 | | | +| 3 | 26442000005432937661 | 2026/05/18 | *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 | 集成电路 LED一批 个 2638.92 1% 26.39 | ¥2,665.31 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,661.61 | | | +| 4 | 26442000005468940571 | 2026/05/18 | *电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | 元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | ¥96.00 | 东莞市长安顺淘电子工具经营部 | 陈陈 | 2026/05/07 | | ¥96.00 | | | + +**价税合计总计: ¥8,561.31** +**刷卡金额总计: ¥8,487.61** diff --git a/run.py b/run.py new file mode 100644 index 0000000..12bccff --- /dev/null +++ b/run.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +财务报销自动化 + +依次执行: + 1. 发票提取 — 从 PDF 发票提取信息,生成 invoice_summary.csv + 2. OCR 识别 — 从支付截图识别刷卡信息,回填 CSV + 3. 报销提交 — 打开浏览器登录财务系统并自动填报 + +用法: + python run.py # 全流程 + python run.py --step invoice # 仅发票提取 + python run.py --step ocr # 仅 OCR 识别 + python run.py --step submit # 仅浏览器填报 + python run.py -u 工号 -p 密码 # 覆盖登录凭据 +""" + +import argparse +import sys +from pathlib import Path + +# 确保项目根目录在 sys.path 中 +sys.path.insert(0, str(Path(__file__).parent.resolve())) + +from app.pipeline import run_pipeline + + +def main(): + parser = argparse.ArgumentParser( + description="财务报销自动化 - 发票提取 → OCR 识别 → 浏览器填报", + ) + parser.add_argument( + "--step", + choices=["all", "invoice", "ocr", "submit"], + default="all", + help="执行步骤 (默认: all)", + ) + parser.add_argument( + "-u", "--username", + default=None, + help="信息门户登录账号(覆盖 config.json)", + ) + parser.add_argument( + "-p", "--password", + default=None, + help="信息门户登录密码(覆盖 config.json)", + ) + args = parser.parse_args() + + exit_code = run_pipeline( + step=args.step, + username=args.username, + password=args.password, + ) + sys.exit(exit_code) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/run_all.py b/run_all.py new file mode 100644 index 0000000..cc51676 --- /dev/null +++ b/run_all.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +财务报销全流程编排脚本 + +依次执行: + 1. extract_invoice.py — 从 PDF 发票提取信息,生成 invoice_summary.csv + 2. extract_image_ocr.py — 从支付截图 OCR 识别刷卡信息,更新 CSV + 3. reimburse.py — 打开浏览器登录财务系统并自动填报 + +用法: + python run_all.py # 默认执行全部三步 + python run_all.py --step invoice # 仅执行第 1 步 + python run_all.py --step ocr # 仅执行第 2 步 + python run_all.py --step submit # 仅执行第 3 步 +""" + +import subprocess +import sys +from pathlib import Path + +PROJECT_DIR = Path(__file__).parent.resolve() +PYTHON = sys.executable + + +def step(name: str, module: str, args: list[str]) -> bool: + """运行单个步骤,返回是否成功""" + cmd = [PYTHON, str(PROJECT_DIR / module), *args] + print(f"\n{'=' * 60}") + print(f" [{name}] {module}") + print(f"{'=' * 60}") + + result = subprocess.run(cmd, cwd=str(PROJECT_DIR)) + ok = result.returncode == 0 + + if ok: + print(f"\n[{name}] 完成") + else: + print(f"\n[错误] {name} 失败 (exit code: {result.returncode})") + + return ok + + +def run_all() -> int: + print("=" * 60) + print(" 财务报销自动化流程") + print(f" 工作目录: {PROJECT_DIR}") + print(f" Python: {PYTHON}") + print("=" * 60) + + if not step("发票提取", "extract_invoice.py", []): + return 1 + + if not step("OCR 识别", "extract_image_ocr.py", []): + return 1 + + if not step("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]): + return 1 + + print("\n" + "=" * 60) + print(" 全流程执行完毕") + print("=" * 60) + return 0 + + +def main() -> int: + if len(sys.argv) >= 3 and sys.argv[1] == "--step": + name = sys.argv[2] + steps = { + "invoice": ("发票提取", "extract_invoice.py", []), + "ocr": ("OCR 识别", "extract_image_ocr.py", []), + "submit": ("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]), + } + if name not in steps: + print(f"未知步骤: {name},可选: {', '.join(steps)}") + return 1 + label, module, args = steps[name] + return 0 if step(label, module, args) else 1 + + return run_all() + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6fcf93c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,314 @@ + + + + + +财务报销自动化 + + + +
+ +
+

财务报销自动化

+

上传 PDF 发票和支付截图,自动提取信息并填报报销系统

+
+ + +
+

1. 上传文件

+
+
📂
+

拖拽 PDF 和图片到此处,或点击选择文件

+ +
+
+
+ + +
+

2. 配置

+
+
+
+
+
+
+
+
+
+
+ 高级配置 (JSON) + +
+
+ + +
+

3. 处理

+
+
等待上传文件...
+
+
+ + + +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..51498c9 --- /dev/null +++ b/web/app.py @@ -0,0 +1,375 @@ +""" +财务报销自动化 — Web 界面 + +用户上传 PDF 发票和支付截图,配置账号信息,自动完成: + 1. 发票提取 2. OCR 识别 3. 浏览器填报(可选) + +启动: python web/app.py +访问: http://localhost:5000 +""" + +import io +import json +import logging +import sys +import threading +import time +import uuid +from pathlib import Path + +from flask import Flask, Response, jsonify, render_template, request, stream_with_context + +# 确保项目根目录在 sys.path +PROJECT_ROOT = Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(PROJECT_ROOT)) + +from app.config import load_config as load_project_config +from app.extractor import extract_invoices, save_csv, save_markdown +from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv + +app = Flask(__name__, template_folder="templates") + +UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads" +SESSION_LOG_FILE = "session.log" +SESSION_RESULT_FILE = "result.json" + + +# ================================================================ +# 日志收集器 — 捕获管道日志到文件,SSE 端点通过 tail -f 读取 +# ================================================================ + +class _SSELogHandler(logging.Handler): + """将日志写入指定文件(线程安全)""" + + def __init__(self, log_path: Path): + super().__init__() + self._lock = threading.Lock() + self._file = open(log_path, "w", encoding="utf-8") + + def emit(self, record: logging.LogRecord): + try: + msg = self.format(record) + "\n" + with self._lock: + self._file.write(msg) + self._file.flush() + except Exception: + pass + + def close_file(self): + try: + self._file.close() + except Exception: + pass + + +def _install_log_collector(session_dir: Path) -> _SSELogHandler: + """安装日志收集器到 app.* 模块""" + log_path = session_dir / SESSION_LOG_FILE + fmt = logging.Formatter( + "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s", + "%Y-%m-%d %H:%M:%S", + ) + handler = _SSELogHandler(log_path) + handler.setFormatter(fmt) + handler.setLevel(logging.INFO) + + for name in ["extractor", "ocr", "pipeline", "bot"]: + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.addHandler(handler) + + return handler + + +def _remove_log_collector(handler: _SSELogHandler): + for name in ["extractor", "ocr", "pipeline", "bot"]: + logging.getLogger(name).removeHandler(handler) + handler.close_file() + + +# ================================================================ +# 管道入口 +# ================================================================ + +# ================================================================ +# 管道入口 +# ================================================================ + +def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False): + """在 Web 会话目录中执行管道,结果写入 session 目录下的文件""" + start = time.time() + + # ---- Step 1: 发票提取 ---- + invoices = extract_invoices(str(session_dir)) + if not invoices: + return {"ok": False, "error": "未提取到任何发票数据"} + + save_csv(invoices, session_dir / "invoice_summary.csv") + save_markdown(invoices, session_dir / "invoice_summary.md") + + # ---- Step 2: OCR 识别 ---- + csv_path = session_dir / "invoice_summary.csv" + rows = load_ocr_csv(csv_path) + if rows is None: + return {"ok": False, "error": "CSV 读取失败"} + + rows = enrich_with_ocr(rows, str(session_dir)) + save_ocr_csv(csv_path, rows) + save_markdown_from_csv(csv_path, rows) + + # ---- Step 3: 浏览器填报(可选)---- + if run_bot: + from app.bot import load_invoice_data, run_bot_web + + bot_invoices = load_invoice_data(str(csv_path), config) + run_bot_web(config, bot_invoices, session_dir) + + elapsed = time.time() - start + return { + "ok": True, + "elapsed": f"{elapsed:.1f}s", + "invoice_count": len(rows), + "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", + "md_url": f"/api/download/{session_dir.name}/invoice_summary.md", + } + + +def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False): + """直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR""" + start = time.time() + + csv_path = session_dir / csv_filename + if not csv_path.exists(): + return {"ok": False, "error": "CSV 文件不存在"} + + # 读取 CSV 行数 + rows = load_ocr_csv(csv_path) + if rows is None: + return {"ok": False, "error": "CSV 读取失败"} + + # 浏览器填报 + if run_bot: + from app.bot import load_invoice_data, run_bot_web + + bot_invoices = load_invoice_data(str(csv_path), config) + run_bot_web(config, bot_invoices, session_dir) + + elapsed = time.time() - start + return { + "ok": True, + "elapsed": f"{elapsed:.1f}s", + "invoice_count": len(rows), + "csv_url": f"/api/download/{session_dir.name}/{csv_filename}", + } + + +# ================================================================ +# Flask 路由 +# ================================================================ + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/session", methods=["POST"]) +def create_session(): + """创建上传会话,返回 session_id""" + sid = uuid.uuid4().hex[:12] + session_dir = UPLOAD_BASE / sid + session_dir.mkdir(parents=True, exist_ok=True) + return jsonify({"session_id": sid}) + + +@app.route("/api/upload/", methods=["POST"]) +def upload_file(session_id: str): + """上传 PDF 或图片""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "未选择文件"}), 400 + + safe_name = Path(f.filename).name + f.save(str(session_dir / safe_name)) + return jsonify({"ok": True, "filename": safe_name}) + + +@app.route("/api/upload-csv/", methods=["POST"]) +def upload_csv(session_id: str): + """上传 CSV 发票数据文件(跳过 PDF 提取和 OCR)""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "未选择文件"}), 400 + + safe_name = Path(f.filename).name + f.save(str(session_dir / safe_name)) + return jsonify({"ok": True, "filename": safe_name}) + + +@app.route("/api/files/", methods=["GET"]) +def list_files(session_id: str): + """列出会话目录中的文件""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + pdfs = sorted(f.name for f in session_dir.glob("*.pdf")) + imgs = sorted( + f.name for ext in {".png", ".jpg", ".jpeg", ".bmp", ".webp"} + for f in session_dir.glob(f"*{ext}") + ) + return jsonify({"pdfs": pdfs, "images": imgs}) + + +@app.route("/api/process/", methods=["POST"]) +def start_process(session_id: str): + """启动管道处理""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + run_bot_flag = body.get("submit", False) + mode = body.get("mode", "auto") # "pdf", "csv", or "auto" + + # 读取配置 + config = _build_web_config(body) + + # 写入配置到会话目录 + with open(session_dir / "config.json", "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2, default=str) + + # 在后台线程执行 + handler = _install_log_collector(session_dir) + + def _run(): + result = {"ok": False, "error": "未知错误"} + try: + if mode == "csv": + # CSV 模式:直接使用上传的 CSV,跳过 PDF 提取和 OCR + csv_files = list(session_dir.glob("*.csv")) + if not csv_files: + result = {"ok": False, "error": "未找到 CSV 文件"} + else: + result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag) + else: + # 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道 + csv_files = list(session_dir.glob("*.csv")) + pdf_files = list(session_dir.glob("*.pdf")) + if csv_files and not pdf_files: + result = run_csv_pipeline_web(session_dir, config, csv_files[0].name, run_bot_flag) + else: + result = run_pipeline_web(session_dir, config, run_bot_flag) + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, (KeyboardInterrupt, SystemExit)): + raise + finally: + try: + # 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件 + tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + tmp_path.replace(session_dir / SESSION_RESULT_FILE) + except Exception: + pass + _remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + + return jsonify({"status": "started"}) + + +@app.route("/api/logs/") +def stream_logs(session_id: str): + """SSE 日志流""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + def generate(): + # 先发送已有日志 + log_file = session_dir / SESSION_LOG_FILE + last_size = 0 + start_time = time.time() + timeout = 600 # 10 分钟超时 + + while time.time() - start_time < timeout: + if log_file.exists(): + current_size = log_file.stat().st_size + if current_size > last_size: + with open(log_file, encoding="utf-8", errors="replace") as f: + f.seek(last_size) + chunk = f.read() + if chunk: + yield f"data: {_escape_sse(chunk)}\n\n" + last_size = current_size + + # 也通过队列发送实时日志 + # 检查是否完成 + result_file = session_dir / SESSION_RESULT_FILE + if result_file.exists(): + with open(result_file, encoding="utf-8") as f: + result = json.load(f) + yield f"data: {_escape_sse(json.dumps({'type': 'done', 'result': result}, ensure_ascii=False))}\n\n" + break + + time.sleep(0.5) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.route("/api/download//") +def download_file(session_id: str, filename: str): + """下载生成的文件""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + filepath = session_dir / filename + if not filepath.exists(): + return jsonify({"error": "文件不存在"}), 404 + + return Response(filepath.read_bytes(), mimetype="application/octet-stream") + + +# ================================================================ +# 辅助函数 +# ================================================================ + +def _validate_session(session_id: str): + session_dir = UPLOAD_BASE / session_id + if not session_dir.exists(): + return jsonify({"error": "会话不存在"}), 404 + return session_dir + + +def _build_web_config(body: dict) -> dict: + """从请求体构建配置""" + config = load_project_config() + for key in ("username", "password", "default_name", "default_card_no", "default_person_id"): + if body.get(key): + config[key] = body[key] + return config + + +def _escape_sse(text: str) -> str: + """SSE 数据转义,同时处理 Windows 行尾 \\r\\n""" + return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ") + + +if __name__ == "__main__": + UPLOAD_BASE.mkdir(parents=True, exist_ok=True) + print(f"启动 Web 服务: http://localhost:5000") + app.run(host="0.0.0.0", port=5000, debug=True, threaded=True, use_reloader=False) \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..cb03d0d --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,362 @@ + + + + + +财务报销自动化 + + + + + +
+

财务报销自动化

+

上传发票 PDF 和支付截图,自动提取、OCR 识别并填报

+
+ +
+ + +
+
+
📄 发票 PDF (多选)
+
+
📁
+
点击或拖拽上传 PDF 文件
+
+
+ +
+
+
🖼️ 支付截图 (多选)
+
+
🖼️
+
点击或拖拽上传图片文件
+
+
+ +
+
+ + +
+
📊 CSV 快捷上传 (已有发票数据 CSV 可直接上传,跳过提取和 OCR)
+
+
📊
+
点击或拖拽上传 CSV 文件
+
+
+ +
+ + +
+
+
⚙️ 配置 + + 📤 上传 config.json + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+ + +
+ + +
+
📊 处理结果
+
+
+ + +
📋 处理日志
+
等待开始...
+ +
+ + + + \ No newline at end of file diff --git a/报销操作指南.md b/报销操作指南.md new file mode 100644 index 0000000..d994566 --- /dev/null +++ b/报销操作指南.md @@ -0,0 +1,302 @@ +# 阜阳师范大学财务报销系统 - 自动化脚本操作指南 + +> 适用场景:日常报销录入(基于 `reimburse.py` 脚本) +> 最后更新:2026-05-23 + +--- + +## 一、系统概览 + +``` +整体流程: + +信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入 +(tyrz.fynu.edu.cn) (点击"财务系统") (新标签页) (a:has(img)) (/expen/common/common) + +目标系统: http://210.45.32.214:8081 +用户: 王建锋 (工号: 202407021) +``` + +### 关键 URL + +| 系统 | URL | 说明 | +|------|-----|------| +| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 | +| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 | +| 报销系统 | `http://210.45.32.214:8081` | 网络报销主系统 | +| 日常报销录入 | `/expen/common/common?v=4.0` | 目标录入页面 | + +--- + +## 二、数据准备 + +### 2.1 发票数据 CSV + +脚本从 `invoice_summary.csv`(GBK 编码)读取发票数据,CSV 需包含以下列: + +| 列名 | 说明 | 示例 | +|------|------|------| +| 序号 | 发票序号 | 1, 2, 3... | +| 发票号码 | 发票编号 | 26442000005432652421 | +| 开票日期 | 发票开具日期 | 2026/5/18 | +| 项目名称 | 采购项目名称 | 电阻一批 | +| 规格型号 | 规格型号 | — | +| 价税合计 | 发票金额 | 2900.00 | +| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 | +| 人员姓名 | 报销人 | 王建锋(默认值) | +| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 | +| 公务卡号 | 公务卡卡号 | 6282880139161682(默认值) | +| 刷卡金额 | 实际刷卡金额 | 2900.00 | +| 备注 | 备注信息 | — | +| 工号 | 人员工号 | 202407021(默认值) | + +### 2.2 附件文件 + +脚本自动扫描当前工作目录下所有 `.pdf` 文件,按文件名排序后与发票一一对应上传。确保 PDF 文件名与发票顺序一致。 + +--- + +## 三、脚本执行流程 + +### 运行方式 + +```bash +python reimburse.py --data invoice_summary.csv +``` + +支持命令行覆盖配置: + +```bash +python reimburse.py \ + --data invoice_summary.csv \ + --username 202407021 \ + --password "your_password" \ + --user-data-dir browser_profile +``` + +### 执行步骤 + +脚本按以下顺序自动执行,截图保存在 `images/` 目录: + +``` +Step 0: 登录信息门户 + ├── 访问 SSO 登录页 + ├── 填写工号 + 密码 + ├── 勾选用户协议复选框 + ├── 点击"登录"按钮 + └── 等待跳转到信息门户 (zs-uip/oshall/portal) + +Step 1: 进入报销系统 + ├── 点击"财务系统"快捷入口 + ├── 等待新标签页打开 (含 dddl 或 210.45.32.214) + ├── 切换到新标签页 + ├── 关闭旧标签页 + ├── 通过 a:has(img[src*="wlbx"]) 定位"网络报销"链接 + ├── 提取链接 URL 并导航 + ├── 等待"报销录入"文本出现 + └── 导航到 /expen/common/common?v=4.0 + +Step 2: 创建新报销单 + ├── 等待 2 秒 + ├── 点击 button:has-text("新增") + └── 等待表单加载 3 秒 + +Step 3: 填写基本信息 + ├── 填写 #EXPENEXPLAIN → "元器件采购报销" + ├── 点击 #PROJECTCODE 打开项目选择弹窗 + ├── 在 #promodal .fixed-table-body tbody tr 中点击第一行 + └── 点击 #saveAndNext 进入下一步 + +Step 4: 录入报销明细(一条总明细) + ├── 计算所有发票的刷卡金额合计 + ├── 点击 #insertDetail 打开增加明细弹窗 + ├── 点击 #economicscode2 打开经济科目选择 + ├── 在 #econmodal 中选择第 3 行经济科目 + ├── 填写单据数 = 发票张数 + ├── 填写报销总金额 = 刷卡金额合计 + └── 点击 #detailAdd 确认 + +Step 5: 录入支付方式(逐张发票) + ├── 点击 "下一步(支付方式)" + ├── 对每张发票循环: + │ ├── 点击 #insertPay + │ ├── 填写 #personid2 (工号) + │ ├── 填写 #accountname2 (姓名) + │ ├── 填写 #receiptdate2 (刷卡日期) + │ ├── 填写 #localaccount2 (固定卡号: 6282880139161682) + │ ├── 填写 #receiptmoney2 (刷卡金额) + │ ├── 填写 #money2 (实报金额 = 刷卡金额) + │ ├── 填写 #merchant2 (销售方名称) + │ ├── 填写 #smark2 (备注) + │ └── 点击 #payAdd 确认 + └── 所有发票录入完成 + +Step 6: 上传附件(逐张发票) + ├── 点击 #next3 切换到附件清单页面 + ├── 对每张发票循环: + │ ├── 点击 #insertAcc 打开附件弹窗 + │ ├── select_option #fjlx → '1' (发票类型) + │ ├── 填写 #fpsmxx (项目名称 - 发票号码) + │ ├── set_input_files #file (对应 PDF 文件) + │ └── 点击 #cjtj 确认 + └── 所有附件上传完成 + +提交阶段: + └── 点击 #submit (当前已注释,需手动取消注释) +``` + +--- + +## 四、页面元素速查 + +### 基本信息页 (Step 3) + +| 字段 | 选择器 | 操作 | +|------|--------|------| +| 报销说明 | `#EXPENEXPLAIN` | fill | +| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 | +| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 | +| 下一步按钮 | `#saveAndNext` | click | + +### 报销明细页 (Step 4) + +| 字段 | 选择器 | 操作 | +|------|--------|------| +| 增加按钮 | `#insertDetail` | click | +| 经济事项代码 | `#economicscode2` | click → 弹窗选择 | +| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 | +| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill | +| 报销总金额 | `#je_zwzcdz` | fill | +| 确定按钮 | `#detailAdd` | click | + +### 支付方式页 (Step 5) + +| 字段 | 选择器 | 操作 | +|------|--------|------| +| 增加按钮 | `#insertPay` | click | +| 人员编号 | `#personid2` | fill | +| 人员姓名 | `#accountname2` | fill | +| 刷卡日期 | `#receiptdate2` | fill | +| 公务卡号 | `#localaccount2` | fill (固定值) | +| 刷卡金额 | `#receiptmoney2` | fill | +| 实报金额 | `#money2` | fill | +| 商户 | `#merchant2` | fill | +| 备注 | `#smark2` | fill | +| 确定按钮 | `#payAdd` | click | + +### 附件清单页 (Step 6) + +| 字段 | 选择器 | 操作 | +|------|--------|------| +| 增加按钮 | `#insertAcc` | click | +| 附件类型 | `#fjlx` | select_option → '1'(发票) | +| 附件说明 | `#fpsmxx` | fill | +| 文件上传 | `#file` | set_input_files | +| 确定按钮 | `#cjtj` | click | + +### 提交 + +| 操作 | 选择器 | +|------|--------| +| 提交按钮 | `#submit` | +| 提交按钮(备用) | `#submit2` | + +--- + +## 五、数据流向 + +``` +invoice_summary.csv (GBK) + │ + ▼ load_invoice_data() + │ + ├── 读取 CSV 行 + ├── 日期格式转换 (2026/5/18 → 2026-05-18) + ├── 填充默认值 (姓名/卡号/工号) + └── 输出: list[dict] + │ + ▼ add_reimburse_items() + ├── 计算: card_amount = sum(所有发票刷卡金额) + ├── 单据数 = len(invoices) + └── 录入 1 条总明细 + │ + ▼ fill_payment() + └── 对每张发票录入 1 条支付记录 + │ + ▼ upload_attachments() + ├── 扫描 *.pdf 文件 + └── 按索引匹配发票 → PDF,逐张上传 +``` + +--- + +## 六、关键设计说明 + +### 6.1 浏览器复用 + +脚本使用 `launch_persistent_context` 持久化浏览器上下文,登录状态保存在 `browser_profile/` 目录。再次运行时复用已有会话,无需重复登录。 + +### 6.2 明细录入策略 + +脚本采用"一条总明细"策略:将所有发票合并为一条报销明细,报销总金额为所有发票刷卡金额之和,单据数为发票总张数。支付方式则逐张发票分别录入,每张发票对应一条支付记录。 + +### 6.3 经济科目选择 + +脚本在经济科目弹窗中固定选择第 3 行。如需更改科目,修改 `rows[2]` 的索引即可。 + +### 6.4 项目选择 + +脚本在项目选择弹窗中固定选择第 1 行。如需更改项目,修改 `first_row` 的选择逻辑即可。 + +### 6.5 网络报销链接动态获取 + +单点登录页的"网络报销"链接参数每次不同,脚本通过 `a:has(img[src*="wlbx"])` 精确定位链接,动态提取 `href` 属性后导航,不硬编码 URL。 + +### 6.6 提交控制 + +脚本默认注释了 `bot.submit()` 调用。完成所有录入后停留在附件清单页面,需人工确认数据无误后,取消注释 `bot.submit()` 再运行,或手动点击提交按钮。 + +--- + +## 七、日志与调试 + +### 日志输出 + +- 控制台实时输出(DEBUG 级别) +- 文件日志:`reimburse.log`(UTF-8 编码) + +### 截图保存 + +每个关键步骤自动截图到 `images/` 目录: + +| 截图文件 | 对应步骤 | +|----------|----------| +| `debug_portal_loaded.png` | 登录成功 | +| `debug_step3_project_modal.png` | 项目弹窗打开 | +| `debug_step3_project_selected.png` | 项目选择完成 | +| `debug_step3_done.png` | 基本信息完成 | +| `debug_after_add_click.png` | 点击新增后 | +| `debug_item_total.png` | 总明细录入完成 | +| `debug_step5_done.png` | 支付方式完成 | +| `debug_step6_done.png` | 附件上传完成 | +| `debug_submitted.png` | 提交完成 | +| `debug_error.png` | 异常状态 | + +### 超时设置 + +- 页面默认超时:30 秒 +- 登录门户等待:最多 30 秒 +- 单点登录页等待:最多 15 秒 + +--- + +## 八、常见问题 + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| 登录超时 | SSO 需要手动验证码/微信扫码 | 手动完成验证后脚本继续 | +| 未找到财务系统入口 | 门户页面结构变化 | 检查 `images/debug_*` 截图定位 | +| 经济科目选择失败 | 弹窗加载延迟 | 检查超时设置,增加等待时间 | +| 附件上传失败 | PDF 文件不存在或路径错误 | 确认 PDF 在当前工作目录 | +| 金额不匹配 | 明细合计 ≠ 支付合计 | 检查 CSV 数据中刷卡金额 | +| 提交被拦截 | 必填项为空 | 检查 `reimburse.log` 定位失败步骤 | \ No newline at end of file