"""财务系统文本宽度上限的回归锁 踩过的坑(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()) == []