""" 浏览器自动化填报 使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。 对外接口: run_bot(config, invoices) 启动浏览器并执行填报流程 """ import json from pathlib import Path from typing import Any 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 def _classify_invoice_batch( invoices: list[dict[str, str]], ) -> dict[str, list[dict[str, str]]]: """按发票类型分组""" travel: list[dict[str, str]] = [] general: list[dict[str, str]] = [] application: list[dict[str, str]] = [] for inv in invoices: inv_type = inv.get("invoice_type", "general") if inv_type == "application": application.append(inv) elif inv_type in ("train", "hotel"): travel.append(inv) else: general.append(inv) return {"travel": travel, "general": general, "application": application} # ------------------------------------------------------------------ # 报销机器人 # ------------------------------------------------------------------ class ReimburseBot: """财务报销自动化机器人""" def __init__(self, config: dict[str, Any], headless: bool = False, work_dir: Path | None = None): self.config = config self.headless = headless self.work_dir: Path | None = None self.browser: Any = None self.context: Any = None self.page: Any = None from playwright.sync_api import sync_playwright self._pw_ctx = sync_playwright() self.pw = self._pw_ctx.__enter__() def launch(self) -> None: """启动浏览器""" self.browser = self.pw.chromium.launch(headless=self.headless) self.context = self.browser.new_context(viewport={"width": 1360, "height": 768}) self.page = self.context.new_page() self.page.set_default_timeout(30000) def login_portal(self) -> None: """登录信息门户""" 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) -> None: """等待跳转到统一信息平台""" 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, page_key: str = "reimburse_page") -> None: """从统一信息平台进入报销系统""" 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[page_key] self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000) self._wait_for('text="单据状态:"', timeout=5000) def create_new_form(self) -> None: """点击「新增」创建新单据""" log.info("创建新单据...") self.page.wait_for_timeout(2000) try: self.page.click("#insert", timeout=5000) except Exception: try: self.page.click("text=新增", timeout=3000) except Exception as err: self._screenshot("no_add_button") raise RuntimeError("无法点击新增按钮") from err self.page.wait_for_timeout(3000) self._screenshot("after_add_click") def fill_travel_info(self, basic_info: dict[str, Any]) -> None: """填写差旅报销基本信息""" log.info("填写差旅报销信息...") try: self.page.fill("#CAUSE", basic_info.get("travel_purpose", "")) self.page.fill("#SITE", basic_info.get("travel_location", "")) self.page.click("#PROJECTCODE", timeout=10000) self.page.wait_for_timeout(1000) 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) self.page.fill("#THEKSRQ", _format_date(basic_info.get("start_date", ""))) self.page.fill("#THEJSRQ", _format_date(basic_info.get("end_date", ""))) self.page.click("#saveAndNext", timeout=5000) self.page.wait_for_timeout(2000) self._screenshot("travel_basic_done") except Exception: log.error("填写基本信息失败") self._screenshot("travel_basic_error") def fill_basic_info(self, description: str = "元器件采购报销") -> None: """填写基本信息""" 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_travel_items(self, travel_items: dict[str, Any]) -> None: """录入差旅报销明细""" vehicle_map = { "火车": "01", "汽车": "02", "轮船": "03", "自带车": "04", "公务车": "05", "飞机": "06", "租车": "07", "自驾车": "08", } try: traffic_info = travel_items.get("transport_fee") or [] for item in traffic_info: self.page.click("#insertDetail", timeout=5000) self._wait_for('text="增加明细"', timeout=5000) self.page.select_option("#cost", "1") self.page.wait_for_timeout(500) vehicle = item.get("vehicle_type", "") if vehicle in vehicle_map: self.page.select_option("#jtgj", vehicle_map[vehicle]) self.page.fill("#ksdd", item.get("departure_place", "")) self.page.fill("#jsdd", item.get("arrival_place", "")) self.page.fill( '#t1 input[name="expenPwTraveldetail.MONEY"]', str(item.get("amount", "")), ) self.page.fill( '#t1 input[name="expenPwTraveldetail.HOWBILL"]', str(item.get("bill_count", "")), ) self.page.fill( '#t1 input[name="expenPwTraveldetail.SMARK"]', str(item.get("remark", "")), ) self.page.click("#detailAdd", timeout=3000) self.page.wait_for_timeout(1000) hotel_info = travel_items.get("hotel_fee") or [] for item in hotel_info: self.page.click("#insertDetail", timeout=5000) self._wait_for('text="增加明细"', timeout=5000) self.page.select_option("#cost", "2") self.page.wait_for_timeout(500) self.page.fill("#ksrq2", _format_date(str(item.get("checkin_date", "")))) self.page.fill("#jsrq2", _format_date(str(item.get("checkout_date", "")))) self.page.fill("#ts2", str(item.get("days", ""))) self.page.fill("#rs2", str(item.get("person_count", ""))) self.page.fill( '#t2 input[name="expenPwTraveldetail.FPMONEY"]', str(item.get("invoice_amount", "")), ) self.page.fill( '#t2 input[name="expenPwTraveldetail.MONEY"]', str(item.get("reimburse_amount", "")), ) self.page.fill( '#t2 input[name="expenPwTraveldetail.SMARK"]', str(item.get("remark", "")), ) self.page.click("#detailAdd", timeout=3000) self.page.wait_for_timeout(1000) conference_info = travel_items.get("conference_fee") or [] for item in conference_info: self.page.click("#insertDetail", timeout=5000) self._wait_for('text="增加明细"', timeout=5000) self.page.select_option("#cost", "3") self.page.wait_for_timeout(500) self.page.fill( '#t3 input[name="expenPwTraveldetail.HOWBILL"]', str(item.get("bill_count", "")), ) self.page.fill( '#t3 input[name="expenPwTraveldetail.MONEY"]', str(item.get("amount", "")), ) self.page.fill( '#t3 input[name="expenPwTraveldetail.SMARK"]', str(item.get("remark", "")), ) self.page.click("#detailAdd", timeout=3000) self.page.wait_for_timeout(1000) except Exception as e: log.error(f"录入总明细失败: {e}") self._screenshot("item_total_error") raise def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None: """录入报销明细(一条总明细)""" card_amount = sum(inv["card_amount"] for inv in invoices) log.info(f"录入报销明细 (合计 ¥{card_amount:.2f})...") try: self.page.click("#insertDetail", timeout=5000) 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_travel_payment(self, payment_info: list[dict[str, Any]]) -> None: """录入差旅支付信息""" log.info("录入差旅支付信息...") try: self.page.click('text="下一步(支付方式)"', timeout=5000) self._wait_for('text="下一步(补助清单)"', timeout=5000) for info in payment_info: self.page.click("#insertPay", timeout=5000) self.page.wait_for_timeout(1000) self.page.fill("#personid2", self.config["default_name"]) self.page.fill("#accountname2", self.config["default_person_id"]) self.page.fill("#receiptdate2", _format_date(info["card_date"])) self.page.fill("#localaccount2", self.config["default_card_no"]) self.page.fill("#receiptmoney2", str(info["card_amount"])) self.page.fill("#money2", str(info["card_amount"])) self.page.fill("#merchant2", info.get("merchant", "")) self.page.fill("#smark2", info.get("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 fill_payment(self, invoices: list[dict[str, Any]]) -> None: """录入支付信息""" 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[str, Any]]) -> None: """上传附件""" log.info("上传附件...") try: self.page.click("#next4", 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 try: self._wait_for("#insertAcc", timeout=20000) self.page.click("#insertAcc", timeout=5000) self._wait_for("#fjlx", timeout=5000) self.page.select_option("#fjlx", "1") 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) except Exception: try: self.page.press("body", "Escape") except Exception: pass log.info("上传附件完成") except Exception as e: log.error(f"附件上传失败: {e}") self._screenshot("step6_error") raise self._screenshot("step6_done") def upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None: """上传差旅附件""" log.info("上传差旅附件...") try: self.page.click("#next4", timeout=5000) self._wait_for("#submit2", timeout=5000) for info in attachment_info: attachment_file = self.work_dir / info["filename"] self._wait_for("#insertAcc", timeout=20000) self.page.click("#insertAcc", timeout=5000) self._wait_for("#fjlx", timeout=5000) if info["attachment_type"] == "invoice": self.page.select_option("#fjlx", "1") else: self.page.select_option("#fjlx", "2") self.page.fill("#fpsmxx", info["attachment_desc"]) if attachment_file and attachment_file.exists(): self.page.set_input_files("#file", str(attachment_file)) self.page.wait_for_timeout(1000) self.page.click("#cjtj", timeout=5000) log.info("上传差旅附件完成") except Exception as e: log.error(f"差旅附件上传失败: {e}") self._screenshot("travel_attachment_error") raise self._screenshot("travel_attachment_done") def close(self) -> None: """关闭浏览器""" 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 = None) -> None: self.page.wait_for_selector(selector, timeout=timeout) def _screenshot(self, name: str) -> None: 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 fill_travel_subsidy(self, subsidy_info: list[dict[str, Any]]) -> None: """录入差旅补助清单""" log.info("录入差旅补助清单...") try: self.page.click("#next3", timeout=5000) self._wait_for("#next4", timeout=5000) for info in subsidy_info: self.page.click("#insertSubsidy", timeout=5000) self._wait_for('text="增加补助清单"', timeout=5000) self.page.click("#jzg3", timeout=5000) self.page.wait_for_timeout(500) if info["person_name"] and info["person_name"] != "": self.page.fill("#seacher", info["person_name"]) elif info["person_id"] and info["person_id"] != "": self.page.fill("#seacher", info["person_id"]) else: raise ValueError(f"人员编号和人员姓名不能同时为空: {info}") self.page.click("#cx", timeout=5000) self.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000) self.page.click("#tableEmp tbody tr", timeout=10000) self.page.wait_for_timeout(1000) open_bank = self.page.input_value("#openbank1") if not open_bank: log.info("员工开户行未填写,默认填写中国工商银行") self.page.fill("#openbank1", "中国工商银行") self.page.fill("#startdate1", _format_date(info["start_date"])) self.page.fill("#enddate1", _format_date(info["end_date"])) self.page.fill("#trafficdays1", str(info["days"])) self.page.fill("#fooddays1", str(info["days"])) self.page.fill("#trafficnorm1", str(80)) self.page.fill("#foodnorm1", str(100)) trafficmoney = int(info["days"]) * 80 foodmoney = int(info["days"]) * 100 subsidymoney = trafficmoney + foodmoney self.page.fill("#trafficmoney1", str(trafficmoney)) self.page.fill("#foodmoney1", str(foodmoney)) self.page.fill("#subsidymoney1", str(subsidymoney)) self.page.click("#add", timeout=3000) self.page.wait_for_timeout(1000) except Exception as e: log.error(f"差旅补助清单录入失败: {e}") self._screenshot("subsidy_error") raise self._screenshot("subsidy_done") # ------------------------------------------------------------------ # 对外入口 # ------------------------------------------------------------------ def run_bot(config: dict[str, Any], headless: bool = False, work_dir: Path | None = None) -> None: """执行完整的浏览器填报流程,根据发票类型自动路由""" if not config["username"] or not config["password"]: raise ValueError("缺少用户名或密码") if not work_dir: raise ValueError("缺少工作目录") from .doc.llm_extractor import load_cache cache_map = load_cache(work_dir) doc_values = [v for k, v in cache_map.items() if k != "travel_info"] invoices = [data for data in doc_values if data.get("invoice_type") not in ("application", "payment")] applications = [data for data in doc_values if data.get("invoice_type") == "application"] groups = _classify_invoice_batch(invoices) travel_invoices = groups["travel"] general_invoices = groups["general"] log.info( f"发票分类: 差旅 {len(travel_invoices)} 张, 普通 {len(general_invoices)} 张, " f"出差事前申请单 {len(applications)} 份" ) if travel_invoices and general_invoices: raise ValueError( f"发票类型混合(差旅 {len(travel_invoices)} 张 + 普通 {len(general_invoices)} 张),不支持混报,请分开提交" ) is_travel = bool(travel_invoices) travel_info: dict[str, Any] = cache_map.get("travel_info", {}) if is_travel and not travel_info: from .doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info travel_info = extract_travel_info(source_dir=work_dir) cache_dir = work_dir / CACHE_DIR_NAME cache_dir.mkdir(parents=True, exist_ok=True) with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: json.dump(travel_info, f, ensure_ascii=False, indent=2) log.info("差旅信息已保存到缓存") bot = ReimburseBot(config, headless=headless, work_dir=work_dir) bot.work_dir = work_dir try: bot.launch() bot.login_portal() if is_travel: log.info("处理差旅发票...") bot.navigate_to_reimburse(page_key="travel_page") bot.create_new_form() basic_info = travel_info["basic_info"] bot.fill_travel_info(basic_info) travel_items = travel_info["reimbursement_details"] bot.add_travel_items(travel_items) payment_info = travel_info["payment_methods"] bot.fill_travel_payment(payment_info) subsidy_info = travel_info["subsidy_list"] bot.fill_travel_subsidy(subsidy_info) attachment_info = travel_info["attachments"] bot.upload_travel_attachments(attachment_info) else: log.info("处理普通发票...") bot.navigate_to_reimburse(page_key="reimburse_page") bot.create_new_form() bot.fill_basic_info() bot.add_reimburse_items(general_invoices) bot.fill_payment(general_invoices) bot.upload_attachments(general_invoices) 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[str, Any], work_dir: Path) -> None: """Web 模式填报 — headless,附件从指定目录读取""" run_bot(config, headless=True, work_dir=work_dir)