Files
Auto-Finance/tests/test_vehicles_and_scope.py
T
wandering 0d9cc7b4ca feat(core): 报销口径与文本宽度两道闸——会议费并入差旅单,超宽文本不再静默 500
这一批是 2026-10-21 正式填报踩出来的两类问题,一起收进来。

**一、报销口径只在一处判定(新增 src/core/expense_kind.py)**

用户定案:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**(明细的会务费培训费栏、
会议通知当附件),不拆成两张单、也不因此放弃差旅补助;只有货物类(易耗品/材料/配件)才走
普通报销 + 申购单。以前 case_status、validate_info、browser_new_form 三处都写"混合发票只能走
普通报销",等于把这条合法路径说成错的,Agent 照做就会白拆一张单还漏补助。现在 is_conference_like /
goods_invoices 是唯一判定,工具提示与 case_status 的 warnings 都由它驱动。

**二、文本宽度闸:自由文本超宽 = 无提示 500(新增宽度口径 + 两道预检)**

联机实测:差旅明细备注写到 101 字,POST 回 500 且页面无任何提示、弹窗停在原地,现场看着像"脚本没点着
确定";同一张单 97 字(宽度 130)却存进去了,上限夹在两者之间且后端不肯说。于是取汉字/全角记 2、
字母数字记 1 的"宽度",validator.TEXT_WIDTH_LIMIT=100(≈50 汉字,同时满足"100 字符"与"131 字节"
两种可能口径),普通报销说明另用 DESCRIPTION_WIDTH_LIMIT=40。拦截点两处:规则里的 max_len_bytes
(validate_extracted_info 的 missing_fields 会写明"过长:宽度 N > 上限 M")与 browser_fill 在 POST 前的
_text_limit_problems(失败返回 too_long 列出超长字段)。

**三、交通工具代码表挪进 core(新增 src/core/vehicles.py)**

财务系统 #jtgj 的 value 表与中文别名(train→火车 等)从填报层挪到 core,一处三用:填报取 value、
体检判合法、报错给候选。老写法 if vehicle in vehicle_map 遇英文枚举是**静默不选**且仍返回成功,
直到提交被必填挡回来——现在认不出的值直接报错。

其余:cases.goods_general_items 给 case_status 补"有 N 张货物类普通发票"的 warning;
travel.py 改用 vehicles 并显式报错;config/validation_rules.json 加 max_len_bytes;
INSTRUCTIONS/SKILL/判读参考/操作指南同步口径与宽度提示。新增 tests/test_text_limits.py 与
tests/test_vehicles_and_scope.py。

验证:ruff / format / mypy src / deptry / pytest / mcp-smoke / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
2026-10-21 13:49:12 +08:00

