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:
@@ -0,0 +1,42 @@
|
||||
"""费用的报销口径:哪些普通发票能并进差旅单,哪些必须另走普通报销
|
||||
|
||||
用户 2026-10-21 定案:**差旅 + 会议费这类因出差发生的普通发票,走差旅单**——
|
||||
明细里有「会务费培训费」这一栏,会议通知作为附件即可,不用为一张 1500 的会议费
|
||||
另开一张普通报销单。反过来,货物类(易耗品、材料、配件)是普通报销 + 申购单那条路,
|
||||
塞进差旅单会被打回。
|
||||
|
||||
之前 `case_status`、`validate_info`、`browser_new_form` 三处的话都说成
|
||||
「混合发票按财务口径只能走普通报销」,等于把上面这条合法路径也说成了错的——
|
||||
Agent 听了会去拆单、还会漏掉差旅补助。判定只留在这一个地方。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 因出差而发生的费用名目(发票项目名称/备注里出现即算)
|
||||
CONFERENCE_ITEM_KEYWORDS = ("会议", "会务", "培训", "论坛", "研讨", "报名", "注册", "展位", "场地费")
|
||||
|
||||
|
||||
def item_text(invoice: dict[str, Any]) -> str:
|
||||
"""发票上可供判断的文本:项目名称 + 备注(抽取阶段会把说明写进这两处)。"""
|
||||
return f"{invoice.get('item_name') or ''} {invoice.get('remark') or ''}"
|
||||
|
||||
|
||||
def is_conference_like(invoice: dict[str, Any]) -> bool:
|
||||
"""是否为「因出差发生」的会议/会务/培训类费用(可并入差旅单的会务费栏)。"""
|
||||
text = item_text(invoice)
|
||||
return any(k in text for k in CONFERENCE_ITEM_KEYWORDS)
|
||||
|
||||
|
||||
def goods_invoices(general: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""普通发票里属于货物类的(不能并入差旅单,要走普通报销 + 申购单)。"""
|
||||
return [g for g in general if isinstance(g, dict) and not is_conference_like(g)]
|
||||
|
||||
|
||||
def describe_invoice(invoice: dict[str, Any]) -> str:
|
||||
"""报给人看的发票标识:优先物品名,其次来源文件名。"""
|
||||
name = str(invoice.get("item_name") or "").strip()
|
||||
if name:
|
||||
return name[:30]
|
||||
return str(invoice.get("_source_file") or invoice.get("invoice_number") or "?")
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from ... import get_logger
|
||||
from ..vehicles import is_valid_vehicle
|
||||
|
||||
log = get_logger("validator")
|
||||
|
||||
@@ -48,8 +49,42 @@ _CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = {
|
||||
"is_valid_date": _is_valid_date,
|
||||
"is_positive_number": _is_positive_number,
|
||||
"is_positive_integer": _is_positive_integer,
|
||||
# 交通工具必须能落到财务系统下拉框上(含 train→火车 之类别名),
|
||||
# 填报层以前对认不出的值是静默跳过,先在这里拦住
|
||||
"is_valid_vehicle": is_valid_vehicle,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 文本宽度上限(财务后端的「备注/说明」列没有公开上限,只能实测)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# 2026-10-21 联机踩坑:差旅明细里「会务费培训费」那行备注 101 字(宽度 171),
|
||||
# POST /expen/travel/detailadd 直接 500 Internal Server Error,页面一个字提示都不给,
|
||||
# 弹窗停在原地,看上去像脚本没点着「确定」;同一张单里 97 字(宽度 130)的交通费备注却存进去了。
|
||||
# 结论:上限落在两者之间,而财务系统不肯说具体数字——所以别拿真单据去试长度,
|
||||
# 在体检阶段就按宽度 100(约 50 个汉字)卡死:比已知会 500 的那条留了余量,
|
||||
# 也比侥幸存进去的那条更短。宁可让 Agent 把话写短,也不要用户去浏览器里撞 500。
|
||||
TEXT_WIDTH_LIMIT = 100
|
||||
|
||||
# 普通报销的「报销说明」在系统里更短(参考口径:20 字以内)
|
||||
DESCRIPTION_WIDTH_LIMIT = 40
|
||||
|
||||
# 校验不通过时,可读原因用这个分隔符跟在字段路径后面(missing_fields 是字符串列表,
|
||||
# 加个并列结构会牵动所有调用方,带内提示最省)
|
||||
HINT_SEP = "|"
|
||||
|
||||
|
||||
def text_width(value: str) -> int:
|
||||
"""按财务后端的计宽方式估算文本宽度:汉字/全角算 2,字母数字/半角算 1。
|
||||
|
||||
用 GBK 编码长度近似(财务系统跑在中文 Windows + GBK 库上)。
|
||||
GBK 编不出的字符(生僻字、emoji)退回 UTF-8 长度——宁可高估,不要低估后放过去撞 500。
|
||||
"""
|
||||
try:
|
||||
return len(value.encode("gbk"))
|
||||
except UnicodeEncodeError:
|
||||
return len(value.encode("utf-8", "replace"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 规则定义
|
||||
@@ -64,9 +99,10 @@ class FieldRule(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
path: list[str] # 字段路径(统一定位方式)
|
||||
required: NotRequired[bool] # 是否必填(读取端默认 True)
|
||||
required: NotRequired[bool] # 是否必填(读取端默认 True;false 表示键不存在就跳过)
|
||||
check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True)
|
||||
custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数)
|
||||
max_len_bytes: NotRequired[int] # 文本宽度上限(汉字算 2,见 text_width)
|
||||
description: NotRequired[str] # 字段描述(用于生成友好提示)
|
||||
|
||||
|
||||
@@ -175,14 +211,23 @@ def _load_default_rules() -> ValidationConfig:
|
||||
"path": ["reimbursement_details", "transport_fee"],
|
||||
"min_items": 1,
|
||||
"element_fields": [
|
||||
{"path": ["vehicle_type"], "description": "交通工具类型"},
|
||||
{
|
||||
"path": ["vehicle_type"],
|
||||
"custom_check": is_valid_vehicle,
|
||||
"description": "交通工具类型",
|
||||
},
|
||||
{"path": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"},
|
||||
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
|
||||
{"path": ["departure_place"], "description": "出发地"},
|
||||
{"path": ["arrival_place"], "description": "目的地"},
|
||||
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
|
||||
{"path": ["bill_count"], "custom_check": _is_positive_integer, "description": "票据张数"},
|
||||
{"path": ["remark"], "check_empty": False, "description": "备注说明"},
|
||||
{
|
||||
"path": ["remark"],
|
||||
"check_empty": False,
|
||||
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||
"description": "备注说明",
|
||||
},
|
||||
],
|
||||
"description": "交通费用明细",
|
||||
},
|
||||
@@ -193,7 +238,12 @@ def _load_default_rules() -> ValidationConfig:
|
||||
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
||||
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
||||
{"path": ["merchant"], "description": "商户名称"},
|
||||
{"path": ["remark"], "check_empty": False, "description": "备注"},
|
||||
{
|
||||
"path": ["remark"],
|
||||
"check_empty": False,
|
||||
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||
"description": "备注",
|
||||
},
|
||||
],
|
||||
"description": "支付方式记录",
|
||||
},
|
||||
@@ -215,6 +265,13 @@ def _load_default_rules() -> ValidationConfig:
|
||||
"element_fields": [
|
||||
{"path": ["filename"], "description": "文件名"},
|
||||
{"path": ["attachment_type"], "description": "附件类型"},
|
||||
{
|
||||
"path": ["attachment_desc"],
|
||||
"required": False,
|
||||
"check_empty": False,
|
||||
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||
"description": "附件说明",
|
||||
},
|
||||
],
|
||||
"description": "附件列表",
|
||||
},
|
||||
@@ -242,7 +299,12 @@ def _load_default_rules() -> ValidationConfig:
|
||||
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
|
||||
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
|
||||
{"path": ["merchant"], "description": "商户名称"},
|
||||
{"path": ["remark"], "check_empty": False, "description": "备注"},
|
||||
{
|
||||
"path": ["remark"],
|
||||
"check_empty": False,
|
||||
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||
"description": "备注",
|
||||
},
|
||||
],
|
||||
"description": "支付方式记录",
|
||||
},
|
||||
@@ -252,6 +314,13 @@ def _load_default_rules() -> ValidationConfig:
|
||||
"element_fields": [
|
||||
{"path": ["filename"], "description": "文件名"},
|
||||
{"path": ["attachment_type"], "description": "附件类型"},
|
||||
{
|
||||
"path": ["attachment_desc"],
|
||||
"required": False,
|
||||
"check_empty": False,
|
||||
"max_len_bytes": TEXT_WIDTH_LIMIT,
|
||||
"description": "附件说明",
|
||||
},
|
||||
],
|
||||
"description": "附件列表",
|
||||
},
|
||||
@@ -298,30 +367,49 @@ def _check_field(
|
||||
data: dict[str, Any],
|
||||
rule: FieldRule,
|
||||
) -> tuple[bool, str]:
|
||||
"""根据字段规则检查字段。"""
|
||||
"""根据字段规则检查字段。
|
||||
|
||||
Returns:
|
||||
(是否通过, 字段路径)。不通过且原因可说清时(如文本超宽),路径后拼
|
||||
「{HINT_SEP}说明」,_check_array 会把说明转接到 payment_methods[0].remark|… 上,
|
||||
让 Agent 一眼看出是「缺字段」还是「话写太长了」。
|
||||
"""
|
||||
path = rule["path"]
|
||||
required = rule.get("required", True)
|
||||
check_empty = rule.get("check_empty", True)
|
||||
custom_check = rule.get("custom_check")
|
||||
max_len_bytes = rule.get("max_len_bytes")
|
||||
path_str = ".".join(path)
|
||||
|
||||
current = data
|
||||
for key in path:
|
||||
if not isinstance(current, dict):
|
||||
return (False, ".".join(path))
|
||||
if key not in current:
|
||||
return (False, ".".join(path))
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
# required=false 的字段(如 attachment_desc)不存在就跳过:
|
||||
# 填报端有自己的兜底(附件说明留空会写成「发票:文件名」)
|
||||
return (True, path_str) if not required else (False, path_str)
|
||||
current = current[key]
|
||||
|
||||
if check_empty and isinstance(current, str) and not current.strip():
|
||||
return (False, ".".join(path))
|
||||
return (False, path_str)
|
||||
|
||||
if custom_check is not None:
|
||||
if isinstance(custom_check, str):
|
||||
# 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误
|
||||
raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析")
|
||||
if not custom_check(current):
|
||||
return (False, ".".join(path))
|
||||
return (False, path_str)
|
||||
|
||||
return (True, ".".join(path))
|
||||
# 文本超宽:财务后端不会告诉你为什么,只会回 500,所以在这里拦住
|
||||
if max_len_bytes is not None and isinstance(current, str):
|
||||
width = text_width(current)
|
||||
if width > max_len_bytes:
|
||||
desc = rule.get("description") or path_str
|
||||
return (
|
||||
False,
|
||||
f"{path_str}{HINT_SEP}{desc}过长:宽度 {width} > 上限 {max_len_bytes}(约 {max_len_bytes // 2} 个汉字),请改短",
|
||||
)
|
||||
|
||||
return (True, path_str)
|
||||
|
||||
|
||||
def _check_array(
|
||||
@@ -346,11 +434,16 @@ def _check_array(
|
||||
current = data
|
||||
for key in path:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
# min_items=0 的明细段允许整段缺省:没有住宿/会议费的差旅单不该报缺失
|
||||
if min_items == 0:
|
||||
return ([], total_checks, 1)
|
||||
return ([path_str], total_checks, passed_checks)
|
||||
current = current[key]
|
||||
|
||||
# 检查数组是否满足最小数量要求
|
||||
if not isinstance(current, list) or len(current) < min_items:
|
||||
if min_items == 0 and current is None:
|
||||
return ([], total_checks, 1)
|
||||
return ([path_str], total_checks, passed_checks)
|
||||
|
||||
passed_checks += 1 # 数组检查通过
|
||||
@@ -371,13 +464,15 @@ def _check_array(
|
||||
else:
|
||||
field_rule_dict = field_rule
|
||||
|
||||
# 复用 _check_field 函数检查元素字段
|
||||
ok, _ = _check_field(item, field_rule_dict)
|
||||
# 复用 _check_field 函数检查元素字段(超宽之类的说明转接到条目上)
|
||||
ok, msg = _check_field(item, field_rule_dict)
|
||||
if ok:
|
||||
passed_checks += 1
|
||||
else:
|
||||
field_path_str = ".".join(field_rule_dict["path"])
|
||||
missing.append(f"{path_str}[{i}].{field_path_str}")
|
||||
hint = msg.split(HINT_SEP, 1)[1] if HINT_SEP in msg else ""
|
||||
tail = f"{HINT_SEP}{hint}" if hint else ""
|
||||
missing.append(f"{path_str}[{i}].{field_path_str}{tail}")
|
||||
|
||||
return missing, total_checks, passed_checks
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""交通工具口径:财务系统下拉框的代码表与别名归一
|
||||
|
||||
财务系统的差旅明细「交通工具」下拉框(`#jtgj`)只认中文选项,而 Agent 侧的判读参考历来
|
||||
写英文枚举(train/car/...)。老写法 `if vehicle in vehicle_map:` 在英文枚举下是**静默跳过**:
|
||||
下拉框停在默认值,填报工具照样返回成功,直到人去点提交才被必填挡回来——排查方向还指着
|
||||
「脚本没点着按钮」(2026-10-21 联机踩过)。
|
||||
|
||||
所以这张表从填报层挪到 core,一处三用:填报层取 value、体检层判合法、报错层给候选清单。
|
||||
认不出来的值一律显式报错,不再静默。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 财务系统 #jtgj 下拉框:选项显示文字 → option value(顺序即下拉框顺序,别按字母排)
|
||||
VEHICLE_CODES: dict[str, str] = {
|
||||
"火车": "01",
|
||||
"汽车": "02",
|
||||
"轮船": "03",
|
||||
"自带车": "04",
|
||||
"公务车": "05",
|
||||
"飞机": "06",
|
||||
"租车": "07",
|
||||
"自驾车": "08",
|
||||
}
|
||||
|
||||
# 别名(英文枚举、口语写法、繁体)→ 财务系统的中文选项
|
||||
VEHICLE_ALIASES: dict[str, str] = {
|
||||
"train": "火车",
|
||||
"rail": "火车",
|
||||
"railway": "火车",
|
||||
"train_ticket": "火车",
|
||||
"high_speed_rail": "火车",
|
||||
"高铁": "火车",
|
||||
"动车": "火车",
|
||||
"火車": "火车",
|
||||
"car": "汽车",
|
||||
"bus": "汽车",
|
||||
"coach": "汽车",
|
||||
"大巴": "汽车",
|
||||
"长途汽车": "汽车",
|
||||
"客车": "汽车",
|
||||
"ship": "轮船",
|
||||
"boat": "轮船",
|
||||
"船": "轮船",
|
||||
"personal_car": "自带车",
|
||||
"own_car": "自带车",
|
||||
"私家车": "自带车",
|
||||
"official_car": "公务车",
|
||||
"公车": "公务车",
|
||||
"plane": "飞机",
|
||||
"airplane": "飞机",
|
||||
"flight": "飞机",
|
||||
"航班": "飞机",
|
||||
"rental_car": "租车",
|
||||
"self_drive": "自驾车",
|
||||
"selfdrive": "自驾车",
|
||||
"自驾": "自驾车",
|
||||
}
|
||||
|
||||
|
||||
def _norm(value: object) -> str:
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
return text.lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def canonical_vehicle(value: object) -> str:
|
||||
"""归一成财务系统的中文选项;认不出来返回 ""(调用方决定是报错还是忽略)。"""
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
if text in VEHICLE_CODES:
|
||||
return text
|
||||
return VEHICLE_ALIASES.get(_norm(value), "")
|
||||
|
||||
|
||||
def vehicle_code(value: object) -> str:
|
||||
"""取 `#jtgj` 的 option value;认不出来返回 ""。"""
|
||||
name = canonical_vehicle(value)
|
||||
return VEHICLE_CODES.get(name, "")
|
||||
|
||||
|
||||
def is_valid_vehicle(value: object) -> bool:
|
||||
"""体检用:这个 vehicle_type 能不能落到财务系统的下拉框上。"""
|
||||
return bool(canonical_vehicle(value))
|
||||
|
||||
|
||||
def vehicle_error(value: object) -> str:
|
||||
"""给 Agent 的报错文案:带上能填什么,别让人去猜下拉框。"""
|
||||
return (
|
||||
f"交通工具 {value!r} 财务系统认不出来,也不会替你默认选一个。"
|
||||
f"可填:{'、'.join(VEHICLE_CODES)}(也接受 train/car/plane 等英文写法)"
|
||||
)
|
||||
Reference in New Issue
Block a user