Expense Management Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Expense Management 领域负责管理演讲者(Speaker)在演讲项目中产生的费用报销流程,包括:
- 费用报告创建:演讲者为参与的会议创建费用报告
- 费用明细管理:在费用报告下添加、编辑、删除费用明细(交通、住宿、餐饮等)
- 收据上传:为费用报告或费用明细上传收据图片/PDF
- 费用提交:演讲者提交费用报告供审批
- 费用审批/拒绝:Planner 审批或拒绝费用报告
- 费用历史记录:记录每次状态变更的审计历史
- 费用分类管理:维护费用类别(支持层级分类)
1.2 涉及的后端模块和包
| 模块/包 | 路径 | 描述 |
|---|---|---|
| speaker module | modules/v1/speaker/controller/ExpenseController.java | 费用报告控制器 |
| speaker module | modules/v1/speaker/controller/ExpenseItemController.java | 费用明细控制器 |
| speaker module | modules/v1/speaker/controller/ExpenseCategoryController.java | 费用分类控制器 |
| speaker module | modules/v1/speaker/service/ExpenseService.java | 费用报告服务 |
| speaker module | modules/v1/speaker/service/ExpenseItemService.java | 费用明细服务 |
| entity | common/persistence/entity/Expense.java | 费用报告实体 |
| entity | common/persistence/entity/ExpenseItem.java | 费用明细实体 |
| entity | common/persistence/entity/ExpenseCategory.java | 费用分类实体 |
| entity | common/persistence/entity/ExpenseHistory.java | 费用历史实体 |
2. Data Model Analysis
2.1 Entity Overview Table
Expense (t_expense) - 费用报告
| 字段 | 类型 | 说明 |
|---|---|---|
| expense_id | Integer (PK, auto, seq: s_expense) | 费用报告ID |
| submitter | Integer | 提交者(Speaker ID) |
| submit_date | Date | 提交日期 |
| meeting_request_id | Integer | 关联的会议请求ID |
| amount | BigDecimal | 报销金额 |
| expense_status | Integer | 状态: 0=草稿, 1=已批准, 2=已拒绝, 3=待审批 |
| reject_reason | String | 拒绝原因 |
| approve_amount | BigDecimal | 批准金额 |
| update_date | Date | 更新日期 |
| pdf_receipts | String | PDF 收据路径/标识 |
| field_id | Integer | 关联的文件ID (实际应为 file_id) |
| check_number | String | 支票号码 |
| issue_date | Date | 签发日期 |
| pv_ext_user_id | String | 外部用户ID(PhysicianView 遗留) |
ExpenseItem (t_expense_item) - 费用明细
| 字段 | 类型 | 说明 |
|---|---|---|
| expense_item_id | Integer (PK, auto, seq: s_expense_item) | 明细ID |
| expense_id | Integer | 关联的费用报告ID |
| expense_category | Integer | 费用分类ID |
| expense_sub_category | Integer | 费用子分类ID |
| expense_item_name | String | 明细名称 |
| submit_date | Date | 提交日期 |
| vendor_id | Integer | 供应商ID |
| amount | BigDecimal | 金额 |
| notes | String | 备注 |
| vendor_name | String | 供应商名称 |
| mile | BigDecimal | 里程数(用于里程报销) |
| currency | String | 货币类型 |
| file_id | Integer | 收据文件ID |
| pv_ext_user_id | String | 外部用户ID(PhysicianView 遗留) |
ExpenseCategory (t_expense_category) - 费用分类
| 字段 | 类型 | 说明 |
|---|---|---|
| expense_category_id | Integer (PK, auto, seq: s_expense_category) | 分类ID |
| expense_category_name | String | 分类名称 |
| parent_expense_category_id | Integer | 父分类ID (支持层级) |
| notes | String | 备注 |
ExpenseHistory (t_expense_history) - 费用历史
| 字段 | 类型 | 说明 |
|---|---|---|
| expense_history_id | Integer (PK, auto, seq: s_expense_history) | 历史ID |
| expense_id | Integer | 关联的费用报告ID |
| submitter | Integer | 提交者(Speaker ID) |
| submit_date | Date | 提交日期 |
| meeting_request_id | Integer | 会议请求ID |
| amount | BigDecimal | 金额 |
| expense_status | Integer | 状态 |
| reject_reason | String | 拒绝原因 |
| approve_amount | BigDecimal | 批准金额 |
| update_date | Date | 更新日期 |
| update_user_id | Integer | 更新用户ID |
| pv_ext_user_id | String | 外部用户ID |
| update_unified_user_id | Integer | 统一用户ID |
2.2 Table Relationships (ER Diagram - ASCII)
t_expense (费用报告)
|-- expense_id (PK)
|-- submitter --> t_speaker.speaker_id
|-- meeting_request_id --> t_meeting_request.meeting_request_id
|-- field_id --> t_file.file_id (收据文件)
|
|-- 1:N --> t_expense_item (费用明细)
| |-- expense_item_id (PK)
| |-- expense_id (FK -> t_expense)
| |-- expense_category --> t_expense_category.expense_category_id
| |-- expense_sub_category --> t_expense_category.expense_category_id
| |-- file_id --> t_file.file_id
|
|-- 1:N --> t_expense_history (费用历史)
|-- expense_history_id (PK)
|-- expense_id (FK -> t_expense)
|-- update_unified_user_id --> t_unified_user.user_id
t_expense_category (费用分类 - 自引用层级)
|-- expense_category_id (PK)
|-- parent_expense_category_id --> t_expense_category.expense_category_id2.3 Data Model Issues
DM-1: Expense.field_id 命名错误
- 文件:
common/persistence/entity/Expense.java:56-57 - 数据库字段名为
field_id,实际存储的是文件ID,应为file_id - 与
ExpenseItem.file_id命名不一致
DM-2: pv_ext_user_id 是遗留字段
- 文件:
Expense.java:66,ExpenseItem.java:66 pv_ext_user_id源自已废弃的 PhysicianView (PharmaginConnect) 功能- 仍在
ExpenseMapper.xml:61中用于关联t_attendee,用于处理外部参会者的费用
DM-3: ExpenseHistory 完全复制 Expense 字段
- 文件:
common/persistence/entity/ExpenseHistory.java ExpenseHistory几乎复制了Expense的所有字段,加上update_user_id和update_unified_user_id- 这种审计方式占用大量存储,且
update_user_id字段未使用(代码中只设置了update_unified_user_id)
DM-4: ExpenseCategory 未使用 Lombok
- 文件:
common/persistence/entity/ExpenseCategory.java - 手写 getter/setter 方法,与其他实体(使用
@Data)风格不一致
DM-5: 缺少外键约束
expense_id,expense_category,expense_sub_category等关联字段无数据库级外键约束- 依赖应用层保证数据一致性
3. Business Flow Analysis
3.1 Core Business Flows (ASCII Flow Diagrams)
费用报告创建与提交流程
Speaker (speakerview)
|
v
[选择会议] --> [创建费用报告] --> POST /v1/expenses?meetingRequestId=X
| |
| v
| createExpense()
| - 获取当前 Speaker ID
| - 设置初始状态 DRAFT(0)
| - amount = 0, approveAmount = 0
| - 创建 ExpenseHistory 记录
|
v
[添加费用明细] --> POST /v1/expenses/items
| |
| v
| createExpenseItem()
| - 如果有 base64 图片,保存到文件系统
| - 设置 expenseItemName = vendorName
| - 如果 expenseItemId 存在则更新,否则插入
|
v
[上传收据] --> POST /v1/expenses/{id}/receipts
| |
| v
| createExpenseReceipt()
| - 更新 Expense 的 field_id 和 pdf_receipts
| - 创建 ExpenseHistory 记录
|
v
[提交报告] --> PUT /v1/expenses/{id}/submit?amount=XXX
|
v
submitExpense()
- 设置 amount,状态 -> PENDING_APPROVED(3)
- 创建 ExpenseHistory 记录
- 创建 Alert 通知 Planner费用审批流程
Planner (speakerview - ExpenseReports)
|
v
[查看待审批列表] --> GET /v1/expenses?status=3
|
v
[查看详情] --> GET /v1/expenses/{expenseId}
|
v
[查看明细] --> GET /v1/expenses/items?expenseId=X
|
v
[审批/拒绝]
|
+-- [批准] --> PUT /v1/expenses/{id}/approval
| |
| v
| approveExpense()
| - 设置 approveAmount,状态 -> APPROVED(1)
| - 清除 rejectReason
| - 创建 ExpenseHistory 记录
| - 删除旧 Alert,创建新 Alert
|
+-- [拒绝] --> PUT /v1/expenses/{id}/rejection?rejectReason=XXX
|
v
rejectExpense()
- 设置 approveAmount = 0
- 设置 rejectReason,状态 -> REJECTED(2)
- 创建 ExpenseHistory 记录
- 删除旧 Alert,创建新 Alert3.2 Validation Rules
| 规则 | 位置 | 描述 |
|---|---|---|
| Speaker 权限校验 | ExpenseService.java:83,110,121,133,153,187 | 每个操作都通过 speakerService.checkSpeakerPermission(expense.getSubmitter()) 验证权限 |
| 费用报告必须存在 | ExpenseService.java:108,119,132,149,185 | 操作前检查费用报告是否存在 |
| 费用明细必须存在 | ExpenseItemService.java:100,109 | 更新/删除前检查明细是否存在 |
| 提交需要金额 | ExpenseController.java:111 | 提交时 amount 为必填参数 |
3.3 Business Logic Issues
BL-1: 删除费用报告直接物理删除
- 文件:
ExpenseService.java:116-126 deleteExpense使用deleteByPrimaryKey进行物理删除,并级联删除所有明细- 没有软删除机制,数据一旦删除不可恢复
BL-2: ExpenseItemService.createExpenseItem 存在两个同名方法
- 文件:
ExpenseItemService.java:35-66和ExpenseItemService.java:93-96 - 一个接受
ExpenseRequest(来自 meeting 模块),一个接受CreateExpenseItemRequest ExpenseRequest版本包含 base64 图片上传逻辑,base64 处理直接写在 Service 层
BL-3: base64 图片上传逻辑不安全
- 文件:
ExpenseItemService.java:44-58 - 直接将 base64 解码写入文件系统,无文件大小限制、无文件类型验证
e.printStackTrace()替代正规错误处理
BL-4: createExpenseItem 使用 upsert 逻辑
- 文件:
ExpenseItemService.java:36-66 - 当
expenseItemId不为 null 时执行更新而非创建,违反 POST 创建语义 item.setExpenseItemName(item.getVendorName())强制覆盖名称
BL-5: Alert 类型硬编码
- 文件:
ExpenseService.java:142,162,200-204 - Alert type=2 硬编码为费用类型,
AlertType.EXPENSE.getValue()在一些地方使用了常量 - 但在
example.createCriteria().andEqualTo("type", 2)中仍使用魔法数字
BL-6: submitExpense 金额由前端传入
- 文件:
ExpenseService.java:182-206 - 提交时的 amount 由前端传入,而非后端根据明细合计计算
- 存在前后端金额不一致的风险
4. API Inventory
4.1 REST Endpoints Table
ExpenseController (v1/expenses)
| Method | Path | 描述 | Controller 行号 |
|---|---|---|---|
| GET | /v1/expenses | 分页查询费用报告列表 | 49 |
| GET | /v1/expenses/{expenseId} | 获取费用报告详情 | 54 |
| POST | /v1/expenses | 创建费用报告 | 60 |
| PUT | /v1/expenses/{expenseId} | 更新费用报告(支票号/签发日期) | 67 |
| DELETE | /v1/expenses/{expenseId} | 删除费用报告(物理删除) | 75 |
| PUT | /v1/expenses/{expenseId}/approval | 批准费用报告 | 81 |
| PUT | /v1/expenses/{expenseId}/rejection | 拒绝费用报告 | 90 |
| GET | /v1/expenses/{expenseId}/histories | 查询费用历史 | 97 |
| PUT | /v1/expenses/{expenseId}/submit | 提交费用报告 | 109 |
| POST | /v1/expenses/{expenseId}/receipts | 上传费用收据 | 117 |
ExpenseItemController (v1/expenses/items)
| Method | Path | 描述 | Controller 行号 |
|---|---|---|---|
| GET | /v1/expenses/items | 查询费用明细列表(按 expenseId) | 38 |
| GET | /v1/expenses/items/{itemId} | 获取费用明细详情 | 45 |
| POST | /v1/expenses/items | 创建费用明细 | 53 |
| PUT | /v1/expenses/items/{itemId} | 更新费用明细 | 59 |
| DELETE | /v1/expenses/items/{itemId} | 删除费用明细(物理删除) | 69 |
| POST | /v1/expenses/items/{expenseItemId}/receipts | 上传明细收据 | 74 |
ExpenseCategoryController (v1/expenses/categories)
| Method | Path | 描述 | Controller 行号 |
|---|---|---|---|
| GET | /v1/expenses/categories | 查询所有费用分类 | 27 |
| GET | /v1/expenses/categories/{categoryId} | 查询子分类 | 33 |
4.2 API Design Issues
API-1: createExpense 只需 meetingRequestId 查询参数
POST /v1/expenses?meetingRequestId=X创建空的费用报告- 应将 meetingRequestId 放在 request body 中
API-2: submitExpense 金额通过查询参数传入
PUT /v1/expenses/{id}/submit?amount=XXX将关键的金额数据放在查询参数中- 应放在 request body 中
API-3: rejectExpense 拒绝原因通过查询参数传入
PUT /v1/expenses/{id}/rejection?rejectReason=XXX将可能很长的文本放在查询参数中- 应使用 request body
API-4: ExpenseItemController.getExpenseItem 使用 @RequestParam 而非 @PathVariable
- 文件:
ExpenseItemController.java:46 @GetMapping("/{itemId}")路径中有{itemId}但方法用的是@RequestParam Integer itemId- 这会导致 API 行为不符合预期
API-5: 缺少费用报告与会议的关联查询
- 没有提供
GET /v1/expenses?meetingRequestId=X的过滤方式(ExpenseQuery 中无此字段) - 前端只能按 speakerId、status、keyword 筛选
API-6: histories 端点直接暴露 Mapper
- 文件:
ExpenseController.java:97-105 - Controller 直接注入
ExpenseHistoryMapper并执行查询,跳过了 Service 层
5. Frontend Analysis
5.1 Pages & Components
Speakerview (费用管理主要前端)
| 组件 | 路径 | 描述 |
|---|---|---|
| ExpenseReports | containers/ExpenseReports/index.js | 费用报告列表页 |
| SpeakerExpenseReport | containers/SpeakerExpenseReport/index.js | 演讲者费用报告详情 |
| SpeakerExpenseReport/UploadReceipts | containers/SpeakerExpenseReport/UploadReceipts.js | 上传收据 |
| SpeakerExpenseReport (component) | components/SpeakerExpenseReport/index.js | 费用报告展示组件 |
| SpeakerExpenseReport/UploadReceipts (component) | components/SpeakerExpenseReport/UploadReceipts.js | 上传收据组件 |
| ExpenseReportItems | components/ExpenseReportItems/index.js | 费用明细列表 |
| ExpenseReportItems/print | components/ExpenseReportItems/print.js | 打印费用明细 |
| ExpenseReportItems/PrintModal | components/ExpenseReportItems/PrintModal.js | 打印弹窗 |
| ExpenseItem | components/ExpenseItem/index.js | 单个费用明细表单 |
| ExpenseAddForm | components/ExpenseAddForm/index.js | 添加费用表单 |
| ExpenseApproveForm | components/ExpenseApproveForm/index.js | 审批表单 |
| ExpenseRejectForm | components/ExpenseRejectForm/index.js | 拒绝表单 |
| ExpenseCheckNumForm | components/ExpenseCheckNumForm/index.js | 支票号码表单 |
| ExpenseMileageReimbursement | components/ExpenseMileageReimbursement/index.js | 里程报销组件 |
Plannerview (有限的费用相关功能)
| 组件 | 路径 | 描述 |
|---|---|---|
| AddTransferOfValueModal | components/RegReport/AddTransferOfValueModal.js | 添加费用转移值(间接引用) |
| MTBudgetForm | components/MTBudgetForm/index.js | 预算表单中引用费用数据 |
5.2 Redux State Structure
Speakerview
state.expenseReports:
- list: Array // 费用报告列表
- items: Array // 当前费用报告的明细列表
- categories: Array // 费用分类列表
- expenseInfo: Object // 当前费用报告详情
- itemInfo: Object // 当前明细详情
- rejectHistory: Array // 拒绝历史
- approveSuccess: Boolean // 审批成功标记
- rejectSuccess: Boolean // 拒绝成功标记
- updateSuccess: Boolean // 更新成功标记
- checkNumSuccess: Boolean // 支票号更新成功
- loading: Boolean5.3 Frontend Issues
FE-1: 容器和组件同名重复
containers/SpeakerExpenseReport/和components/SpeakerExpenseReport/存在相同名称的文件- 包括
index.js和UploadReceipts.js,职责划分不清
FE-2: 费用管理仅在 speakerview 中
- Planner 查看和审批费用也在 speakerview 中完成,而非 plannerview
- 这意味着 Planner 需要登录 speakerview 才能审批费用
FE-3: Redux 状态管理中的 reset 模式
- 大量使用
*_RESETaction 手动清除成功状态标记 - 如
EXPENSEREPORTS_APPROVE_RESET,EXPENSEREPORTS_REJECT_RESET等 - 这种模式容易导致状态不一致
FE-4: 缺少 Salesview 费用功能
- Salesview 中没有任何费用管理功能
- 考虑到 Sales Rep 可能需要查看关联会议的费用状态
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
- 物理删除无法恢复 -
deleteExpense和deleteExpenseItem直接物理删除数据,应改为软删除 - 提交金额由前端控制 -
submitExpense的金额应由后端根据明细自动合计 - base64 图片上传无安全校验 - 缺少文件大小和类型限制,存在安全风险
- Expense.field_id 命名错误 - 数据库和实体中的字段名应为
file_id
6.2 Design Defects (should improve)
- ExpenseHistory 冗余存储 - 应只记录变更的字段和操作人,而非复制完整记录。可以考虑使用 JaVers 已有的审计框架
- Controller 直接注入 Mapper -
ExpenseController.java:45直接注入ExpenseHistoryMapper,绕过 Service 层 - ExpenseItemService 有两个同名 createExpenseItem - 方法重载造成混淆,接受不同的 Request 类型
- API 参数放置不规范 - 金额、拒绝原因等重要数据放在查询参数而非 request body
- ExpenseItemController.getExpenseItem 注解错误 -
@PathVariable和@RequestParam混用
6.3 Technical Debt (nice to have)
- pv_ext_user_id 遗留字段 - PhysicianView 已废弃,应清理 Expense 和 ExpenseItem 中的
pv_ext_user_id - ExpenseCategory 未使用 Lombok - 应统一使用
@Data注解 - ExpenseHistory.update_user_id 未使用 - 代码中只设置
update_unified_user_id,update_user_id永远为 null - 缺少费用报告状态流转校验 - 未限制非法状态转换(如从 APPROVED 直接到 DRAFT)
- ExpenseMapper.xml 中的 SQL 注入风险 -
${direction}使用$而非#进行 SQL 拼接(行118-125)
7. Rewrite Recommendations
改用软删除 - 为 Expense 和 ExpenseItem 添加
deleted标记字段,用软删除替代物理删除重新设计金额管理:
- 后端自动从 ExpenseItem 合计 amount
- 提交时后端计算总额,不接受前端传入的 amount
- 审批时 approveAmount 可以不同于 amount
文件上传标准化:
- 移除 base64 图片上传逻辑
- 统一使用文件上传服务(File Module),先上传文件获取 fileId,再关联到 ExpenseItem
- 添加文件类型和大小限制
简化审计历史:
- 使用 JaVers 现有框架记录实体变更,替代 ExpenseHistory 全量复制
- 或改为只记录变更差异(字段名、旧值、新值、操作人、操作时间)
状态机设计:
DRAFT(0) --> PENDING_APPROVED(3) --> APPROVED(1) --> REJECTED(2) --> DRAFT(0)- 明确定义允许的状态转换
- 在 Service 层校验状态流转合法性
API 重新设计:
POST /v1/expensesbody 中包含 meetingRequestIdPUT /v1/expenses/{id}/submitbody 中不需要 amount(后端计算)PUT /v1/expenses/{id}/rejectbody 中包含 rejectReason- 修复
getExpenseItem的注解错误 - 将 histories 端点从 Controller 移到 Service 层
修复命名问题:
Expense.field_id重命名为file_id(需要数据库迁移)- 清理
pv_ext_user_id字段 - 清理
ExpenseHistory.update_user_id字段