refactor: 架构重组 — doc/bot → core/infra,新增 Agent 调度模块
- src/doc/ 拆分为 src/core/extraction/, matching/, validation/(核心业务逻辑) - src/bot/ 重命名为 src/infra/browser/(浏览器自动化基础设施) - fill_consumable_doc.py → src/infra/documents/consumable.py - 新增 Agent 调度模块:coordinator.py, events.py, session.py,重构 orchestrator.py - 更新 AGENTS.md、README.md 及所有子目录 README
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
---
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# src/infra/browser — 浏览器自动化
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `base.py` | `BaseBot` 基类:浏览器生命周期、登录信息门户、导航到报销系统、创建新单据、截图 |
|
||||
| `travel.py` | 差旅报销填报流程:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传 |
|
||||
| `normal.py` | 普通报销填报流程:基本信息 → 总明细 → 支付方式 → 附件上传 |
|
||||
| `__init__.py` | 入口函数:`run_bot()` / `run_bot_web()`,负责类型路由和流程调度 |
|
||||
|
||||
## 对外接口
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `run_bot(config, travel_info, normal_info)` | CLI 模式:根据传入信息判断差旅/普通报销 |
|
||||
| `run_bot_web(config, work_dir)` | Web 模式:从缓存加载信息后执行填报 |
|
||||
|
||||
## 填报流程
|
||||
|
||||
### 差旅报销(travel)
|
||||
1. 填写基本信息(事由、地点、日期、项目编号)
|
||||
2. 添加差旅明细(交通费用逐条录入)
|
||||
3. 填写支付方式(公务卡刷卡记录)
|
||||
4. 填写补助清单(按天计算交通补助 + 伙食补助)
|
||||
5. 上传附件(发票、申请单等)
|
||||
|
||||
### 普通报销(normal)
|
||||
1. 填写基本信息(报销事由、金额)
|
||||
2. 填写发票明细(总数、总金额)
|
||||
3. 填写支付方式
|
||||
4. 上传附件
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 浏览器填报会启动 Chromium,请勿手动干扰自动化流程
|
||||
- 调试截图保存在 `images/` 目录
|
||||
- Web 模式以无头模式运行
|
||||
@@ -0,0 +1,100 @@
|
||||
"""浏览器自动化填报
|
||||
|
||||
使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。
|
||||
|
||||
对外接口:
|
||||
run_bot(config, travel_info, normal_info) 启动浏览器并执行填报流程
|
||||
run_bot_web(config, work_dir) Web 模式填报(从缓存加载信息)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
from .base import BaseBot
|
||||
|
||||
log = get_logger("bot")
|
||||
|
||||
|
||||
def run_bot(
|
||||
config: dict[str, Any],
|
||||
headless: bool = False,
|
||||
work_dir: Path | None = None,
|
||||
travel_info: dict[str, Any] | None = None,
|
||||
normal_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""启动浏览器并执行填报流程。
|
||||
|
||||
根据传入的报销信息判断执行差旅报销还是普通报销流程。
|
||||
|
||||
Args:
|
||||
config: 财务系统配置(含 URL、账号密码等)。
|
||||
headless: 是否无头模式。
|
||||
work_dir: 工作目录。
|
||||
travel_info: 差旅报销信息(可选)。
|
||||
normal_info: 普通报销信息(可选)。
|
||||
"""
|
||||
invoice_type = ""
|
||||
if travel_info and normal_info:
|
||||
invoice_type = "mixed"
|
||||
elif travel_info:
|
||||
invoice_type = "travel"
|
||||
elif normal_info:
|
||||
invoice_type = "normal"
|
||||
else:
|
||||
log.error("未提供任何报销信息")
|
||||
return
|
||||
|
||||
log.info(f"启动填报流程: {invoice_type}")
|
||||
|
||||
if invoice_type == "travel":
|
||||
from .travel import run as run_travel
|
||||
|
||||
bot = BaseBot(config, headless=headless)
|
||||
bot.work_dir = work_dir
|
||||
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse(page_key="travel_page")
|
||||
bot.create_new_form()
|
||||
run_travel(bot, travel_info)
|
||||
finally:
|
||||
bot.close()
|
||||
elif invoice_type == "normal":
|
||||
from .normal import run as run_normal
|
||||
|
||||
bot = BaseBot(config, headless=headless)
|
||||
bot.work_dir = work_dir
|
||||
|
||||
try:
|
||||
bot.launch()
|
||||
bot.login_portal()
|
||||
bot.navigate_to_reimburse(page_key="reimburse_page")
|
||||
bot.create_new_form()
|
||||
run_normal(bot, normal_info)
|
||||
finally:
|
||||
bot.close()
|
||||
else:
|
||||
log.warning("暂不支持混合报销流程")
|
||||
|
||||
|
||||
def run_bot_web(config: dict[str, Any], work_dir: str | Path) -> None:
|
||||
"""Web 模式填报(从缓存加载信息)。
|
||||
|
||||
根据 work_dir 下的 .invoice_cache 目录中已提取的信息,
|
||||
自动判断执行差旅报销还是普通报销流程。
|
||||
|
||||
Args:
|
||||
config: 财务系统配置(含 URL、账号密码等)。
|
||||
work_dir: 工作目录(包含 .invoice_cache 子目录)。
|
||||
"""
|
||||
from ...core.extraction import load_cache
|
||||
|
||||
work_dir = Path(work_dir)
|
||||
cache = load_cache(work_dir)
|
||||
|
||||
travel_info = cache.get("travel_info")
|
||||
normal_info = cache.get("normal_info")
|
||||
|
||||
run_bot(config, headless=True, work_dir=work_dir, travel_info=travel_info, normal_info=normal_info)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""浏览器自动化填报 — 公共基类
|
||||
|
||||
提供浏览器生命周期管理、登录、导航、截图等公共操作。
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基类
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseBot:
|
||||
"""浏览器自动化基类 — 管理浏览器生命周期与公共操作"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], headless: bool = False) -> 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 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 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 _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.parent / "images"
|
||||
img_dir.mkdir(exist_ok=True)
|
||||
self.page.screenshot(path=str(img_dir / f"debug_{name}.png"))
|
||||
@@ -0,0 +1,179 @@
|
||||
"""普通报销填报流程
|
||||
|
||||
负责普通发票报销的完整填报步骤:
|
||||
基本信息 → 总明细 → 支付方式 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.normal")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 普通报销流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
bot: BaseBot,
|
||||
normal_info: dict[str, Any],
|
||||
) -> None:
|
||||
"""执行普通发票报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
normal_info: 普通发票信息字典。
|
||||
"""
|
||||
log.info("开始普通发票报销填报...")
|
||||
|
||||
log.info("填写基本信息...")
|
||||
description = normal_info.get("basic_info", {}).get("reimbursement_description", "元器件采购报销")
|
||||
fill_basic_info(bot, description)
|
||||
|
||||
total_invoices = normal_info.get("reimbursement_details", {}).get("total_invoices", 0)
|
||||
total_amount = normal_info.get("reimbursement_details", {}).get("total_amount", 0)
|
||||
log.info(f"录入普通发票总明细 (共 {total_invoices} 张, 合计 ¥{total_amount:.2f})...")
|
||||
add_normal_item(bot, total_invoices, total_amount)
|
||||
|
||||
payment_info = normal_info.get("payment_methods", [])
|
||||
log.info(f"录入普通发票支付信息 (共 {len(payment_info)} 笔)...")
|
||||
fill_normal_payment(bot, payment_info)
|
||||
|
||||
log.info("上传普通发票附件...")
|
||||
attachment_info = normal_info.get("attachments", [])
|
||||
upload_normal_attachments(bot, attachment_info)
|
||||
|
||||
log.info("普通发票报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
try:
|
||||
bot.page.fill("#EXPENEXPLAIN", description)
|
||||
bot.page.click("#PROJECTCODE", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
|
||||
if first_row:
|
||||
first_row.click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.click("#saveAndNext", timeout=5000)
|
||||
bot.page.wait_for_timeout(2000)
|
||||
|
||||
bot._screenshot("step3_done")
|
||||
except Exception as e:
|
||||
log.error(f"填写基本信息失败: {e}")
|
||||
bot._screenshot("basic_info_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 总明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
|
||||
"""录入普通发票总明细"""
|
||||
try:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="经济事项名称"', timeout=5000)
|
||||
bot.page.click("#economicscode2")
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
try:
|
||||
bot.page.wait_for_selector("#econmodal .fixed-table-body tbody tr", timeout=10000)
|
||||
rows = bot.page.query_selector_all("#econmodal .fixed-table-body tbody tr")
|
||||
if len(rows) >= 3:
|
||||
rows[2].click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot.page.fill('input[name="expenPwCommondetail.HOWBILLS"]', str(total_invoices))
|
||||
bot.page.fill("#je_zwzcdz", f"{total_amount:.2f}")
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot._screenshot("normal_item_done")
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("normal_item_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_normal_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入普通发票支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(附件清单)"', timeout=5000)
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.fill("#personid2", bot.config["default_person_id"])
|
||||
bot.page.fill("#accountname2", bot.config["default_name"])
|
||||
bot.page.fill("#receiptdate2", format_date(str(info.get("card_date", ""))))
|
||||
bot.page.fill("#localaccount2", bot.config["default_card_no"])
|
||||
bot.page.fill("#receiptmoney2", str(info.get("card_amount", 0)))
|
||||
bot.page.fill("#money2", str(info.get("card_amount", 0)))
|
||||
bot.page.fill("#merchant2", str(info.get("merchant", "")))
|
||||
bot.page.fill("#smark2", str(info.get("remark", "")))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("normal_payment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_payment_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传普通发票附件"""
|
||||
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info.get("attachment_type") == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传普通发票附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
bot._screenshot("normal_attachment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("normal_attachment_done")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""差旅报销填报流程
|
||||
|
||||
负责差旅报销的完整填报步骤:
|
||||
基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件上传
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ... import get_logger
|
||||
from .base import BaseBot, format_date
|
||||
|
||||
log = get_logger("bot.travel")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅填报流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
|
||||
"""执行差旅报销填报流程
|
||||
|
||||
Args:
|
||||
bot: 已启动并登录的 BaseBot 实例。
|
||||
travel_info: 差旅信息字典(包含 basic_info、reimbursement_details 等)。
|
||||
"""
|
||||
log.info("开始差旅报销填报...")
|
||||
|
||||
log.info("填写差旅报销信息...")
|
||||
basic_info = travel_info["basic_info"]
|
||||
fill_travel_info(bot, basic_info)
|
||||
|
||||
log.info("填写差旅报销明细...")
|
||||
details = travel_info["reimbursement_details"]
|
||||
add_travel_items(bot, details)
|
||||
|
||||
log.info("填写差旅报销支付方式...")
|
||||
payment_info = travel_info["payment_methods"]
|
||||
fill_travel_payment(bot, payment_info)
|
||||
|
||||
log.info("填写差旅报销补助清单...")
|
||||
subsidy_info = travel_info["subsidy_list"]
|
||||
fill_travel_subsidy(bot, subsidy_info)
|
||||
|
||||
log.info("上传差旅报销附件...")
|
||||
attachment_info = travel_info["attachments"]
|
||||
upload_travel_attachments(bot, attachment_info)
|
||||
|
||||
log.info("差旅报销填报完成")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基本信息
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
|
||||
"""填写差旅报销基本信息"""
|
||||
try:
|
||||
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
|
||||
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
|
||||
|
||||
bot.page.click("#PROJECTCODE", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000)
|
||||
first_row = bot.page.query_selector("#promodal .fixed-table-body tbody tr")
|
||||
if first_row:
|
||||
first_row.click()
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
bot.page.fill("#THEKSRQ", format_date(basic_info.get("start_date", "")))
|
||||
bot.page.fill("#THEJSRQ", format_date(basic_info.get("end_date", "")))
|
||||
bot.page.click("#saveAndNext", timeout=5000)
|
||||
bot.page.wait_for_timeout(2000)
|
||||
bot._screenshot("travel_basic_done")
|
||||
except Exception as e:
|
||||
log.error(f"填写基本信息失败: {e}")
|
||||
bot._screenshot("travel_basic_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 差旅明细
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
||||
"""录入差旅报销明细
|
||||
|
||||
Args:
|
||||
bot: 已启动的 BaseBot 实例。
|
||||
details: 报销明细字典(travel_info["reimbursement_details"]),包含
|
||||
transport_fee、hotel_fee、conference_fee 等子字段。
|
||||
"""
|
||||
vehicle_map = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
"轮船": "03",
|
||||
"自带车": "04",
|
||||
"公务车": "05",
|
||||
"飞机": "06",
|
||||
"租车": "07",
|
||||
"自驾车": "08",
|
||||
}
|
||||
|
||||
try:
|
||||
traffic_info = details.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "1")
|
||||
bot.page.wait_for_timeout(500)
|
||||
vehicle = item.get("vehicle_type", "")
|
||||
if vehicle in vehicle_map:
|
||||
bot.page.select_option("#jtgj", vehicle_map[vehicle])
|
||||
|
||||
bot.page.fill("#ksdd", item.get("departure_place", ""))
|
||||
bot.page.fill("#jsdd", item.get("arrival_place", ""))
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t1 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
hotel_info = details.get("hotel_fee") or []
|
||||
for item in hotel_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "2")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill("#ksrq2", format_date(str(item.get("checkin_date", ""))))
|
||||
bot.page.fill("#jsrq2", format_date(str(item.get("checkout_date", ""))))
|
||||
bot.page.fill("#ts2", str(item.get("days", "")))
|
||||
bot.page.fill("#rs2", str(item.get("person_count", "")))
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.FPMONEY"]',
|
||||
str(item.get("invoice_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("reimburse_amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t2 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
conference_info = details.get("conference_fee") or []
|
||||
for item in conference_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="增加明细"', timeout=5000)
|
||||
bot.page.select_option("#cost", "3")
|
||||
bot.page.wait_for_timeout(500)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.HOWBILL"]',
|
||||
str(item.get("bill_count", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.MONEY"]',
|
||||
str(item.get("amount", "")),
|
||||
)
|
||||
bot.page.fill(
|
||||
'#t3 input[name="expenPwTraveldetail.SMARK"]',
|
||||
str(item.get("remark", "")),
|
||||
)
|
||||
bot.page.click("#detailAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"录入总明细失败: {e}")
|
||||
bot._screenshot("item_total_error")
|
||||
raise
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 支付方式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅支付信息"""
|
||||
try:
|
||||
bot.page.click('text="下一步(支付方式)"', timeout=5000)
|
||||
bot._wait_for('text="下一步(补助清单)"', timeout=5000)
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.fill("#personid2", bot.config["default_person_id"])
|
||||
bot.page.fill("#accountname2", bot.config["default_name"])
|
||||
bot.page.fill("#receiptdate2", format_date(info["card_date"]))
|
||||
bot.page.fill("#localaccount2", bot.config["default_card_no"])
|
||||
bot.page.fill("#receiptmoney2", str(info["card_amount"]))
|
||||
bot.page.fill("#money2", str(info["card_amount"]))
|
||||
bot.page.fill("#merchant2", info.get("merchant", ""))
|
||||
bot.page.fill("#smark2", info.get("remark", ""))
|
||||
bot.page.click("#payAdd", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"支付方式录入失败: {e}")
|
||||
bot._screenshot("step5_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("step5_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 补助清单
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> None:
|
||||
"""录入差旅补助清单"""
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
bot._wait_for("#next4", timeout=5000)
|
||||
|
||||
for info in subsidy_info:
|
||||
bot.page.click("#insertSubsidy", timeout=5000)
|
||||
bot._wait_for('text="增加补助清单"', timeout=5000)
|
||||
bot.page.click("#jzg3", timeout=5000)
|
||||
bot.page.wait_for_timeout(500)
|
||||
# 注意:此处使用直接索引而非 .get(),是故意的设计。
|
||||
# LLM 必须返回 person_name 和 person_id 字段,若缺失则说明数据质量有问题,
|
||||
# 应当立即报错终止流程,而非静默跳过。
|
||||
if info["person_name"] and info["person_name"] != "":
|
||||
bot.page.fill("#seacher", info["person_name"])
|
||||
elif info["person_id"] and info["person_id"] != "":
|
||||
bot.page.fill("#seacher", info["person_id"])
|
||||
else:
|
||||
raise ValueError(f"人员编号和人员姓名不能同时为空: {info}")
|
||||
bot.page.click("#cx", timeout=5000)
|
||||
bot.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000)
|
||||
bot.page.click("#tableEmp tbody tr", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
|
||||
open_bank = bot.page.input_value("#openbank1")
|
||||
if not open_bank:
|
||||
log.info("员工开户行未填写,默认填写中国工商银行")
|
||||
bot.page.fill("#openbank1", "中国工商银行")
|
||||
|
||||
bot.page.fill("#startdate1", format_date(info["start_date"]))
|
||||
bot.page.fill("#enddate1", format_date(info["end_date"]))
|
||||
bot.page.fill("#trafficdays1", str(info["days"]))
|
||||
bot.page.fill("#fooddays1", str(info["days"]))
|
||||
# 补助标准硬编码:交通补助 80 元/天,伙食补助 100 元/天。
|
||||
# 此为阜阳师范大学现行标准,如需适配其他单位,可改为从 config.json 读取。
|
||||
bot.page.fill("#trafficnorm1", str(80))
|
||||
bot.page.fill("#foodnorm1", str(100))
|
||||
trafficmoney = int(info["days"]) * 80
|
||||
foodmoney = int(info["days"]) * 100
|
||||
subsidymoney = trafficmoney + foodmoney
|
||||
bot.page.fill("#trafficmoney1", str(trafficmoney))
|
||||
bot.page.fill("#foodmoney1", str(foodmoney))
|
||||
bot.page.fill("#subsidymoney1", str(subsidymoney))
|
||||
bot.page.click("#add", timeout=3000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log.error(f"差旅补助清单录入失败: {e}")
|
||||
bot._screenshot("subsidy_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("subsidy_done")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传差旅附件"""
|
||||
try:
|
||||
bot.page.click("#next4", timeout=5000)
|
||||
bot._wait_for("#submit2", timeout=5000)
|
||||
|
||||
for info in attachment_info:
|
||||
attachment_file = bot.work_dir / info["filename"]
|
||||
bot._wait_for("#insertAcc", timeout=20000)
|
||||
bot.page.click("#insertAcc", timeout=5000)
|
||||
bot._wait_for("#fjlx", timeout=5000)
|
||||
if info["attachment_type"] == "invoice":
|
||||
bot.page.select_option("#fjlx", "1")
|
||||
else:
|
||||
bot.page.select_option("#fjlx", "2")
|
||||
bot.page.fill("#fpsmxx", info.get("attachment_desc", ""))
|
||||
if attachment_file and attachment_file.exists():
|
||||
bot.page.set_input_files("#file", str(attachment_file))
|
||||
bot.page.wait_for_timeout(1000)
|
||||
bot.page.click("#cjtj", timeout=5000)
|
||||
log.info("上传差旅附件完成")
|
||||
except Exception as e:
|
||||
log.error(f"差旅附件上传失败: {e}")
|
||||
bot._screenshot("travel_attachment_error")
|
||||
raise
|
||||
|
||||
bot._screenshot("travel_attachment_done")
|
||||
Reference in New Issue
Block a user