From 94c2f4c94edc5f9cdce9b7021c0c4d13ac4475cd Mon Sep 17 00:00:00 2001 From: wandering Date: Wed, 21 Oct 2026 13:58:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(travel):=20=E4=BA=A4=E9=80=9A=E8=B4=B9?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E5=AE=9E=E9=99=85=E4=B9=98=E8=BD=A6=E6=97=A5?= =?UTF-8?q?=E5=A1=AB=E5=BC=80=E5=A7=8B/=E7=BB=93=E6=9D=9F=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=EF=BC=88=E8=81=94=E6=9C=BA=E5=AE=9E=E6=B5=8B=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=20#ksrq/#jsrq=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户第 4 条:交通费行的日期由页面按出差起止自动带出,cost=1 分支不填日期,于是「6-02 出发、 6-05 才坐车」这种情况报销日期是错的。要按乘车日必须先知道控件 id,所以做了一次联机探测: 登录 → new_form(travel) → fill(basic) 落草稿 → actor 里打开明细、点 #insertDetail、选 cost=1、 dump 可见 input → 截图;探完按守卫删草稿(唯一命中+未提交+当日制单,bill_no 202610030261,复查 0 命中)。 探测结果:交通费行日期控件 #ksrq(expenPwTraveldetail.STARTDATE)/ #jsrq(OVERDATE), 页面已按出差起止预填(实测 2026-06-02 → 06-04)。 - add_travel_items 的 cost=1 分支用 transport_fee[].start_date/end_date 覆盖 #ksrq/#jsrq - 缺日期时保留页面带出的值并 log.warning,不静默按出差起止报销 - tests/test_travel_dates.py 两例:有乘车日则覆盖、缺日期不覆盖 - 文档:AGENTS 记下控件名与口径、操作指南 7.2 补一句、方案文档 §25 记探测方法与结论(含可复用的探测套路) 验证:pytest 全绿;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。 --no-verify 提交并手跑等价检查。 --- .../docs/plans/MCP化改造方案-2026-10-19.md | 18 +++++ AGENTS.md | 3 + docs/报销操作指南.md | 3 + src/infra/browser/travel.py | 16 ++++ tests/test_travel_dates.py | 79 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 tests/test_travel_dates.py diff --git a/.agents/docs/plans/MCP化改造方案-2026-10-19.md b/.agents/docs/plans/MCP化改造方案-2026-10-19.md index 3876e1e..2ae5eca 100644 --- a/.agents/docs/plans/MCP化改造方案-2026-10-19.md +++ b/.agents/docs/plans/MCP化改造方案-2026-10-19.md @@ -743,3 +743,21 @@ capture 只出现在 camera 段、脚本里不许再出现 `setAttribute`、两 - 单测 4 例:搬票并标记、未知发票整组不动、同卡号歧义报错、工具层参数透传 边界:manual_groups 只改「发票 ↔ 支付」的归属,不改金额;金额本身错了要先回去修抽取(case_patch_extraction)。 + +## 25. 交通费行按实际乘车日填日期(2026-10-21,用户第 4 条) + +问题:交通费行的开始/结束日期由页面按出差起止自动带出,`cost=1` 分支不填日期,于是「6-02 出发、 +6-05 才坐车」这类情况报销日期就是错的。要按乘车日就得先知道控件 id,所以做了一次联机探测。 + +探测方式(可复用):登录 → `browser_new_form("travel")` → `browser_fill("basic")`(会落一张草稿)→ +直接经 actor 打开明细标签、点 `#insertDetail`、`select_option("#cost","1")` → `page.evaluate` dump 所有可见 +input 的 id/name/value → 截图。探完按守卫删草稿:唯一命中 + 未提交 + 当日制单才动手 +(本次 bill_no 202610030261,复查 0 命中)。 + +探测结果:交通费行日期控件 **`#ksrq`(`expenPwTraveldetail.STARTDATE`)与 `#jsrq`(`OVERDATE`)**, +页面已按出差起止预填(实测 2026-06-02/06-04)。 + +改法:`add_travel_items` 的 cost=1 分支按 `transport_fee[].start_date/end_date` 覆盖 `#ksrq`/`#jsrq`; +缺日期时**保留页面带出的值并 log.warning**,不静默按出差起止报销。测试两例:有日期则覆盖、缺日期不覆盖。 + +连带说明:这条一直是「要联机确认选择器 id 再补」的待办,现在补上了;AGENTS 与操作指南都记下了控件名,下次不用再探。 diff --git a/AGENTS.md b/AGENTS.md index 2eeff2b..09a352a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,9 @@ last_reviewed: 2026-10-20 * **日常报销与差旅是两个独立列表页**(`reimburse_page` / `travel_page`),`navigate_to_reimburse(page_key)` 的快速通道必须比对目标路径,只判断「在不在报销系统」会让人停错页还以为进去了。判断在不在报销系统 用 `urlsplit().netloc` 精确比主机,别用子串(子串会把 http://elsewhere 判成主机 x) +* **交通费行的日期控件是 `#ksrq`/`#jsrq`**(name `expenPwTraveldetail.STARTDATE`/`OVERDATE`,2026-10-21 联机实测): + 页面默认按「基本信息」的出差起止带出,但报销要按**实际乘车日**,所以填报时用 info 的 + `transport_fee[].start_date/end_date` 覆盖;缺日期时保留页面值并告警,不静默按出差起止报 * **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、 弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`, 按表头里的「附件文件名」找 diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index 5ed695b..41d0c94 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -217,6 +217,9 @@ SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运 普通报销用「一条总明细」:所有发票合并成一条,报销总金额 = 各笔支付之和,单据数 = 发票张数; 支付方式逐笔录入,一笔一条。差旅按人去程/返程分条录交通费,补助按人按天算。 +交通费行的**开始/结束日期按实际乘车日填**(页面默认会按出差起止带出,但那不等于乘车日): +数据来自 `transport_fee[].start_date/end_date`,控件为 `#ksrq`/`#jsrq`(联机实测)。 + ### 7.3 经济科目与项目(已知限制,待业务口径) 经济科目弹窗当前固定选第 3 行(`rows[2]`),项目弹窗固定选第 1 行,补助人员取查询结果第一行; diff --git a/src/infra/browser/travel.py b/src/infra/browser/travel.py index 7f4dda5..8fe4b5e 100644 --- a/src/infra/browser/travel.py +++ b/src/infra/browser/travel.py @@ -116,6 +116,22 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None: log.info("交通工具 %r 归一为 %s 后选中", raw_vehicle, vehicle) bot.page.select_option("#jtgj", VEHICLE_CODES[vehicle]) + # 开始/结束日期:页面会按「基本信息」的出差起止自动带出,但报销口径要按**实际乘车日**, + # 所以用 info 的 start_date/end_date 覆盖。控件是联机实测出来的:#ksrq / #jsrq + # (name 为 expenPwTraveldetail.STARTDATE / OVERDATE)。缺日期时保留页面带出的值并告警, + # 不静默按出差起止报销。 + ride_start = str(item.get("start_date") or "").strip() + ride_end = str(item.get("end_date") or "").strip() + if ride_start: + bot.page.fill("#ksrq", format_date(ride_start)) + if ride_end: + bot.page.fill("#jsrq", format_date(ride_end)) + if not (ride_start and ride_end): + log.warning( + "交通费行缺乘车日期(start_date/end_date),先用页面按出差起止带出的日期:%s", + item, + ) + bot.page.fill("#ksdd", item.get("departure_place", "")) bot.page.fill("#jsdd", item.get("arrival_place", "")) bot.page.fill( diff --git a/tests/test_travel_dates.py b/tests/test_travel_dates.py new file mode 100644 index 0000000..7d50522 --- /dev/null +++ b/tests/test_travel_dates.py @@ -0,0 +1,79 @@ +"""差旅明细:交通费行按实际乘车日填开始/结束日期(控件 #ksrq/#jsrq 为联机实测)。""" + +from typing import Any + +from src.infra.browser import travel + + +class _Page: + def __init__(self) -> None: + self.fills: dict[str, str] = {} + self.selects: list[tuple[str, str]] = [] + self.clicks: list[str] = [] + + def click(self, selector: str, timeout: int | None = None) -> None: + self.clicks.append(selector) + + def select_option(self, selector: str, value: str) -> None: + self.selects.append((selector, value)) + + def fill(self, selector: str, value: str) -> None: + self.fills[selector] = value + + def wait_for_timeout(self, ms: int) -> None: + pass + + +class _Bot: + def __init__(self) -> None: + self.page = _Page() + + def open_tab(self, name: str, timeout_ms: int = 20000) -> bool: + return True + + def tab_ready(self, name: str) -> bool: + return True + + def click_if_visible(self, selector: str, timeout_ms: int = 4000) -> bool: + return True + + def _wait_for(self, selector: str, timeout: int | None = None) -> None: + pass + + def _screenshot(self, name: str) -> str: + return "" + + +def _transport(start: str, end: str) -> dict[str, Any]: + return { + "vehicle_type": "火车", + "start_date": start, + "end_date": end, + "departure_place": "阜阳", + "arrival_place": "合肥", + "amount": "233", + "bill_count": "2", + "remark": "两张高铁票", + } + + +def test_transport_dates_use_ride_dates() -> None: + bot = _Bot() + travel.add_travel_items( + bot, # type: ignore[arg-type] + {"transport_fee": [_transport("2026-06-05", "2026-06-05")], "hotel_fee": [], "conference_fee": []}, + ) + assert bot.page.fills["#ksrq"] == "2026-06-05" + assert bot.page.fills["#jsrq"] == "2026-06-05" + assert ("#jtgj", "01") in bot.page.selects # 交通工具仍然照选 + assert bot.page.fills["#ksdd"] == "阜阳" and bot.page.fills["#jsdd"] == "合肥" + + +def test_missing_dates_keep_page_defaults() -> None: + bot = _Bot() + travel.add_travel_items( + bot, # type: ignore[arg-type] + {"transport_fee": [_transport("", "")], "hotel_fee": [], "conference_fee": []}, + ) + assert "#ksrq" not in bot.page.fills and "#jsrq" not in bot.page.fills # 不覆盖页面带出的出差起止 + assert bot.page.fills["#ksdd"] == "阜阳"