214 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁
两条都是 2026-10-21 实单踩出来的:
1. `vehicle_type` 填英文枚举(参考文档历来写 `train`)时,老写法 `if vehicle in vehicle_map:`
会**静默不选**下拉框,本步还返回成功——人提交时才被必填拦下,看着像"脚本没点着按钮"。
现在别名归一(train→火车),认不出的值直接报错。
2. `case_status`/`validate_info`/`browser_new_form` 三处以前一律说"混合发票只能走普通报销",
把「会议费并入差旅单」这条合法路径说成错的(用户 2026-10-21 定案:差旅 + 会议费走差旅单)。
口径判定收进 `src/core/expense_kind.py` 一处。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from src.cases import CaseStore
from src.core.expense_kind import goods_invoices, is_conference_like
from src.core.validation.validator import validate_extracted_info
from src.core.vehicles import VEHICLE_CODES, canonical_vehicle, is_valid_vehicle, vehicle_code, vehicle_error
from src.infra.browser import travel as travel_flow
# ------------------------------------------------------------------
# 假 bot:只记录 select_option / fill,不开真浏览器
# ------------------------------------------------------------------
class FakePage:
def __init__(self) -> None:
self.options: list[tuple[str, str]] = []
self.fills: list[tuple[str, str]] = []
def click(self, selector: str, timeout: int | None = None) -> None:
return None
def select_option(self, selector: str, value: str) -> None:
self.options.append((selector, value))
def fill(self, selector: str, value: str) -> None:
self.fills.append((selector, value))
def wait_for_timeout(self, ms: int) -> None:
return None
class FakeBot:
def __init__(self) -> None:
self.page = FakePage()
def open_tab(self, name: str) -> bool:
return True
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
return None
def _screenshot(self, name: str = "") -> str:
return ""
def _details(vehicle_type: str) -> dict[str, Any]:
return {
"transport_fee": [
{
"vehicle_type": vehicle_type,
"start_date": "2026-10-16",
"end_date": "2026-10-16",
"departure_place": "阜阳",
"arrival_place": "长沙",
"amount": 455.5,
"bill_count": 2,
"remark": "去程车票2张",
}
],
"hotel_fee": [],
"conference_fee": [],
}
# ------------------------------------------------------------------
# 代码表与别名
# ------------------------------------------------------------------
def test_canonical_vehicle_accepts_chinese_and_aliases() -> None:
assert canonical_vehicle("火车") == "火车"
assert canonical_vehicle(" 火车 ") == "火车" # 手滑多敲的空格不该变成"认不出"
assert canonical_vehicle("train") == "火车"
assert canonical_vehicle("TRAIN") == "火车"
assert canonical_vehicle("self-drive") == "自驾车"
assert canonical_vehicle("plane") == "飞机"
assert canonical_vehicle("高铁") == "火车"
assert canonical_vehicle("official_car") == "公务车"
def test_canonical_vehicle_rejects_unknown() -> None:
assert canonical_vehicle("地铁") == ""
assert canonical_vehicle("") == ""
assert canonical_vehicle(None) == ""
assert not is_valid_vehicle("地铁")
assert not is_valid_vehicle("自行车") # 财务系统没这个选项,别臆造
assert "自行车" in vehicle_error("自行车")
assert "火车" in vehicle_error("自行车") # 报错要带上能填什么
def test_vehicle_codes_are_the_finance_dropdown() -> None:
"""下拉框 value 别顺手重排:01..08 是财务系统页面上的顺序。"""
assert VEHICLE_CODES == {
"火车": "01",
"汽车": "02",
"轮船": "03",
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
assert vehicle_code("train") == "01"
assert vehicle_code("地铁") == ""
def test_filler_selects_dropdown_for_alias_and_raises_for_unknown() -> None:
bot = FakeBot()
travel_flow.add_travel_items(bot, _details("train"))
assert ("#jtgj", "01") in bot.page.options, bot.page.options # 英文枚举也得真的选中
bot2 = FakeBot()
travel_flow.add_travel_items(bot2, _details("火车"))
assert ("#jtgj", "01") in bot2.page.options
bot3 = FakeBot()
with pytest.raises(ValueError, match="火车"): # 认不出就停下,别静默留默认值
travel_flow.add_travel_items(bot3, _details("地铁"))
def test_validator_rejects_vehicle_that_cannot_be_selected() -> None:
info: dict[str, Any] = {
"basic_info": {
"travel_purpose": "参加研讨会",
"travel_location": "长沙",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
},
"reimbursement_details": {"transport_fee": _details("train")["transport_fee"]},
"payment_methods": [
{"card_date": "2026-10-11", "card_amount": 455.5, "merchant": "中国铁路", "remark": "去程车票2张"}
],
"subsidy_list": [
{
"person_id": "202407021",
"person_name": "王建锋",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
"days": 4,
}
],
"attachments": [],
}
assert validate_extracted_info(info, invoice_type="travel").valid, "train 是合法写法(会被归一成火车)"
info["reimbursement_details"]["transport_fee"][0]["vehicle_type"] = "地铁"
report = validate_extracted_info(info, invoice_type="travel")
assert not report.valid
assert any("vehicle_type" in m for m in report.missing_fields)
# ------------------------------------------------------------------
# 报销口径:会议费并入差旅单
# ------------------------------------------------------------------
def test_conference_like_invoices_merge_into_travel() -> None:
assert is_conference_like({"item_name": "会议费(*生产生活服务*会议费)"})
assert is_conference_like({"item_name": "培训费"})
assert is_conference_like({"item_name": "住宿服务", "remark": "会务费由易科捷代收"})
assert not is_conference_like({"item_name": "电子元器件(*生产生活服务*)"})
assert not is_conference_like({"item_name": "低值易耗品"})
def test_goods_invoices_are_the_only_ones_needing_own_form() -> None:
general = [
{"item_name": "会议费", "_source_file": "a.pdf"},
{"item_name": "五金配件", "_source_file": "b.pdf"},
]
goods = goods_invoices(general)
assert len(goods) == 1
assert goods[0]["_source_file"] == "b.pdf"
def test_case_status_lists_only_goods_invoices(tmp_path: Path) -> None:
"""case_status 的提醒只针对货物类——别再把会议费说成"只能走普通报销"。"""
store = CaseStore()
groups = {
"travel": [{"invoice_type": "train"}],
"general": [{"item_name": "会议费(*生产生活服务*会议费)", "_source_file": "hui.pdf"}],
}
(tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8")
assert store.goods_general_items(tmp_path) == []
groups["general"].append({"item_name": "低值易耗品", "_source_file": "huo.pdf"})
(tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8")
assert store.goods_general_items(tmp_path) == ["低值易耗品"]
def test_case_status_survives_broken_groups_file(tmp_path: Path) -> None:
"""派生产物读坏了不能把状态查询拖崩(它只该少一条提示)。"""
broken = "{不是 JSON"
(tmp_path / "invoice_groups.json").write_text(broken, encoding="utf-8")
assert CaseStore().goods_general_items(tmp_path) == []
# 只读不写:坏了的派生产物留给 case_reset 处理,校验路径不去动它
assert (tmp_path / "invoice_groups.json").read_text(encoding="utf-8") == broken