Initial commit: Auto-Finance 财务报销自动化系统
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user