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
+27 -2
View File
@@ -252,8 +252,13 @@ class CaseStore:
warnings: list[str] = []
if has_travel and has_normal:
warnings.append(
"travel_info 与 normal_info 同时存在:browser_new_form 必须显式传 kind;"
"混合发票按财务口径只能走普通报销"
"travel_info 与 normal_info 同时存在:browser_new_form 必须显式传 kind,两张单各自走差旅/日常列表页"
)
goods = self.goods_general_items(d)
if goods:
warnings.append(
"有 " + str(len(goods)) + " 张货物类普通发票(" + "、".join(goods[:3]) + "):"
"这类不能并入差旅单,走普通报销并生成申购单"
)
if stale:
warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale))
@@ -290,6 +295,26 @@ class CaseStore:
"warnings": warnings,
}
def goods_general_items(self, d: Path) -> list[str]:
"""货物类普通发票的物品名(会议/会务/培训类不算——那些并入差旅单的会务费栏)。
口径只在 src/core/expense_kind.py 一处,别在这里另写一份关键词表。
"""
path = d / "invoice_groups.json"
if not path.exists():
return []
try:
groups = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: # 派生产物读坏了不该拖垮状态查询
log.warning("读 invoice_groups.json 失败(%s):%s", path, exc)
return []
general = groups.get("general")
if not isinstance(general, list):
return []
from .core.expense_kind import describe_invoice, goods_invoices
return [describe_invoice(g) for g in goods_invoices(general)]
def browser_lock_state(self, d: Path) -> dict[str, Any] | None:
"""读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。"""
lock = d / BROWSER_LOCK_FILENAME
+42
View File
@@ -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 "?")
+111 -16
View File
@@ -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
+90
View File
@@ -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 等英文写法)"
)
+13 -14
View File
@@ -7,6 +7,7 @@
from typing import Any
from ... import get_logger
from ...core.vehicles import VEHICLE_CODES, canonical_vehicle, vehicle_error
from .base import BaseBot, format_date
log = get_logger("bot.travel")
@@ -93,17 +94,10 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
details: 报销明细字典(travel_info["reimbursement_details"]),包含
transport_fee、hotel_fee、conference_fee 等子字段。
"""
vehicle_map = {
"火车": "01",
"汽车": "02",
"轮船": "03",
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
# 交通工具的代码表与别名归一搬到了 src/core/vehicles.py:
# 填报层取 value、体检层判合法、报错层给候选,三处共用一张表。
# 原来这里 `if vehicle in vehicle_map:` 是静默跳过——英文枚举(train)匹配不上,
# 下拉框停在默认值而本步照样返回成功,等人提交时才被必填拦下(2026-10-21 踩过)。
try:
if not bot.open_tab("details"):
raise RuntimeError("切不到差旅明细标签页")
@@ -113,9 +107,14 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "1")
bot.page.wait_for_timeout(500)
vehicle = item.get("vehicle_type", "")
if vehicle in vehicle_map:
bot.page.select_option("#jtgj", vehicle_map[vehicle])
raw_vehicle = item.get("vehicle_type", "")
vehicle = canonical_vehicle(raw_vehicle)
if not vehicle:
# 宁可在这一行停下(截图会跟着截到本步错误),也不要留一张缺交通工具的单子给人提交
raise ValueError(vehicle_error(raw_vehicle))
if vehicle != str(raw_vehicle).strip():
log.info("交通工具 %r 归一为 %s 后选中", raw_vehicle, vehicle)
bot.page.select_option("#jtgj", VEHICLE_CODES[vehicle])
bot.page.fill("#ksdd", item.get("departure_place", ""))
bot.page.fill("#jsdd", item.get("arrival_place", ""))
+7
View File
@@ -32,6 +32,12 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info,
用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则,
或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。
报销口径:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**(明细的会务费培训费栏、
会议通知当附件),不为它另开普通报销单,也不因此放弃差旅补助;只有货物类(易耗品/材料/配件)
才另走普通报销 + 申购单。住宿发票没印入住退房日期时,按会议报到日至返程日推实际晚数。
备注/说明类文本要写短:上限按"宽度"算(汉字与全角标点记 2、字母数字记 1),
remark/merchant/attachment_desc ≤100(约 50 个汉字),普通报销说明 ≤40。写超了财务后端不会说"太长了",
而是在填报时回一记没有提示的 500;save_info 与 browser_fill 都会拦,别拿真单据去试它的上限。
拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel;
@@ -46,6 +52,7 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
某步返回 too_long 就是那段文本超宽(见第 5 条),改短后重跑同一 step,别去浏览器里手动点确定。
**提交与提交前后的核对都由用户本人在财务系统里做**:submit_* 这一类工具(代提交、提交前预览)
已被用户整体否决,本项目不提供、也不会计划提供。填报完成就是终点——告诉用户单据编号与
+18 -1
View File
@@ -45,6 +45,11 @@ def save_info(case_id: str, info_type: str, data: dict[str, Any]) -> dict[str, A
info_type 取 travel|normal。完整性以返回的 validation 为准,
不要自己在 data 里塞 can_submit。
规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1):
备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到,
所以在这里就拦住,missing_fields 会写成
"payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。
"""
d = _store.resolve(case_id)
if d is None:
@@ -89,7 +94,19 @@ def validate_info(case_id: str, info_type: str | None = None, draft: dict[str, A
report = validate_extracted_info(data, invoice_type=info_type)
payload = _report_dict(report)
if groups_path.exists() and info_type == "travel":
payload["note"] = "已存在 invoice_groups.json:混合发票(差旅+普通)只能走普通报销,别按纯差旅提交"
# 财务口径(用户 2026-10-21 定案):因出差发生的会议费/会务费/培训费并入差旅单,
# 只有货物类普通发票才必须另走普通报销——别把前者也说成"只能走普通报销"
goods = _store.goods_general_items(d)
if goods:
payload["note"] = (
"有货物类普通发票(" + "、".join(goods[:3]) + ")不能并入差旅单:"
"另建 normal_info 走普通报销并生成申购单;差旅单只留车票/住宿/会务费"
)
else:
payload["note"] = (
"发票分组已就绪:会议费/会务费/培训费这类因出差发生的普通发票"
"并入差旅单的「会务费培训费」栏即可,会议通知作为附件"
)
return ok(case_id=case_id, info_type=info_type, **payload)
+105 -2
View File
@@ -38,6 +38,91 @@ STEP_LABEL: dict[str, str] = {
PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"}
# 附件原始文件名要保住证据原样(不重命名),所以只按一个很宽的口径拦;
# 真被财务系统的列宽截断的话,报的是「文件不存在/上传失败」,和备注那种静默 500 不同。
FILENAME_WIDTH_LIMIT = 200
def _step_text_items(kind: str, step: str, info: dict[str, Any]) -> list[tuple[str, str, int]]:
"""列出这一步会 POST 给财务系统的自由文本:(路径, 内容, 宽度上限)。
为什么要预先量长度:财务后端对「备注/说明」这类列超宽的回答是一记
500 Internal Server Error,页面一个字的提示都不给,弹窗停在原地——看上去就像
脚本没点着「确定」,排查成本极高(2026-10-21 实测:会务费那行备注 101 字/宽度 171
被拒,同一张单 97 字/宽度 130 的交通费备注却存进去了)。本地拦一道,别拿真单据试。
"""
from ...core.validation.validator import DESCRIPTION_WIDTH_LIMIT, TEXT_WIDTH_LIMIT
items: list[tuple[str, str, int]] = []
def add(path: str, value: Any, limit: int = TEXT_WIDTH_LIMIT) -> None:
if isinstance(value, str) and value.strip():
items.append((path, value, limit))
def sub(key: str) -> dict[str, Any]:
raw = info.get(key)
return raw if isinstance(raw, dict) else {}
if step == "basic":
basic = sub("basic_info")
if kind == "travel":
add("basic_info.travel_purpose", basic.get("travel_purpose"))
add("basic_info.travel_location", basic.get("travel_location"))
else:
add("basic_info.reimbursement_description", basic.get("reimbursement_description"), DESCRIPTION_WIDTH_LIMIT)
return items
if step == "details" and kind == "travel":
details = sub("reimbursement_details")
for key in ("transport_fee", "hotel_fee", "conference_fee"):
rows = details.get(key) or []
if isinstance(rows, list):
for i, row in enumerate(rows):
if isinstance(row, dict):
add(f"reimbursement_details.{key}[{i}].remark", row.get("remark"))
return items
if step == "payment":
rows = info.get("payment_methods") or []
if isinstance(rows, list):
for i, row in enumerate(rows):
if isinstance(row, dict):
add(f"payment_methods[{i}].merchant", row.get("merchant"))
add(f"payment_methods[{i}].remark", row.get("remark"))
return items
if step == "attachments":
rows = info.get("attachments") or []
if isinstance(rows, list):
for i, row in enumerate(rows):
if not isinstance(row, dict):
continue
desc = str(row.get("attachment_desc") or "").strip()
if not desc:
# 说明留空时 upload_attachment 会兜底成「类型:文件名」,兜底文案同样要量长度
label = {"invoice": "发票", "payment": "支付凭证", "apply": "事前申请单"}.get(
str(row.get("attachment_type")), "附件"
)
desc = f"{label}:{row.get('filename', '')}"
add(f"attachments[{i}].attachment_desc", desc)
add(f"attachments[{i}].filename", row.get("filename"), FILENAME_WIDTH_LIMIT)
return items
return items
def _text_limit_problems(kind: str, step: str, info: dict[str, Any]) -> list[str]:
"""这一步要提交、但宽度超过财务系统上限的文本(返回可直接给人看的描述)。"""
from ...core.validation.validator import text_width
problems: list[str] = []
for path, value, limit in _step_text_items(kind, step, info):
width = text_width(value)
if width > limit:
preview = value[:14] + ("…" if len(value) > 14 else "")
problems.append(f"{path}:宽度 {width} > 上限 {limit}(约 {limit // 2} 个汉字)——「{preview}」")
return problems
def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id)
@@ -169,7 +254,9 @@ def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
if both:
return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)",
next_action=(
"显式传 kind='travel' 或 'normal'(因出差发生的会议费并入差旅单;货物类普通发票才另走普通报销)"
),
)
if kind is None:
return fail(
@@ -211,7 +298,12 @@ def browser_new_form(case_id: str, kind: str | None = None) -> dict[str, Any]:
def browser_fill(case_id: str, step: str) -> dict[str, Any]:
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。"""
"""执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
提交前会量这一步要 POST 的自由文本宽度(汉字算 2、字母数字算 1,上限 100,
普通报销说明 40):财务后端对超宽只回 500、页面上任何提示都没有,弹窗停在原地
看着像"按钮没点着",所以宁可在这里拦下来,失败时 too_long 会列出超长字段。
"""
d = _case_dir(case_id)
ctx = _require_open(case_id)
if d is None or ctx is None:
@@ -242,6 +334,17 @@ def browser_fill(case_id: str, step: str) -> dict[str, Any]:
if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息")
# 超宽文本先在这里拦住:财务后端只会回 500,页面上什么提示都没有,
# 等到那时候看上去就是「脚本没点着按钮」,得开 F12 才看得懂(见 _step_text_items 注释)
too_long = _text_limit_problems(kind, step, info)
if too_long:
return fail(
f"{STEP_LABEL[step]}有文本超宽,提交上去财务后端会 500 且不报错,已本地拦下",
next_action="把下列字段改短(备注≈50 个汉字以内)后重新 save_info,再重跑本 step",
step=step,
too_long=too_long,
)
def _fill() -> dict[str, Any]:
bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info)