Files
wandering 0d9cc7b4ca 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 提交并手跑等价检查。
2026-10-21 13:49:12 +08:00

142 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: reimbursement
description: >
财务报销自动化(阜阳师范大学):把发票、支付截图、出差申请单变成结构化报销数据,
生成低值易耗品申购单,并在财务系统填报到「待人工提交」。当用户提到报销、发票、差旅费、
公务卡刷卡记录、易耗品申购、财务系统填报时使用。通过 mcp__arb__* 工具操作,禁止臆造财务数据。
本技能与当前工程无关:在任意项目目录下都能用,产物在 MCP 服务器自己的会话目录里。
---
# 财务报销(arb MCP 工具)
本技能**不依赖当前工作目录**,也不需要你打开报销项目的仓库:所有产物都由 arb MCP 服务器写到
它自己的会话目录 `cases/<case_id>/`。字段与规则的**唯一权威来源是工具本身**
(`extraction_spec` / `info_skeleton` / `rules_get`)——别凭记忆造结构,更别去找某个文件里的字段表。
## 第 0 步:确认工具可用
- 先调 `mcp__arb__case_list`(无参数)。返回会话列表就说明服务器已接入,直接进流程。
- 工具不存在或报 unknown tool:arb 服务器没注册。这时**不要绕道自己写脚本处理发票**,
把下面的配置给用户,让他在宿主(如 DSH)的 MCP 配置里加一条 stdio 服务器:
```json
{ "arb": { "command": "uv",
"args": ["run", "python", "-m", "src.mcp.server"],
"cwd": "D:/Code/AI Agent/自动报销系统" } }
```
前提:那台机器上有该仓库(Windows-only,申购单要 Excel COM),且 `uv sync` 与
`uv run playwright install chromium` 装好;手机扫码上传还要单独起
`uv run python -m src.upload.server`。仓库路径以用户实际位置为准,上面的路径只是本机现状。
- 加了配置要重连宿主才生效(stdio 子进程不会自己加载新代码)。
## 流程
1. `mcp__arb__case_create` 建会话,记下 `case_id`,把返回的 **`upload_url` 发给用户**——那是这一单专用的
手机直链,点开即是上传页,不用在首页一堆二维码里挑。链接含会话令牌:**只给这一次**,别在后续
回答里重复它,也别写进任何文件;用户说丢了就用 `case_upload_link(case_id)` 重取一次
2. 材料入库:`case_add_files(paths=[...])`——**绝对路径、正斜杠**(`C:/Users/xx/Downloads/发票1.pdf`);
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程**
(`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听
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),
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
`references/invoice-fields.md`(本技能自带,不依赖仓库)
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。
贪心结果 `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`(自动跑规则校验)。拿不准的用
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。
- **报销口径(用户 2026-10-21 定案)**:因出差发生的会议费/会务费/培训费**并入差旅单**
(明细「会务费培训费」栏 + 会议通知当附件),不为它另开普通报销单,也不因此放弃补助;
只有货物类(易耗品/材料/配件)才另走 `normal_info` + 申购单。`suggested_invoice_type`
看见 general 发票就报 `normal`,这时**以口径为准、不以它为准**
- 住宿发票一般不印入住/退房日期:按会议报到日至返程日推**实际晚数**(4 天行程 3 晚),
别照搬参考文档 `days=1` 的保守默认——那会把 333 元/晚报成 1000 元/晚,财务必打回
- 中转到站的多张车票合并成「去程一条、返程一条」,`bill_count` 记实际张数、金额相加;
明细的日期列由页面按出差起止带出,填报脚本不填它
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
纯差旅没有申购明细会返回失败提示,那不是错误
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
`browser_fill(step=...)` → `browser_page_state` 复查。`browser_login` 返回 `need_human` 时让用户在
**已打开的那个窗口**里完成扫码/验证码再重调;travel_info 与 normal_info 同时存在时
`browser_new_form` 必须显式传 `kind`。`browser_close` 留到**用户提交之后**再叫:
填完那会儿单据还不存在(没有编号、列表页查不到),窗口一关就是全丢,不是"存了草稿"。
收尾话术固定两句:「还没提交,别关这个窗口」+「提交后把编号发我,我用 browser_open_form 核对」
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
最稳的是**单据编号**
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断。
**唯独 `attachments` 不能重跑**:它逐个弹窗上传、没有去重,重跑一遍附件就翻倍;
要补附件让人在附件清单页点「增加」手动传(会话目录里的原始文件路径给他)。
还有:差旅单几个标签共用一个页面,「基本信息」那步的截图经常仍拍在明细表格上,
别拿它当基本信息的证据;要确认就让用户目视,或看 `browser_page_state` 的 markers
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
要写清楚就把 `attachment_desc` 填进 attachments 项
## 必须遵守
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
禁止说「可以提交了」
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
- **口径不自己发明**:因出差发生的会议费/会务费/培训费并入差旅单(不为它拆单、不放弃补助),
货物类普通发票才走普通报销 + 申购单;判定表在 `src/core/expense_kind.py`,工具提示由它驱动,
`suggested_invoice_type` 只是参考。住宿实际晚数、中转车票的合并方式同理——按证据推,
拿不准就问用户一句,别照默认值交卷
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
- `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` 里**
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,
那等于把身份证号提交进仓库
## 提交、删除、撤回:都由人在财务系统里做
`submit_*` 整类(代提交、提交前预览)已被用户整体否决,**填报完成就是本技能的终点**:
把**单据编号**和会话目录(从 `case_status` 的 `case_dir` 读,不要自己拼)告诉用户,
他自己去系统里核对、提交、查状态。删除(`#delete`)与撤回同理,工具层不给这类动作开口子。
提交错了要写情况说明,所以宁可停在填完,也不要替用户按下任何不可逆的按钮。
浏览器被别的进程占用时返回 `owned_by_other_process`:让持有者 `browser_close`,或等心跳超时 180 秒
自动接管,不要绕过去另开一个 Chromium(那样你看到的截图就不是你在点的页面)。
## 工具与宿主的两条怪脾气
- 调工具时路径**一律正斜杠**,反斜杠会被宿主的参数绑定层拒收
- 服务器代码改了要重连宿主才生效(常驻 stdio 子进程内存里有旧代码;本机同时存在多代子进程是常态,
所以浏览器/Office 都有磁盘锁,别假设只有一个实例)
## 参考(随本技能一起安装,不需要仓库)
- `references/invoice-fields.md` — 七类票据的字段判读细则(火车票/支付截图/酒店/普通发票/事前申请单/出入库单/其他附件)
- `references/travel-info-fields.md` — 差旅报销数据:交通/住宿/会议费、支付方式、按人按天的补助清单
- `references/normal-info-fields.md` — 普通报销数据:明细与支付方式
这三份是**判读经验**(怎么认票、冲突怎么取、常见坑),不是 schema。schema 一律以工具返回为准。