--- schema: "hl-changelog/v2" ticket: "7232" title: "费用报销(申请→审批→出纳付款)+ 出纳队列接通费用线" consumer: "admin" author: "yst" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "860ad6bc" target_release: "v2.1" verified_at: "2026-09-10" status_note: "verified 2026-09-10 mmg: expense.js 八端点+EXPENSE_STATUS 五态映射;expense/expense 列表页(状态/部门/公司/关键字筛选,分类末级按名传/公司按名传/部门手录注释,驳回 rejectReason 必填,详情按 REJECTED/PAID 条件显,操作列按状态机分叉);出纳 cashier 三端点扩容 EXPENSE 线随 #7217 一并交付(commit 7278c5f9)。expense.spec 锁端点/ID 透传/状态兜底。checkpoint 全绿。2026-09-10 后端更正:§10 出纳付款登记入参字段名勘误——付款账户/金额实为统一扁平 payAccountId/amount(此前误写输出字段 fundAccountId/actualAmount,前端据此调用触发 400)。2026-09-10 mmg 跟进修复(commit 860ad6bc):buildCashierPayPayload 删 EXPENSE 分叉统一 payAccountId/amount,CashierQueuePage 清理双键+EXPENSE 手续费恒 0 不传 fee,cashier.spec 断言翻转;checkpoint 全绿(含 Vitest 全量+生产构建)。此前联调记录的 cashier/pay EXPENSE 400 实为本文档笔误所致,非后端校验 bug。" updated_at: "2026-09-10" base: "dev-v3" --- # 费用报销(申请→审批→出纳付款)+ 出纳队列接通费用线 财务域新增**费用报销**子域:员工申请报销 → 财务审批(本期手工)→ 出纳付款 → 回写付讫。同时**出纳域待付款队列 / 付款登记 / 已付款台账**三个既有接口的入参枚举扩容,接通费用报销线(`payType/bizType` 新增 `EXPENSE` 值)。 - expense 新域路径前缀:`/admin/finance/expenses`(8 个新端点) - cashier 既有域路径前缀:`/admin/finance/cashier`(3 个端点入参枚举扩容,无字段增删) ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | |---:|---|---|---|---| | 1 | 报销分页 | GET | `/admin/finance/expenses/page` | 新增 | | 2 | 申请报销 | POST | `/admin/finance/expenses` | 新增 | | 3 | 报销详情 | GET | `/admin/finance/expenses/{id}` | 新增 | | 4 | 编辑草稿 | PUT | `/admin/finance/expenses/{id}` | 新增 | | 5 | 删除草稿 | DELETE | `/admin/finance/expenses/{id}` | 新增 | | 6 | 提交报销 | PUT | `/admin/finance/expenses/{id}/submit` | 新增 | | 7 | 批准报销 | PUT | `/admin/finance/expenses/{id}/approve` | 新增 | | 8 | 驳回报销 | PUT | `/admin/finance/expenses/{id}/reject` | 新增 | | 9 | 出纳待付款队列 | GET | `/admin/finance/cashier/queue` | 修改(入参 `payType` 扩容 `EXPENSE`) | | 10 | 出纳付款登记 | POST | `/admin/finance/cashier/pay` | 修改(入参 `bizType` 扩容 `EXPENSE`) | | 11 | 出纳已付款台账 | GET | `/admin/finance/cashier/payments/page` | 修改(入参 `bizType` 扩容 `EXPENSE`) | ## 三、接口详情 ### 1. 报销分页 `GET /admin/finance/expenses/page` #### 使用场景 管理后台「费用报销」列表页,按状态 / 部门 / 公司主体 / 关键字筛选分页查询。 #### 认证 / 幂等性 / 限流 - 认证:管理后台管理员 Token(`Authorization: Bearer `),未登录返 401。 - 幂等性:查询接口,天然幂等。 - 限流:走网关默认限流,无特殊配置。 #### 入参(Query) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---:|---|---| | `status` | Query | String | 否 | 见状态枚举 | 按报销状态筛选,省略查全部 | | `departmentId` | Query | Number | 否 | 正整数 | 按部门 ID 筛选 | | `company` | Query | String | 否 | — | 按公司主体名称筛选 | | `keyword` | Query | String | 否 | — | 关键字模糊搜索(单号 / 事由) | | `page` | Query | Number | 是 | ≥1 | 页码 | | `pageSize` | Query | Number | 是 | 1-100 | 每页条数 | #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data.records[].id` | String | 报销单 ID(雪花 ID,Long 转字符串防 JS 精度丢失) | | `data.records[].expenseNo` | String | 报销单号(`FY-` 前缀) | | `data.records[].expenseCategory` | String | 费用分类末级名称 | | `data.records[].departmentId` | String | 部门 ID | | `data.records[].departmentName` | String | 部门名称 | | `data.records[].company` | String | 公司主体名称 | | `data.records[].amount` | Number | 报销金额 | | `data.records[].offsetLoanAmt` | Number | 冲销借款金额(本期恒 0,见业务边界) | | `data.records[].payableAmt` | Number | 应付金额(本期 = amount) | | `data.records[].reason` | String | 报销事由 | | `data.records[].rejectReason` | String | 驳回原因(仅 REJECTED 状态有值,其余为 null) | | `data.records[].fundAccountId` | String | 付讫资金账户 ID(仅 PAID 有值) | | `data.records[].status` | String | 状态码,见状态枚举 | | `data.records[].paidAt` | String | 付讫时间(仅 PAID 有值) | | `data.records[].approvalInstanceId` | String | 审批实例 ID(本期恒空,企微审批未接) | | `data.records[].createTime` | String | 创建时间 | | `data.total` / `page` / `pageSize` | Number | 分页元信息 | #### 请求示例(典型) ```http GET /admin/finance/expenses/page?page=1&pageSize=20&status=APPROVED&departmentId=10086 Authorization: Bearer ``` #### 响应示例(典型) ```json { "code": 200, "message": "成功", "success": true, "data": { "records": [ { "id": "2094278854020399106", "expenseNo": "FY-20260907-0001", "expenseCategory": "市内交通费", "departmentId": "10086", "departmentName": "市场部", "company": "呼籁旅行社有限公司", "amount": 300.00, "offsetLoanAmt": 0, "payableAmt": 300.00, "reason": "客户拜访打车费", "rejectReason": null, "fundAccountId": null, "status": "APPROVED", "paidAt": null, "approvalInstanceId": null, "createTime": "2026-09-07 10:00:00" } ], "total": 1, "page": 1, "pageSize": 20 } } ``` #### 空数据 / 边界响应 无匹配时 `records=[]`、`total=0`,HTTP 200,前端正常渲染空列表。 ### 2. 申请报销 `POST /admin/finance/expenses` **VO**: `ExpenseCreateReqVO` #### 使用场景 员工在管理后台提交一张费用报销单,落库为 PENDING 草稿(未进审批)。 #### 认证 / 幂等性 / 限流 - 认证:管理后台管理员 Token。 - 幂等性:非幂等(每次创建生成新单号),重复提交产生多张单据,前端提交后应禁用按钮。 - 限流:网关默认。 #### 入参(Body) | 字段 | 类型 | 必填 | 约束 | 说明 | |---|---|---:|---|---| | `expenseCategory` | String | 是 | 必须存在、为末级(level=2)、未停用 | 费用分类**末级名称**,按名称传不按 ID(取自 `fin_expense_category` 字典) | | `departmentId` | Number | 是 | 必须存在 | 部门 ID(用户域组织树 `wechat_department`) | | `company` | String | 是 | 必须为启用项 | 公司主体名称,按名称传(取自 `travel_agency` 启用项) | | `amount` | Number | 是 | >0 | 报销金额 | | `reason` | String | 是 | 非空 | 报销事由 | #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data.id` | String | 新建报销单 ID | | `data.expenseNo` | String | 报销单号(`FY-` 前缀) | | `data.status` | String | 创建后为 `PENDING` | #### 请求示例(典型成功) ```json { "expenseCategory": "市内交通费", "departmentId": 10086, "company": "呼籁旅行社有限公司", "amount": 300.00, "reason": "客户拜访打车费" } ``` #### 请求示例(边界情况) 金额为最小粒度 / 事由最长: ```json { "expenseCategory": "办公用品费", "departmentId": 10086, "company": "呼籁旅行社有限公司", "amount": 0.01, "reason": "采购 A4 打印纸一箱" } ``` #### 请求示例(业务失败) 费用分类传了非末级 / 已停用名称 → 598703: ```json { "expenseCategory": "交通费", "departmentId": 10086, "company": "呼籁旅行社有限公司", "amount": 300.00, "reason": "打车" } ``` ```json {"code":598703,"message":"费用分类非法(不存在/非末级/已停用)","success":false,"data":null} ``` 金额 ≤0 → 598704;部门或公司主体非法 → 598705。 ### 3. 报销详情 `GET /admin/finance/expenses/{id}` #### 入参 | 字段 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---:|---| | `id` | Path | String | 是 | 报销单 ID(雪花字符串) | #### 出参 字段与分页行一致(`id/expenseNo/expenseCategory/departmentId/departmentName/company/amount/offsetLoanAmt/payableAmt/reason/rejectReason/fundAccountId/status/paidAt/approvalInstanceId/createTime`),见 §1 出参表。 #### 错误响应 ```json {"code":598701,"message":"报销单不存在","success":false,"data":null} ``` ### 4. 编辑草稿 `PUT /admin/finance/expenses/{id}` **VO**: `ExpenseUpdateReqVO`(字段与 `ExpenseCreateReqVO` 完全一致,见 §2 入参表) #### 业务边界 - 仅 `PENDING` 状态可编辑,其余状态调用返回 598702。 - 编辑不改单号,状态保持 PENDING。 #### 请求示例 ```json { "expenseCategory": "市内交通费", "departmentId": 10086, "company": "呼籁旅行社有限公司", "amount": 350.00, "reason": "客户拜访打车费(含返程)" } ``` #### 错误响应 ```json {"code":598702,"message":"报销单状态非法(仅PENDING可编辑删除、提交须PENDING、审批须SUBMITTED)","success":false,"data":null} ``` ### 5. 删除草稿 `DELETE /admin/finance/expenses/{id}` #### 业务边界 - 仅 `PENDING` 状态可删除,其余状态调用返回 598702。 - 删除为软删除,单据不再出现在任何列表。 #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data` | Boolean | `true` 表示删除成功 | ### 6. 提交报销 `PUT /admin/finance/expenses/{id}/submit` #### 业务边界 - 仅 `PENDING` 状态可提交,提交后流转 `PENDING → SUBMITTED`,单据进入锁定态(不可编辑 / 删除)。 - 无 Body 入参。 #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data.id` | String | 报销单 ID | | `data.status` | String | 提交后为 `SUBMITTED` | ### 7. 批准报销 `PUT /admin/finance/expenses/{id}/approve` #### 业务边界 - 仅 `SUBMITTED` 状态可批准,流转 `SUBMITTED → APPROVED`,进入出纳待付款队列。 - 本期为**手工审批**(不接企微审批流),`approvalInstanceId` 恒空。 - 无 Body 入参。 #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data.id` | String | 报销单 ID | | `data.status` | String | 批准后为 `APPROVED` | ### 8. 驳回报销 `PUT /admin/finance/expenses/{id}/reject` #### 入参(Body) | 字段 | 类型 | 必填 | 约束 | 说明 | |---|---|---:|---|---| | `rejectReason` | String | 是 | 非空 | 驳回原因(回显在详情 `rejectReason` 字段) | #### 业务边界 - 仅 `SUBMITTED` 状态可驳回,流转 `SUBMITTED → REJECTED`,不进出纳队列。 #### 请求示例 ```json {"rejectReason": "缺少打车发票附件,请补充后重新提交"} ``` #### 出参 | 字段 | 类型 | 说明 | |---|---|---| | `data.id` | String | 报销单 ID | | `data.status` | String | 驳回后为 `REJECTED` | ### 9. 出纳待付款队列 `GET /admin/finance/cashier/queue`(修改:payType 扩容) #### 变更点 入参 `payType` 枚举由仅 `NONBIZ` 扩容为 `NONBIZ / EXPENSE`。传 `EXPENSE` 时返回费用报销线(`fin_expense` 表 `status=APPROVED` 的单据);其余字段、结构、分页契约不变。 #### 入参(Query,增量) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---:|---|---| | `payType` | Query | String | 否 | `NONBIZ` / `EXPENSE` | 付款类型筛选;省略 / 空 = 既有行为 | #### 出参(队列行,费用线字段填充规则) | 字段 | 类型 | 费用线(payType=EXPENSE)取值 | |---|---|---| | `id` | String | 报销单 ID | | `bizNo` | String | 报销单号(= expenseNo) | | `payType` | String | `EXPENSE` | | `payTypeName` | String | `费用` | | `unitId` / `unitName` | String | 槽位复用:部门 ID / 部门名称 | | `category` / `categoryName` | String | 费用分类(末级名称) | | `amount` | Number | 报销金额 | | `fee` | Number | 手续费(费用线为 0) | | `actualAmount` | Number | 应付金额(= payableAmt) | | `operatorName` | String | 申请人 | | `createTime` | String | 创建时间 | | `occurDate` | String | 发生日期 | | `status` | String | 队列状态 | | `remark` | String | 报销事由(= reason) | #### 请求示例 ```http GET /admin/finance/cashier/queue?payType=EXPENSE Authorization: Bearer ``` ### 10. 出纳付款登记 `POST /admin/finance/cashier/pay`(修改:bizType 扩容) #### 变更点 入参 `bizType` 枚举新增 `EXPENSE` 值。`bizType=EXPENSE` 时 `bizId` 传报销单 ID,登记付款后回写 `fin_expense` 状态为 `PAID`、记录 `fundAccountId` / `paidAt`(回写列名,非入参字段),同事务记资金流水。 #### 入参(Body,关键字段) > ⚠️ 出纳付款登记为**全 bizType 统一扁平入参**(NONBIZ / EXPENSE / PAYMENT / PREPAY 共用同一套字段),付款账户 / 金额字段名固定为 `payAccountId` / `amount`。下方为全量必填/关键字段: | 字段 | 类型 | 必填 | 约束 | 说明 | |---|---|---:|---|---| | `bizType` | String | 是 | 新增 `EXPENSE` | 业务类型;费用报销传 `EXPENSE` | | `bizId` | String | 是 | 对应业务单据 ID | `bizType=EXPENSE` 时传报销单 ID | | `payAccountId` | String | 是 | 必须存在且 ACTIVE | 出账资金账户 ID(fin_fund_account) | | `amount` | Number | 是 | >0 | 付款金额(出纳按实际打款金额录入,可与应付不一致) | | `payDate` | String | 是 | yyyy-MM-dd | 付款日期(可回溯补录) | | `payMethod` | String | 否 | 字典 fin_pay_way | 付款方式(CASH/BANK_TRANSFER/WECHAT/ALIPAY) | | `fee` | Number | 否 | ≥0 | 手续费(挂出账流水) | | `voucherNo` | String | 否 | — | 付款凭证号 | | `voucherUrl` | String | 否 | — | 付款凭证影像 URL | #### 请求示例(bizType=EXPENSE) ```json { "bizType": "EXPENSE", "bizId": "2097885645098659841", "payAccountId": "2095340438490583041", "amount": 300.00, "payDate": "2026-09-10", "payMethod": "BANK_TRANSFER" } ``` #### 成功响应 ```json { "code": 200, "message": "成功", "data": { "flowId": "2097946971271495682", "flowNo": "LS202609100002", "balanceAfter": 114594.0, "bizId": "2097885645098659841" }, "success": true } ``` #### 业务边界 - 出纳实际打款金额与 `payableAmt` 不一致时后端**打 WARN 审计日志但不硬拦**(允许出纳按实际打款登记)。 - 并发防重:同事务记资金流水 + 条件更新回写,对同一报销单重复付款返回 598602。 #### 错误响应 重复付款: ```json {"code":598602,"message":"业务单状态非已批准,不可付款","success":false,"data":null} ``` 付款类型非法(598607 文案已扩为含 EXPENSE): ```json {"code":598607,"message":"付款类型非法,本期支持 NONBIZ/EXPENSE","success":false,"data":null} ``` ### 11. 出纳已付款台账 `GET /admin/finance/cashier/payments/page`(修改:bizType 扩容) #### 变更点 入参新增 `bizType` 筛选,支持 `EXPENSE`;传空 / 省略 = 既有 NONBIZ 行为。其余字段、结构不变。 #### 入参(Query,增量) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---:|---|---| | `bizType` | Query | String | 否 | `NONBIZ` / `EXPENSE` | 业务类型筛选;空 = NONBIZ(既有行为) | #### 请求示例 ```http GET /admin/finance/cashier/payments/page?page=1&pageSize=20&bizType=EXPENSE Authorization: Bearer ``` ## 六.5、枚举 / 数据字典 ### `status`(报销单状态,代码枚举) **所属字段**: `ExpensePageReqVO.status / ExpenseRespVO.status` | **类型**: `String` | 值 | 中文 | 说明 | |---|---|---| | `PENDING` | 待提交 | 草稿态,可编辑可删除 | | `SUBMITTED` | 审批中 | 已提交,锁定(不可编辑 / 删除) | | `APPROVED` | 已批准 | 审批通过,待出纳付款 | | `REJECTED` | 已驳回 | 审批驳回,`rejectReason` 有值 | | `PAID` | 已付讫 | 出纳付款完成,终态只读 | 状态机:`PENDING →(submit)→ SUBMITTED →(approve)→ APPROVED →(出纳付款)→ PAID`;`SUBMITTED →(reject)→ REJECTED`。 ### `payType` / `bizType`(出纳域,枚举值扩容) **所属字段**: `cashier/queue` 入参 `payType`、`cashier/pay` 入参 `bizType`、`cashier/payments/page` 入参 `bizType` | **类型**: `String` | 值 | 中文 | 说明 | |---|---|---| | `NONBIZ` | 非业务付款 | 既有值,行为不变 | | `EXPENSE` | 费用(报销) | 新增值,费用报销线 | ### `expenseCategory`(费用分类,数据字典 `fin_expense_category`) - 按**末级名称**(level=2)传值,按名称不按 ID。 - 仅正常(未停用)项可选;传非末级 / 不存在 / 已停用名称返回 598703。 ### `company`(公司主体,数据字典 `travel_agency`) - 按名称传值,仅启用项可选;非法返回 598705。 ### 错误码(新段 5987xx + 出纳段扩充) | 错误码 | 含义 | 触发场景 | |---|---|---| | 598701 | 报销单不存在 | 详情 / 编辑 / 删除 / 提交 / 审批传错 ID | | 598702 | 报销单状态非法 | 非 PENDING 编辑删除、非 PENDING 提交、非 SUBMITTED 审批 | | 598703 | 费用分类非法 | 分类不存在 / 非末级 / 已停用 | | 598704 | 金额无效 | `amount ≤ 0` | | 598705 | 部门或公司主体非法 | `departmentId` 不存在或 `company` 非启用项 | | 598706 | 单号取号撞号重试耗尽 | 极端并发下取号失败(重试后仍撞号) | | 598607 | 付款类型非法(文案扩充) | 出纳付款 / 台账传非 `NONBIZ/EXPENSE` 值 | ## 六.6、修改前后对比 | 项目 | 修改前 | 修改后 | |---|---|---| | 费用报销接口 | 无 | 新增 `/admin/finance/expenses` 下 8 端点(分页 / 申请 / 详情 / 编辑 / 删除 / 提交 / 批准 / 驳回) | | 出纳队列 `payType` | 仅 `NONBIZ` | 扩容 `NONBIZ / EXPENSE` | | 出纳付款 `bizType` | 仅 `NONBIZ` | 扩容 `NONBIZ / EXPENSE` | | 出纳台账筛选 | 无 `bizType` 入参 | 新增 `bizType` 入参(空 = NONBIZ) | | 598607 文案 | 仅提示 NONBIZ | 扩为「付款类型非法,本期支持 NONBIZ/EXPENSE」 | ## 六.7、影响评估 - **是否破坏向后兼容**:否。expense 8 端点为纯新增;出纳 3 端点为入参枚举扩容,省略新值时行为与之前完全一致。 - **前端是否必须同步上线**:否。旧功能不受影响;费用报销页面与出纳队列费用线 Tab 可按节奏上线。 - **前端 workaround 清理点**:无(全新能力)。 ## 七、不影响范围 - 小程序端(C 端):费用报销为纯管理后台能力,`/mp/**` 零改动。 - 出纳 NONBIZ 线:不传 `payType/bizType=EXPENSE` 时行为与之前完全一致。 - 员工借款 / 冲销:本期未建借款域,`offsetLoanAmt` 恒 0、`payableAmt = amount`,留 TODO 后续接入。 ## 八、测试环境已验证 192.168.100.236 测试服行为级 E2E **16 PASS / 0 FAIL**: - 全链路走通:申请 → 提交 → 批准 → 出纳付款 → 回写 PAID。 - 结存勾稽正确:付款前资金账户余额 114894 − 打款 300 = 114594。 - 并发防重生效:重复付款返回 598602。 - 四类校验生效:598702(状态非法)/ 598703(分类非法)/ 598704(金额无效)/ 598705(部门或公司非法)。 ## 当前状态 - 后端:已部署并已验证。 - 前端:待处理(费用报销页面 + 出纳队列费用线 Tab)。 ## 十、相关文档 - Issue:[#7232](https://git.1814.love:8443/wx/HL/issues/7232) - 后端 PR:[#7247](https://git.1814.love:8443/wx/HL/pulls/7247) ## 关联 / 联系人 - **Issue**: [#7232](https://git.1814.love:8443/wx/HL/issues/7232) - **PR**: [#7247](https://git.1814.love:8443/wx/HL/pulls/7247) - **后端负责人**: @yst - **当前状态**: 后端已就绪(TEST 已验证),前端待接入。