0d9cc7b4ca
这一批是 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 提交并手跑等价检查。
214 lines
8.3 KiB
Python
214 lines
8.3 KiB
Python
"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁
|
||
|
||
两条都是 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
|