"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁 两条都是 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