0d9cc7b4ca
这一批是 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 提交并手跑等价检查。
327 lines
16 KiB
Markdown
327 lines
16 KiB
Markdown
---
|
||
last_reviewed: 2026-10-19
|
||
---
|
||
|
||
# 阜阳师范大学财务报销系统 - 自动化操作指南
|
||
|
||
> 适用场景:日常报销录入。入口是 MCP 服务器,编排由宿主 Agent(如 DSH)完成;工具参数表见 [MCP.md](./MCP.md)。
|
||
> 本文讲财务侧口径、页面要素与材料要求。文中账号、卡号、姓名一律是占位示例——**真实值不要写进仓库任何位置**。
|
||
|
||
---
|
||
|
||
## 一、系统概览
|
||
|
||
```
|
||
信息门户(SSO登录) → 财务系统入口 → 单点登录页 → 网络报销 → 日常报销录入
|
||
(tyrz.fynu.edu.cn) (点击「财务系统」) (新标签页) (a:has(img)) (/expen/common/common)
|
||
```
|
||
|
||
| 系统 | 地址 | 说明 |
|
||
|------|------|------|
|
||
| SSO 登录 | `https://tyrz.fynu.edu.cn/sso/login` | 统一认证入口 |
|
||
| 信息门户 | `https://tyrz.fynu.edu.cn/oshall` | 登录后跳转目标 |
|
||
| 报销系统 | `http://<财务系统内网地址>:8081` | 网络报销主系统 |
|
||
| 日常报销录入 | `/expen/common/common?v=4.0` | 普通报销目标页 |
|
||
| 差旅报销 | `/expen/travel/travel?v=4.0` | 差旅报销目标页 |
|
||
|
||
取值优先级:**环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认**。
|
||
环境变量名是 `SSO_LOGIN_URL` / `PORTAL_URL` / `REIMBURSE_URL` / `REIMBURSE_PAGE` / `TRAVEL_PAGE`
|
||
(见 `.env.example`;项目**不会**自动读 `.env` 文件,要自己 export 或写进宿主的 MCP env)。
|
||
配置文件里这几项**可以不写**;写了占位假值(含 `your-`)会被当成没配并打 WARNING,不会拿去登录。
|
||
|
||
---
|
||
|
||
## 二、使用方式
|
||
|
||
没有 Web 界面,也没有 CLI 产品入口。准备三步,之后全部在 Agent 对话里完成:
|
||
|
||
1. **注册 MCP 服务器**到宿主:`python -m src.mcp.server`(stdio),DSH 的 patch 写法见 `docs/MCP.md`
|
||
2. **配好** `scripts/data/config.json`(参考根目录 `config.example.json`)
|
||
3. **要手机拍照上传时**启动收件器:`make upload`(默认 `0.0.0.0:5055`)
|
||
|
||
自检:`make mcp-smoke` 会真起 stdio 子进程握手并列出 25 个工具。
|
||
|
||
---
|
||
|
||
## 三、材料准备
|
||
|
||
| 材料 | 必需 | 说明 |
|
||
|------|------|------|
|
||
| 发票 PDF / 图片 | 是 | 电子发票尽量给 PDF:有文本层,准确率明显高于读图 |
|
||
| 支付截图 | 是 | 公务卡刷卡记录;要能看清**商户**、日期、金额、卡号后四位 |
|
||
| 出差事前申请单 | 差旅必需 | 缺它财务不认,Agent 会主动索要 |
|
||
| 其他附件 | 视情况 | 会议通知、合同等 |
|
||
|
||
### 发票类型分流
|
||
|
||
| 类型 | 判断依据 | 产物 | 报销流程 |
|
||
|------|----------|------|----------|
|
||
| 差旅发票 | 高铁票、机票、酒店住宿等 | 不生成申购单 | 差旅报销(含按人按天补助) |
|
||
| 普通发票 | 其他(办公用品、耗材、元件等) | 低值易耗品**申购单** | 普通报销(一条总明细 + 逐笔支付) |
|
||
|
||
> Word 版「易耗品出库单」已下线:模板自 2026-08-31 起不在仓库,那条路径此前一直静默失败。
|
||
|
||
---
|
||
|
||
## 四、Agent 操作流程
|
||
|
||
| 步 | 做什么 | 用什么 |
|
||
|----|--------|--------|
|
||
| 1 | 建会话,拿到手机上传页地址 | `case_create` |
|
||
| 2 | 材料入库(本地路径用正斜杠;手机上传后用 `source="inbox"` 收取) | `case_add_files` |
|
||
| 3 | 渲染页面与文本层 | `case_render_pages` → `case_read_text` 或直接读 PNG |
|
||
| 4 | 逐张抽取落盘;核对后局部修正 | `case_save_extraction` / `case_patch_extraction` |
|
||
| 5 | 金额匹配,**逐条处理 `review_required`** | `match_payments` |
|
||
| 6 | 取骨架 → 填值 → 落盘校验(试算用 `validate_info` 不落盘) | `info_skeleton` → `save_info` |
|
||
| 7 | 普通发票生成申购单(`exclude` 剔除不参与申购的物品) | `doc_requisition` |
|
||
| 8 | 随时复查态势;要重跑就清派生产物 | `case_status` / `case_reset` |
|
||
| 9 | 填报(可选):开浏览器 → 登录 → 建单 → 按步填 → 复查 → 关 | `browser_open` → `browser_login` → `browser_new_form` → `browser_fill` → `browser_page_state` → `browser_close` |
|
||
|
||
### 填报这段要注意
|
||
|
||
- 浏览器默认**有头**:提交不可逆,得看得见在点什么;期间别手动点页面
|
||
- `browser_login` 返回 `need_human` 时,人在已经打开的那个窗口里扫码或输验证码,然后**重调 `browser_login`**
|
||
——不要另开浏览器。`cases/<id>/.browser.lock` 记着谁拥有它,返回 `owned_by_other_process` 就等对方释放
|
||
- `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
|
||
- 每步截图按 `001-步骤名.png` 落在 `cases/<id>/screenshots/`;失败时截图路径在返回的 `detail.screenshot`
|
||
- **`browser_close` 不提交也不存草稿**,填了一半会丢;**提交永远由人做**——本项目不提供点提交的工具,
|
||
- 中途断了不必重来:重新开浏览器登录后用 `browser_open_form`(先 `preview_only=true` 核对**单据编号**,
|
||
命中多张会直接拒绝)从列表页找回原单续填;**别再 `browser_new_form`**,那会多建一张单
|
||
- 列表页那个「查看」按钮是看审批流程图,不是打开单据;要接着填是选中行后点「修改」
|
||
|
||
**`ok: true` 的含义有限**:`match_payments` 成功只代表贪心跑完。一对多凑额、发票总额小于支付总额、
|
||
未匹配项都会进 `review_required`,必须逐条确认——等额发票尤其容易配错。
|
||
|
||
**什么时候算完成**:`validate_info` 报告 `valid: true` 且无业务约束违反。缺料就向用户要材料;
|
||
Agent 不许自己宣布「可以提交了」。
|
||
|
||
### 产物位置
|
||
|
||
全部在 `cases/<case_id>/`(整个目录已 gitignore,含身份证号与卡号):
|
||
|
||
| 产物 | 说明 |
|
||
|------|------|
|
||
| `pages/<票据>/p01.png` | 渲染页面(已按文本方向自动转正) |
|
||
| `.invoice_cache/*.json` | 单张票据结构化数据 |
|
||
| `match_result.json` / `invoice_groups.json` | 匹配与分组结果 |
|
||
| `invoice_summary.csv` / `payment_records.csv` | 发票级与支付级汇总 |
|
||
| `travel_info.json` / `normal_info.json` | 填报所需的完整结构化数据 |
|
||
| `artifacts/低值易耗品申购_<case>.xls` | 申购单,拿去资产/财务口径核对 |
|
||
|
||
---
|
||
|
||
## 五、页面元素速查
|
||
|
||
### 基本信息页
|
||
|
||
| 字段 | 选择器 | 操作 |
|
||
|------|--------|------|
|
||
| 报销说明 | `#EXPENEXPLAIN` | fill |
|
||
| 项目代码 | `#PROJECTCODE` | click → 弹窗选择 |
|
||
| 项目弹窗 | `#promodal .fixed-table-body tbody tr` | 点击第一行 |
|
||
| 下一步按钮 | `#saveAndNext` | click |
|
||
|
||
### 报销明细页
|
||
|
||
| 字段 | 选择器 | 操作 |
|
||
|------|--------|------|
|
||
| 增加按钮 | `#insertDetail` | click |
|
||
| 经济事项代码 | `#economicscode2` | click → 弹窗选择 |
|
||
| 经济科目弹窗 | `#econmodal .fixed-table-body tbody tr` | 点击第 3 行 |
|
||
| 单据数 | `input[name="expenPwCommondetail.HOWBILLS"]` | fill |
|
||
| 报销总金额 | `#je_zwzcdz` | fill |
|
||
| 确定按钮 | `#detailAdd` | click |
|
||
|
||
### 支付方式页
|
||
|
||
| 字段 | 选择器 | 操作 |
|
||
|------|--------|------|
|
||
| 增加按钮 | `#insertPay` | click |
|
||
| 人员编号 | `#personid2` | fill |
|
||
| 人员姓名 | `#accountname2` | fill |
|
||
| 刷卡日期 | `#receiptdate2` | fill |
|
||
| 公务卡号 | `#localaccount2` | fill |
|
||
| 刷卡金额 | `#receiptmoney2` | fill |
|
||
| 实报金额 | `#money2` | fill |
|
||
| 商户 | `#merchant2` | fill |
|
||
| 备注 | `#smark2` | fill |
|
||
| 确定按钮 | `#payAdd` | click |
|
||
|
||
### 附件清单页
|
||
|
||
| 字段 | 选择器 | 操作 |
|
||
|------|--------|------|
|
||
| 增加按钮 | `#insertAcc` | click |
|
||
| 附件类型 | `#fjlx` | select_option → 1(发票) |
|
||
| 附件说明 | `#fpsmxx` | fill |
|
||
| 文件上传 | `#file` | set_input_files |
|
||
| 确定按钮 | `#cjtj` | click |
|
||
|
||
### 提交(只作页面知识保留,工具不提供这个动作)
|
||
|
||
下表是财务系统里提交相关控件的位置,写在这里是为了排查时看得懂页面;
|
||
**本项目的工具不会点它们**,提交一律由人来做。
|
||
|
||
| 操作 | 选择器 |
|
||
|------|--------|
|
||
| 提交按钮 | `#submit` |
|
||
| 提交按钮(备用) | `#submit2` |
|
||
|
||
---
|
||
|
||
## 六、数据流向
|
||
|
||
```
|
||
材料(PDF / 图片)
|
||
│ case_render_pages:页面 PNG + 文本层 + 自动转正
|
||
▼
|
||
宿主 Agent 读图与判断 ← 本项目不调用任何模型
|
||
│ case_save_extraction(schemas.py 规范化日期与金额)
|
||
▼
|
||
.invoice_cache/*.json
|
||
│ match_payments(分组 + 一对一/一对多贪心 + 业务约束)
|
||
▼
|
||
match_result.json / invoice_groups.json / 两份 CSV
|
||
│ info_skeleton → 填值 → save_info(声明式规则校验)
|
||
▼
|
||
travel_info.json 或 normal_info.json
|
||
│ doc_requisition(Excel COM,仅普通发票)
|
||
▼
|
||
artifacts/ 申购单
|
||
│ browser_open → browser_login → browser_new_form / browser_open_form → browser_fill
|
||
▼
|
||
财务系统报销单
|
||
```
|
||
|
||
---
|
||
|
||
## 七、关键设计说明
|
||
|
||
### 7.1 浏览器启动
|
||
|
||
Playwright `sync_playwright()` 启动 Chromium,**默认有头**(`headless=False`)——提交不可逆,得看清楚在点什么。
|
||
SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运行都要重新登录。
|
||
|
||
### 7.2 明细录入策略
|
||
|
||
普通报销用「一条总明细」:所有发票合并成一条,报销总金额 = 各笔支付之和,单据数 = 发票张数;
|
||
支付方式逐笔录入,一笔一条。差旅按人去程/返程分条录交通费,补助按人按天算。
|
||
|
||
### 7.3 经济科目与项目(已知限制,待业务口径)
|
||
|
||
经济科目弹窗当前固定选第 3 行(`rows[2]`),项目弹窗固定选第 1 行,补助人员取查询结果第一行;
|
||
位置在 `src/infra/browser/travel.py` 与 `normal.py`。
|
||
|
||
**这是已知限制而不是正确策略**:科目选错财务会退单,而代码不知道该按什么规则选。
|
||
等给出业务口径(按发票品名?按项目性质?按经费用途?)后改成可配置加唯一匹配。
|
||
在此之前,填报完成务必人工核对科目与项目。
|
||
|
||
### 7.4 网络报销链接动态获取
|
||
|
||
单点登录页的「网络报销」链接参数每次不同,因此用 `a:has(img[src*="wlbx"])` 定位并读 `href`,不硬编码 URL。
|
||
|
||
### 7.5 手机上传
|
||
|
||
收件器是**独立进程**。`case_create` 直接返回这一单的**手机直链** `upload_url`(含令牌),点开即是上传页;
|
||
链接丢了用 `case_upload_link` 重取。PC 首页是备用入口(列出全部会话的二维码,容易分不清扫哪个)。
|
||
上传落 `cases/<id>/inbox/`,
|
||
再由 Agent 调 `case_add_files(source="inbox")` 收取。它只写不读(没有列表也没有下载路由)、令牌强制、
|
||
扩展名白名单、25MB/文件、200 文件/会话。旧 Web 的「PC 端轮询同步」已删除。
|
||
|
||
---
|
||
|
||
## 八、配置说明
|
||
|
||
`scripts/data/config.json`(已在 `.gitignore`,**永远不要提交**):
|
||
|
||
```json
|
||
{
|
||
"username": "<工号>",
|
||
"password": "<密码>",
|
||
"default_name": "<报销人姓名>",
|
||
"default_card_no": "<公务卡号>",
|
||
"default_person_id": "<人员编号>",
|
||
"upload_pin": "<手机上传访问码,可留空>"
|
||
}
|
||
```
|
||
|
||
| 字段 | 用途 |
|
||
|------|------|
|
||
| `username` / `password` | 信息门户登录 |
|
||
| `default_name` | 支付方式的「人员姓名」 |
|
||
| `default_person_id` | 支付方式的「人员编号」 |
|
||
| `default_card_no` | 支付方式的「公务卡号」 |
|
||
| `upload_pin` | 上传收件器访问码(等价 `--pin` 或环境变量 `UPLOAD_PIN`) |
|
||
|
||
URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_page`、`travel_page`)有内置默认值,
|
||
需要才配;配了就生效(早先版本这里写了也不读,已修)。想临时改地址用环境变量更方便,不用动文件。
|
||
凭据不放环境变量:DSH 会清洗 stdio 子进程里含 KEY/PASSWORD/SECRET/TOKEN 的变量。
|
||
|
||
校验规则在 `config/validation_rules.json`;改规则要同步跑测试(骨架与规则有一致性用例)。
|
||
|
||
---
|
||
|
||
## 九、日志与排查
|
||
|
||
- 控制台 + `logs/<日期>.log`(UTF-8)。**没有 SSE 或任何长连接事件流**,进度靠 `case_status` 查
|
||
- 浏览器调试截图在 `images/`(已 gitignore);P3 之后过程截图进 `cases/<id>/screenshots/NNN-<step>.png`
|
||
- 超时:页面默认 30 秒、门户登录 30 秒、单点登录页 15 秒
|
||
- 排查顺序:`case_status` → `match_result.json` → 某张 `.invoice_cache/*.json` → `logs/<日期>.log`
|
||
|
||
---
|
||
|
||
## 十、常见问题
|
||
|
||
| 问题 | 原因 | 解决 |
|
||
|------|------|------|
|
||
| 登录超时 | SSO 要扫码或验证码 | 有头模式下人工完成,流程会继续 |
|
||
| 未找到财务系统入口 | 门户页面结构变了 | 看 `images/debug_*` 截图定位选择器 |
|
||
| 经济科目选择失败 | 弹窗加载延迟 | 增大等待,或调整点第几行的索引 |
|
||
| 金额不匹配 | 明细合计 ≠ 支付合计 | 复核 `match_result.json` 与 `review_required` |
|
||
| 附件上传失败 | PDF 路径不对 | 材料要在 `cases/<id>/` 下,路径用正斜杠 |
|
||
| 宿主看不到新增工具 | 旧 stdio 子进程还在跑旧代码 | 杀掉子进程,DSH 会自动重启并重同步工具 |
|
||
| 报「binding arguments must be lossless JSON」 | Windows 路径用了反斜杠 | 改成 `D:/Code/...` |
|
||
| 手机上传 403 | URL 缺令牌或令牌不对 | 用 `case_create` 返回的完整地址 |
|
||
| 抽取字段读错(编号/卡号/金额位数) | 文本层与读图本来就会冲突 | 放大该区域重看,两路都不许默认采信 |
|
||
| 申购单生成失败 | 未装 Excel 或 pywin32;或有弹窗未关 | 需要 Windows + Excel,人工关掉残留 Excel 窗口再试 |
|
||
| 浏览器起不来 | 没装 Chromium | `uv run playwright install chromium` |
|
||
| 返回 `owned_by_other_process` | 上一代 MCP 子进程还占着浏览器 | 让持有者 `browser_close`;心跳超 180 秒自动接管 |
|
||
| 登录一直不跳转 | SSO 要扫码或有验证码 | 看返回的 `need_human` 与截图,人工完成后重调 `browser_login` |
|
||
| 手机只能拍照、选不了相册和已有文件 | 两代原因:`capture` 挂到了唯一 input;相册入口带 `accept` 被微信内置浏览器/
|
||
部分国产 ROM 直接路由到相机。都已修:相册入口不带 accept,类型交给服务端白名单 |
|
||
| 改了却像没生效 | 手机侧重开上传页;PC 侧开 `/api/ping` 看是不是旧进程在服务,重启收件器加载新代码 |
|
||
| 「选择已有文件」里没有相册页签 | 各手机给的文件选择器不同;照片在里面能翻到(多在 图片 / DCIM / Download 目录),或者直接用「拍照上传」 |
|
||
| 改了收件器页面却"没生效" | 多半是旧的孤儿进程还在听 5055。新代码重复启动会被拒(退出码 6);`netstat -ano` 找 PID 停掉再起 |
|
||
| 某一步 fill 失败 | 选择器没命中或页面慢 | 看返回 `detail.screenshot` 的截图,人工看一眼再重填该步 |
|
||
|
||
---
|
||
|
||
## 十一、相关命令
|
||
|
||
```bash
|
||
make check # ruff + format + mypy src + deptry
|
||
make test # pytest + 覆盖率
|
||
make mcp-smoke # MCP stdio 自检:真起子进程握手并列工具
|
||
make mcp-docs # 由线上 schema 重新生成 docs/MCP.md 的工具参考
|
||
make skill-install # 把报销技能装到用户级目录(跨项目可用)
|
||
make upload # 启动扫码上传收件器
|
||
|
||
# 不经 MCP 单独重生成申购单(调试用)
|
||
uv run python -m src.infra.documents.requisition --session cases/<case_id> --exclude 千斤顶
|
||
```
|
||
|
||
---
|
||
|
||
## 相关文档
|
||
|
||
- [docs/MCP.md](./MCP.md) — 25 个工具的参数与返回约定
|
||
- [.agents/skills/reimbursement/SKILL.md](../.agents/skills/reimbursement/SKILL.md) — 宿主 Agent 的操作纪律
|
||
- [AGENTS.md](../AGENTS.md) — 开发约束与踩坑记录
|