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