Initial commit: Auto-Finance 财务报销自动化系统
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -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
|
||||
BIN
1. 电容一批.pdf
Normal file
BIN
1. 电容一批.pdf
Normal file
Binary file not shown.
BIN
1. 电容一批.png
Normal file
BIN
1. 电容一批.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
2. 电阻一批.pdf
Normal file
BIN
2. 电阻一批.pdf
Normal file
Binary file not shown.
BIN
2. 电阻一批.png
Normal file
BIN
2. 电阻一批.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
BIN
3. LED一批.pdf
Normal file
BIN
3. LED一批.pdf
Normal file
Binary file not shown.
BIN
3. LED一批.png
Normal file
BIN
3. LED一批.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
4. 元器件盒.pdf
Normal file
BIN
4. 元器件盒.pdf
Normal file
Binary file not shown.
BIN
4. 元器件盒.png
Normal file
BIN
4. 元器件盒.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
146
README.md
Normal file
146
README.md
Normal file
@@ -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()`
|
||||
35
app/__init__.py
Normal file
35
app/__init__.py
Normal file
@@ -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
|
||||
435
app/bot.py
Normal file
435
app/bot.py
Normal file
@@ -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()
|
||||
33
app/config.py
Normal file
33
app/config.py
Normal file
@@ -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",
|
||||
}
|
||||
256
app/extractor.py
Normal file
256
app/extractor.py
Normal file
@@ -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
|
||||
454
app/ocr.py
Normal file
454
app/ocr.py
Normal file
@@ -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
|
||||
128
app/pipeline.py
Normal file
128
app/pipeline.py
Normal file
@@ -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
|
||||
11
config.json
Normal file
11
config.json
Normal file
@@ -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"
|
||||
}
|
||||
5
invoice_summary.csv
Normal file
5
invoice_summary.csv
Normal file
@@ -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,,
|
||||
|
11
invoice_summary.md
Normal file
11
invoice_summary.md
Normal file
@@ -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**
|
||||
60
run.py
Normal file
60
run.py
Normal file
@@ -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()
|
||||
84
run_all.py
Normal file
84
run_all.py
Normal file
@@ -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())
|
||||
314
templates/index.html
Normal file
314
templates/index.html
Normal file
@@ -0,0 +1,314 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card: #fff;
|
||||
--text: #333;
|
||||
--muted: #888;
|
||||
--primary: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--warning: #d97706;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; margin-bottom: 16px; }
|
||||
.card h2 { font-size: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.card h2 .badge { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: var(--primary); color: #fff; }
|
||||
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .2s; }
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: #f8f8ff; }
|
||||
.upload-zone p { color: var(--muted); margin-top: 8px; }
|
||||
.file-list { margin-top: 12px; }
|
||||
.file-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #f9fafb; border-radius: 6px; margin-bottom: 4px; font-size: 14px; }
|
||||
.file-item .remove { margin-left: auto; cursor: pointer; color: var(--danger); font-size: 16px; }
|
||||
label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
|
||||
input, textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
|
||||
input:focus, textarea:focus { border-color: var(--primary); }
|
||||
textarea { font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; height: 160px; resize: vertical; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background .2s; }
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-success { background: var(--success); color: #fff; }
|
||||
.btn-success:hover { background: #15803d; }
|
||||
.btn-success:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.log-panel { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius); padding: 16px; max-height: 380px; overflow-y: auto; font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; }
|
||||
.log-panel .entry { margin-bottom: 2px; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-panel .INFO { color: #6a9955; }
|
||||
.log-panel .WARNING { color: #d97706; }
|
||||
.log-panel .ERROR { color: #f87171; }
|
||||
.log-panel .DEBUG { color: #8b8b8b; }
|
||||
|
||||
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-badge.uploaded { background: #e5e7eb; color: #6b7280; }
|
||||
.status-badge.extracting { background: #dbeafe; color: #2563eb; }
|
||||
.status-badge.done { background: #dcfce7; color: #16a34a; }
|
||||
.status-badge.submitting { background: #fef3c7; color: #d97706; }
|
||||
.status-badge.submitted { background: #dcfce7; color: #16a34a; }
|
||||
.status-badge.error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
||||
.result-table th, .result-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||
.result-table th { background: #f9fafb; font-weight: 500; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<h2>财务报销自动化</h2>
|
||||
<p style="color:var(--muted);font-size:14px;">上传 PDF 发票和支付截图,自动提取信息并填报报销系统</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div class="card">
|
||||
<h2>1. 上传文件</h2>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div style="font-size:36px;margin-bottom:4px;">📂</div>
|
||||
<p>拖拽 PDF 和图片到此处,或点击选择文件</p>
|
||||
<input type="file" id="fileInput" multiple accept=".pdf,.png,.jpg,.jpeg,.bmp,.webp" style="display:none">
|
||||
</div>
|
||||
<div class="file-list" id="fileList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 配置 -->
|
||||
<div class="card">
|
||||
<h2>2. 配置</h2>
|
||||
<div class="form-row">
|
||||
<div><label>用户名</label><input id="cfgUser" placeholder="工号/学号"></div>
|
||||
<div><label>密码</label><input id="cfgPass" type="password" placeholder="登录密码"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div><label>默认姓名</label><input id="cfgName" placeholder="报销人姓名"></div>
|
||||
<div><label>默认公务卡号</label><input id="cfgCard" placeholder="公务卡号后四位或完整号"></div>
|
||||
</div>
|
||||
<div><label>默认人员编号</label><input id="cfgPid" placeholder="人员编号"></div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="font-size:14px;color:var(--muted);cursor:pointer;">高级配置 (JSON)</summary>
|
||||
<textarea id="cfgJson" style="margin-top:8px;">{}</textarea>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 进度 -->
|
||||
<div class="card">
|
||||
<h2>3. 处理 <span class="status-badge" id="statusBadge" style="display:none"></span></h2>
|
||||
<div class="log-panel" id="logPanel">
|
||||
<div class="entry" style="color:var(--muted)">等待上传文件...</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnStart" disabled>开始处理</button>
|
||||
<button class="btn btn-success hidden" id="btnSubmit">提交报销</button>
|
||||
<button class="btn hidden" id="btnDownload" style="background:#6b7280;color:#fff;">下载 CSV</button>
|
||||
</div>
|
||||
<div id="resultArea" class="hidden" style="margin-top:12px;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => document.querySelectorAll(s);
|
||||
|
||||
let sessionId = null;
|
||||
let files = [];
|
||||
let evtSource = null;
|
||||
|
||||
// ---- 文件选择 ----
|
||||
const uploadZone = $('#uploadZone');
|
||||
const fileInput = $('#fileInput');
|
||||
const fileList = $('#fileList');
|
||||
|
||||
uploadZone.addEventListener('click', () => fileInput.click());
|
||||
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||||
uploadZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('dragover');
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
});
|
||||
fileInput.addEventListener('change', () => addFiles(Array.from(fileInput.files)));
|
||||
|
||||
function addFiles(newFiles) {
|
||||
for (const f of newFiles) {
|
||||
if (!files.find(x => x.name === f.name && x.size === f.size)) {
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
renderFiles();
|
||||
checkReady();
|
||||
}
|
||||
|
||||
function removeFile(idx) {
|
||||
files.splice(idx, 1);
|
||||
renderFiles();
|
||||
checkReady();
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = files.length === 0 ? '' : files.map((f, i) =>
|
||||
`<div class="file-item">
|
||||
<span>${['.pdf','.PDF'].some(e => f.name.endsWith(e)) ? '📄' : '🖼'}</span>
|
||||
<span>${f.name}</span>
|
||||
<span style="color:var(--muted);font-size:12px;">${(f.size/1024).toFixed(1)} KB</span>
|
||||
<span class="remove" onclick="removeFile(${i})">×</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- 配置 ----
|
||||
const cfgUser = $('#cfgUser');
|
||||
const cfgPass = $('#cfgPass');
|
||||
const cfgName = $('#cfgName');
|
||||
const cfgCard = $('#cfgCard');
|
||||
const cfgPid = $('#cfgPid');
|
||||
const cfgJson = $('#cfgJson');
|
||||
|
||||
[cfgUser, cfgPass, cfgName, cfgCard, cfgPid, cfgJson].forEach(el =>
|
||||
el.addEventListener('input', checkReady)
|
||||
);
|
||||
|
||||
function getConfig() {
|
||||
let base = {};
|
||||
try { base = JSON.parse(cfgJson.value || '{}'); } catch (e) {}
|
||||
if (cfgUser.value) base.username = cfgUser.value;
|
||||
if (cfgPass.value) base.password = cfgPass.value;
|
||||
if (cfgName.value) base.default_name = cfgName.value;
|
||||
if (cfgCard.value) base.default_card_no = cfgCard.value;
|
||||
if (cfgPid.value) base.default_person_id = cfgPid.value;
|
||||
return base;
|
||||
}
|
||||
|
||||
function checkReady() {
|
||||
const hasFiles = files.length > 0;
|
||||
const hasUser = !!(cfgUser.value && cfgPass.value);
|
||||
$('#btnStart').disabled = !(hasFiles && hasUser);
|
||||
}
|
||||
|
||||
// ---- 开始处理 ----
|
||||
const btnStart = $('#btnStart');
|
||||
const btnSubmit = $('#btnSubmit');
|
||||
const btnDownload = $('#btnDownload');
|
||||
const statusBadge = $('#statusBadge');
|
||||
const logPanel = $('#logPanel');
|
||||
const resultArea = $('#resultArea');
|
||||
|
||||
btnStart.addEventListener('click', async () => {
|
||||
btnStart.disabled = true;
|
||||
statusBadge.style.display = 'inline-block';
|
||||
statusBadge.textContent = '上传中...';
|
||||
statusBadge.className = 'status-badge extracting';
|
||||
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append('files', f);
|
||||
|
||||
const config = getConfig();
|
||||
formData.append('config', JSON.stringify(config));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/start', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.error) { alert(data.error); btnStart.disabled = false; return; }
|
||||
sessionId = data.session_id;
|
||||
startStream();
|
||||
btnSubmit.classList.remove('hidden');
|
||||
btnDownload.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
btnStart.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function startStream() {
|
||||
logPanel.innerHTML = '';
|
||||
if (evtSource) evtSource.close();
|
||||
evtSource = new EventSource(`/api/stream/${sessionId}`);
|
||||
|
||||
evtSource.addEventListener('message', e => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'log') {
|
||||
const lvl = (data.msg.match(/\[(\w+)\]/) || [])[1] || '';
|
||||
logPanel.innerHTML += `<div class="entry ${lvl}">${escHtml(data.msg)}</div>`;
|
||||
logPanel.scrollTop = logPanel.scrollHeight;
|
||||
}
|
||||
if (data.type === 'heartbeat' || data.type === 'status') {
|
||||
statusBadge.textContent = data.status;
|
||||
statusBadge.className = `status-badge ${data.status}`;
|
||||
}
|
||||
if (data.type === 'complete') {
|
||||
statusBadge.textContent = data.status;
|
||||
statusBadge.className = `status-badge ${data.status}`;
|
||||
evtSource.close();
|
||||
|
||||
if (data.status === 'done') {
|
||||
btnSubmit.classList.remove('hidden');
|
||||
loadResults();
|
||||
}
|
||||
btnStart.disabled = data.status !== 'done';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
try {
|
||||
const resp = await fetch(`/api/status/${sessionId}`);
|
||||
const data = await resp.json();
|
||||
if (!data.preview) return;
|
||||
|
||||
let html = '<table class="result-table"><thead><tr>';
|
||||
const keys = Object.keys(data.preview[0]);
|
||||
for (const k of keys) html += `<th>${k}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const row of data.preview) {
|
||||
html += '<tr>';
|
||||
for (const k of keys) html += `<td>${row[k] || ''}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
html += `<p style="margin-top:8px;color:var(--muted);">共 ${data.total} 条记录</p>`;
|
||||
resultArea.innerHTML = html;
|
||||
resultArea.classList.remove('hidden');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ---- 浏览器填报 ----
|
||||
btnSubmit.addEventListener('click', async () => {
|
||||
if (!sessionId) return;
|
||||
btnSubmit.disabled = true;
|
||||
statusBadge.textContent = 'submitting';
|
||||
statusBadge.className = 'status-badge submitting';
|
||||
|
||||
try {
|
||||
await fetch(`/api/submit/${sessionId}`, { method: 'POST' });
|
||||
startStream();
|
||||
} catch (e) {
|
||||
alert('提交失败: ' + e.message);
|
||||
btnSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 下载 CSV ----
|
||||
btnDownload.addEventListener('click', () => {
|
||||
if (sessionId) window.open(`/api/download/${sessionId}/csv`, '_blank');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
375
web/app.py
Normal file
375
web/app.py
Normal file
@@ -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/<session_id>", 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/<session_id>", 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/<session_id>", 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/<session_id>", 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/<session_id>")
|
||||
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/<session_id>/<filename>")
|
||||
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)
|
||||
362
web/templates/index.html
Normal file
362
web/templates/index.html
Normal file
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务报销自动化</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-container .empty { color: #666; font-style: italic; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||||
.status-badge { font-size: 13px; }
|
||||
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
|
||||
.result-box a { text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header text-center mb-4">
|
||||
<h3>财务报销自动化</h3>
|
||||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:960px">
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||
<div class="icon">📁</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||||
<div id="pdf-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||
<div class="icon">🖼️</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||||
<div id="img-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV 快捷上传 -->
|
||||
<div class="mb-4">
|
||||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 OCR)</span></div>
|
||||
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
|
||||
<div class="icon">📊</div>
|
||||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
|
||||
<div id="csv-list" class="mt-2"></div>
|
||||
</div>
|
||||
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="section-title">⚙️ 配置
|
||||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||||
📤 上传 config.json
|
||||
</span>
|
||||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||
</div>
|
||||
<div class="col-sm-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="cfg-submit">
|
||||
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mb-4">
|
||||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||||
<span id="status" class="ms-3"></span>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="result-box mb-3" id="result-box">
|
||||
<div class="section-title">📊 处理结果</div>
|
||||
<div id="result-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="section-title">📋 处理日志</div>
|
||||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
let csvFile = null;
|
||||
|
||||
// ---- Session ----
|
||||
async function ensureSession() {
|
||||
if (sessionId) return sessionId;
|
||||
const r = await fetch('/api/session', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
sessionId = d.session_id;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
function handleFiles(input, type) {
|
||||
const files = Array.from(input.files);
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const listId = type === 'pdf' ? 'pdf-list' : 'img-list';
|
||||
const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone';
|
||||
|
||||
files.forEach(f => {
|
||||
if (!list.find(x => x.name === f.name)) list.push(f);
|
||||
});
|
||||
|
||||
renderFileList(type);
|
||||
document.getElementById(zoneId).classList.add('active');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeFile(type, index) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
list.splice(index, 1);
|
||||
renderFileList(type);
|
||||
if (list.length === 0) {
|
||||
document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(type) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list');
|
||||
box.innerHTML = list.map((f, i) =>
|
||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ---- CSV 上传 ----
|
||||
function handleCsvFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
csvFile = file;
|
||||
document.getElementById('csv-zone').classList.add('active');
|
||||
document.getElementById('csv-list').innerHTML =
|
||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeCsvFile() {
|
||||
csvFile = null;
|
||||
document.getElementById('csv-zone').classList.remove('active');
|
||||
document.getElementById('csv-list').innerHTML = '';
|
||||
}
|
||||
|
||||
// ---- 配置上传 ----
|
||||
function handleConfigUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const cfg = JSON.parse(e.target.result);
|
||||
const map = {
|
||||
'cfg-username': cfg.username,
|
||||
'cfg-password': cfg.password,
|
||||
'cfg-name': cfg.default_name,
|
||||
'cfg-card': cfg.default_card_no,
|
||||
'cfg-person-id': cfg.default_person_id,
|
||||
};
|
||||
for (const [id, val] of Object.entries(map)) {
|
||||
if (val) document.getElementById(id).value = val;
|
||||
}
|
||||
alert('配置已加载');
|
||||
} catch (err) {
|
||||
alert('config.json 解析失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ---- 拖拽 ----
|
||||
['pdf','img'].forEach(type => {
|
||||
const zone = document.getElementById(type + '-zone');
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => {
|
||||
if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf');
|
||||
/\.(png|jpe?g|bmp|webp)$/i.test(f.name);
|
||||
});
|
||||
if (files.length) {
|
||||
const list = type === 'pdf' ? pdfFiles : imgFiles;
|
||||
files.forEach(f => { if (!list.find(x => x.name === f.name)) list.push(f); });
|
||||
renderFileList(type);
|
||||
zone.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// CSV 拖拽
|
||||
const csvZone = document.getElementById('csv-zone');
|
||||
csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); });
|
||||
csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover'));
|
||||
csvZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
csvZone.classList.remove('dragover');
|
||||
const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv'));
|
||||
if (file) handleCsvFile({ files: [file] });
|
||||
});
|
||||
|
||||
// ---- 处理 ----
|
||||
async function startProcess() {
|
||||
const isCsvMode = !!csvFile;
|
||||
if (!isCsvMode && !pdfFiles.length && !imgFiles.length) {
|
||||
alert('请先上传文件或 CSV');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-start');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '处理中...';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||
document.getElementById('log-box').innerHTML = '';
|
||||
document.getElementById('result-box').style.display = 'none';
|
||||
|
||||
try {
|
||||
await ensureSession();
|
||||
|
||||
if (isCsvMode) {
|
||||
// CSV 模式:只上传 CSV 文件
|
||||
const fd = new FormData();
|
||||
fd.append('file', csvFile);
|
||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
||||
} else {
|
||||
// 传统模式:上传 PDF 和图片
|
||||
const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))];
|
||||
for (const {f} of allFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||
|
||||
// 启动管道
|
||||
const cfg = {
|
||||
username: document.getElementById('cfg-username').value,
|
||||
password: document.getElementById('cfg-password').value,
|
||||
default_name: document.getElementById('cfg-name').value,
|
||||
default_card_no: document.getElementById('cfg-card').value,
|
||||
default_person_id: document.getElementById('cfg-person-id').value,
|
||||
submit: document.getElementById('cfg-submit').checked,
|
||||
mode: isCsvMode ? 'csv' : 'auto',
|
||||
};
|
||||
|
||||
await fetch(`/api/process/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
|
||||
// 监听 SSE 日志
|
||||
const es = new EventSource(`/api/logs/${sessionId}`);
|
||||
const logBox = document.getElementById('log-box');
|
||||
let firstLine = true;
|
||||
|
||||
es.addEventListener('message', e => {
|
||||
if (firstLine) { logBox.innerHTML = ''; firstLine = false; }
|
||||
// 尝试解析 JSON(完成信号),否则按日志文本处理
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'done') {
|
||||
es.close();
|
||||
const result = msg.result;
|
||||
document.getElementById('status').innerHTML = result.ok
|
||||
? '<span class="badge bg-success status-badge">完成</span>'
|
||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
if (result.ok) {
|
||||
showResult(result);
|
||||
} else {
|
||||
document.getElementById('result-box').style.display = 'block';
|
||||
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
logBox.innerHTML += e.data;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
alert('请求失败: ' + (e.message || '未知错误'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始处理';
|
||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(r) {
|
||||
const box = document.getElementById('result-box');
|
||||
box.style.display = 'block';
|
||||
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
|
||||
document.getElementById('result-content').innerHTML = `
|
||||
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
|
||||
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
|
||||
${mdLink}
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
302
报销操作指南.md
Normal file
302
报销操作指南.md
Normal file
@@ -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` 定位失败步骤 |
|
||||
Reference in New Issue
Block a user