Files
Auto-Finance/tests/test_text_limits.py
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

267 lines
11 KiB
Python
Raw Permalink 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 联机):差旅明细「会务费培训费」那行备注写了 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()) == []