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 44f6466e8b
commit 0d9cc7b4ca
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()) == []