Skip to content

Expense Management Domain - Deep Dive Analysis

1. Domain Overview

1.1 领域职责描述

Expense Management 领域负责管理演讲者(Speaker)在演讲项目中产生的费用报销流程,包括:

  • 费用报告创建:演讲者为参与的会议创建费用报告
  • 费用明细管理:在费用报告下添加、编辑、删除费用明细(交通、住宿、餐饮等)
  • 收据上传:为费用报告或费用明细上传收据图片/PDF
  • 费用提交:演讲者提交费用报告供审批
  • 费用审批/拒绝:Planner 审批或拒绝费用报告
  • 费用历史记录:记录每次状态变更的审计历史
  • 费用分类管理:维护费用类别(支持层级分类)

1.2 涉及的后端模块和包

模块/包路径描述
speaker modulemodules/v1/speaker/controller/ExpenseController.java费用报告控制器
speaker modulemodules/v1/speaker/controller/ExpenseItemController.java费用明细控制器
speaker modulemodules/v1/speaker/controller/ExpenseCategoryController.java费用分类控制器
speaker modulemodules/v1/speaker/service/ExpenseService.java费用报告服务
speaker modulemodules/v1/speaker/service/ExpenseItemService.java费用明细服务
entitycommon/persistence/entity/Expense.java费用报告实体
entitycommon/persistence/entity/ExpenseItem.java费用明细实体
entitycommon/persistence/entity/ExpenseCategory.java费用分类实体
entitycommon/persistence/entity/ExpenseHistory.java费用历史实体

2. Data Model Analysis

2.1 Entity Overview Table

Expense (t_expense) - 费用报告

字段类型说明
expense_idInteger (PK, auto, seq: s_expense)费用报告ID
submitterInteger提交者(Speaker ID)
submit_dateDate提交日期
meeting_request_idInteger关联的会议请求ID
amountBigDecimal报销金额
expense_statusInteger状态: 0=草稿, 1=已批准, 2=已拒绝, 3=待审批
reject_reasonString拒绝原因
approve_amountBigDecimal批准金额
update_dateDate更新日期
pdf_receiptsStringPDF 收据路径/标识
field_idInteger关联的文件ID (实际应为 file_id)
check_numberString支票号码
issue_dateDate签发日期
pv_ext_user_idString外部用户ID(PhysicianView 遗留)

ExpenseItem (t_expense_item) - 费用明细

字段类型说明
expense_item_idInteger (PK, auto, seq: s_expense_item)明细ID
expense_idInteger关联的费用报告ID
expense_categoryInteger费用分类ID
expense_sub_categoryInteger费用子分类ID
expense_item_nameString明细名称
submit_dateDate提交日期
vendor_idInteger供应商ID
amountBigDecimal金额
notesString备注
vendor_nameString供应商名称
mileBigDecimal里程数(用于里程报销)
currencyString货币类型
file_idInteger收据文件ID
pv_ext_user_idString外部用户ID(PhysicianView 遗留)

ExpenseCategory (t_expense_category) - 费用分类

字段类型说明
expense_category_idInteger (PK, auto, seq: s_expense_category)分类ID
expense_category_nameString分类名称
parent_expense_category_idInteger父分类ID (支持层级)
notesString备注

ExpenseHistory (t_expense_history) - 费用历史

字段类型说明
expense_history_idInteger (PK, auto, seq: s_expense_history)历史ID
expense_idInteger关联的费用报告ID
submitterInteger提交者(Speaker ID)
submit_dateDate提交日期
meeting_request_idInteger会议请求ID
amountBigDecimal金额
expense_statusInteger状态
reject_reasonString拒绝原因
approve_amountBigDecimal批准金额
update_dateDate更新日期
update_user_idInteger更新用户ID
pv_ext_user_idString外部用户ID
update_unified_user_idInteger统一用户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_id

2.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_idupdate_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,创建新 Alert

3.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-66ExpenseItemService.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)

MethodPath描述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)

MethodPath描述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)

MethodPath描述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 (费用管理主要前端)

组件路径描述
ExpenseReportscontainers/ExpenseReports/index.js费用报告列表页
SpeakerExpenseReportcontainers/SpeakerExpenseReport/index.js演讲者费用报告详情
SpeakerExpenseReport/UploadReceiptscontainers/SpeakerExpenseReport/UploadReceipts.js上传收据
SpeakerExpenseReport (component)components/SpeakerExpenseReport/index.js费用报告展示组件
SpeakerExpenseReport/UploadReceipts (component)components/SpeakerExpenseReport/UploadReceipts.js上传收据组件
ExpenseReportItemscomponents/ExpenseReportItems/index.js费用明细列表
ExpenseReportItems/printcomponents/ExpenseReportItems/print.js打印费用明细
ExpenseReportItems/PrintModalcomponents/ExpenseReportItems/PrintModal.js打印弹窗
ExpenseItemcomponents/ExpenseItem/index.js单个费用明细表单
ExpenseAddFormcomponents/ExpenseAddForm/index.js添加费用表单
ExpenseApproveFormcomponents/ExpenseApproveForm/index.js审批表单
ExpenseRejectFormcomponents/ExpenseRejectForm/index.js拒绝表单
ExpenseCheckNumFormcomponents/ExpenseCheckNumForm/index.js支票号码表单
ExpenseMileageReimbursementcomponents/ExpenseMileageReimbursement/index.js里程报销组件

