Files
Auto-Finance/docs/报销操作指南.md
T
wandering 94c2f4c94e feat(travel): 交通费行按实际乘车日填开始/结束日期(联机实测控件 #ksrq/#jsrq)
用户第 4 条:交通费行的日期由页面按出差起止自动带出,cost=1 分支不填日期,于是「6-02 出发、
6-05 才坐车」这种情况报销日期是错的。要按乘车日必须先知道控件 id,所以做了一次联机探测:
登录 → new_form(travel) → fill(basic) 落草稿 → actor 里打开明细、点 #insertDetail、选 cost=1、
dump 可见 input → 截图;探完按守卫删草稿(唯一命中+未提交+当日制单,bill_no 202610030261,复查 0 命中)。

探测结果:交通费行日期控件 #ksrq(expenPwTraveldetail.STARTDATE)/ #jsrq(OVERDATE),
页面已按出差起止预填(实测 2026-06-02 → 06-04)。

- add_travel_items 的 cost=1 分支用 transport_fee[].start_date/end_date 覆盖 #ksrq/#jsrq
- 缺日期时保留页面带出的值并 log.warning,不静默按出差起止报销
- tests/test_travel_dates.py 两例:有乘车日则覆盖、缺日期不覆盖
- 文档:AGENTS 记下控件名与口径、操作指南 7.2 补一句、方案文档 §25 记探测方法与结论(含可复用的探测套路)

验证:pytest 全绿;ruff / format / mypy / deptry / mcp-smoke / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
2026-10-21 13:58:27 +08:00

333 lines
17 KiB
Markdown
Raw 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.
---
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` |
| 5b | 等额发票配错时人工改组(整组唯一命中才生效,没生效的原因在 `manual_gaps`) | `match_payments(manual_groups=...)` |
| 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 不许自己宣布「可以提交了」。
- 每步填报后工具会回读页面做**落库后自检**:数据行数与表尾合计跟 info 比对,差值写进 warnings,
返回里的 self_check 是原始读数。看到「自检不一致」先看图再决定重填,别当没看见。
### 产物位置
全部在 `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 明细录入策略
普通报销用「一条总明细」:所有发票合并成一条,报销总金额 = 各笔支付之和,单据数 = 发票张数;
支付方式逐笔录入,一笔一条。差旅按人去程/返程分条录交通费,补助按人按天算。
交通费行的**开始/结束日期按实际乘车日填**(页面默认会按出差起止带出,但那不等于乘车日):
数据来自 `transport_fee[].start_date/end_date`,控件为 `#ksrq`/`#jsrq`(联机实测)。
### 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) — 开发约束与踩坑记录