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 提交并手跑等价检查。
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""财务系统文本宽度上限的回归锁
|
||
|
||
踩过的坑(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()) == []
|