Plannerview (有限的费用相关功能)

组件路径描述
AddTransferOfValueModalcomponents/RegReport/AddTransferOfValueModal.js添加费用转移值(间接引用)
MTBudgetFormcomponents/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: Boolean

5.3 Frontend Issues

FE-1: 容器和组件同名重复

  • containers/SpeakerExpenseReport/components/SpeakerExpenseReport/ 存在相同名称的文件
  • 包括 index.jsUploadReceipts.js,职责划分不清

FE-2: 费用管理仅在 speakerview 中

  • Planner 查看和审批费用也在 speakerview 中完成,而非 plannerview
  • 这意味着 Planner 需要登录 speakerview 才能审批费用

FE-3: Redux 状态管理中的 reset 模式

  • 大量使用 *_RESET action 手动清除成功状态标记
  • EXPENSEREPORTS_APPROVE_RESET, EXPENSEREPORTS_REJECT_RESET
  • 这种模式容易导致状态不一致

FE-4: 缺少 Salesview 费用功能

  • Salesview 中没有任何费用管理功能
  • 考虑到 Sales Rep 可能需要查看关联会议的费用状态

6. Problem Summary

6.1 Critical Issues (must fix in rewrite)

  1. 物理删除无法恢复 - deleteExpensedeleteExpenseItem 直接物理删除数据,应改为软删除
  2. 提交金额由前端控制 - submitExpense 的金额应由后端根据明细自动合计
  3. base64 图片上传无安全校验 - 缺少文件大小和类型限制,存在安全风险
  4. Expense.field_id 命名错误 - 数据库和实体中的字段名应为 file_id

6.2 Design Defects (should improve)

  1. ExpenseHistory 冗余存储 - 应只记录变更的字段和操作人,而非复制完整记录。可以考虑使用 JaVers 已有的审计框架
  2. Controller 直接注入 Mapper - ExpenseController.java:45 直接注入 ExpenseHistoryMapper,绕过 Service 层
  3. ExpenseItemService 有两个同名 createExpenseItem - 方法重载造成混淆,接受不同的 Request 类型
  4. API 参数放置不规范 - 金额、拒绝原因等重要数据放在查询参数而非 request body
  5. ExpenseItemController.getExpenseItem 注解错误 - @PathVariable@RequestParam 混用

6.3 Technical Debt (nice to have)

  1. pv_ext_user_id 遗留字段 - PhysicianView 已废弃,应清理 Expense 和 ExpenseItem 中的 pv_ext_user_id
  2. ExpenseCategory 未使用 Lombok - 应统一使用 @Data 注解
  3. ExpenseHistory.update_user_id 未使用 - 代码中只设置 update_unified_user_idupdate_user_id 永远为 null
  4. 缺少费用报告状态流转校验 - 未限制非法状态转换(如从 APPROVED 直接到 DRAFT)
  5. ExpenseMapper.xml 中的 SQL 注入风险 - ${direction} 使用 $ 而非 # 进行 SQL 拼接(行118-125)

7. Rewrite Recommendations

  1. 改用软删除 - 为 Expense 和 ExpenseItem 添加 deleted 标记字段,用软删除替代物理删除

  2. 重新设计金额管理:

    • 后端自动从 ExpenseItem 合计 amount
    • 提交时后端计算总额,不接受前端传入的 amount
    • 审批时 approveAmount 可以不同于 amount
  3. 文件上传标准化:

    • 移除 base64 图片上传逻辑
    • 统一使用文件上传服务(File Module),先上传文件获取 fileId,再关联到 ExpenseItem
    • 添加文件类型和大小限制
  4. 简化审计历史:

    • 使用 JaVers 现有框架记录实体变更,替代 ExpenseHistory 全量复制
    • 或改为只记录变更差异(字段名、旧值、新值、操作人、操作时间)
  5. 状态机设计:

    DRAFT(0) --> PENDING_APPROVED(3) --> APPROVED(1)
                                     --> REJECTED(2) --> DRAFT(0)
    • 明确定义允许的状态转换
    • 在 Service 层校验状态流转合法性
  6. API 重新设计:

    • POST /v1/expenses body 中包含 meetingRequestId
    • PUT /v1/expenses/{id}/submit body 中不需要 amount(后端计算)
    • PUT /v1/expenses/{id}/reject body 中包含 rejectReason
    • 修复 getExpenseItem 的注解错误
    • 将 histories 端点从 Controller 移到 Service 层
  7. 修复命名问题:

    • Expense.field_id 重命名为 file_id(需要数据库迁移)
    • 清理 pv_ext_user_id 字段
    • 清理 ExpenseHistory.update_user_id 字段