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"] == "阜阳"