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
+42 -8
View File
@@ -39,30 +39,54 @@ description: >
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录, 材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程** 原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程**
(`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听 (`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听
`upload_base_url`(**带端口**,默认 5055),否则探出来的地址手机连不上 5055——`GET /api/ping` 回的是 pid 与启动时间,改完 `src/upload/` 的代码必须重启那个进程才生效。
重启要杀**真正持有 socket 的那个 PID**(`Get-NetTCPConnection -LocalPort 5055` 查得到),别只杀
`uv run` 外壳:Windows 允许两个进程绑同一端口,两代收件器并存时手机命中谁全凭运气。
多网卡机器还要在 `scripts/data/config.json` 配 `upload_base_url`(**带端口**,默认 5055),
否则探出来的地址手机连不上。**发链接之前先自检**:端口有没有人听、`/api/ping` 回的是哪个进程,
再用 `Get-NetIPAddress` 确认链接里那个 IP 还在本机网卡上——别用 `ipconfig`,
第二个 WLAN 适配器它会漏(2026-10-21 就是这么差点把链接发给错网段的手机)
3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token), 3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token),
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律) 否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看 4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
`references/invoice-fields.md`(本技能自带,不依赖仓库) `references/invoice-fields.md`(本技能自带,不依赖仓库)
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。 5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。
贪心结果 `ok` 不等于「已核对」 贪心结果 `ok` 不等于「已核对」:它只按金额凑数,**等额的发票最容易配错**。实单翻过一次车——
同一次出差两张 338.00 的车票被分到两笔支付上,凑出「453.50 → 338.00+117.50=455.50」这种
多两块钱的组合,还把 115.50 那张留成未匹配。复核要看**付款时间与行程方向**(先付的款配先乘的程),
不是看金额对不对得上;`reimbursement_details` 与 `payment_methods` 两边总额相等
也不等于逐笔配对正确。工具没有人工作改组的入口(只有 `tolerance`),
所以把人工归组的结论写进对应 payment 的 `remark`,下游和人看得懂
6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用 6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。差旅与普通可能并存,按 `validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。
`suggested_invoice_type` 与明细性质决定,别只有一种 info 就以为报完了 - **报销口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费**并入差旅单**
(明细「会务费培训费」栏 + 会议通知当附件),不为它另开普通报销单,也不因此放弃补助;
只有货物类(易耗品/材料/配件)才另走 `normal_info` + 申购单。`suggested_invoice_type`
看见 general 发票就报 `normal`,这时**以口径为准、不以它为准**
- 住宿发票一般不印入住/退房日期:按会议报到日至返程日推**实际晚数**(4 天行程 3 晚),
别照搬参考文档 `days=1` 的保守默认——那会把 333 元/晚报成 1000 元/晚,财务必打回
- 中转到站的多张车票合并成「去程一条、返程一条」,`bill_count` 记实际张数、金额相加;
明细的日期列由页面按出差起止带出,填报脚本不填它
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品); 7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
纯差旅没有申购明细会返回失败提示,那不是错误 纯差旅没有申购明细会返回失败提示,那不是错误
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)` 8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个 9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。 `browser_fill(step=...)` → `browser_page_state` 复查。`browser_login` 返回 `need_human` 时让用户在
`browser_login` 返回 `need_human` 时让用户在**已打开的那个窗口**里完成扫码/验证码再重调; **已打开的那个窗口**里完成扫码/验证码再重调;travel_info 与 normal_info 同时存在时
travel_info 与 normal_info 同时存在时 `browser_new_form` 必须显式传 `kind` `browser_new_form` 必须显式传 `kind`。`browser_close` 留到**用户提交之后**再叫:
填完那会儿单据还不存在(没有编号、列表页查不到),窗口一关就是全丢,不是"存了草稿"。
收尾话术固定两句:「还没提交,别关这个窗口」+「提交后把编号发我,我用 browser_open_form 核对」
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用 10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`, `browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单, 确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断, 在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
最稳的是**单据编号** 最稳的是**单据编号**
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑; 11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断 续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断。
**唯独 `attachments` 不能重跑**:它逐个弹窗上传、没有去重,重跑一遍附件就翻倍;
要补附件让人在附件清单页点「增加」手动传(会话目录里的原始文件路径给他)。
还有:差旅单几个标签共用一个页面,「基本信息」那步的截图经常仍拍在明细表格上,
别拿它当基本信息的证据;要确认就让用户目视,或看 `browser_page_state` 的 markers
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时 12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住), 工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
要写清楚就把 `attachment_desc` 填进 attachments 项 要写清楚就把 `attachment_desc` 填进 attachments 项
@@ -72,12 +96,22 @@ description: >
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料, - **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
禁止说「可以提交了」 禁止说「可以提交了」
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜 - **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
- **口径不自己发明**:因出差发生的会议费/会务费/培训费并入差旅单(不为它拆单、不放弃补助),
货物类普通发票才走普通报销 + 申购单;判定表在 `src/core/expense_kind.py`,工具提示由它驱动,
`suggested_invoice_type` 只是参考。住宿实际晚数、中转车票的合并方式同理——按证据推,
拿不准就问用户一句,别照默认值交卷
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认 - **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系; 采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形 康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`) - **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报 - **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件 - `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件
- **备注/说明要写短**:上限按「宽度」算——汉字与全角标点记 2、字母数字记 1,
`remark` / `merchant` / `attachment_desc` ≤ **100**(约 50 个汉字),普通报销说明 ≤ **40**。
财务后端对超宽的答复是 `500 Internal Server Error`,页面上一个字提示都没有,弹窗停在原地,
现场看着像「脚本没点着确定」(2026-10-21 实测:97 字/宽度 130 存进去了,101 字/宽度 171 被拒)。
`save_info` 体检与 `browser_fill`(返回 `too_long`)两道闸都会拦,但最好一开始就别把整串发票号
塞进备注——发票本身就是附件,备注只写「谁、哪段、几张票」
- 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里** - 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里**
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`) (比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**, - 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,
@@ -82,7 +82,11 @@
| 字段 | 类型 | 说明 | 推断优先级 | | 字段 | 类型 | 说明 | 推断优先级 |
|------|------|------|-----------| |------|------|------|-----------|
| `reimbursement_description` | string | 报销说明,20 字以内 | ①发票商品/服务名称 → ②根据所有信息总结 | | `reimbursement_description` | string | 报销说明,20 个汉字以内(宽度 40,超了财务后端静默 500) | ①发票商品/服务名称 → ②根据所有信息总结 |
> **先判这单该不该走普通报销**:因出差发生的会议费/会务费/培训费**并入差旅单**
> (明细「会务费培训费」栏 + 会议通知当附件),不进本表;只有货物类(易耗品、材料、配件)
> 才走普通报销 + 申购单。判定在 `src/core/expense_kind.py`,`case_status`/`validate_info` 的提示由它驱动。
--- ---
@@ -135,6 +139,10 @@
3. **支付记录排除**:付款记录不放入 `attachments` 3. **支付记录排除**:付款记录不放入 `attachments`
4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项 4. **附件全覆盖**:所有源文件(除支付记录外)都应在 `attachments` 中有一项
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` 5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
6. **文本必须写短**:`reimbursement_description` 宽度 ≤ **40**(20 个汉字),
`remark` / `merchant` / `attachment_desc` 宽度 ≤ **100**(约 50 个汉字)。
汉字算 2、字母数字算 1。超宽不会得到「太长了」这种提示,财务后端直接回 500,
填报时看上去像按钮没点着(2026-10-21 实测夹住的上限区间,取的是保守值)
### 语义完整性校验 ### 语义完整性校验
@@ -102,7 +102,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `vehicle_type` | string | 枚举:`train` / `car` / `ship` / `personal_car` / `official_car` / `plane` / `rental_car` / `self_drive` | | `vehicle_type` | string | **推荐填中文**:`火车` / `汽车` / `轮船` / `自带车` / `公务车` / `飞机` / `租车` / `自驾车`。代码表与别名在 `src/core/vehicles.py`:`train`→火车、`plane`→飞机、`高铁/动车`→火车 这类写法会被归一后选中;**认不出的值现在直接报错**(以前是静默不选、下拉框留默认值,等提交才被必填拦下) |
| `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` | | `start_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
| `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` | | `end_date` | string | 乘车日期,格式 `YYYY-MM-DD` |
| `departure_place` | string | 出发地(通常为城市名称) | | `departure_place` | string | 出发地(通常为城市名称) |
@@ -125,9 +125,12 @@
| `reimburse_amount` | number | 酒店付款记录合计总额 | | `reimburse_amount` | number | 酒店付款记录合计总额 |
| `remark` | string | 住宿人员姓名,例:`示例报销人、示例人员甲住宿` | | `remark` | string | 住宿人员姓名,例:`示例报销人、示例人员甲住宿` |
**日期推断优先级**:①交通工具发票日期(最高)→ ②酒店发票信息 → ③申请单时间(可能不准) **日期推断优先级**:①交通工具发票日期(最高)→ ②会议通知的报到/会议日 → ③酒店发票信息 → ④申请单时间(可能不准)
**默认值**:单张酒店发票且无明确信息时,`days` 和 `person_count` 均默认为 1。 **晚数要按行程推,不是照抄默认值**:住宿发票通常不印入住/退房日期,但行程是有证据的——
16 日报到、17-18 日会议、19 日返程车票,那就是 10-16 入住、10-19 退房、**days=3**。
只有完全推不出行程(没有车票也没有通知)时才写 `days=1`、`person_count=1`;
照抄默认值会把 333 元/晚的房价报成 1000 元/晚,超标准必被打回。
--- ---
@@ -148,7 +151,12 @@
| `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` | | `card_date` | string | 刷卡日期,格式 `YYYY-MM-DD` |
| `card_amount` | number | 刷卡金额(元) | | `card_amount` | number | 刷卡金额(元) |
| `merchant` | string | 商户信息(高铁票统一为`中国铁路`) | | `merchant` | string | 商户信息(高铁票统一为`中国铁路`) |
| `remark` | string | 关联的发票信息,例:`示例报销人和示例人员甲从阜阳西-合肥南高铁票` | | `remark` | string | 关联的发票信息,例:`公务卡付去程车票2张(发票尾号55222、44418)`(宽度 ≤100,别塞整串发票号) |
**归组按付款时间与行程方向,不按金额相等**:`match_payments` 是贪心凑数,等额的发票最容易配错
(实单:两张 338.00 的车票被分到两笔支付上,凑出 453.50 → 338.00+117.50=455.50 这种多两块钱的组合,
还把 115.50 那张留成未匹配)。先付的款配先乘的程;两边总额相等也**不等于**逐笔配对正确。
工具没有人工作改组入口,把人工结论写进对应 `remark`,下游与人才看得懂。
--- ---
@@ -191,11 +199,20 @@
### 关键规则 ### 关键规则
1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并 1. **去回分开**:交通费的去程和返程必须分两条记录,禁止合并。**中转到站的多张票合并成一条**:
阜阳→长沙要在合肥换车(117.50 + 338.00),就写一条去程 `amount=455.50、bill_count=2`,
备注里把两段车次写清;不要拆成三条(去程两段 + 返程两段会让"去程/返程"各两条,对不上行程)
2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0 2. **住宿天数**:`days = checkout_date - checkin_date`,结果必须 ≥ 0
3. **补助天数**:`days = end_date - start_date + 1` 3. **补助天数**:`days = end_date - start_date + 1`
4. **支付记录排除**:付款记录不放入 `attachments` 4. **支付记录排除**:付款记录不放入 `attachments`
5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null` 5. **合理猜测**:无直接信息时给出合理猜测,不得留空或返回 `null`
6. **文本必须写短**(`remark` / `attachment_desc` / `travel_purpose` / `merchant`):宽度上限 **100**
——汉字、全角标点算 2,字母数字算 1,也就是**约 50 个汉字**。发票号一串就 20 个宽度,
两张票的号写进备注必然超。财务后端对超宽的回答是 `500 Internal Server Error`,
**页面一个字提示都不给**,弹窗停在原地,看上去像脚本没点着「确定」(2026-10-21 实测:
97 字/宽度 130 存进去了,101 字/宽度 171 被拒,真实上限它没说)。
`save_info` 体检与 `browser_fill` 提交前都会拦,但最好一开始就写短:
备注只写「谁、哪段行程、几张票」,发票号留在发票本身(它就是附件)
### 语义完整性校验 ### 语义完整性校验
@@ -219,6 +236,7 @@
**一定要关注的** **一定要关注的**
- `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户 - `reimbursement_details` 和 `payment_methods` 两个的总金额应该一样,如果不一样,要么是缺发票,要么是缺支付记录,需要提醒用户
- 用户一定要提供出差事情申请单 - 用户一定要提供出差事情申请单
- 每个 `remark` / `attachment_desc` 的宽度是否 ≤ 100(约 50 个汉字):超了不是报错,是填报时静默 500
### 示例 ### 示例
+20 -1
View File
@@ -126,6 +126,17 @@ last_reviewed: 2026-10-20
* **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、 * **附件两处硬约束**:「附件说明」是 required,留空点确定只会被 HTML5 校验挡住(不报错、不发请求、
弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`, 弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`,
按表头里的「附件文件名」找 按表头里的「附件文件名」找
* **自由文本超宽 = 静默 500**(2026-10-21 联机实测):差旅明细的备注写到 101 个字,
`POST /expen/travel/detailadd` 回 500,页面上一个字的提示都没有,弹窗停在原地——
现场看着像「脚本没点着确定」,白查一轮。同一张单里 97 字(宽度 130)的备注却存进去了,
说明上限夹在两者之间(要么按 100 字符,要么按 131~170 之间的字节数;后端不肯说,别拿真单据试)。
于是 `validator.TEXT_WIDTH_LIMIT = 100`(汉字算 2、字母数字算 1 ⇒ 约 50 个汉字:宽度 ≤100
同时满足"100 字符"与"131 字节"两种可能口径,是两边都安全的取法),
普通报销的说明另用更短的 `DESCRIPTION_WIDTH_LIMIT = 40`。两道闸:
`validate_extracted_info`(规则里的 `max_len_bytes`,`missing_fields` 会带上
「过长:宽度 N > 上限 M」的说明)与 `browser_fill` 在 POST 之前的
`_text_limit_problems`(失败时返回 `too_long` 列出超长字段)。
备注不是记事本:一个发票号 20 位就吃掉 20 宽度,发票本身就是附件,备注只写"谁、哪段、几张票"
* **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。 * **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。
上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程, 上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程,
而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping` 而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping`
@@ -136,7 +147,15 @@ last_reviewed: 2026-10-20
可以进日志/回显的那份,`load_config()` 里有明文密码 可以进日志/回显的那份,`load_config()` 里有明文密码
* **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`, * **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`,
绝不要提交,也不要回显密码 绝不要提交,也不要回显密码
* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单 * **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;货物类普通发票生成低值易耗品申购单。
**口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费这类普通发票**并入差旅单**
(明细「会务费培训费」栏 + 会议通知当附件),不拆成两张单、也不因此放弃差旅补助;判定只在
`src/core/expense_kind.py` 一处(`is_conference_like`/`goods_invoices`),
`case_status` 与 `validate_info` 的提示都由它驱动——以前三处一律写"混合发票只能走普通报销",
等于把这条合法路径说成错的,Agent 照做就会白拆一张单、还漏掉补助。
交通工具的代码表在 `src/core/vehicles.py`(中文选项 → `#jtgj` 的 value,含 train→火车 等别名),
体检与填报共用;认不出的值现在**直接报错**,不再静默不选(老写法 `if vehicle in vehicle_map`
遇上英文枚举就是不选、还返回成功)
## 技能(skill)分发 ## 技能(skill)分发
+40 -11
View File
@@ -1,9 +1,10 @@
{ {
"version": "1.0", "version": "1.1",
"custom_checks": { "custom_checks": {
"is_valid_date": "检查日期格式是否为 YYYY-MM-DD", "is_valid_date": "检查日期格式是否为 YYYY-MM-DD",
"is_positive_number": "检查是否为正数(整数或浮点数)", "is_positive_number": "检查是否为正数(整数或浮点数)",
"is_positive_integer": "检查是否为正整数" "is_positive_integer": "检查是否为正整数",
"is_valid_vehicle": "检查交通工具能否落到财务系统的下拉框上(含 train→火车 等别名)"
}, },
"travel": { "travel": {
"description": "差旅报销校验规则", "description": "差旅报销校验规则",
@@ -12,6 +13,7 @@
"path": ["basic_info", "travel_purpose"], "path": ["basic_info", "travel_purpose"],
"required": true, "required": true,
"check_empty": true, "check_empty": true,
"max_len_bytes": 100,
"description": "出差事由" "description": "出差事由"
}, },
{ {
@@ -41,14 +43,38 @@
"min_items": 1, "min_items": 1,
"description": "交通费用明细", "description": "交通费用明细",
"element_fields": [ "element_fields": [
{"path": ["vehicle_type"], "required": true, "check_empty": true, "description": "交通工具类型"}, {"path": ["vehicle_type"], "required": true, "check_empty": true, "custom_check": "is_valid_vehicle", "description": "交通工具类型(火车/汽车/轮船/自带车/公务车/飞机/租车/自驾车,也接受 train/car/plane 等英文写法)"},
{"path": ["start_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "出发日期"}, {"path": ["start_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "出发日期"},
{"path": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "到达日期"}, {"path": ["end_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "到达日期"},
{"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"}, {"path": ["departure_place"], "required": true, "check_empty": true, "description": "出发地"},
{"path": ["arrival_place"], "required": true, "check_empty": true, "description": "目的地"}, {"path": ["arrival_place"], "required": true, "check_empty": true, "description": "目的地"},
{"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"}, {"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"},
{"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"}, {"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注说明"} {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明(财务后端超宽直接 500,见 config/README 或 AGENTS.md)"}
]
},
{
"path": ["reimbursement_details", "hotel_fee"],
"min_items": 0,
"description": "住宿费用明细(没有住宿时可整段缺省)",
"element_fields": [
{"path": ["checkin_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "入住日期"},
{"path": ["checkout_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "退房日期"},
{"path": ["days"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "住宿晚数"},
{"path": ["person_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "住宿人数"},
{"path": ["invoice_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "发票金额"},
{"path": ["reimburse_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "报销金额"},
{"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明"}
]
},
{
"path": ["reimbursement_details", "conference_fee"],
"min_items": 0,
"description": "会务费/培训费明细(没有会务费时可整段缺省)",
"element_fields": [
{"path": ["bill_count"], "required": true, "check_empty": true, "custom_check": "is_positive_integer", "description": "票据张数"},
{"path": ["amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "金额"},
{"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注说明"}
] ]
}, },
{ {
@@ -58,8 +84,8 @@
"element_fields": [ "element_fields": [
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"}, {"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
{"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"}, {"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"}, {"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"} {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"}
] ]
}, },
{ {
@@ -80,7 +106,8 @@
"description": "附件列表", "description": "附件列表",
"element_fields": [ "element_fields": [
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"}, {"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
{"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"} {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"},
{"path": ["attachment_desc"], "required": false, "check_empty": false, "max_len_bytes": 100, "description": "附件说明"}
] ]
} }
] ]
@@ -92,7 +119,8 @@
"path": ["basic_info", "reimbursement_description"], "path": ["basic_info", "reimbursement_description"],
"required": true, "required": true,
"check_empty": true, "check_empty": true,
"description": "报销事由" "max_len_bytes": 40,
"description": "报销说明(财务系统里这栏很短,20 个汉字以内)"
}, },
{ {
"path": ["reimbursement_details", "total_invoices"], "path": ["reimbursement_details", "total_invoices"],
@@ -117,8 +145,8 @@
"element_fields": [ "element_fields": [
{"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"}, {"path": ["card_date"], "required": true, "check_empty": true, "custom_check": "is_valid_date", "description": "刷卡日期"},
{"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"}, {"path": ["card_amount"], "required": true, "check_empty": true, "custom_check": "is_positive_number", "description": "支付金额"},
{"path": ["merchant"], "required": true, "check_empty": true, "description": "商户名称"}, {"path": ["merchant"], "required": true, "check_empty": true, "max_len_bytes": 100, "description": "商户名称"},
{"path": ["remark"], "required": true, "check_empty": false, "description": "备注"} {"path": ["remark"], "required": true, "check_empty": false, "max_len_bytes": 100, "description": "备注"}
] ]
}, },
{ {
@@ -127,7 +155,8 @@
"description": "附件列表", "description": "附件列表",
"element_fields": [ "element_fields": [
{"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"}, {"path": ["filename"], "required": true, "check_empty": true, "description": "文件名"},
{"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"} {"path": ["attachment_type"], "required": true, "check_empty": true, "description": "附件类型"},
{"path": ["attachment_desc"], "required": false, "check_empty": false, "max_len_bytes": 100, "description": "附件说明"}
] ]
} }
] ]
+9
View File
@@ -283,6 +283,11 @@ cases/<case_id>/
info_type 取 travel|normal。完整性以返回的 validation 为准, info_type 取 travel|normal。完整性以返回的 validation 为准,
不要自己在 data 里塞 can_submit。 不要自己在 data 里塞 can_submit。
规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1):
备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到,
所以在这里就拦住,missing_fields 会写成
"payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。
| 参数 | 类型 | 必填 | 默认 | 说明 | | 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------| |------|------|------|------|------|
@@ -381,6 +386,10 @@ cases/<case_id>/
执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。 执行一步填报。step 按 browser_new_form 返回的 steps 顺序,不能跳步。
提交前会量这一步要 POST 的自由文本宽度(汉字算 2、字母数字算 1,上限 100,
普通报销说明 40):财务后端对超宽只回 500、页面上任何提示都没有,弹窗停在原地
看着像"按钮没点着",所以宁可在这里拦下来,失败时 too_long 会列出超长字段。
| 参数 | 类型 | 必填 | 默认 | 说明 | | 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------| |------|------|------|------|------|
| `case_id` | string | 是 | — | — | | `case_id` | string | 是 | — | — |
+7 -1
View File
@@ -82,7 +82,13 @@ last_reviewed: 2026-10-19
- 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面 - 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面
- `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`** - `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`**
——不要另开浏览器。`cases/<id>/.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放 ——不要另开浏览器。`cases/<id>/.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放
- `travel_info` 与 `normal_info` 同时存在时,`browser_new_form` 必须显式传 `kind`(混合发票只能走普通报销) - `travel_info` 与 `normal_info` 同时存在时,`browser_new_form` 必须显式传 `kind`,两张单分别走差旅/日常列表页
- 口径:**因出差发生的会议费/会务费/培训费并入差旅单**(明细「会务费培训费」栏,会议通知当附件),
不为此另开一张普通报销单;只有货物类(易耗品、材料)才走普通报销 + 申购单。判定写在
`src/core/expense_kind.py`,`case_status` 与 `validate_info` 的提示由它驱动
- 明细页顶部的红字把「公务卡刷卡pos单」也列进了附件清单要求里:支付记录默认**不**当附件
(它们体现在「支付方式」页),要不要补传由你决定——要补就在附件清单页点「增加」手动传,
别让工具重跑 attachments 那一步(会重复上传已有的附件)
- 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy - 步骤不能跳:travel 是 basic/details/payment/subsidy/attachments,normal 没有 subsidy
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot` - 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
- **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具, - **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具,
+27 -2
View File
@@ -252,8 +252,13 @@ class CaseStore:
warnings: list[str] = [] warnings: list[str] = []
if has_travel and has_normal: if has_travel and has_normal:
warnings.append( 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: if stale:
warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale)) warnings.append("源文件比缓存新,这些票据要重抽:" + "、".join(stale))
@@ -290,6 +295,26 @@ class CaseStore:
"warnings": warnings, "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: def browser_lock_state(self, d: Path) -> dict[str, Any] | None:
"""读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。""" """读 cases/<id>/.browser.lock;没有或损坏返回 None。不依赖 browser 包,避免循环导入。"""
lock = d / BROWSER_LOCK_FILENAME 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 typing import Any, NotRequired, TypedDict
from ... import get_logger from ... import get_logger
from ..vehicles import is_valid_vehicle
log = get_logger("validator") log = get_logger("validator")
@@ -48,8 +49,42 @@ _CUSTOM_CHECKS: dict[str, Callable[[Any], bool]] = {
"is_valid_date": _is_valid_date, "is_valid_date": _is_valid_date,
"is_positive_number": _is_positive_number, "is_positive_number": _is_positive_number,
"is_positive_integer": _is_positive_integer, "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] # 字段路径(统一定位方式) path: list[str] # 字段路径(统一定位方式)
required: NotRequired[bool] # 是否必填(读取端默认 True) required: NotRequired[bool] # 是否必填(读取端默认 True;false 表示键不存在就跳过)
check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True) check_empty: NotRequired[bool] # 是否检查空字符串(读取端默认 True)
custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数) custom_check: NotRequired[str | Callable[[Any], bool]] # 自定义校验函数(名称或已解析函数)
max_len_bytes: NotRequired[int] # 文本宽度上限(汉字算 2,见 text_width)
description: NotRequired[str] # 字段描述(用于生成友好提示) description: NotRequired[str] # 字段描述(用于生成友好提示)
@@ -175,14 +211,23 @@ def _load_default_rules() -> ValidationConfig:
"path": ["reimbursement_details", "transport_fee"], "path": ["reimbursement_details", "transport_fee"],
"min_items": 1, "min_items": 1,
"element_fields": [ "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": ["start_date"], "custom_check": _is_valid_date, "description": "出发日期"},
{"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"}, {"path": ["end_date"], "custom_check": _is_valid_date, "description": "到达日期"},
{"path": ["departure_place"], "description": "出发地"}, {"path": ["departure_place"], "description": "出发地"},
{"path": ["arrival_place"], "description": "目的地"}, {"path": ["arrival_place"], "description": "目的地"},
{"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"}, {"path": ["amount"], "custom_check": _is_positive_number, "description": "金额"},
{"path": ["bill_count"], "custom_check": _is_positive_integer, "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": "交通费用明细", "description": "交通费用明细",
}, },
@@ -193,7 +238,12 @@ def _load_default_rules() -> ValidationConfig:
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"}, {"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"}, {"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"}, {"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"}, {
"path": ["remark"],
"check_empty": False,
"max_len_bytes": TEXT_WIDTH_LIMIT,
"description": "备注",
},
], ],
"description": "支付方式记录", "description": "支付方式记录",
}, },
@@ -215,6 +265,13 @@ def _load_default_rules() -> ValidationConfig:
"element_fields": [ "element_fields": [
{"path": ["filename"], "description": "文件名"}, {"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"}, {"path": ["attachment_type"], "description": "附件类型"},
{
"path": ["attachment_desc"],
"required": False,
"check_empty": False,
"max_len_bytes": TEXT_WIDTH_LIMIT,
"description": "附件说明",
},
], ],
"description": "附件列表", "description": "附件列表",
}, },
@@ -242,7 +299,12 @@ def _load_default_rules() -> ValidationConfig:
{"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"}, {"path": ["card_date"], "custom_check": _is_valid_date, "description": "刷卡日期"},
{"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"}, {"path": ["card_amount"], "custom_check": _is_positive_number, "description": "支付金额"},
{"path": ["merchant"], "description": "商户名称"}, {"path": ["merchant"], "description": "商户名称"},
{"path": ["remark"], "check_empty": False, "description": "备注"}, {
"path": ["remark"],
"check_empty": False,
"max_len_bytes": TEXT_WIDTH_LIMIT,
"description": "备注",
},
], ],
"description": "支付方式记录", "description": "支付方式记录",
}, },
@@ -252,6 +314,13 @@ def _load_default_rules() -> ValidationConfig:
"element_fields": [ "element_fields": [
{"path": ["filename"], "description": "文件名"}, {"path": ["filename"], "description": "文件名"},
{"path": ["attachment_type"], "description": "附件类型"}, {"path": ["attachment_type"], "description": "附件类型"},
{
"path": ["attachment_desc"],
"required": False,
"check_empty": False,
"max_len_bytes": TEXT_WIDTH_LIMIT,
"description": "附件说明",
},
], ],
"description": "附件列表", "description": "附件列表",
}, },
@@ -298,30 +367,49 @@ def _check_field(
data: dict[str, Any], data: dict[str, Any],
rule: FieldRule, rule: FieldRule,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""根据字段规则检查字段。""" """根据字段规则检查字段。
Returns:
(是否通过, 字段路径)。不通过且原因可说清时(如文本超宽),路径后拼
「{HINT_SEP}说明」,_check_array 会把说明转接到 payment_methods[0].remark|… 上,
让 Agent 一眼看出是「缺字段」还是「话写太长了」。
"""
path = rule["path"] path = rule["path"]
required = rule.get("required", True)
check_empty = rule.get("check_empty", True) check_empty = rule.get("check_empty", True)
custom_check = rule.get("custom_check") custom_check = rule.get("custom_check")
max_len_bytes = rule.get("max_len_bytes")
path_str = ".".join(path)
current = data current = data
for key in path: for key in path:
if not isinstance(current, dict): if not isinstance(current, dict) or key not in current:
return (False, ".".join(path)) # required=false 的字段(如 attachment_desc)不存在就跳过:
if key not in current: # 填报端有自己的兜底(附件说明留空会写成「发票:文件名」)
return (False, ".".join(path)) return (True, path_str) if not required else (False, path_str)
current = current[key] current = current[key]
if check_empty and isinstance(current, str) and not current.strip(): 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 custom_check is not None:
if isinstance(custom_check, str): if isinstance(custom_check, str):
# 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误 # 规则名未在 _resolve_custom_checks 里解析成函数,属于配置/调用错误
raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析") raise TypeError(f"custom_check 仍为规则名 {custom_check!r},未经 _resolve_custom_checks 解析")
if not custom_check(current): 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( def _check_array(
@@ -346,11 +434,16 @@ def _check_array(
current = data current = data
for key in path: for key in path:
if not isinstance(current, dict) or key not in current: 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) return ([path_str], total_checks, passed_checks)
current = current[key] current = current[key]
# 检查数组是否满足最小数量要求 # 检查数组是否满足最小数量要求
if not isinstance(current, list) or len(current) < min_items: 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) return ([path_str], total_checks, passed_checks)
passed_checks += 1 # 数组检查通过 passed_checks += 1 # 数组检查通过
@@ -371,13 +464,15 @@ def _check_array(
else: else:
field_rule_dict = field_rule field_rule_dict = field_rule
# 复用 _check_field 函数检查元素字段 # 复用 _check_field 函数检查元素字段(超宽之类的说明转接到条目上)
ok, _ = _check_field(item, field_rule_dict) ok, msg = _check_field(item, field_rule_dict)
if ok: if ok:
passed_checks += 1 passed_checks += 1
else: else:
field_path_str = ".".join(field_rule_dict["path"]) 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 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 typing import Any
from ... import get_logger from ... import get_logger
from ...core.vehicles import VEHICLE_CODES, canonical_vehicle, vehicle_error
from .base import BaseBot, format_date from .base import BaseBot, format_date
log = get_logger("bot.travel") 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"]),包含 details: 报销明细字典(travel_info["reimbursement_details"]),包含
transport_fee、hotel_fee、conference_fee 等子字段。 transport_fee、hotel_fee、conference_fee 等子字段。
""" """
vehicle_map = { # 交通工具的代码表与别名归一搬到了 src/core/vehicles.py:
"火车": "01", # 填报层取 value、体检层判合法、报错层给候选,三处共用一张表。
"汽车": "02", # 原来这里 `if vehicle in vehicle_map:` 是静默跳过——英文枚举(train)匹配不上,
"轮船": "03", # 下拉框停在默认值而本步照样返回成功,等人提交时才被必填拦下(2026-10-21 踩过)。
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
try: try:
if not bot.open_tab("details"): if not bot.open_tab("details"):
raise RuntimeError("切不到差旅明细标签页") raise RuntimeError("切不到差旅明细标签页")
@@ -113,9 +107,14 @@ def add_travel_items(bot: BaseBot, details: dict[str, Any]) -> None:
bot._wait_for('text="增加明细"', timeout=5000) bot._wait_for('text="增加明细"', timeout=5000)
bot.page.select_option("#cost", "1") bot.page.select_option("#cost", "1")
bot.page.wait_for_timeout(500) bot.page.wait_for_timeout(500)
vehicle = item.get("vehicle_type", "") raw_vehicle = item.get("vehicle_type", "")
if vehicle in vehicle_map: vehicle = canonical_vehicle(raw_vehicle)
bot.page.select_option("#jtgj", vehicle_map[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("#ksdd", item.get("departure_place", ""))
bot.page.fill("#jsdd", item.get("arrival_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, 5. 依据 totals 与 suggested_invoice_type 综合缓存和匹配结果,产出 travel_info 或 normal_info,
用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则, 用 mcp__arb__save_info 落盘(会自动跑规则校验);字段拿不准先 mcp__arb__rules_get 对照规则,
或用 mcp__arb__validate_info(draft=...) 不落盘反复试。不完整就向用户要补充材料,禁止自己宣布可提交。 或用 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 的键必须存在(含值为空串的备注类 拼 JSON 前先 mcp__arb__info_skeleton(info_type) 取骨架:required 为 true 的键必须存在(含值为空串的备注类
字段),骨架与 validation_rules.json 同源,别凭记忆造结构。 字段),骨架与 validation_rules.json 同源,别凭记忆造结构。
6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel; 6. 普通发票用 mcp__arb__doc_requisition 生成 Excel 申购单(COM 自动化,需本机装有 Excel;
@@ -46,6 +52,7 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配
normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。 normal 没有 subsidy)→ mcp__arb__browser_page_state 复查 → mcp__arb__browser_close(不提交也不存草稿)。
浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口, 浏览器归属写在 cases/<id>/.browser.lock(PID + 心跳):返回 owned_by_other_process 时不要另开窗口,
让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。 让持有者 close 或等心跳超时 180 秒后自动接管。截图按 001-步骤名.png 落在 cases/<id>/screenshots/。
某步返回 too_long 就是那段文本超宽(见第 5 条),改短后重跑同一 step,别去浏览器里手动点确定。
**提交与提交前后的核对都由用户本人在财务系统里做**:submit_* 这一类工具(代提交、提交前预览) **提交与提交前后的核对都由用户本人在财务系统里做**: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 为准, info_type 取 travel|normal。完整性以返回的 validation 为准,
不要自己在 data 里塞 can_submit。 不要自己在 data 里塞 can_submit。
规则里带 max_len_bytes 的是**文本宽度**上限(汉字算 2、字母数字算 1):
备注写太长,财务后端不回"太长了"而是一记 500,页面上什么都看不到,
所以在这里就拦住,missing_fields 会写成
"payment_methods[0].remark|备注过长:宽度 171 > 上限 100(约 50 个汉字),请改短"。
""" """
d = _store.resolve(case_id) d = _store.resolve(case_id)
if d is None: 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) report = validate_extracted_info(data, invoice_type=info_type)
payload = _report_dict(report) payload = _report_dict(report)
if groups_path.exists() and info_type == "travel": 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) 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"} PAGE_KEY = {"travel": "travel_page", "normal": "reimburse_page"}
INFO_FILE = {"travel": "travel_info.json", "normal": "normal_info.json"} 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: def _case_dir(case_id: str) -> Path | None:
return _store.resolve(case_id) 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: if both:
return fail( return fail(
"会话里同时有 travel_info 与 normal_info,无法判断单据类型", "会话里同时有 travel_info 与 normal_info,无法判断单据类型",
next_action="显式传 kind='travel' 或 'normal'(混合发票只能走普通报销)", next_action=(
"显式传 kind='travel' 或 'normal'(因出差发生的会议费并入差旅单;货物类普通发票才另走普通报销)"
),
) )
if kind is None: if kind is None:
return fail( 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]: 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) d = _case_dir(case_id)
ctx = _require_open(case_id) ctx = _require_open(case_id)
if d is None or ctx is None: 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: if info is None:
return fail(f"读不到 {INFO_FILE[kind]}", next_action="先 mcp__arb__save_info 落盘报销信息") 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]: def _fill() -> dict[str, Any]:
bot = ctx["bot"] bot = ctx["bot"]
filled = _apply_step(bot, kind, step, info) filled = _apply_step(bot, kind, step, info)
+266
View File
@@ -0,0 +1,266 @@
"""财务系统文本宽度上限的回归锁
踩过的坑(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()) == []
+213
View File
@@ -0,0 +1,213 @@
"""交通工具代码表与「哪些普通发票并入差旅单」的回归锁
两条都是 2026-10-21 实单踩出来的:
1. `vehicle_type` 填英文枚举(参考文档历来写 `train`)时,老写法 `if vehicle in vehicle_map:`
会**静默不选**下拉框,本步还返回成功——人提交时才被必填拦下,看着像"脚本没点着按钮"。
现在别名归一(train→火车),认不出的值直接报错。
2. `case_status`/`validate_info`/`browser_new_form` 三处以前一律说"混合发票只能走普通报销",
把「会议费并入差旅单」这条合法路径说成错的(用户 2026-10-21 定案:差旅 + 会议费走差旅单)。
口径判定收进 `src/core/expense_kind.py` 一处。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from src.cases import CaseStore
from src.core.expense_kind import goods_invoices, is_conference_like
from src.core.validation.validator import validate_extracted_info
from src.core.vehicles import VEHICLE_CODES, canonical_vehicle, is_valid_vehicle, vehicle_code, vehicle_error
from src.infra.browser import travel as travel_flow
# ------------------------------------------------------------------
# 假 bot:只记录 select_option / fill,不开真浏览器
# ------------------------------------------------------------------
class FakePage:
def __init__(self) -> None:
self.options: list[tuple[str, str]] = []
self.fills: list[tuple[str, str]] = []
def click(self, selector: str, timeout: int | None = None) -> None:
return None
def select_option(self, selector: str, value: str) -> None:
self.options.append((selector, value))
def fill(self, selector: str, value: str) -> None:
self.fills.append((selector, value))
def wait_for_timeout(self, ms: int) -> None:
return None
class FakeBot:
def __init__(self) -> None:
self.page = FakePage()
def open_tab(self, name: str) -> bool:
return True
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
return None
def _screenshot(self, name: str = "") -> str:
return ""
def _details(vehicle_type: str) -> dict[str, Any]:
return {
"transport_fee": [
{
"vehicle_type": 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": [],
"conference_fee": [],
}
# ------------------------------------------------------------------
# 代码表与别名
# ------------------------------------------------------------------
def test_canonical_vehicle_accepts_chinese_and_aliases() -> None:
assert canonical_vehicle("火车") == "火车"
assert canonical_vehicle(" 火车 ") == "火车" # 手滑多敲的空格不该变成"认不出"
assert canonical_vehicle("train") == "火车"
assert canonical_vehicle("TRAIN") == "火车"
assert canonical_vehicle("self-drive") == "自驾车"
assert canonical_vehicle("plane") == "飞机"
assert canonical_vehicle("高铁") == "火车"
assert canonical_vehicle("official_car") == "公务车"
def test_canonical_vehicle_rejects_unknown() -> None:
assert canonical_vehicle("地铁") == ""
assert canonical_vehicle("") == ""
assert canonical_vehicle(None) == ""
assert not is_valid_vehicle("地铁")
assert not is_valid_vehicle("自行车") # 财务系统没这个选项,别臆造
assert "自行车" in vehicle_error("自行车")
assert "火车" in vehicle_error("自行车") # 报错要带上能填什么
def test_vehicle_codes_are_the_finance_dropdown() -> None:
"""下拉框 value 别顺手重排:01..08 是财务系统页面上的顺序。"""
assert VEHICLE_CODES == {
"火车": "01",
"汽车": "02",
"轮船": "03",
"自带车": "04",
"公务车": "05",
"飞机": "06",
"租车": "07",
"自驾车": "08",
}
assert vehicle_code("train") == "01"
assert vehicle_code("地铁") == ""
def test_filler_selects_dropdown_for_alias_and_raises_for_unknown() -> None:
bot = FakeBot()
travel_flow.add_travel_items(bot, _details("train"))
assert ("#jtgj", "01") in bot.page.options, bot.page.options # 英文枚举也得真的选中
bot2 = FakeBot()
travel_flow.add_travel_items(bot2, _details("火车"))
assert ("#jtgj", "01") in bot2.page.options
bot3 = FakeBot()
with pytest.raises(ValueError, match="火车"): # 认不出就停下,别静默留默认值
travel_flow.add_travel_items(bot3, _details("地铁"))
def test_validator_rejects_vehicle_that_cannot_be_selected() -> None:
info: dict[str, Any] = {
"basic_info": {
"travel_purpose": "参加研讨会",
"travel_location": "长沙",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
},
"reimbursement_details": {"transport_fee": _details("train")["transport_fee"]},
"payment_methods": [
{"card_date": "2026-10-11", "card_amount": 455.5, "merchant": "中国铁路", "remark": "去程车票2张"}
],
"subsidy_list": [
{
"person_id": "202407021",
"person_name": "王建锋",
"start_date": "2026-10-16",
"end_date": "2026-10-19",
"days": 4,
}
],
"attachments": [],
}
assert validate_extracted_info(info, invoice_type="travel").valid, "train 是合法写法(会被归一成火车)"
info["reimbursement_details"]["transport_fee"][0]["vehicle_type"] = "地铁"
report = validate_extracted_info(info, invoice_type="travel")
assert not report.valid
assert any("vehicle_type" in m for m in report.missing_fields)
# ------------------------------------------------------------------
# 报销口径:会议费并入差旅单
# ------------------------------------------------------------------
def test_conference_like_invoices_merge_into_travel() -> None:
assert is_conference_like({"item_name": "会议费(*生产生活服务*会议费)"})
assert is_conference_like({"item_name": "培训费"})
assert is_conference_like({"item_name": "住宿服务", "remark": "会务费由易科捷代收"})
assert not is_conference_like({"item_name": "电子元器件(*生产生活服务*)"})
assert not is_conference_like({"item_name": "低值易耗品"})
def test_goods_invoices_are_the_only_ones_needing_own_form() -> None:
general = [
{"item_name": "会议费", "_source_file": "a.pdf"},
{"item_name": "五金配件", "_source_file": "b.pdf"},
]
goods = goods_invoices(general)
assert len(goods) == 1
assert goods[0]["_source_file"] == "b.pdf"
def test_case_status_lists_only_goods_invoices(tmp_path: Path) -> None:
"""case_status 的提醒只针对货物类——别再把会议费说成"只能走普通报销"。"""
store = CaseStore()
groups = {
"travel": [{"invoice_type": "train"}],
"general": [{"item_name": "会议费(*生产生活服务*会议费)", "_source_file": "hui.pdf"}],
}
(tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8")
assert store.goods_general_items(tmp_path) == []
groups["general"].append({"item_name": "低值易耗品", "_source_file": "huo.pdf"})
(tmp_path / "invoice_groups.json").write_text(json.dumps(groups, ensure_ascii=False), encoding="utf-8")
assert store.goods_general_items(tmp_path) == ["低值易耗品"]
def test_case_status_survives_broken_groups_file(tmp_path: Path) -> None:
"""派生产物读坏了不能把状态查询拖崩(它只该少一条提示)。"""
broken = "{不是 JSON"
(tmp_path / "invoice_groups.json").write_text(broken, encoding="utf-8")
assert CaseStore().goods_general_items(tmp_path) == []
# 只读不写:坏了的派生产物留给 case_reset 处理,校验路径不去动它
assert (tmp_path / "invoice_groups.json").read_text(encoding="utf-8") == broken