feat(travel): 交通费行按实际乘车日填开始/结束日期(联机实测控件 #ksrq/#jsrq)

用户第 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 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 13:58:27 +08:00
parent 5903dae864
commit 94c2f4c94e
5 changed files with 119 additions and 0 deletions
@@ -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 与操作指南都记下了控件名,下次不用再探。
+3
View File
@@ -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`,
按表头里的「附件文件名」找
+3
View File
@@ -217,6 +217,9 @@ SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运
普通报销用「一条总明细」:所有发票合并成一条,报销总金额 = 各笔支付之和,单据数 = 发票张数;
支付方式逐笔录入,一笔一条。差旅按人去程/返程分条录交通费,补助按人按天算。
交通费行的**开始/结束日期按实际乘车日填**(页面默认会按出差起止带出,但那不等于乘车日):
数据来自 `transport_fee[].start_date/end_date`,控件为 `#ksrq`/`#jsrq`(联机实测)。
### 7.3 经济科目与项目(已知限制,待业务口径)
经济科目弹窗当前固定选第 3 行(`rows[2]`),项目弹窗固定选第 1 行,补助人员取查询结果第一行;
+16
View File
@@ -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(
+79
View File
@@ -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"] == "阜阳"