# 财务-发票报销 - 前端联调方案 > 模块:`yudao-module-finance`(独立模块,不与其他业务模块关联) > 后端接口前缀:`/finance/reimbursement` > 附件关联:`recordType = finance_reimbursement`,`application = file` ## 涉及页面 前端 `finance` 目录已按本项目约定建好,共 8 个文件(含字典枚举注册): | 文件 | 说明 | |------|------| | `src/packages/constants/src/dict-enum.ts` | 新增 `FINANCE_DICT`(`FINANCE_REIMBURSEMENT_STATUS` / `FINANCE_EXPENSE_TYPE`),并加入 `DICT_TYPE` 汇总导出 | | `src/api/finance/reimbursement/index.ts` | 报销单 API 封装 | | `src/views/finance/reimbursement/index.vue` | 报销单列表页(菜单指向此组件) | | `src/views/finance/reimbursement/data.ts` | 表单排布 + 表格列定义 + 状态常量 `ReimbursementStatus` | | `src/views/finance/reimbursement/modules/form.vue` | 新建/编辑/详情弹窗(主表字段 + 发票上传) | | `src/views/finance/reimbursement/modules/item-form.vue` | 报销明细行编辑(可增删行,金额汇总回填主表) | | `src/views/finance/reimbursement/modules/process-select-modal.vue` | 选择审批流程弹窗 | | `src/views/finance/reimbursement/detail/approve/index.vue` | 审批详情页(BPM 审批中心动态加载用) | > 字典枚举必须登记到 `dict-enum.ts`,否则 `DICT_TYPE.FINANCE_*` 取不到值,列表与表单下拉会空白。 **菜单已配置完成**,无需前端手工加路由(本项目为动态路由): | 菜单 | id | 路径 | 组件 | 说明 | |------|----|------|------|------| | 财务报销 | 1075425 | `/finance` | — | **顶级目录**(`parent_id=0`,sort=75,排在「售后管理」与「基础设施」之间) | | 报销单 | 1075417 | `reimbursement` | `finance/reimbursement/index` | 挂在「财务报销」下 | > 报销单为**独立模块**,因此刻意**不挂在 ERP 系统 → 财务管理**下(那里是付款单/收款单/结算账户,属 ERP 业务),而是新建顶级目录「财务报销」自成一条链路。 > > ⚠ **菜单位置易错点**:报销单是**顶级菜单的二级项** —— 侧边栏展开 **「财务报销」** 即可看到;面包屑显示为 `财务报销 > 报销单`,最终路由 `/finance/reimbursement`(**注意不是** `/erp/finance/reimbursement`)。ERP 系统下的「财务管理」里**没有**报销单。 > > ⚠ **授权易错点**:`system_role_menu` 中 1075425 与 1075417 **均未授予任何角色**,因此**只有超级管理员**(`super_admin`,代码直返全部菜单)能看到;普通角色登录看不到,需要在「系统管理 → 角色」里勾选授权。 > > 菜单**每次进入应用都会重新拉取**(`accessStore` 的 `persist.pick` 只含 token/accessCodes,不含 `accessMenus`),所以不存在「需要清缓存才看得到」的问题;看不到只可能是**位置没展开**或**账号角色未授权**。 **字典已配置完成**,前端直接用 `getDictOptions` / `DictTag` 取值,无需自建枚举: | 字典类型 | 名称 | 取值 | |----------|------|------| | `finance_reimbursement_status` | 报销状态 | 0 草稿 / 15 审批中 / 20 已通过 / 35 已驳回 / 40 已归档 | | `finance_expense_type` | 费用类型 | 1 差旅 / 2 办公 / 3 招待 / 4 交通 / 5 其他 | **按钮权限标识**(已落 `system_menu`,前端用 `v-access:code` 或 `AccessControl` 控制显隐): `finance:reimbursement:query`、`:create`、`:update`、`:delete`、`:export`、`:update-status`、`:archive` ## 业务流程与数据带入 ``` 新建报销单(填主表 + 明细行 + 上传发票) │ ├── 主表字段:报销标题、报销总金额、申请日期、备注 └── 明细行(1..n):费用类型、发票号码、发票金额、发票日期、开票方、费用说明 │ 保存(create) │ ← 附件:先上传拿 blobId,再把全部 blobId 放进本次请求体一起提交 ▼ status = 0 草稿 │ 【提交审批】选流程 → 生成 BPM 实例 ▼ status = 15 审批中 ←──────────────┐ │ │ ┌──────────┴──────────┐ │ 审批通过 审批驳回 │ ▼ ▼ │ status = 20 已通过 status = 35 已驳回 ──┘ 重新提交 │ 【归档】 ▼ status = 40 已归档(终态) ``` **数据带入要点** 1. **明细行不单独调用接口**:明细随主表的 `create` / `update` 一次性提交(`items` 数组),后端按「先删后增」全量覆盖。前端不需要逐行调接口。 2. **发票附件两步走**(不得在业务表存文件地址): 1. 先调 `/system/storage-blob/upload` 拿到 `blobId`; 2. 保存报销单时把**该单当前全部** `blobId` 放进 `blobIds` 一起提交,由后端统一绑定到 `recordType=finance_reimbursement` + `recordId=报销单id`。 3. **主表「报销总金额」建议前端自动汇总**:后端不校验 `totalAmount` 与 `items[].invoiceAmount` 之和是否相等,直接入库。若要保证一致,请前端在 `items` 变化时把 `invoiceAmount` 求和回填 `totalAmount`(用户仍可手改)。 4. **审批流程 key 来自 `approve-process-list`**:前端不得写死流程 key,必须实时取列表后由用户选择(后端在提交时会按所选 key 创建流程实例)。 5. **审批详情页跳转**:BPM 审批中心带 `processInstanceId` 跳转,前端应从详情接口 `get` 取到 `processInstanceId` 后拼接详情页路由。 ## API | 方法 | 路径 | 说明 | 权限标识 | |------|------|------|----------| | POST | `/finance/reimbursement/create` | 创建报销单 | `finance:reimbursement:create` | | PUT | `/finance/reimbursement/update` | 修改报销单 | `finance:reimbursement:update` | | DELETE | `/finance/reimbursement/delete` | 删除报销单 | `finance:reimbursement:delete` | | GET | `/finance/reimbursement/get` | 查询报销单详情 | `finance:reimbursement:query` | | GET | `/finance/reimbursement/page` | 分页查询报销单 | `finance:reimbursement:query` | | GET | `/finance/reimbursement/export-excel` | 导出 Excel | `finance:reimbursement:export` | | POST | `/finance/reimbursement/submit` | 提交审批 | `finance:reimbursement:update-status` | | POST | `/finance/reimbursement/archive` | 归档 | `finance:reimbursement:archive` | | GET | `/finance/reimbursement/approve-process-list` | 获取可用审批流程列表 | `finance:reimbursement:query` | ### POST /finance/reimbursement/create **请求参数**(JSON body): | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | no | String | 否 | 报销单号,最长 36。**留空(含只填空格)则由后端自动生成**;填了则按自定义单号校验后落库 | | title | String | 是 | 报销标题,最长 200 | | totalAmount | BigDecimal | 是 | 报销总金额 | | applyDate | String | 是 | 申请日期,格式 `yyyy-MM-dd` | | remark | String | 否 | 备注,最长 500 | | blobIds | Long[] | 否 | 发票附件的 blobId 列表(可为空数组表示无发票) | | items | Object[] | 是 | 报销明细,至少 1 行 | **自定义单号规则(`no`)**: | 输入 | 后端行为 | |------|----------| | 不传 / `null` / 空串 / 纯空格(含全角空格 ` `、制表符) | 视为「未填」,由系统按 `BX` + `yyyyMMdd` + 6 位流水生成 | | 有值 | 去掉首尾空格后作为自定义单号;**中间**含空格 / 制表符 / 全角空格则报错 | | 有值但与其他报销单重复 | 报错 `报销单号({})已被其他报销单使用,请更换其他单号,或清空该字段由系统自动生成` | | 有值且中间有空格 | 报错 `报销单号({})中间不能包含空格,请去掉空格后重新输入,或清空该字段由系统自动生成` | | 长度 > 36 | 校验失败 `报销单号长度不能超过 36 个字符` | > 前端在提交前建议先 `trim()`,把纯空格视为空串再提交,避免用户误以为填了单号。 `items` 元素: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | expenseType | Integer | 是 | 费用类型,取字典 `finance_expense_type` | | invoiceNo | String | 否 | 发票号码,最长 64 | | invoiceAmount | BigDecimal | 是 | 发票金额 | | invoiceDate | String | 否 | 发票日期,格式 `yyyy-MM-dd` | | drawer | String | 否 | 开票方,最长 200 | | description | String | 否 | 费用说明,最长 500 | **响应**:`data` 为新建报销单的 `id`(Long)。 ### PUT /finance/reimbursement/update 请求参数与 `create` 相同,额外**必填** `id`(Long)。仅 `status=0 草稿` 或 `status=35 已驳回` 可改。 **`no` 在修改时的语义与新增不同**: | 输入 | 后端行为 | |------|----------| | 不传 / `null` / 空串 / 纯空格 | **保持原单号不变**(不会重新生成) | | 与当前单号相同 | 视为不变更 | | 有值且与当前不同 | 校验格式与唯一性后覆盖为新单号 | > 前端编辑态应回显原单号;若用户清空该输入框,提交时传空串即表示「不改单号」,不会被系统重新生成。 **响应**:`data = true`。 ### DELETE /finance/reimbursement/delete | 参数 | 位置 | 类型 | 必填 | 说明 | |------|------|------|------|------| | id | query | Long | 是 | 报销单 id | 仅 `status=0 草稿` 可删。删除时会同时清理明细、发票附件。 ### GET /finance/reimbursement/get | 参数 | 位置 | 类型 | 必填 | |------|------|------|------| | id | query | Long | 是 | **响应字段**: | 字段 | 类型 | 说明 | |------|------|------| | id | Long | 报销单 id | | no | String | 报销单号。可能是后端生成的 `BX` + `yyyyMMdd` + 6 位流水(如 `BX20260928000001`),也可能是用户自定义的单号 | | title | String | 报销标题 | | totalAmount | BigDecimal | 报销总金额 | | status | Integer | 状态,取字典 `finance_reimbursement_status` | | applicantId | Long | 申请人 id | | applicantName | String | 申请人姓名(后端已解析昵称,前端不用再查) | | applyDate | String | 申请日期 `yyyy-MM-dd` | | processInstanceId | String | BPM 流程实例 id,未提交时为 `null` | | archiveTime | String | 归档时间 `yyyy-MM-dd HH:mm:ss`,未归档为 `null` | | remark | String | 备注 | | creator | String | 创建人 id(字符串形式) | | creatorName | String | 创建人姓名(后端已解析) | | createTime | String | 创建时间 | | updateTime | String | 更新时间 | | items | Object[] | 报销明细列表 | | attachmentList | Object[] | 发票附件列表 | | hasAttachment | Boolean | 是否有发票附件 | `items` 元素:`id`、`reimbursementId`、`expenseType`、`invoiceNo`、`invoiceAmount`、`invoiceDate`、`drawer`、`description`。 `attachmentList` 元素:`id`(blobId)、`url`(文件地址)、`previewURL`(预览地址)、`name`(文件名)、`downloadURL`(下载地址)、`application`、`storageAttachmentId`(附件关联 id)。 ### GET /finance/reimbursement/page | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | pageNo | Integer | 是 | 页码,从 1 开始 | | pageSize | Integer | 是 | 每页条数 | | no | String | 否 | 报销单号,模糊匹配 | | title | String | 否 | 报销标题,模糊匹配 | | status | Integer | 否 | 状态,精确匹配 | | applicantId | Long | 否 | 申请人 id | | applyDate | String[] | 否 | 申请日期区间 | **日期区间传参(易错点)**:后端字段是数组,需按下面形式传两个值,格式 **`yyyy-MM-dd`(不含时分秒)**: - `applyDate[0]=2026-09-01&applyDate[1]=2026-09-30` **响应**:`data.list` 为详情字段的数组(含 `items`、`attachmentList`),`data.total` 为总数。 ### GET /finance/reimbursement/export-excel 查询参数同 `page`,无分页限制(忽略 `pageSize`)。响应为文件流,前端按 `blob` 处理即可。 导出列为:报销单号、报销标题、报销总金额、状态(自动转中文)、申请人、申请日期、归档时间、备注、创建时间、是否上传发票。 ### POST /finance/reimbursement/submit | 参数 | 位置 | 类型 | 必填 | 说明 | |------|------|------|------|------| | id | query | Long | 是 | 报销单 id | | processDefinitionKey | query | String | 是 | 流程定义 key,取自 `approve-process-list` | 注意:参数在 **query string**,请求体为空。 **响应**:`data = true`。成功后报销单 `status` 变为 `15 审批中`,`processInstanceId` 被回写。 ### POST /finance/reimbursement/archive | 参数 | 位置 | 类型 | 必填 | |------|------|------|------| | id | query | Long | 是 | **响应**:`data = true`。成功后 `status=40 已归档`、`archiveTime` 被写入。 ### GET /finance/reimbursement/approve-process-list 无请求参数。 **响应**:`data` 为数组,元素形如: | 字段 | 类型 | 说明 | |------|------|------| | id | String | 流程定义 id | | key | String | 流程定义 key,提交审批时回传此值 | | name | String | 流程名称 | 若返回空数组,说明「报销审批」分类下**尚无已发布的流程定义**,此时必须禁用「提交审批」按钮并提示用户先到 BPM 流程设计器发布流程。 ## 字段展示规则 | 字段 | 展示位置 | 说明 | |------|----------|------| | 报销单号 `no` | 列表、详情、编辑表单 | 可编辑(选填)。新增时留空由系统生成;编辑时留空表示不改单号 | | 报销标题 `title` | 列表(可点开详情)、编辑表单 | 必填 | | 状态 `status` | 列表、详情 | 用 `DictTag` 渲染字典 `finance_reimbursement_status`,取后端返回的颜色 | | 报销总金额 `totalAmount` | 列表(右对齐)、详情 | 必填,建议由明细汇总回填 | | 申请人 `applicantName` | 列表、详情 | 只读,后端已解析姓名 | | 申请日期 `applyDate` | 列表、编辑表单 | 必填,日期选择器(不带时间) | | 创建人 `creatorName` / 创建时间 `createTime` | 列表、详情 | 只读 | | 归档时间 `archiveTime` | 详情 | 只读,未归档显示 `-` | | 备注 `remark` | 详情、编辑表单 | 选填 | | 明细行 `items` | 编辑表单(子表格)、详情 | 列表页可不展示,详情页可折叠展开 | | 明细.费用类型 `expenseType` | 明细表格 | 用字典 `finance_expense_type` 渲染 | | 明细.发票金额 `invoiceAmount` | 明细表格(右对齐) | 必填 | | 发票附件 `attachmentList` | 编辑表单、详情 | 只读列表 + 预览/下载;编辑态允许增删(见「注意事项」) | | 是否有发票 `hasAttachment` | 列表 | 可展示为「有/无」标签,方便财务扫单 | ## 业务规则说明 ### 状态与可用操作矩阵 | 状态 | 编辑 | 删除 | 提交审批 | 归档 | 说明 | |------|:----:|:----:|:--------:|:----:|------| | 0 草稿 | ✅ | ✅ | ✅ | ❌ | 唯一可删除的状态 | | 15 审批中 | ❌ | ❌ | ❌ | ❌ | 全部只读,等待审批结果 | | 20 已通过 | ❌ | ❌ | ❌ | ✅ | 仅可归档 | | 35 已驳回 | ✅ | ❌ | ✅ | ❌ | 可改发票后重新提交;**不可删除** | | 40 已归档 | ❌ | ❌ | ❌ | ❌ | 终态,全部只读 | ### 校验规则(后端已实现,前端应做同步前置校验以提升体验) | 场景 | 规则 | 后端返回的提示 | |------|------|----------------| | 创建/修改时 `items` 为空 | 至少 1 行明细 | `报销明细不能为空` | | 创建/修改时 `title` / `totalAmount` / `applyDate` 为空 | 必填 | `报销标题不能为空` / `报销总金额不能为空` / `申请日期不能为空` | | 自定义单号与其他报销单重复 | 拒绝 | `报销单号({})已被其他报销单使用,请更换其他单号,或清空该字段由系统自动生成` | | 自定义单号中间含空格/制表符/全角空格 | 拒绝 | `报销单号({})中间不能包含空格,请去掉空格后重新输入,或清空该字段由系统自动生成` | | 自定义单号长度 > 36 | 拒绝 | `报销单号长度不能超过 36 个字符` | | 非草稿/非已驳回状态调 `update` | 拒绝 | `报销单({})不是草稿或已驳回状态,无法修改,只有草稿和已驳回的报销单可以编辑` | | 非草稿状态调 `delete` | 拒绝 | `报销单({})不是草稿状态,无法删除` | | 非草稿/非已驳回调 `submit` | 拒绝 | `提交审批失败,只有草稿或已驳回状态的报销单才能提交` | | 提交时无明细 | 拒绝 | `提交审批失败,报销单没有明细行,无法提交` | | 非已通过调 `archive` | 拒绝 | `归档失败,只有已通过状态的报销单才能归档` | | 报销单不存在 | 拒绝 | `报销单不存在` | ### 审批结果如何回写状态(前端不用管,仅作了解) BPM 审批流完成后,后端监听器按结果自动改状态: - 审批通过 → `20 已通过` - 审批驳回 → `35 已驳回` - 审批取消 → 退回 `0 草稿` 前端只需在审批通过后重新拉列表/详情即可看到新状态。 ## 注意事项 1. **附件更新是全量替换,不是增量**(最容易踩的坑):`update` 接口的 `blobIds` 语义是「该报销单保存后应持有的**全部**附件」。因此编辑态必须把**已有附件的 blobId 与新增附件的 blobId 合并成一个数组**一起提交;只传新增的那几个,会把原有发票全部删掉。传空数组 `[]` 表示清空全部附件;不传(`null`)表示不动附件。 2. **上传与保存分离**:上传走 `/system/storage-blob/upload` 拿 `blobId`,保存走 `create` / `update` 提交 `blobIds`。禁止调用 `/infra/file/upload`、`/infra/file/presigned-url`,也禁止在业务表/表单里存 `fileUrl` 之类的文件地址字段。 3. **删除时附件会一并清理**(后端已处理),前端不需要额外调附件删除接口。但要注意:**上传后未保存就关闭弹窗**的孤儿文件不会被自动清理,建议前端在关闭时提示,或复用同一批 `blobId` 下次保存。 4. **`submit` 的参数在 query,请求体为空**。若按 JSON body 传会取不到 `id` 与 `processDefinitionKey`。 5. **审批流程 key 严禁写死**:必须先调 `approve-process-list`,把返回的 `name`+`key` 渲染成下拉(可参考 ERP 付款单的 `process-select-modal`)。列表为空时禁用提交按钮。 6. **「已驳回」可编辑,但不可删除**:后端 `update` 放行 `status=0 草稿` 与 `status=35 已驳回`;`delete` 仍只放行草稿。因此编辑按钮在 0/35 显示,删除按钮只在 0 显示。 7. **`totalAmount` 与明细汇总不联动校验**:后端不做一致性校验,总金额由前端负责回填与展示,用户可手改。若要求强一致,需后端补校验规则。 8. **日期区间筛选传参格式是 `yyyy-MM-dd`**(如 `applyDate[0]=2026-09-01`),**不要带时分秒**,否则后端解析失败报 400。注意列表日期区间选择器必须显式设置 `valueFormat: 'YYYY-MM-DD'`,项目通用的 `getRangePickerDefaultProps()` 默认是 `YYYY-MM-DD HH:mm:ss`,直接用会踩这个坑。 9. **列表页请不要默认展开 `items` 与 `attachmentList`**:分页接口对每行都会查明细和附件,行数多时开销较大;建议列表只展示主表字段(含 `hasAttachment`),明细与附件放到详情页按需加载。 10. **报销单号可自定义,留空则由后端生成**:表单里保留「报销单号」输入框,占位提示写「留空则由系统自动生成」。提交前请 `trim()`,纯空格要当作未填提交(后端也会再 trim 一次兜底)。生成规则为 Redis 序列 `BX` + `yyyyMMdd` + 6 位流水(每日重置)。**不要在编辑态用空串覆盖已有单号**——后端把空值解释为「保持不变」,不会重新生成。 11. **明细行提交时不要带 `id`**:后端保存明细是「先删后增」全量覆盖,而明细删除是**软删除**,若回传了原行 `id`,重新插入时会与已软删的行主键冲突。前端提交前必须剥掉每行的 `id` 与 `reimbursementId`,只保留 `expenseType`、`invoiceNo`、`invoiceAmount`、`invoiceDate`、`drawer`、`description`。 12. **审批详情页由 BPM 流程设计器挂载**:BPM 审批中心通过流程定义的 `formCustomViewPath` 动态加载业务详情页(`registerComponent` 按路径匹配),因此 BPM 流程设计器里必须把「自定义表单路径」配置为 `/finance/reimbursement/detail/approve/index.vue`,否则审批中心里看不到报销单内容。