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 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 13:49:12 +08:00
parent 87bd700913
commit e706c1f09b
17 changed files with 1042 additions and 62 deletions
+266
View File
@@ -0,0 +1,266 @@
"""财务系统文本宽度上限的回归锁
踩过的坑(2026-10-21 联机):差旅明细「会务费培训费」那行备注写了 101 个字(GBK 宽度 171),
POST /expen/travel/detailadd 直接 500 Internal Server Error,页面上一个字的提示都没有,
弹窗停在原地——看上去完全像脚本没点着「确定」。同一张单里 97 字(宽度 130)的交通费备注
却存了进去,说明上限夹在两者之间,而财务后端不肯告诉我们具体数字。
这里锁三件事:
1. text_width 的计宽口径(汉字 2、字母数字 1)——上限是按它写的,口径变了数字就没意义;
2. 体检(validate_extracted_info)在落盘阶段就拦住超宽文本,并给出可读原因;
3. 填报(browser_fill)在真的 POST 之前再拦一道,免得拿真单据去撞 500。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from src.core.validation.validator import (
DESCRIPTION_WIDTH_LIMIT,
TEXT_WIDTH_LIMIT,
reload_validation_rules,
text_width,
validate_extracted_info,
)
from src.mcp.tools import browser
REPO_ROOT = Path(__file__).resolve().parents[1]
# 与实单同样形状、曾经真的把后端打挂的那句话
LONG_REMARK = "2026年全国大学生电子设计竞赛赛区赛及TI杯模拟电子系统设计专题赛赛题解析与技术交流研讨会会议费1500元/人"
def _travel_info(**overrides: Any) -> dict[str, Any]:
info: dict[str, Any] = {
"basic_info": {
"travel_purpose": "参加电子设计竞赛研讨会",
"travel_location": "长沙",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
},
"reimbursement_details": {
"transport_fee": [
{
"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": [
{
"checkin_date": "2026-10-16",
"checkout_date": "2026-10-19",
"days": 3,
"person_count": 1,
"invoice_amount": 1000.56,
"reimburse_amount": 1000.56,
"remark": "王建锋长沙住宿3晚",
}
],
"conference_fee": [{"bill_count": 1, "amount": 1500.0, "remark": "研讨会会议费1500元/人"}],
},
"payment_methods": [
{
"card_date": "2026-10-16",
"card_amount": 1500.0,
"merchant": "天津市易科捷科技有限公司",
"remark": "财付通付会议费",
}
],
"subsidy_list": [
{
"person_id": "202407021",
"person_name": "王建锋",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
"days": 4,
}
],
"attachments": [
{"filename": "出差事前申请.pdf", "attachment_type": "other", "attachment_desc": "出差事前申请单"}
],
}
info.update(overrides)
return info
def _normal_info(desc: str = "元器件采购") -> dict[str, Any]:
return {
"basic_info": {"reimbursement_description": desc},
"reimbursement_details": {"total_invoices": 1, "total_amount": 100.0},
"payment_methods": [
{"card_date": "2026-10-16", "card_amount": 100.0, "merchant": "某公司", "remark": "采购付款"}
],
"attachments": [{"filename": "发票.pdf", "attachment_type": "invoice"}],
}
# ------------------------------------------------------------------
# 计宽口径
# ------------------------------------------------------------------
def test_text_width_counts_cjk_as_two() -> None:
assert text_width("汉字") == 4
assert text_width("ab12") == 4
assert text_width("票1500元") == 2 + 4 + 2
assert text_width(",") == 2 # 全角标点也占两个宽度
assert text_width("😀") >= 2 # GBK 编不出的按 UTF-8 估,宁可高估
# ------------------------------------------------------------------
# 体检阶段就要拦住
# ------------------------------------------------------------------
def test_travel_info_passes_with_short_remarks() -> None:
report = validate_extracted_info(_travel_info(), invoice_type="travel")
assert report.valid, report.missing_fields
def test_overlong_conference_remark_is_rejected_with_reason() -> None:
info = _travel_info()
assert text_width(info["reimbursement_details"]["conference_fee"][0]["remark"]) < TEXT_WIDTH_LIMIT
info["reimbursement_details"]["conference_fee"][0]["remark"] = LONG_REMARK
report = validate_extracted_info(info, invoice_type="travel")
assert not report.valid
hit = [m for m in report.missing_fields if "conference_fee" in m and "remark" in m]
assert hit, report.missing_fields
# 只报「缺字段」不够:超宽不是缺失,得说清是太长、上限多少
assert "过长" in hit[0] and str(TEXT_WIDTH_LIMIT) in hit[0]
def test_overlong_payment_and_transport_remarks_are_rejected() -> None:
info = _travel_info()
info["payment_methods"][0]["remark"] = LONG_REMARK
info["reimbursement_details"]["transport_fee"][0]["remark"] = LONG_REMARK
report = validate_extracted_info(info, invoice_type="travel")
assert not report.valid
joined = "|".join(report.missing_fields)
assert "payment_methods[0].remark" in joined
assert "transport_fee[0].remark" in joined
def test_normal_description_limit_is_stricter_than_remark() -> None:
assert DESCRIPTION_WIDTH_LIMIT == 40
assert validate_extracted_info(_normal_info("元器件采购"), invoice_type="normal").valid
# 21 个汉字 = 宽度 42 超上限;20 个汉字刚好能过
assert not validate_extracted_info(_normal_info("件" * 21), invoice_type="normal").valid
assert validate_extracted_info(_normal_info("件" * 20), invoice_type="normal").valid
def test_optional_arrays_and_desc_may_be_absent() -> None:
"""min_items=0 / required=false 的段落整段缺省不该报缺失(纯车票差旅是合法单据)。"""
info = _travel_info()
del info["reimbursement_details"]["hotel_fee"]
del info["reimbursement_details"]["conference_fee"]
del info["attachments"][0]["attachment_desc"]
report = validate_extracted_info(info, invoice_type="travel")
assert report.valid, report.missing_fields
def test_overlong_attachment_desc_is_rejected() -> None:
info = _travel_info()
info["attachments"][0]["attachment_desc"] = LONG_REMARK
report = validate_extracted_info(info, invoice_type="travel")
assert not report.valid
assert any("attachment_desc" in m and "过长" in m for m in report.missing_fields)
# ------------------------------------------------------------------
# 规则文件本身的锁
# ------------------------------------------------------------------
def test_rules_file_declares_limits_for_every_posted_text() -> None:
"""填报时要 POST 的每个自由文本,规则文件里都得有 max_len_bytes。
少一个就等于把那个字段裸奔交给财务后端——它的答复是 500,不带原因。
"""
reload_validation_rules()
rules = json.loads((REPO_ROOT / "config" / "validation_rules.json").read_text(encoding="utf-8"))
def limits_of(kind: str) -> dict[str, int]:
out: dict[str, int] = {}
section = rules[kind]
for rule in section.get("fields", []):
if rule.get("max_len_bytes"):
out[".".join(rule["path"])] = int(rule["max_len_bytes"])
for arr in section.get("arrays", []):
for ef in arr.get("element_fields", []):
if isinstance(ef, dict) and ef.get("max_len_bytes"):
out[f"{'.'.join(arr['path'])}[].{'.'.join(ef['path'])}"] = int(ef["max_len_bytes"])
return out
travel = limits_of("travel")
normal = limits_of("normal")
for key in (
"basic_info.travel_purpose",
"reimbursement_details.transport_fee[].remark",
"reimbursement_details.hotel_fee[].remark",
"reimbursement_details.conference_fee[].remark",
"payment_methods[].remark",
"payment_methods[].merchant",
"attachments[].attachment_desc",
):
assert key in travel, f"travel 规则里 {key} 没有长度上限"
for key in (
"basic_info.reimbursement_description",
"payment_methods[].remark",
"payment_methods[].merchant",
"attachments[].attachment_desc",
):
assert key in normal, f"normal 规则里 {key} 没有长度上限"
# 住宿/会务费明细必须在规则里(否则那两栏的备注没人管),且允许整段缺省
arrays = {tuple(a["path"]): a.get("min_items") for a in rules["travel"]["arrays"]}
assert arrays[("reimbursement_details", "hotel_fee")] == 0
assert arrays[("reimbursement_details", "conference_fee")] == 0
# ------------------------------------------------------------------
# 填报前的第二道闸(真的 POST 之前)
# ------------------------------------------------------------------
def test_browser_preflight_blocks_overlong_detail_remark() -> None:
info = _travel_info()
assert browser._text_limit_problems("travel", "details", info) == []
info["reimbursement_details"]["conference_fee"][0]["remark"] = LONG_REMARK
problems = browser._text_limit_problems("travel", "details", info)
assert len(problems) == 1
assert "conference_fee[0].remark" in problems[0]
assert str(TEXT_WIDTH_LIMIT) in problems[0]
def test_browser_preflight_covers_each_step_text() -> None:
long_basic = {"basic_info": {"travel_purpose": LONG_REMARK, "travel_location": "长沙"}}
assert any("travel_purpose" in p for p in browser._text_limit_problems("travel", "basic", long_basic))
pay = {
"payment_methods": [
{"card_date": "2026-10-16", "card_amount": 1.0, "merchant": "小商户", "remark": LONG_REMARK}
]
}
assert any("payment_methods[0].remark" in p for p in browser._text_limit_problems("travel", "payment", pay))
assert any("payment_methods[0].remark" in p for p in browser._text_limit_problems("normal", "payment", pay))
# 说明留空时兜底成「发票:文件名」,兜底文案超宽一样拦
att = {"attachments": [{"filename": "关于举办" + "赛" * 60 + ".pdf", "attachment_type": "invoice"}]}
problems = browser._text_limit_problems("travel", "attachments", att)
assert any("attachment_desc" in p for p in problems), problems
# 普通报销的说明口径更短
desc = {"basic_info": {"reimbursement_description": "件" * 21}}
assert any("reimbursement_description" in p for p in browser._text_limit_problems("normal", "basic", desc))
def test_browser_preflight_ignores_steps_without_text() -> None:
assert browser._text_limit_problems("travel", "subsidy", _travel_info()) == []
assert browser._text_limit_problems("normal", "details", _normal_info()) == []
+213
View File
@@ -0,0 +1,213 @@
"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁
两条都是 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