6aec1bc837
两条真实草稿走完全流程并逐张截图核对:普通报销 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 提交并手跑等价检查。
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
"""普通报销填报流程
|
|
|
|
负责普通发票报销的完整填报步骤:
|
|
基本信息 → 总明细 → 支付方式 → 附件上传
|
|
"""
|
|
|
|
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.open_tab("basic")
|
|
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:
|
|
if not bot.open_tab("details"):
|
|
raise RuntimeError("切不到报销明细标签页")
|
|
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:
|
|
# 不靠「下一步」:续填时页面停在基本信息,#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)
|
|
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:
|
|
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:
|
|
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")
|
|
raise
|
|
|
|
bot._screenshot("normal_attachment_done")
|