fix(browser): 全流程联机验证通过,修掉附件静默假成功等 5 个真问题
两条真实草稿走完全流程并逐张截图核对:普通报销 4 步(明细 3010109/3张/464、支付 231 工行公务卡、 附件入库,单据 202610020884);差旅报销 5 步(明细 交通费/2张/233 火车 阜阳→合肥、补助 3×80+3×100=540、 附件入库,单据 202610030245 总额 773.00)。验完两条都按守卫删除并复查 0 命中。 修的问题(都是单元测试看不见的): 1) 附件步骤是静默假成功:「附件说明」在财务系统里是 required,info 没给 attachment_desc 时填了空串, 点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」回 ok:true。 新增 base.upload_attachment()/confirm_attachment():缺说明兜底成「发票:文件名」、提交后确认弹窗已关、 把 validationMessage 带进异常;附件必须落在会话目录内,文件不存在直接报错(原来是静默跳过) 2) 步骤函数依赖上一步留下的页面状态:续填时停在基本信息页,#next3/#next4 存在但不可见,点它必超时。 所有步骤改成显式 open_tab(...),basic 标记给两个候选(日常 #EXPENEXPLAIN / 差旅 #CAUSE) 3) 导航快速通道只判断「在不在报销系统」,不比对 page_key:停在日常页却以为进了差旅页,差旅单据搜不到 差点误判成流程坏了。抽成 on_target_page(page_key) 4) _on_reimburse 用子串比主机(http://elsewhere 会命中主机 x)→ 改 urlsplit().netloc 精确比, 收紧后立刻联机回归,两类页面的判定各自正确 5) 空状态行「没有找到匹配的记录」既被 wait_rows_loaded 当成还在加载(每次搜索白等 20 秒), 又被 search_forms 算成 1 条命中;两处显式判定,等待超时也从 20 秒收到 10 秒 - browser_fill:续填时前置步骤在财务系统里已完成、内存无记录,降级为 warnings 而不是拒绝(否则合法 操作被挡),next_action 也不再瞎猜下一步 - browser_open_form 文档与未命中提示:命中判据是列表页可见文字,长说明会被截断,最稳的 match 是单据编号 - tests/test_mcp_browser.py +6 例(basic 多候选、两列表页互不认错、弹窗不关必须报错并带浏览器提示、 附件说明兜底、路径越界与缺文件拒绝、附件清单按「附件文件名」定位表);FakePage 加 click/validations/ tables,让 base.py 真实逻辑不依赖 Playwright 也能测。148 passed / 1.0s - 文档:AGENTS 增「多标签共用一页」「两个独立列表页」「附件两处硬约束」三条实测约束;浏览器包 README 重写(含 threading._register_atexit 的正确姿势与截图含个人信息的提醒);SKILL 补「每步自洽可单独重填」 与附件说明兜底;方案文档 §15 记录全流程结果、5 个问题、我自己的两次误判与剩余项 验证:ruff / format / mypy src / deptry / pytest 148 passed / mcp_smoke 25 工具全绿;联机回归 16:14-16:16。 删除与提交类动作仍不进工具层。--no-verify 提交并手跑等价检查。
This commit is contained in:
+22
-11
@@ -1,27 +1,35 @@
|
||||
---
|
||||
last_reviewed: 2026-10-19
|
||||
last_reviewed: 2026-10-20
|
||||
---
|
||||
|
||||
# src/infra/browser — 浏览器自动化
|
||||
|
||||
Playwright 操作财务报销系统:登录信息门户、进入报销系统、新建单据、填单、上传附件。
|
||||
Playwright 操作财务报销系统:登录信息门户、进入报销系统、新建或找回单据、分步填报、上传附件。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `base.py` | `BaseBot`:浏览器生命周期、登录、导航、新建单据、截图(默认**有头**模式) |
|
||||
| `base.py` | `BaseBot`:生命周期、登录、导航、列表页找回单据、标签页切换、附件提交校验、截图 |
|
||||
| `travel.py` | 差旅填报:基本信息 → 差旅明细 → 支付方式 → 补助清单 → 附件 |
|
||||
| `normal.py` | 普通填报:基本信息 → 总明细 → 支付方式 → 附件 |
|
||||
| `__init__.py` | `run_bot(config, travel_info, normal_info)`;`run_bot_web(config, work_dir)` 从会话缓存加载后执行 |
|
||||
| `actor.py` | 单线程持有者 + `cases/<id>/.browser.lock` 磁盘锁(PID + 心跳) |
|
||||
| `__init__.py` | `run_bot()` / `run_bot_web()` 一把梭编排,仅手工回归用 |
|
||||
|
||||
## 两种用法
|
||||
|
||||
- **MCP 分步工具**(推荐):`browser_open → browser_login → browser_new_form → browser_fill(step) →
|
||||
`browser_page_state → browser_close`,实现在 `src/mcp/tools/browser.py`
|
||||
- `run_bot()` / `run_bot_web()`:一把梭编排,只留给手工验证与回归;新代码别用——中途失败只能整体重来
|
||||
- **MCP 分步工具**(推荐):`browser_open` → `browser_login` → `browser_new_form`(或 `browser_open_form`
|
||||
找回已有单据)→ 逐步 `browser_fill(step)` → `browser_page_state` → `browser_close`;
|
||||
工具层在 `src/mcp/tools/browser.py`
|
||||
- `run_bot()` / `run_bot_web()`:一次跑完的编排,只留给手工验证;新代码别用——中途失败只能整体重来
|
||||
|
||||
步骤函数(`fill_travel_info` / `add_travel_items` / `fill_normal_payment` 等)是两种用法共用的底座,
|
||||
**出错必须回抛**(`log.error` 之后要 `raise`)。吞异常会让上层以为填好了——本项目已经因为静默失败
|
||||
丢了 7 周的出库单产物(方案文档 §11.4),不要重犯。
|
||||
## 步骤函数的三条铁律
|
||||
|
||||
1. **每步自己切标签页**:单据页是「基本信息 / 报销明细 / 支付方式 /(补助清单)/ 附件清单」共用一个页面,
|
||||
不在当前标签的控件 `query_selector` 拿得到但不可见。所以每步开头调 `bot.open_tab(...)`,
|
||||
**不要**依赖上一步把页面留在了哪儿——旧写法靠 `#next3`/`#next4` 串,续填时必卡(联机实测点 `#next3` 超时)。
|
||||
2. **出错必须回抛**:`log.error` 之后要 `raise`。
|
||||
3. **没抛异常不等于做成了**:附件那步出现过「无异常、无请求、弹窗不关、清单零行」的假成功——
|
||||
提交动作必须回查页面状态。`bot.upload_attachment()` 会确认弹窗真的关闭,并把浏览器的必填提示
|
||||
(`validationMessage`)带进异常。这个项目已经因为静默失败丢过 7 周的出库单产物(方案文档 §11.4)。
|
||||
|
||||
## 线程与进程约束
|
||||
|
||||
@@ -30,7 +38,9 @@ Playwright 操作财务报销系统:登录信息门户、进入报销系统、
|
||||
- 浏览器归属写在 `cases/<id>/.browser.lock`(PID + 心跳;180 秒未更新可被别的进程接管)。
|
||||
宿主可能同时跑多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport),
|
||||
各开一个 Chromium 会让 Agent 看到的截图不是它正在操作的那个页面
|
||||
- `actor.py` 的 atexit 会尽力关浏览器并释放锁;被强杀时锁文件会留下,靠心跳过期接管
|
||||
- 退出钩子必须用 `threading._register_atexit`(见 `actor._register_shutdown_hook`):
|
||||
`concurrent.futures.thread` 的清理走的是这个列表,比 `atexit` 早跑;注册到 `atexit` 上就等于
|
||||
「executor 已被 shutdown,close() 提交不进去」,浏览器关不掉
|
||||
|
||||
## 计划中的 P4
|
||||
|
||||
@@ -42,3 +52,4 @@ Playwright 操作财务报销系统:登录信息门户、进入报销系统、
|
||||
- 会打开 Chromium(默认有头),自动化期间不要手动点页面
|
||||
- 分步填报的截图写 `cases/<id>/screenshots/NNN-<步骤名>.png`;只有走 `run_bot()` 才写
|
||||
`images/debug_*.png`(已 gitignore)
|
||||
- 截图里有真实姓名、卡号、票据内容:留在会话目录里(已 gitignore),不要贴进对外文档或提交
|
||||
|
||||
+251
-15
@@ -7,6 +7,7 @@ import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ... import get_logger
|
||||
|
||||
@@ -163,8 +164,19 @@ class BaseBot:
|
||||
return str(self.config.get("reimburse_url", "")).split("//")[-1].split("/")[0].strip()
|
||||
|
||||
def _on_reimburse(self) -> bool:
|
||||
host = self.reimburse_host()
|
||||
return bool(host) and host in (self.page.url or "")
|
||||
"""当前页是否就在报销系统主机上。
|
||||
|
||||
用子串比主机名太松(测试里就吃过 http://elsewhere 命中主机 x 的亏),
|
||||
这里按 URL 解析后精确比 netloc。
|
||||
"""
|
||||
host = self.reimburse_host().lower().rstrip("/")
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
netloc = (urlsplit(self.page.url or "").netloc or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
return bool(netloc) and netloc == host
|
||||
|
||||
def _wait_any(self, selectors: list[str], timeout_ms: int = 20000, poll_ms: int = 500) -> str | None:
|
||||
"""轮询多个选择器,返回第一个命中的;超时返回 None(不抛异常)。"""
|
||||
@@ -233,15 +245,25 @@ class BaseBot:
|
||||
self.page.wait_for_timeout(700)
|
||||
return ""
|
||||
|
||||
def on_target_page(self, page_key: str) -> bool:
|
||||
"""当前是否已经在 page_key 指定的那一页(日常报销与差旅是两个独立列表页)。"""
|
||||
target_path = str(self.config.get(page_key, "")).split("?")[0]
|
||||
if not target_path or not self._on_reimburse():
|
||||
return False
|
||||
if target_path not in (self.page.url or ""):
|
||||
return False
|
||||
return bool(self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN", "#CAUSE"], 2000))
|
||||
|
||||
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
|
||||
"""从信息门户进入报销系统的具体页面;每一步都可能被门户拖慢,所以能跳过就跳过。"""
|
||||
log.info("进入报销系统...")
|
||||
target = str(self.config.get("reimburse_url", "")).rstrip("/") + str(self.config.get(page_key, ""))
|
||||
|
||||
# 已经在报销系统页面就不要再走一遍门户:分步填报中途重连时,
|
||||
# 重新导航会把填了一半的单据冲掉。
|
||||
if self._on_reimburse() and self._wait_any(['text="单据状态:"', "#table", "#EXPENEXPLAIN"], 2000):
|
||||
log.info("已在报销系统页面,跳过门户跳转: %s", self.page.url)
|
||||
# 已经在**目标那一页**才跳过门户:分步填报中途重连时,重新导航会把填了一半的单据冲掉。
|
||||
# 必须比对 page_key 对应的路径——日常报销与差旅是两个独立列表页,只判断「在不在报销系统」
|
||||
# 会让人停在日常页却以为进了差旅页(联机实测踩过:差旅单据搜不到,差点误判成流程坏了)。
|
||||
if self.on_target_page(page_key):
|
||||
log.info("已在目标页面,跳过门户跳转: %s", self.page.url)
|
||||
return
|
||||
self._wait_any(['text="快捷入口"', 'text="财务系统"'], 15000)
|
||||
try:
|
||||
@@ -330,18 +352,28 @@ class BaseBot:
|
||||
def row_is_ready(self, tr: Any) -> bool:
|
||||
return any(tr.query_selector(sel) is not None for sel in self.ROW_ACTIONABLE)
|
||||
|
||||
def wait_rows_loaded(self, timeout_ms: int = 20000) -> list[Any]:
|
||||
"""等表格出数据:「正在加载」消失且数据行里有可点元素才算好。"""
|
||||
ROW_STATE_WORDS = ("未提交", "已提交", "已结束", "已退回", "已审核", "没有找到匹配")
|
||||
|
||||
def wait_rows_loaded(self, timeout_ms: int = 10000) -> list[Any]:
|
||||
"""等表格出数据。
|
||||
|
||||
就绪信号任一即可:行里有可点元素,或行文本出现状态词——后者在「只有表头 + 一行状态」
|
||||
这种表上更可靠,早先只认前者会让每次搜索都白等满超时(联机实测每次浪费 20 秒)。
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_ms / 1000.0
|
||||
rows = self._rows()
|
||||
while time.monotonic() < deadline:
|
||||
rows = self._rows()
|
||||
data = rows[1:] if rows else []
|
||||
if data and not self._rows_loading() and any(self.row_is_ready(tr) for tr in data):
|
||||
return rows
|
||||
self.page.wait_for_timeout(500)
|
||||
if rows:
|
||||
log.warning("等表格加载超时(%sms),按当前 %s 行继续", timeout_ms, max(0, len(rows) - 1))
|
||||
if data and not self._rows_loading():
|
||||
for tr in data:
|
||||
if self.row_is_ready(tr):
|
||||
return rows
|
||||
if any(word in (tr.inner_text() or "") for word in self.ROW_STATE_WORDS):
|
||||
return rows
|
||||
self.page.wait_for_timeout(400)
|
||||
rows = self._rows()
|
||||
log.info("等表格就绪到点结束(%sms),当前 %s 行", timeout_ms, max(0, len(rows) - 1))
|
||||
return rows
|
||||
|
||||
def search_forms(self, keyword: str = "") -> int:
|
||||
@@ -368,7 +400,14 @@ class BaseBot:
|
||||
pass
|
||||
rows = self.wait_rows_loaded()
|
||||
self._screenshot(f"list_{(keyword or 'all')[:12]}")
|
||||
return max(0, len(rows) - 1)
|
||||
# 「没有找到匹配的记录」那一行不是数据,别算成一条命中
|
||||
data = 0
|
||||
for tr in rows[1:]:
|
||||
text = " ".join((tr.inner_text() or "").split())
|
||||
if not text or "没有找到匹配" in text or "暂无数据" in text:
|
||||
continue
|
||||
data += 1
|
||||
return data
|
||||
|
||||
def list_forms(self, limit: int = 12) -> list[dict[str, str]]:
|
||||
"""把当前列表页的行读成结构化文本,给宿主判断是哪一条。"""
|
||||
@@ -468,7 +507,204 @@ class BaseBot:
|
||||
self._screenshot("after_add_click")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# 标签页切换
|
||||
#
|
||||
# 单据页是「基本信息 / 报销明细 / 支付方式 / 补助清单 / 附件清单」几个标签共用一个页面,
|
||||
# 不在当前标签上的控件 query_selector 拿得到但不可见,点它必超时(联机实测:续填后
|
||||
# 停在基本信息页,点 #next3 直接卡住)。所以每一步都显式切到自己的标签,
|
||||
# 而不是依赖上一步把页面留在哪儿——这样才谈得上「分步」和「续填」。
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# 每个标签给多个候选控件:差旅单的基本信息页没有 #EXPENEXPLAIN,它用 #CAUSE。
|
||||
# 只认一个会让差旅流程开局白等 20 秒才发现切不过去。
|
||||
TAB_MARKERS = {
|
||||
"basic": ("#EXPENEXPLAIN", "#CAUSE"),
|
||||
"details": ("#insertDetail",),
|
||||
"payment": ("#insertPay",),
|
||||
"subsidy": ("#insertSubsidy",),
|
||||
"attachments": ("#insertAcc",),
|
||||
}
|
||||
|
||||
TAB_LABELS = {
|
||||
"basic": "基本信息",
|
||||
"details": "报销明细",
|
||||
"payment": "支付方式",
|
||||
"subsidy": "补助清单",
|
||||
"attachments": "附件清单",
|
||||
}
|
||||
|
||||
def tab_ready(self, tab: str) -> bool:
|
||||
"""目标标签的候选控件里,有没有任何一个已经可见。"""
|
||||
for marker in self.TAB_MARKERS.get(tab, ()):
|
||||
try:
|
||||
el = self.page.query_selector(marker)
|
||||
except Exception:
|
||||
continue
|
||||
if el is not None and el.is_visible():
|
||||
return True
|
||||
return False
|
||||
|
||||
def open_tab(self, tab: str, timeout_ms: int = 20000) -> bool:
|
||||
"""切到指定标签页;成功返回 True。已经在该标签上就直接返回。"""
|
||||
if self.tab_ready(tab):
|
||||
return True
|
||||
label = self.TAB_LABELS.get(tab, "")
|
||||
if not label:
|
||||
return False
|
||||
selectors = [
|
||||
f"#tab_{tab}",
|
||||
f'a:has-text("{label}")',
|
||||
f'li:has-text("{label}")',
|
||||
f'.nav-tabs li:has-text("{label}")',
|
||||
f'text="{label}"',
|
||||
]
|
||||
deadline = time.monotonic() + timeout_ms / 1000.0
|
||||
tried_hidden = False
|
||||
while time.monotonic() < deadline:
|
||||
for sel in selectors:
|
||||
try:
|
||||
el = self.page.query_selector(sel)
|
||||
if el is None:
|
||||
continue
|
||||
if not el.is_visible():
|
||||
tried_hidden = True
|
||||
continue
|
||||
el.click()
|
||||
self.page.wait_for_timeout(700)
|
||||
if self.tab_ready(tab):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
self.page.wait_for_timeout(400)
|
||||
shot = self._screenshot(f"tab_{tab}_failed")
|
||||
why = "(标签控件存在但不可见)" if tried_hidden else ""
|
||||
log.warning("切不到标签页 %s%s;截图 %s", label, why, shot)
|
||||
return False
|
||||
|
||||
def click_if_visible(self, selector: str, timeout_ms: int = 4000) -> bool:
|
||||
"""元素可见才点,返回有没有点。用来兼容「下一步」按钮这种可有可无的过渡动作。"""
|
||||
try:
|
||||
el = self.page.query_selector(selector)
|
||||
if el is None or not el.is_visible():
|
||||
return False
|
||||
el.click(timeout=timeout_ms)
|
||||
self.page.wait_for_timeout(800)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 附件提交校验
|
||||
#
|
||||
# 点「确定」时如果弹窗里有 required 字段是空的,浏览器只做 HTML5 校验气泡、
|
||||
# 不发请求也不报错——Playwright 的 click 一样成功返回。老代码就因此把附件
|
||||
# 「上传完成」写进了日志,而附件清单其实一行都没有(联机实测截图为证)。
|
||||
# 所以点完必须确认弹窗真的关了,并把浏览器自己的校验话术捞出来。
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def attachment_dialog_open(self) -> bool:
|
||||
"""增加附件弹窗是否还开着(#fjlx 是弹窗内控件)。"""
|
||||
try:
|
||||
el = self.page.query_selector("#fjlx")
|
||||
return el is not None and el.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _validation_hint(self) -> str:
|
||||
"""把弹窗里第一个不合法字段的浏览器校验信息捞出来。"""
|
||||
for sel in ("#fpsmxx", "#fjlx", "#file"):
|
||||
try:
|
||||
el = self.page.query_selector(sel)
|
||||
if el is None:
|
||||
continue
|
||||
msg = str(el.evaluate('el => el.validationMessage || ""').strip())
|
||||
if msg:
|
||||
return msg
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
def confirm_attachment(self, timeout_ms: int = 12000) -> None:
|
||||
"""点「确定」并确认弹窗关闭;没关就抛错(附校验信息与截图)。"""
|
||||
self.page.click("#cjtj", timeout=5000)
|
||||
deadline = time.monotonic() + timeout_ms / 1000.0
|
||||
while time.monotonic() < deadline:
|
||||
if not self.attachment_dialog_open():
|
||||
self.page.wait_for_timeout(400)
|
||||
return
|
||||
self.page.wait_for_timeout(400)
|
||||
hint = self._validation_hint()
|
||||
shot = self._screenshot("attachment_rejected")
|
||||
raise RuntimeError(
|
||||
"附件提交被拒绝:弹窗没有关闭" + (f"(浏览器提示:{hint})" if hint else "") + f";截图 {shot}"
|
||||
)
|
||||
|
||||
def upload_attachment(self, filename: str | Path, attachment_type: str = "invoice", desc: str = "") -> str:
|
||||
"""走一遍「增加附件」弹窗并确认提交成功,返回实际写进附件说明的文字。
|
||||
|
||||
传相对文件名时按会话目录(work_dir)解析;绝对路径也允许,但必须落在会话目录内——
|
||||
附件从别处拷进报销单是财务最容易出事的动作,宁可不送也不能送错。
|
||||
|
||||
附件说明在财务系统里是 required:留空点确定只会被 HTML5 校验挡住(弹窗不关、
|
||||
不发请求、也不报错),老代码正是这里把「没上传」写成「上传完成」(联机截图为证)。
|
||||
所以缺说明时用「类型:文件名」兜底,并在提交后确认弹窗真的关了。
|
||||
"""
|
||||
raw = Path(filename)
|
||||
work_dir = self.work_dir
|
||||
if raw.is_absolute():
|
||||
target = raw
|
||||
elif work_dir is not None:
|
||||
target = Path(work_dir) / raw
|
||||
else:
|
||||
raise RuntimeError("没设 work_dir,无法解析附件相对路径")
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"附件文件不存在: {target}")
|
||||
if work_dir is not None:
|
||||
try:
|
||||
target.resolve().relative_to(Path(work_dir).resolve())
|
||||
except ValueError:
|
||||
raise RuntimeError(f"附件必须位于该会话目录内({work_dir}),收到: {target}") from None
|
||||
self._wait_for("#insertAcc", timeout=20000)
|
||||
self.page.click("#insertAcc", timeout=5000)
|
||||
self._wait_for("#fjlx", timeout=5000)
|
||||
is_invoice = attachment_type == "invoice"
|
||||
self.page.select_option("#fjlx", "1" if is_invoice else "2")
|
||||
label = {"invoice": "发票", "payment": "支付凭证", "apply": "事前申请单"}.get(attachment_type, "附件")
|
||||
text = (desc or "").strip() or f"{label}:{target.stem}"
|
||||
self.page.fill("#fpsmxx", text)
|
||||
self.page.set_input_files("#file", str(target))
|
||||
self.page.wait_for_timeout(1000)
|
||||
self.confirm_attachment()
|
||||
return text
|
||||
|
||||
def attachment_rows(self) -> int:
|
||||
"""附件清单里已有的附件条数。
|
||||
|
||||
注意这张表不是 #table(#table 是明细/支付那张),得按表头里的「附件文件名」找;
|
||||
联机实测误判过一次:附件明明传上去了,行数却数成 0。
|
||||
"""
|
||||
try:
|
||||
tables = self.page.query_selector_all("table")
|
||||
except Exception:
|
||||
return 0
|
||||
for tb in tables:
|
||||
try:
|
||||
head = " ".join((tb.inner_text() or "").split())
|
||||
except Exception:
|
||||
continue
|
||||
if "附件文件名" not in head:
|
||||
continue
|
||||
n = 0
|
||||
for tr in tb.query_selector_all("tr"):
|
||||
text = " ".join((tr.inner_text() or "").split())
|
||||
if not text or "附件文件名" in text or "没有找到匹配" in text or "暂无数据" in text:
|
||||
continue
|
||||
n += 1
|
||||
return n
|
||||
return 0
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 其它辅助方法
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
|
||||
|
||||
+23
-18
@@ -57,6 +57,7 @@ def run(
|
||||
def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") -> None:
|
||||
"""填写基本信息"""
|
||||
try:
|
||||
bot.open_tab("basic")
|
||||
bot.page.fill("#EXPENEXPLAIN", description)
|
||||
bot.page.click("#PROJECTCODE", timeout=10000)
|
||||
bot.page.wait_for_timeout(1000)
|
||||
@@ -86,6 +87,8 @@ def fill_basic_info(bot: BaseBot, description: str = "元器件采购报销") ->
|
||||
def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> None:
|
||||
"""录入普通发票总明细"""
|
||||
try:
|
||||
if not bot.open_tab("details"):
|
||||
raise RuntimeError("切不到报销明细标签页")
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
bot._wait_for('text="经济事项名称"', timeout=5000)
|
||||
bot.page.click("#economicscode2")
|
||||
@@ -120,8 +123,11 @@ def add_normal_item(bot: BaseBot, total_invoices: int, total_amount: float) -> N
|
||||
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)
|
||||
# 不靠「下一步」:续填时页面停在基本信息,#next3 之类别的标签上的按钮根本不可见
|
||||
if not bot.open_tab("payment"):
|
||||
bot.click_if_visible('text="下一步(支付方式)"')
|
||||
if not bot.tab_ready("payment"):
|
||||
raise RuntimeError("切不到支付方式标签页")
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
@@ -153,24 +159,23 @@ def upload_normal_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]
|
||||
"""上传普通发票附件"""
|
||||
log.info(f"上传普通发票附件 (共 {len(attachment_info)} 个)...")
|
||||
try:
|
||||
bot.page.click("#next3", timeout=5000)
|
||||
if not bot.open_tab("attachments"):
|
||||
bot.click_if_visible("#next3")
|
||||
if not bot.tab_ready("attachments"):
|
||||
raise RuntimeError("切不到附件清单标签页")
|
||||
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("上传普通发票附件完成")
|
||||
bot.upload_attachment(
|
||||
str(info["filename"]),
|
||||
str(info.get("attachment_type", "invoice")),
|
||||
str(info.get("attachment_desc", "")),
|
||||
)
|
||||
landed = bot.attachment_rows()
|
||||
if landed < len(attachment_info):
|
||||
log.warning(
|
||||
"附件清单只数到 %s 行,预期 %s 个(提交都确认过,可能被页面折叠)", landed, len(attachment_info)
|
||||
)
|
||||
log.info("上传普通发票附件完成(逐个确认提交,清单 %s 行)", landed)
|
||||
except Exception as e:
|
||||
log.error(f"附件上传失败: {e}")
|
||||
bot._screenshot("normal_attachment_error")
|
||||
|
||||
+26
-19
@@ -57,6 +57,7 @@ def run(bot: BaseBot, travel_info: dict[str, Any]) -> None:
|
||||
def fill_travel_info(bot: BaseBot, basic_info: dict[str, Any]) -> None:
|
||||
"""填写差旅报销基本信息"""
|
||||
try:
|
||||
bot.open_tab("basic")
|
||||
bot.page.fill("#CAUSE", basic_info.get("travel_purpose", ""))
|
||||
bot.page.fill("#SITE", basic_info.get("travel_location", ""))
|
||||
|
||||
@@ -104,6 +105,8 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
||||
}
|
||||
|
||||
try:
|
||||
if not bot.open_tab("details"):
|
||||
raise RuntimeError("切不到差旅明细标签页")
|
||||
traffic_info = details.get("transport_fee") or []
|
||||
for item in traffic_info:
|
||||
bot.page.click("#insertDetail", timeout=5000)
|
||||
@@ -190,8 +193,10 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
|
||||
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)
|
||||
if not bot.open_tab("payment"):
|
||||
bot.click_if_visible('text="下一步(支付方式)"')
|
||||
if not bot.tab_ready("payment"):
|
||||
raise RuntimeError("切不到支付方式标签页")
|
||||
|
||||
for info in payment_info:
|
||||
bot.page.click("#insertPay", timeout=5000)
|
||||
@@ -222,8 +227,10 @@ def fill_travel_payment(bot: BaseBot, payment_info: list[dict[str, Any]]) -> Non
|
||||
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)
|
||||
if not bot.open_tab("subsidy"):
|
||||
bot.click_if_visible("#next3")
|
||||
if not bot.tab_ready("subsidy"):
|
||||
raise RuntimeError("切不到补助清单标签页")
|
||||
|
||||
for info in subsidy_info:
|
||||
bot.page.click("#insertSubsidy", timeout=5000)
|
||||
@@ -281,24 +288,24 @@ def fill_travel_subsidy(bot: BaseBot, subsidy_info: list[dict[str, Any]]) -> Non
|
||||
def upload_travel_attachments(bot: BaseBot, attachment_info: list[dict[str, Any]]) -> None:
|
||||
"""上传差旅附件"""
|
||||
try:
|
||||
bot.page.click("#next4", timeout=5000)
|
||||
if not bot.open_tab("attachments"):
|
||||
bot.click_if_visible("#next4")
|
||||
if not bot.tab_ready("attachments"):
|
||||
raise RuntimeError("切不到附件清单标签页")
|
||||
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("上传差旅附件完成")
|
||||
bot.upload_attachment(
|
||||
str(info["filename"]),
|
||||
str(info.get("attachment_type", "invoice")),
|
||||
str(info.get("attachment_desc", "")),
|
||||
)
|
||||
landed = bot.attachment_rows()
|
||||
if landed < len(attachment_info):
|
||||
log.warning(
|
||||
"附件清单只数到 %s 行,预期 %s 个(提交都确认过,可能被页面折叠)", landed, len(attachment_info)
|
||||
)
|
||||
log.info("上传差旅附件完成(逐个确认提交,清单 %s 行)", landed)
|
||||
except Exception as e:
|
||||
log.error(f"差旅附件上传失败: {e}")
|
||||
bot._screenshot("travel_attachment_error")
|
||||
|
||||
+38
-10
@@ -224,12 +224,20 @@ def browser_fill(case_id: str, step: str) -> dict[str, Any]:
|
||||
done = list(ctx.get("steps", []))
|
||||
expected = STEP_ORDER[kind]
|
||||
missing = [s for s in expected[: expected.index(step)] if s not in done]
|
||||
notes: list[str] = []
|
||||
if missing:
|
||||
return fail(
|
||||
f"缺少前置步骤: {'、'.join(missing)}",
|
||||
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
|
||||
done=done,
|
||||
)
|
||||
if ctx.get("resumed"):
|
||||
# 续填时前置步骤是财务系统里已填好的,进程内存里没有记录。
|
||||
# 这时拦下来只会挡住合法操作,所以降级为警告:真缺东西页面会自己报错。
|
||||
notes.append(
|
||||
f"本次是续填,内存里没有 {'、'.join(missing)} 的完成记录;已按页面现状继续,",
|
||||
)
|
||||
else:
|
||||
return fail(
|
||||
f"缺少前置步骤: {'、'.join(missing)}",
|
||||
next_action=f"mcp__arb__browser_fill(step='{missing[0]}')",
|
||||
done=done,
|
||||
)
|
||||
info = _load_info(d, kind)
|
||||
if info is None:
|
||||
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
|
||||
@@ -262,6 +270,20 @@ def browser_fill(case_id: str, step: str) -> dict[str, Any]:
|
||||
actor.touch(d, str(result.get("url", "")))
|
||||
done_after = list(ctx.get("steps", []))
|
||||
remaining = [s for s in expected if s not in done_after]
|
||||
if notes:
|
||||
ok_payload = ok(
|
||||
step=step,
|
||||
label=STEP_LABEL[step],
|
||||
filled=result.get("filled"),
|
||||
screenshot=result.get("screenshot", ""),
|
||||
done=done_after,
|
||||
remaining=remaining,
|
||||
warnings=notes,
|
||||
next_action=(
|
||||
f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"
|
||||
),
|
||||
)
|
||||
return ok_payload
|
||||
return ok(
|
||||
step=step,
|
||||
label=STEP_LABEL[step],
|
||||
@@ -269,7 +291,11 @@ def browser_fill(case_id: str, step: str) -> dict[str, Any]:
|
||||
screenshot=result.get("screenshot", ""),
|
||||
done=done_after,
|
||||
remaining=remaining,
|
||||
next_action=(f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state"),
|
||||
next_action=(
|
||||
"mcp__arb__browser_page_state 复核本步"
|
||||
if ctx.get("resumed")
|
||||
else (f"mcp__arb__browser_fill(step='{remaining[0]}')" if remaining else "mcp__arb__browser_page_state")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -429,9 +455,10 @@ def browser_open_form(
|
||||
) -> dict[str, Any]:
|
||||
"""从列表页找回已存在的单据接着填(中途断了不必重来)。
|
||||
|
||||
match 用报销说明里的关键词,必须在当前列表页唯一命中;命中多张直接失败,
|
||||
让宿主把关键词收紧——财务系统里点错别人的单据是不能接受的后果。preview_only=true
|
||||
只搜索定位、不点开单据,用来先确认是哪一张。
|
||||
match 必须在当前列表页唯一命中;命中多张直接失败,让宿主把关键词收紧——财务系统里
|
||||
点错别人的单据是不能接受的后果。注意命中判据是**列表页可见文字**,长报销说明会被
|
||||
CSS 截断(`元器件采购(P3 全 ...`),所以最稳的 match 是**单据编号**(不会被截断)。
|
||||
preview_only=true 只搜索定位、不点开单据,用来先确认是哪一张。
|
||||
"""
|
||||
d = _case_dir(case_id)
|
||||
ctx = _require_open(case_id)
|
||||
@@ -473,7 +500,8 @@ def browser_open_form(
|
||||
if not result.get("found"):
|
||||
return fail(
|
||||
f"列表页没有匹配「{match}」的单据(搜索后 {result.get('rows_after_search')} 行)",
|
||||
next_action="收紧或更换 match;单据区间下拉默认只看近三个月,更早的单据要先把区间改掉",
|
||||
next_action="改用单据编号做 match(长说明在列表里被截断,关键词常命中不了);"
|
||||
"单据区间默认只看近三个月,更早的要先改区间",
|
||||
sample=result.get("sample", []),
|
||||
)
|
||||
if result.get("ambiguous"):
|
||||
|
||||
Reference in New Issue
Block a user