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:
wandering
2026-10-20 16:32:56 +08:00
parent 64bbe53e71
commit 6aec1bc837
9 changed files with 590 additions and 76 deletions
@@ -522,3 +522,55 @@ P3 联机时顺带发现:文档写着「地址来自 `scripts/data/config.json
测试:`tests/test_config.py` 从 3 例扩到 9 例(文件覆盖默认、env 覆盖文件、占位值忽略、
strip、凭据不读 env、safe_config 不含密码、坏文件与缺文件不崩、返回副本、示例文件不许再出现
`your-` 假值)。全部指向 tmp 的假配置,不碰真凭据。`pytest 141 passed`。
## 15. 全流程联机验证(2026-10-20 下午)
用户要求把 P3 剩下的验完。两条真实草稿走完全流程后逐张看截图核对,数据确实进表:
- **普通报销(4 步)**:明细 `3010109 其他 / 单据数 3 / 464`,合计 ¥464.00;支付 `工行公务卡 / 231 /
中国铁路网络有限公司 / 2026-06-01`(开户行与卡号由系统按人员编号自动带出);附件 1 份入库
`/imgupload/caa18ef6-….pdf`。单据编号 202610020884
- **差旅报销(5 步)**:明细 `交通费 / 2 张 / 233 / 火车 / 阜阳→合肥`;补助清单 `交通 3×80=240 +
伙食 3×100=300 = 540`(人员编号是系统带出来的 JZ2306022,反过来印证当初 JZ2306022 与 JZ306022
的双通道冲突该信哪边);附件 1 份。单据编号 202610030245,总额 773.00
- 两条草稿验完都删掉:日常那条首轮删;差旅那条被下面的 page_key bug 挡住,修完再删,复查
`hits_after: 0`。删除全程用「唯一命中 + 未提交 + 当日制单 + 编号匹配」守卫
### 15.1 这一轮抓到的 5 个真问题
1. **附件步骤是静默假成功**:弹窗里「附件说明」是 required,info 没给 `attachment_desc` 时填了空串,
点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」并回
`ok: true`。修法:缺说明兜底成「发票:文件名」,提交后确认弹窗已关,并把 `validationMessage`
带进异常(`base.upload_attachment` / `confirm_attachment`)
2. **步骤函数依赖上一步的页面状态**:续填打开单据时停在基本信息页,`#next3` 存在但不可见,点它
超时。所有步骤改成显式 `open_tab(...)`;basic 的标记给两个候选(日常 `#EXPENEXPLAIN` /
差旅 `#CAUSE`),否则差旅开局白等 20 秒
3. **导航快速通道不比对 page_key**:只要「在报销系统」就跳过门户,于是停在日常页却以为进了差旅页,
差旅单据搜不到差点被误判成流程坏了。抽成 `on_target_page(page_key)` 比对目标路径
4. **主机用子串比对**:`host in url` 会把 `http://elsewhere` 判成主机 `x`;改成 `urlsplit().netloc`
精确比,收紧后立刻联机回归,差旅页/日常页判定各自正确
5. **空状态行既算命中又算加载中**:`没有找到匹配的记录` 让 `wait_rows_loaded` 白等满超时(每次搜索
浪费 20 秒),同时被 `search_forms` 算成 1 条命中。两处都加了显式判定
### 15.2 我自己的两次误判
- 用 `indexOf(三换行)` 当切片边界:类内方法之间只有一个空行,边界找不到就把 `_wait_for` /
`_screenshot` 连着切掉两次。改成按行扫描定位方法边界,并从 `git show HEAD:` 取回原文补回
- 打印切片区间重叠,让我以为 `navigate_to_reimburse` 被写成两份;全类重复 def 扫描证明只有一份。
教训:**怀疑之前先取证据**,别急着动手改
### 15.3 测试与门禁
- 新增 6 例覆盖上述修复:basic 多候选标记、两个列表页互不认错、弹窗不关必须报错并带浏览器提示、
附件说明兜底、路径越界与文件缺失拒绝、附件清单按「附件文件名」定位表。FakePage 加 `click` /
`validations` / `tables`,让 base.py 的真实逻辑不依赖 Playwright 也能测。`pytest 148 passed / 1.0s`
- ruff / format / mypy / deptry 全绿,`mcp_smoke` 25 工具
- 联机时间线:普通 15:43–15:45、附件复验 15:53–15:55、差旅 15:59–16:00、清理 16:03–16:08、回归 16:14–16:16
### 15.4 剩余
1. P4 提交护栏(`submit_preview` / `submit_execute`)——填报链路已全通,只差提交
2. 经济科目固定取第 3 行(这次落到「其他 3010109」)、项目固定取第 1 行,无配置入口。
真实报销要按事由选科目,这是**业务正确性**问题,不是稳定性问题,建议下一个做
3. 补助清单靠「教师工号弹窗 + 查询 + 选第一行」,同名会选错,应改为按编号唯一匹配
+4
View File
@@ -33,6 +33,10 @@ description: >-
`browser_open_form(match=报销说明关键词)` 找回原单据**续填**。先 `preview_only=true` 核对返回的
`bill_no`,确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
在财务系统里留下垃圾草稿
11. 每一步都会自己切到对应标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
差旅与日常是两个独立列表页,`browser_open_form` 要在对应的 `kind` 下调用
12. 附件说明没填时工具会自动兜底成「发票:文件名」(财务系统里这栏是必填,留空点确定会被静默挡住);
要写清楚就把 `attachment_desc` 填进 attachments 项
## 必须遵守
+12 -1
View File
@@ -108,7 +108,18 @@ last_reviewed: 2026-10-19
`#insert` 新增 / `#update` 修改 / `#delete` 删除都作用于选中行;行内那个「查看」按钮是 `gzlc()`
**工作流程图,不是打开单据**。关键字搜索用右上角可见的 `#seacher`;`#expenexplain` 那套高级查询
在收起的面板里(元素存在但不可见,`fill` 会卡 30 秒)。表格是 AJAX 后加载的,用 `wait_rows_loaded()`
等,别拍脑袋 `wait_for_timeout`。直达报销 URL 不建立会话,会被踢回「长时间未操作,请重新登录!」
等,别拍脑袋 `wait_for_timeout`;空状态行(没有找到匹配的记录)不算命中,也别当成还在加载
直达报销 URL 不建立会话,会被踢回「长时间未操作,请重新登录!」
* **单据页是多标签共用一页**:基本信息 / 报销明细 / 支付方式 /(补助清单)/ 附件清单。不在当前标签上的
控件 `query_selector` 拿得到但**不可见**,点它必超时——所以每一步都显式 `open_tab(...)`,不要依赖
「上一步把页面留在了哪儿」(旧代码靠 `#next3`/`#next4` 串下来,续填时一律失灵)。basic 的候选标记
要给两个:日常单是 `#EXPENEXPLAIN`,差旅单是 `#CAUSE`
* **日常报销与差旅是两个独立列表页**(`reimburse_page` / `travel_page`),`navigate_to_reimburse(page_key)`
的快速通道必须比对目标路径,只判断「在不在报销系统」会让人停错页还以为进去了。判断在不在报销系统
用 `urlsplit().netloc` 精确比主机,别用子串(子串会把 http://elsewhere 判成主机 x)
* **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、
弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`,
按表头里的「附件文件名」找
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例
* **配置优先级**:环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认;含 `your-` 的占位值
+22 -11
View File
@@ -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),不要贴进对外文档或提交
+250 -14
View File
@@ -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):
if data and not self._rows_loading():
for tr in data:
if self.row_is_ready(tr):
return rows
self.page.wait_for_timeout(500)
if rows:
log.warning("等表格加载超时(%sms),按当前 %s 行继续", timeout_ms, max(0, len(rows) - 1))
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
View File
@@ -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
View File
@@ -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")
+33 -5
View File
@@ -224,7 +224,15 @@ 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:
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]}')",
@@ -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"):
+162 -2
View File
@@ -28,6 +28,9 @@ class FakePage:
self.rows: list[Any] = []
self.loading_text = ""
self.update_clicks = 0
self.tables: list[Any] = []
self.validations: dict[str, str] = {}
self.reject_next = False
def query_selector(self, selector: str) -> FakeCell | None:
if selector == "#seacher":
@@ -35,11 +38,28 @@ class FakePage:
if selector == "#update":
return FakeCell("", page=self)
# 已知选择器要返回带 input_value 之类的元素,不能返回裸 object()
return FakeCell("已填内容") if selector in self._existing else None
if selector in self._existing:
return FakeCell(
"已填内容",
page=self,
selector=selector,
validation=self.validations.get(selector, ""),
)
return None
def click(self, selector: str, timeout: int | None = None) -> None:
"""页面级点击:#cjtj 会把弹窗关掉(除非 reject_next=True 模拟必填被挡)。"""
self.calls.append(("click", selector))
if selector == "#cjtj" and not self.reject_next:
self._existing.discard("#fjlx")
if selector == "#insertAcc":
self._existing.add("#fjlx")
def query_selector_all(self, selector: str) -> list[Any]:
if selector == "#table tr":
return list(self.rows)
if selector == "table":
return list(self.tables)
return []
def inner_text(self, selector: str = "", timeout: int | None = None) -> str:
@@ -84,10 +104,19 @@ class FakeRow:
class FakeCell:
def __init__(self, text: str, radio_of: Any = None, page: Any = None) -> None:
def __init__(
self,
text: str,
radio_of: Any = None,
page: Any = None,
selector: str = "",
validation: str = "",
) -> None:
self._text = text
self._radio_of = radio_of
self._page = page
self._selector = selector
self._validation = validation
self.clicks = 0
def inner_text(self) -> str:
@@ -96,12 +125,22 @@ class FakeCell:
def input_value(self) -> str:
return self._text
def is_visible(self) -> bool:
return True
def evaluate(self, _script: str) -> str:
return self._validation
def get_attribute(self, name: str) -> str | None:
return None
def click(self) -> None:
self.clicks += 1
if self._radio_of is not None:
self._radio_of.radio_clicks += 1
if self._page is not None:
self._page.update_clicks += 1
self._page.notify_click(self._selector)
def __getattr__(self, name: str) -> Any:
def _noop(*args: Any, **kwargs: Any) -> None:
@@ -110,6 +149,20 @@ class FakeCell:
return _noop
class FakeTable:
"""页面的一张表:inner_text 是整表文本,rows 是若干 FakeRow。"""
def __init__(self, header: str, rows: list[str] | None = None) -> None:
self._header = header
self._rows = [FakeRow(r, has_radio=False) for r in (rows or [])]
def inner_text(self) -> str:
return self._header
def query_selector_all(self, selector: str) -> list[FakeRow]:
return list(self._rows)
class FakeBot:
"""只实现工具真正依赖的接口,行为由构造参数决定。"""
@@ -123,6 +176,9 @@ class FakeBot:
"default_card_no": "6282880000000000",
"default_person_id": "200000001",
"sso_login_url": "http://sso.test/login",
"reimburse_url": "http://x",
"reimburse_page": "/expen/common/common?v=4.0",
"travel_page": "/expen/travel/travel?v=4.0",
}
self.page = FakePage({'text="微信扫码登录"'} if human else set())
self._portal = portal
@@ -176,6 +232,19 @@ class FakeBot:
# 列表页行逻辑直接用 BaseBot 的真实实现来测(只依赖 page 对象,不需要 Playwright)
from src.infra.browser.base import BaseBot # noqa: E402
FakeBot.TAB_MARKERS = BaseBot.TAB_MARKERS
FakeBot.TAB_LABELS = BaseBot.TAB_LABELS
FakeBot.tab_ready = BaseBot.tab_ready # type: ignore[method-assign]
FakeBot.open_tab = BaseBot.open_tab # type: ignore[method-assign]
FakeBot.click_if_visible = BaseBot.click_if_visible # type: ignore[method-assign]
FakeBot.reimburse_host = BaseBot.reimburse_host # type: ignore[method-assign]
FakeBot._on_reimburse = BaseBot._on_reimburse # type: ignore[method-assign]
FakeBot.on_target_page = BaseBot.on_target_page # type: ignore[method-assign]
FakeBot.attachment_dialog_open = BaseBot.attachment_dialog_open # type: ignore[method-assign]
FakeBot._validation_hint = BaseBot._validation_hint # type: ignore[method-assign]
FakeBot.confirm_attachment = BaseBot.confirm_attachment # type: ignore[method-assign]
FakeBot.upload_attachment = BaseBot.upload_attachment # type: ignore[method-assign]
FakeBot.attachment_rows = BaseBot.attachment_rows # type: ignore[method-assign]
FakeBot.row_is_ready = BaseBot.row_is_ready # type: ignore[method-assign]
FakeBot._wait_any = BaseBot._wait_any # type: ignore[method-assign]
FakeBot.row_cells = BaseBot.row_cells # type: ignore[method-assign]
@@ -459,6 +528,7 @@ def test_open_form_no_match_mentions_date_range(env: dict[str, Any]) -> None:
bot.page.rows = _rows_with(["查看 | 已提交 | 202609020003 | 别的单据 | 3.00"])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "单据编号" in result["next_action"]
assert "近三个月" in result["next_action"]
assert bot.page.update_clicks == 0
@@ -474,8 +544,98 @@ def test_open_form_preview_clicks_nothing(env: dict[str, Any]) -> None:
assert all(row.radio_clicks == 0 for row in bot.page.rows)
def test_resumed_fill_allows_missing_prerequisites_with_warning(env: dict[str, Any]) -> None:
"""续填时前置步骤在财务系统里已填,内存没记录也必须能继续填,但要留警告。"""
_write_info(
env,
"normal",
{
"reimbursement_details": {"total_invoices": 2, "total_amount": 10.0},
"payment_methods": [],
"attachments": [],
},
)
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
browser.browser_open_form(env["id"], "元器件采购")
result = browser.browser_fill(env["id"], "details")
assert result["ok"] is True, result
assert result["warnings"], "续填跳步要给警告"
assert result["filled"] == {"单据数": 2, "总金额": 10.0}
assert result["done"] == ["details"]
def test_open_form_requires_keyword(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open_form(env["id"], " ")
assert result["ok"] is False
assert "match" in result["reason"]
def test_tab_ready_accepts_travel_basic_marker(env: dict[str, Any]) -> None:
"""差旅单的基本信息页没有 #EXPENEXPLAIN,只有 #CAUSE;多候选标记才不白等。"""
bot = _open(env, portal=False, human=False)
bot.page._existing = {"#CAUSE"}
assert bot.tab_ready("basic") is True
bot.page._existing = set()
assert bot.tab_ready("basic") is False
def test_on_target_page_distinguishes_two_lists(env: dict[str, Any]) -> None:
"""日常报销与差旅是两个独立列表页,停在日常页不能算到了差旅页。"""
bot = _open(env, portal=False, human=False)
bot.page.url = "http://x/expen/common/common?v=4.0"
bot.page._existing = {"#table"}
assert bot.on_target_page("reimburse_page") is True
assert bot.on_target_page("travel_page") is False
bot.page.url = "http://elsewhere/expen/travel/travel?v=4.0"
assert bot.on_target_page("travel_page") is False # 不在报销系统主机上
def test_confirm_attachment_raises_with_browser_hint(env: dict[str, Any]) -> None:
"""必填没填时浏览器只弹校验气泡、不发请求;必须报错而不是假装成功。"""
bot = _open(env, portal=True)
bot.page._existing = {"#fjlx", "#fpsmxx"}
bot.page.validations = {"#fpsmxx": "请填写此字段。"}
bot.page.reject_next = True
with pytest.raises(RuntimeError) as exc:
bot.confirm_attachment(timeout_ms=600)
assert "弹窗没有关闭" in str(exc.value)
assert "请填写此字段" in str(exc.value)
def test_upload_attachment_defaults_description(env: dict[str, Any]) -> None:
"""附件说明是必填:info 里没给时要兜底成「发票:文件名」,否则提交会被挡。"""
bot = _open(env, portal=True)
target = env["case"] / "发票1.pdf"
target.write_bytes(b"%PDF")
bot.page._existing = {"#insertAcc", "#fjlx", "#file", "#submit2"}
bot.upload_attachment("发票1.pdf", "invoice")
fills = [c for c in bot.page.calls if c[0] == "fill" and c[1][0] == "#fpsmxx"]
assert fills and fills[0][1][1] == "发票:发票1"
assert bot.attachment_dialog_open() is False
def test_upload_attachment_rejects_bad_paths(env: dict[str, Any]) -> None:
bot = _open(env, portal=True)
with pytest.raises(FileNotFoundError):
bot.upload_attachment("不存在的.pdf")
outside = Path(env["case"]).parent / "别处.pdf"
outside.write_bytes(b"%PDF")
with pytest.raises(RuntimeError, match="会话目录"):
bot.upload_attachment(str(outside))
def test_attachment_rows_finds_the_attachment_table(env: dict[str, Any]) -> None:
"""附件清单不是 #table:早先按 #table 数,附件传成功了却报 0 行。"""
bot = _open(env, portal=True)
bot.page.tables = [
FakeTable("序号 状态 报销说明", ["1 未提交 别的"]),
FakeTable(
"序号 类型 附件说明 附件备注 附件文件名",
["1 1 发票:a 发票 /imgupload/a.pdf", "2 2 其他 b /imgupload/b.pdf"],
),
]
assert bot.attachment_rows() == 2
bot.page.tables = [FakeTable("序号 类型 附件说明 附件文件名")]
assert bot.attachment_rows() == 0