Budget & Financial Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Budget & Financial 领域负责整个医药演讲者平台的预算管理和财务控制。核心职责包括:
- 预算层级管理 (Budget Cap Allocation): 管理从 Admin -> Region -> District 的预算逐级分配
- 品牌预算分配 (Brand Budget Allocation): 按品牌(Brand)维度管理预算的录入、分配、取消分配和转移
- 会议级预算管理 (Meeting Budget Items): 管理单个会议/项目的预算行项目(line items),包含 SOW/EST/BILL/ACT/CXL 五个版本的成本跟踪
- 预算模板管理 (Budget Templates): 按项目类型和服务类型管理可复用的预算模板
- 预算类别管理 (Budget Categories): 管理预算的分类和子分类体系
- 财务年度管理 (Fiscal Year/Period): 管理客户的财务年度定义,包括起止日期
- 预算上限控制 (Budget Cap): 支持三种 Cap 方法: Shared $ Pool, Program Type $ Cap, Program Type Qty Cap
- 预算告警 (Budget Alert): 预算变更时生成告警通知
1.2 涉及的后端模块和包
| 包路径 | 说明 |
|---|---|
com.pharmagin.modules.v1.budget.controller | 6 个 Controller |
com.pharmagin.modules.v1.budget.service | 10 个 Service |
com.pharmagin.modules.v1.budget.model | 37 个 Request/Response/DTO |
com.pharmagin.modules.v1.budget.constant | 3 个枚举常量 |
com.pharmagin.modules.v1.budget.strategy | 策略模式:4 种分配历史记录策略 |
com.pharmagin.modules.v1.budget.util | BudgetHelper 工具类 |
com.pharmagin.common.persistence.entity | 10 个 Budget 相关 Entity |
com.pharmagin.common.constant.BudgetConstant | 4 个预算常量枚举 |
2. Data Model Analysis
2.1 Entity Overview Table
2.1.1 BudgetItem (t_budget_item)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetItem.java
会议级预算行项目,每个会议可有多个预算项目,每个项目有多个版本(SOW/EST/BILL/ACT/CXL)。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetItemId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_item |
| categoryId | Integer | 预算类别 ID (外键 -> BudgetCategory) | |
| subCategoryId | Integer | 子类别 ID (外键 -> BudgetCategory) | |
| itemName | String | 预算项名称 | |
| quantity | BigDecimal | 数量 | |
| unitCost | BigDecimal | 单价 | |
| extCost | BigDecimal | 扩展成本 (quantity * unitCost) | |
| tax | BigDecimal | 税额 | |
| gratuity | BigDecimal | 小费/服务费 | |
| vendorId | Integer | 供应商/Speaker ID | |
| productId | Integer | 产品 ID | |
| meetingId | Integer | 会议 ID | |
| meetingRequestId | Integer | 会议请求 ID (外键 -> MeetingRequest) | |
| gratuityIsTaxed | Integer | 小费是否含税 (0/1) | |
| reconciled | Integer | 是否已核销 (0/1) | |
| invoiceNumber | String | 发票号 | |
| currency | String | 货币类型 | |
| notes | String | 备注 | |
| taxMethod | Integer | 税额计算方式: 1=固定金额, 0=百分比 | |
| gratuityMethod | Integer | 小费计算方式: 1=固定金额, 0=百分比 | |
| parentBudgetItemId | Integer | 父预算项 ID(SOW 版本的 ID 作为其他版本的 parent) | |
| budgetStatus | String | 预算状态名 (SOW/EST/BILL/ACT/CXL) | |
| date | Date | 日期 | |
| status | Integer | 状态 | |
| deleted | Integer | 删除标志 (0=有效, 1=已删除) | |
| speakerId | Integer | 演讲者 ID (与 vendorId 重复) | |
| budgetVersionId | Integer | 预算版本 ID (1=SOW,2=EST,3=BILL,4=ACT,5=CXL) | |
| vendorCategoryId | Integer | 供应商类型: 0=Speaker, 1=PLID, 2=Other | |
| savingsTotal | BigDecimal | 节省总额 | |
| allocateType | Integer | 分配类型 | |
| checkNumber | String | 支票号 | |
| checkIssueDate | Date | 支票签发日期 | |
| plid | String | PLID 标识 | |
| otherVendor | String | 其他供应商名称 |
2.1.2 BudgetCategory (t_budget_category)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCategory.java
预算类别表,支持两级分类(Category / SubCategory 通过 parentCategoryId 实现自关联)。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| categoryId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_category |
| categoryName | String | 类别名称 | |
| productId | Integer | 产品 ID | |
| categoryType | Integer | 类别类型 | |
| resumeId | Integer | 简历 ID (用途不明确) | |
| parentCategoryId | Integer | 父类别 ID (0=顶级类别) | |
| accountId | Integer | 账户 ID | |
| speakerId | Integer | 演讲者 ID | |
| company | String | 公司名称 | |
| categorySequence | Integer | 排序序号 |
2.1.3 BudgetVersion (t_budget_version)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetVersion.java
预算版本定义表,定义 SOW/EST/BILL/ACT/CXL 五个版本。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetVersionId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_version |
| description | String | 版本描述 | |
| name | String | 版本名称 (SOW/EST/BILL/ACT/CXL) | |
| resumeId | Integer | 简历 ID | |
| productId | Integer | 产品 ID | |
| accountId | Integer | 账户 ID | |
| sequence | Integer | 排序序号 | |
| company | String | 公司名称 |
2.1.4 BudgetCapLocale (t_budget_cap_locale)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCapLocale.java
预算上限区域配置表,定义 Region/District 级别的预算上限设置。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetLocaleId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_cap_locale |
| productId | Integer | 产品 ID | |
| regionId | Integer | 区域 ID | |
| districtId | Integer | 地区 ID (0=Region 级别) | |
| localeType | Integer | 区域类型: 0=Region, 1=District | |
| fiscalYear | Integer | 财务年度 | |
| initBgt | BigDecimal | 初始预算 | |
| addBgt | BigDecimal | 追加预算 | |
| enterDate | Date | 录入日期 | |
| portalUserId | Integer | 门户用户 ID | |
| enableStatus | String | 启用状态: "Y"=启用, "N"=禁用 | |
| userId | Integer | 用户 ID | |
| brandId | Integer | 品牌 ID |
2.1.5 BudgetCapLocaleDtl (t_budget_cap_locale_dtl)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCapLocaleDtl.java
预算上限区域明细表,按 ProgramType 细分预算。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetLocaleDtlId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_cap_locale_dtl |
| budgetLocaleId | Integer | 外键 -> BudgetCapLocale | |
| programTypeId | Integer | 项目类型 ID | |
| initBgt | BigDecimal | 初始预算 | |
| addBgt | BigDecimal | 追加预算 | |
| initQty | BigDecimal | 初始数量 | |
| addQty | BigDecimal | 追加数量 | |
| enterDate | Date | 录入日期 | |
| portalUserId | Integer | 门户用户 ID | |
| enableStatus | String | 启用状态: "Y"/"N" | |
| capMethod | Integer | Cap 方法: 0=SharedAmountPool, 2=ProgramTypeQty, 4=ProgramTypeCap | |
| userId | Integer | 用户 ID | |
| estimatedBudgetPerProgram | BigDecimal | 每个项目的预估预算 |
2.1.6 BudgetCapItem (t_budget_cap_item)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCapItem.java
预算上限变更明细记录,记录每次预算金额的增减。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetCapItemId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_cap_item |
| budgetLocaleDtlId | Integer | 外键 -> BudgetCapLocaleDtl | |
| budgetLocaleId | Integer | 外键 -> BudgetCapLocale | |
| budgetType | Integer | 预算类型: 0=InitialBudget, 1=AdditionalBudget, 2=InitialQty, 3=AdditionalQty | |
| budgetAmount | BigDecimal | 预算金额 | |
| portalUserId | Integer | 门户用户 ID | |
| deleteStatus | String | 删除状态: "Y"=已删除, "N"=有效 | |
| enterDate | Date | 录入日期 | |
| lastUpdateDate | Date | 最后更新日期 | |
| userId | Integer | 用户 ID |
2.1.7 BudgetAllocationHistory (t_budget_allocation_history)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetAllocationHistory.java
品牌预算分配历史表,记录所有 Admin->Region->District 的预算分配操作。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| id | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_allocation_history |
| fiscalPeriod | Integer | 财务周期 | |
| brandId | Integer | 品牌 ID | |
| sourceId | Integer | 来源 ID (region_id 或 district_id) | |
| sourceName | String | 来源名称 | |
| targetId | Integer | 目标 ID (region_id 或 district_id) | |
| targetName | String | 目标名称 | |
| allocationType | Integer | 分配类型: 0=Admin->Region, 1=Region->District, 2=District->District, 3=District->Region | |
| amount | BigDecimal | 分配金额 | |
| amountType | Integer | 金额类型: 0=Initial, 1=Additional | |
| regionId | Integer | 区域 ID | |
| comment | String | 操作备注 | |
| delFlag | Integer | 删除标志: 0=有效, 1=已删除 | |
| createdBy | String | 创建人 | |
| createdAt | Date | 创建时间 |
2.1.8 BudgetAlert (t_budget_alert)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetAlert.java
预算告警表,在 Region 级别录入品牌预算时创建。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| id | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_alert |
| fiscalPeriod | Integer | 财务周期 | |
| amount | BigDecimal | 告警金额 | |
| regionId | Integer | 区域 ID | |
| createdAt | Date | 创建时间 |
2.1.9 BudgetAlertViewHistory (t_budget_alert_view_history)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetAlertViewHistory.java
预算告警查看记录表。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| id | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_alert_view_history |
| budgetAlertId | Integer | 外键 -> BudgetAlert | |
| portalUserId | Integer | 门户用户 ID | |
| createdAt | Date | 查看时间 | |
| userId | Integer | 用户 ID |
2.1.10 BudgetItemTemplate (t_budget_item_template)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetItemTemplate.java
预算模板表,按 ProgramType + ServiceType 维度存储可复用的预算项模板。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| budgetItemTemplateId | Integer | @Id, @GeneratedValue | 主键,序列 s_budget_item_template |
| categoryId | Integer | 类别 ID | |
| subCategoryId | Integer | 子类别 ID | |
| itemName | String | 项目名称 | |
| quantity | BigDecimal | 数量 | |
| unitCost | BigDecimal | 单价 | |
| extCost | BigDecimal | 扩展成本 | |
| tax | BigDecimal | 税额 | |
| gratuity | BigDecimal | 小费 | |
| vendorId | Integer | 供应商 ID | |
| productId | Integer | 产品 ID | |
| gratuityIsTaxed | Integer | 小费是否含税 | |
| reconciled | Integer | 是否核销 | |
| invoiceNumber | String | 发票号 | |
| currency | String | 货币 | |
| notes | String | 备注 | |
| taxMethod | Integer | 税额计算方式 | |
| gratuityMethod | Integer | 小费计算方式 | |
| parentBudgetItemId | Integer | 父预算项 ID | |
| budgetStatus | String | 预算状态 | |
| date | Date | 日期 | |
| deleted | Integer | 删除标志 | |
| speakerId | Integer | 演讲者 ID | |
| meetingProgramType | Integer | 项目类型 ID | |
| isSpeaker | Integer | 是否为演讲者: 1=Speaker, 2=Speaker2 | |
| budgetVersionId | Integer | 预算版本 ID | |
| programServiceType | Integer | 服务类型 ID | |
| vendorCategoryId | Integer | 供应商类别 ID | |
| savingsTotal | BigDecimal | 节省总额 | |
| allocateType | Integer | 分配类型 |
2.1.11 FiscalYear (t_fiscal_year)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/FiscalYear.java
财务年度定义表。注意: 该表没有 @Id 注解。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| fiscalYear | Integer | 财务年度编号 | |
| productId | Integer | 产品 ID | |
| startDate | Date | 开始日期 | |
| endDate | Date | 结束日期 | |
| fiscalPeriodName | String | 财务周期名称 |
2.1.12 Brand (t_brand)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/Brand.java
品牌表。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| brandId | Integer | @Id, @GeneratedValue | 主键,序列 s_brand |
| brandName | String | 品牌名称 | |
| productId | Integer | 产品 ID | |
| status | Integer | 状态 |
2.1.13 AllocationTov (t_allocation_tov)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/AllocationTov.java
Transfer of Value 分配表,记录会议的 TOV 使用情况。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| id | Integer | @Id, @GeneratedValue | 主键,序列 s_allocation_tov |
| meetingRequestId | Integer | 会议请求 ID | |
| usedTov | BigDecimal | 已使用 TOV | |
| allocationHeadcount | Integer | 分配人数 | |
| tovChangeReason | String | TOV 变更原因 | |
| headcountChangeReason | String | 人数变更原因 |
2.1.14 TeamBrand (t_team_brand)
文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/TeamBrand.java
团队-品牌关联表(联合主键)。
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| teamId | Integer | @Id | 团队 ID |
| brandId | Integer | @Id | 品牌 ID |
2.2 Table Relationships (ER Diagram - ASCII)
t_fiscal_year
+--------------+
| fiscal_year |
| product_id |
| start_date |
| end_date |
+--------------+
|
| (fiscal_year 关联)
v
t_brand t_budget_cap_locale t_region / t_district
+----------+ +-------------------+ +------------------+
| brand_id |<---brandId-------->| budget_locale_id |<---regionId--->| region_id |
| brand_name| | product_id |<--districtId-->| district_id |
| product_id| | region_id | +------------------+
| status | | district_id |
+----------+ | locale_type (0/1) |
| | fiscal_year |
| | enable_status |
| | brand_id |
| +-------------------+
| |
| | 1:N
| v
| t_budget_cap_locale_dtl
| +------------------------+ t_meeting_program_type
| | budget_locale_dtl_id | +--------------------+
| | budget_locale_id (FK) |<------->| program_type_id |
| | program_type_id (FK) | | brand_id |
| | cap_method (0/2/4) | +--------------------+
| | estimated_budget_per...|
| | enable_status |
| +------------------------+
| |
| | 1:N (双向 FK,可关联 locale 或 dtl)
| v
| t_budget_cap_item
| +--------------------+
| | budget_cap_item_id |
| | budget_locale_dtl_id|
| | budget_locale_id |
| | budget_type (0-3) |
| | budget_amount |
| | delete_status |
| +--------------------+
|
| t_budget_allocation_history
| +--------------------------+
+-->| brand_id |
| fiscal_period |
| source_id / target_id |
| allocation_type (0-3) |
| amount |
| amount_type (0/1) |
| region_id |
+--------------------------+
|
v
t_budget_alert t_budget_alert_view_history
+---------------+ +----------------------------+
| fiscal_period | | budget_alert_id (FK) |
| amount | | portal_user_id |
| region_id | +----------------------------+
+---------------+
t_budget_category (自关联) t_budget_item t_budget_item_template
+------------------+ +------------------+ +---------------------+
| category_id |<---catId----->| budget_item_id | | budget_item_tmpl_id |
| category_name |<--subCatId--->| category_id | | category_id |
| parent_category_id| | sub_category_id | | sub_category_id |
+------------------+ | meeting_request_id| | meeting_program_type|
| budget_version_id | | program_service_type|
| parent_budget_item| | is_speaker (1/2) |
| vendor_id/speaker | +---------------------+
+------------------+
t_budget_version
+------------------+
| budget_version_id| 1=SOW, 2=EST, 3=BILL, 4=ACT, 5=CXL
| name |
+------------------+2.3 Data Model Issues
FiscalYear 缺少主键 (
FiscalYear.java:22-38):t_fiscal_year表没有@Id注解,缺少主键定义。实际使用(productId, fiscalYear)复合键查询,但未在 JPA 层面声明复合主键。BudgetItem 与 BudgetItemTemplate 高度重复 (
BudgetItem.javavsBudgetItemTemplate.java): 两个 Entity 有 20+ 个相同字段,BudgetItemTemplate 多了meetingProgramType、isSpeaker、programServiceType三个字段。代码中频繁使用BeanUtil.map()在两者之间转换,应该提取公共基类。vendorId 与 speakerId 语义混乱 (
BudgetItem.java:53-54,101-102): BudgetItem 同时有vendorId和speakerId两个字段,在 service 层频繁出现item.setSpeakerId(vendorId)的赋值(BudgetItemService.java:99,560),说明两个字段实际指向同一个值。BudgetCapItem 的双 FK 设计 (
BudgetCapItem.java:31-35): 同时有budgetLocaleDtlId和budgetLocaleId两个外键,但只使用其一(根据是 locale 级别还是 dtl 级别的预算),违反了数据库范式。enableStatus/deleteStatus 使用字符串而非布尔 (
BudgetCapLocale.java:57,BudgetCapItem.java:47): 使用 "Y"/"N" 字符串而非数据库布尔类型。BudgetItemStatusFlag 语义反转 (
BudgetItemStatusFlag.java:5):YES(0, "Y")表示不删除/启用,但源码有 TODO 注释// TODO: toggle value,说明 value 和 code 的语义存在已知问题。YES.getCode()返回 "Y" 但YES.getValue()返回 0,在不同上下文使用不同的方式很容易混淆。BudgetAllocationHistory 冗余存储 (
BudgetAllocationHistory.java:41-54): 同时存储了sourceId/targetId(ID) 和sourceName/targetName(名称),名称可能会因 Region/District 重命名而不一致。BudgetCategory 中的 resumeId/accountId/speakerId/company 字段 (
BudgetCategory.java:23-34): 这些字段在当前代码中未见使用,可能是历史遗留。
3. Business Flow Analysis
3.1 Core Business Flows
3.1.1 Budget Hierarchy & Allocation (预算层级分配)
┌─────────────┐
│ Fiscal Year │ 定义财务年度的起止日期
│ (per product)│
└──────┬──────┘
│
┌──────v──────┐
│ Brand │ 每个 Product 有多个 Brand
│ (Drug Brand) │
└──────┬──────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
v v v
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Region 1│ │ Region 2│ │ Region 3│
│ (RM) │ │ (RM) │ │ (RM) │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│ │ │ │ │ │
v v v v v v
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│D1 │ │D2 │ │D3 │ │D4 │ │D5 │ │D6 │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘分配流程:
Admin 录入 Region 预算 (BudgetEnterRequest)
│
├──> 创建 BudgetAllocationHistory (type=0, ADMIN_TO_REGION)
├──> 创建 BudgetAlert
└──> 创建/更新 BudgetCapLocale + BudgetCapLocaleDtl
│
v
RM 分配到 District (BudgetAllocateRequest)
│
├──> 检查 Region unallocatedBudget >= allocatedBudget
├──> allocationType=EVERY: 平均分配到所有 District
│ └─> divideAndRemainder: 余数加到第一个 District
└──> allocationType=SINGLE: 分配到指定 District
├──> 创建 BudgetAllocationHistory (type=1, REGION_TO_DISTRICT)
└──> 创建 BudgetCapItem 记录
│
v
District 间转移 (MoveBudgetRequest)
│
├──> 检查 source District balance >= allocatedBudget
├──> source 扣减 (负数 BudgetCapItem)
├──> target 增加 (正数 BudgetCapItem)
└──> 创建 BudgetAllocationHistory (type=2, DISTRICT_TO_DISTRICT)
│
v
取消分配 (UnallocateBudgetRequest)
│
├──> 检查 District budget >= releaseBudget
├──> 创建 BudgetCapItem 记录
└──> 创建 BudgetAllocationHistory (type=3, DISTRICT_TO_REGION)3.1.2 Budget Cap Calculation (预算上限计算)
Cap 方法选择 (CapMethodRequest)
│
├──> SharedAmountPool (0)
│ └─ 预算上限在 BudgetCapLocale 层级共享
│ 所有 ProgramType 共用一个 $ 池
│ 检查: locale.balance >= meetingCost
│
├──> ProgramTypeCap (4)
│ └─ 每个 ProgramType 有独立 $ 上限
│ 检查: programType.balance >= meetingCost
│
└──> ProgramTypeQty (2)
└─ 只限制数量,不考虑 $ 金额
检查: totalQuantity - programQuantity > 0hasEnoughBudget 检查流程 (BudgetAllocationService.java:709-748):
输入: MeetingRequest (productId, programType, serviceType, regionId, districtId, meetingStartTime)
│
├──> 1. 根据 meetingStartTime 找 FiscalYear
├──> 2. 根据 programTypeId 找 brandId
├──> 3. 获取 localeBudget (区域级预算余额)
│ └─ 如果为 null -> return false
├──> 4. 获取 programTypeBudget (项目类型级预算)
│ └─ 如果为 null -> 检查 SharedPool
└──> 5. 根据 capMethod 选择检查方式:
├─ SharedAmountPool -> 检查 locale.balance >= cost
├─ ProgramTypeCap -> 检查 programType.balance >= cost
└─ ProgramTypeQty -> 检查 quantity > 03.1.3 Meeting Budget Item Version Management (会议预算版本管理)
创建 BudgetItem (saveBudgetItem)
│
└──> 为每个 BudgetVersion (SOW/EST/BILL/ACT/CXL) 创建一行
│
├──> 选中的 version: 使用用户输入的金额
└──> 其他 versions: 金额置零(quantity=0, unitCost=0, etc.)
│
└──> SOW 版本的 budgetItemId 作为其他版本的 parentBudgetItemId
Version Copy (budgetCopy)
│
├──> 将选中 items 从源 version 复制到目标 version
├──> 如果目标 version 已有对应 item -> 更新
├──> 如果目标 version 没有对应 item -> 插入
└──> 更新 MeetingRequest.budgetVersionId
└──> 发布 BudgetVersionChangedEvent
Delete BudgetItem (deleteBudgetItem)
│
├──> 软删除当前 item (deleted = STATUS_INACTIVE)
└──> 级联软删除同一 parent 下、版本号 >= 当前版本的 items3.1.4 Budget Total Cost Calculation (成本计算逻辑)
BudgetHelper.calculateTotalCostByBudgetItem (BudgetHelper.java:9-62):
Total = BaseCost + TaxCost + GratuityCost
BaseCost = quantity * unitCost
TaxCost:
if taxMethod == 1 (固定金额): tax
if taxMethod == 0 (百分比): quantity * unitCost * tax / 100
GratuityCost (复杂分支):
if gratuityIsTaxed == 1 (小费含税):
if taxMethod == 1:
if gratuityMethod == 1: gratuity
if gratuityMethod == 0: quantity * unitCost * gratuity / 100
if taxMethod == 0:
if tax == 0:
[同上处理]
else:
if gratuityMethod == 1: gratuity * (tax/100 + 1)
if gratuityMethod == 0: quantity * unitCost * gratuity / 100 * (tax/100 + 1)
else (小费不含税):
if gratuityMethod == 1: gratuity
if gratuityMethod == 0: quantity * unitCost * gratuity / 1003.1.5 Fiscal Year Flow (财务年度流程)
Admin 配置 Fiscal Year
│
├──> saveFiscalYear: 创建或更新 (productId, fiscalYear) 的起止日期
│
├──> getFiscalYearByMeetingDate: 根据会议日期找到对应的 fiscal year
│ └─ 查询: startDate <= meetingDate <= endDate
│
└──> getCurrentFiscalPeriodMap: 获取所有 product 的当前 fiscal period
└─ 遍历所有 FiscalYear,检查 LocalDate.now() 是否在范围内3.2 Validation Rules
| 位置 | 规则 | 文件:行号 |
|---|---|---|
| 分配预算 | allocatedBudget <= Region unallocatedBudget | BudgetAllocationService.java:230 |
| 取消分配 | releaseBudget <= District budget | BudgetAllocationService.java:257-258 |
| 转移预算 | allocatedBudget <= source District balance | BudgetAllocationService.java:299-300 |
| 预算充足检查 | 根据 capMethod 执行不同检查 | BudgetAllocationService.java:738-745 |
| 创建类别 | 同层级不允许同名类别 | BudgetCategoryService.java:121-131 |
| 删除类别 | 类别被 BudgetItem 或 Template 使用时不允许删除 | BudgetCategoryService.java:68-73 |
| 创建预算项 | meetingRequestId 必须对应存在的 MeetingRequest | BudgetItemService.java:87-89 |
| FiscalYear | 同一 productId 不允许多个重叠的 fiscal year | FiscalYearService.java:52-54 |
| 预算录入 | amount != 0 才创建 cap item | BudgetAllocationService.java:640-642 |
3.3 Business Logic Issues
saveBudgetAllocationHistory 变量名错误 (
BudgetAllocationService.java:462-472): 变量initialHistory赋值的是buildAdditionalHistory()的返回值,additionalHistory赋值的是buildInitialHistory()。名称完全颠倒,虽然不影响功能(两者都会保存),但降低了代码可读性。RegionToDistrictStrategy 中 evenlyAllocated 是实例级状态 (
RegionToDistrictStrategy.java:23):evenlyAllocated是实例变量,由于 Strategy 是 Spring 单例 Bean,在并发场景下会存在线程安全问题。DistrictToDistrictStrategy.regionId同理 (DistrictToDistrictStrategy.java:21)。平均分配余数处理 (
BudgetAllocationService.java:373-375): 使用divideAndRemainder计算平均分配,余数全部加到第一个 District,但没有记录哪个 District 得到了额外的余数,可能导致审计困难。Budget Summary 中 N+1 查询问题 (
BudgetItemService.java:519-535):getSummary()方法遍历每个 DTO 时,对每个currentItem都调用convertToDto(currentItem),而convertToDto内部会单独查询 BudgetCategory 和 Speaker。应该使用批量查询版本convertToDtos。BudgetVersion 硬编码 (
Constants.java:221-244): 版本号(1-5)在代码中大量硬编码(如BudgetVersion.SOW.getValue() == versionId),如果需要添加新版本或调整顺序,改动面很大。MeetingCost 计算依赖 Template 而非实际项 (
BudgetAllocationService.java:750-752):getMeetingCost方法使用 Template 的 totalCost 而非会议实际 BudgetItem 的成本来判断预算是否充足,这意味着预算检查使用的是模板估算值而非实际花费。BudgetItem 删除使用 delete 而非软删除 (
BudgetItemService.java:143-144): 虽然设置了deleted = STATUS_INACTIVE,但随后调用了this.delete(budgetItem)进行物理删除,而不是 update。软删除标志设置后立即物理删除,逻辑矛盾。Hardcoded categoryId (
BudgetItemTemplateService.java:168-169):getHonoraria方法中硬编码了categoryId == 9作为 Honoraria 类别,getSpeakerExpense硬编码了categoryId == 4。类别 ID 应通过配置或常量管理。
4. API Inventory
4.1 REST Endpoints Table
BudgetAllocationController (v1/budget/allocation)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /locale-budget | LocaleBudgetQuery (productId*, brandId, salesForceId, regionId, fiscalYear) | - | List<LocaleBudgetsResponse> | 查询区域级预算列表 |
| PUT | /locale-budget/locale-type | - | SetLocaleTypeRequest (productId*, fiscalYear, regionId, localeType, brandId) | void | 设置预算上限的区域类型 (Region/District) |
| GET | /program-type-budget | ProgramTypeBudgetQuery (productId, brandId, regionId, fiscalYear, programTypeId, districtId) | - | List<ProgramTypeBudgetResponse> | 查询项目类型级预算列表 |
| PUT | /program-type-budget/cap-method | - | CapMethodRequest (budgetLocaleId, programTypeId, productId, capMethod) | void | 设置 Cap 方法 |
| GET | /cap-items | BudgetCapItemsRequest (budgetLocaleId, budgetLocaleDtlId) | - | List<BudgetCapItemsResponse> | 获取预算 Cap 项列表 |
| POST | /cap-items | - | EnterBudgetRequest (budgetLocaleDtlId, budgetLocaleId, initialBudget, additionalBudget, initialQuantity, additionalQuantity, estimatedBudgetPerProgram) | void | 录入预算 Cap |
| DELETE | /cap-items/{budgetCapItemId} | budgetCapItemId | - | void | 删除预算 Cap 项(软删除) |
| GET | /brand-budget/region-budget | RegionBudgetRequest (productId, fiscalPeriod, brandId, salesForceId, regionId, status) | - | List<RegionBudgetResponse> | 获取 Region 级品牌预算 |
| GET | /brand-budget/region-budget/{regionId} | regionId + RegionBudgetRequest | - | RegionBudgetResponse | 获取指定 Region 的品牌预算 |
| GET | /brand-budget/region-budget/{regionId}/cap-items | regionId + RegionBudgetRequest | - | List<RegionBrandBudgetCapItem> | 获取 Region 品牌预算的 Cap 明细 |
| POST | /brand-budget/region-budget | - | BudgetEnterRequest (productId*, regionId*, fiscalPeriod*, brandId*, initialBudget, additionalBudget) | void | 录入 Region 品牌预算 |
| GET | /brand-budget/region-budget/download | DownloadBrandBudgetRequest (productId, fiscalPeriod, brandId, regionId) | - | Excel File | 下载 Region 品牌预算报表 |
| GET | /brand-budget/district-budget | DistrictBudgetRequest (productId, fiscalPeriod, brandId, regionId, districtId, status) | - | List<DistrictBudgetResponse> | 获取 District 级品牌预算 |
| POST | /brand-budget/district-budget | - | BudgetAllocateRequest (productId*, regionId*, fiscalPeriod*, brandId*, allocationType*, allocatedBudget*, districtId) | void | Region->District 分配预算 |
| POST | /brand-budget/district-budget/unallocate | - | UnallocateBudgetRequest (productId*, regionId*, fiscalPeriod*, brandId*, releaseBudget*, districtId*) | void | 取消 District 预算分配 |
| POST | /brand-budget/district-budget/move | - | MoveBudgetRequest (productId*, regionId*, sourceId*, targetId*, fiscalPeriod*, brandId*, allocatedBudget) | void | District 间转移预算 |
BudgetCategoryController (v1/budgets/categories)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | / | - | - | List<BudgetCategoryDTO> | 获取所有预算类别 |
| POST | / | - | BudgetCategoryDTO | BudgetCategoryDTO | 创建类别 |
| PUT | /{id} | id | BudgetCategoryDTO | BudgetCategoryDTO | 更新类别 |
| GET | /{id} | id | - | BudgetCategoryDTO | 获取单个类别 |
| DELETE | /{id} | id | - | void (204) | 删除类别 |
BudgetItemController (v1/budgets/items)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | / | meetingRequestId*, budgetVersionId, budgetCategoryId, reconciled | - | BudgetItemList | 获取会议预算项列表 |
| GET | /{id} | id | - | BudgetItemDTO | 获取单个预算项 |
| POST | / | - | BudgetItemDTO | BudgetItemDTO (201) | 创建预算项 (同时创建所有版本) |
| PUT | /{id} | id | BudgetItemDTO | BudgetItemDTO | 更新预算项 |
| DELETE | / | - | List<Object> ids | void (204) | 批量删除预算项 |
| PUT | /copy | - | BudgetItemCopyDTO (budgetVersionId, meetingRequestId, ids) | List<BudgetItemDTO> | 复制预算项到另一版本 |
| GET | /summary | meetingRequestId | - | BudgetSummary | 预算汇总 (跨版本比较) |
| GET | /download | meetingRequestId, budgetVersionId | - | Excel File | 下载预算项 |
BudgetTemplateController (v1/budgets/templates)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | / | productId | - | List<BudgetItemTemplateDTO> | 获取预算模板列表 |
| POST | /upload | file (MultipartFile), serviceTypeId, programTypeId | - | void | 上传预算模板 Excel |
| GET | /download | serviceTypeId, programTypeId | - | Excel File | 下载预算模板 |
BudgetVersionController (v1/budgets/versions)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | / | - | - | List<BudgetVersion> | 获取所有预算版本 |
FiscalYearController (v1/products/{productId})
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /fiscal-years | productId | - | List<FiscalYearResponse> | 获取财务年度列表 |
| POST | /fiscal-years | productId | FiscalYearRequest (fiscalYear*, startDate*, endDate*, fiscalPeriodName) | void | 创建/更新财务年度 |
| GET | /fiscal-years/{fiscalYear} | productId, fiscalYear | - | FiscalYearResponse | 获取单个财务年度 |
FiscalPeriodMigrationController (v1/fiscal-periods-migration)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | / | - | - | void | 迁移 fiscal period name (一次性工具) |
4.2 API Design Issues
DELETE 方法接收 RequestBody (
BudgetItemController.java:99-102):DELETE /v1/budgets/items使用@RequestBody List<Object> ids,HTTP 标准不保证 DELETE 请求会传递 body,某些代理/网关会移除 DELETE body。应改为 query parameter 或 POST 方法。GET 方法产生副作用 (
FiscalPeriodMigrationController.java:15-18):GET /v1/fiscal-periods-migration实际执行数据迁移操作,违反了 HTTP GET 的幂等性原则。API 路径风格不一致:
v1/budget/allocation(单数)v1/budgets/categories(复数)v1/budgets/items(复数)v1/products/{productId}/fiscal-years(RESTful 嵌套)
返回 null 而非 404 (
BudgetAllocationController.java:94-98):getRegionBudget在数据为空时返回null而非抛出 404 异常。缺少分页 (
BudgetAllocationController.java): 所有列表接口返回List而非分页结果,大数据量下存在性能隐患。原始类型返回 (
BudgetVersionController.java:22-26): 直接返回 Entity(List<BudgetVersion>)而非 DTO,暴露了内部数据模型。参数命名不一致:
fiscalPeriod和fiscalYear在不同 API 中指代同一概念,令人困惑。如BudgetEnterRequest用fiscalPeriod,SetLocaleTypeRequest用fiscalYear,FiscalYearRequest也用fiscalYear,但BudgetAllocationHistory表字段是fiscal_period。
5. Frontend Analysis
5.1 Pages & Components
Plannerview (Planner 端)
| 组件 | 路径 | 说明 |
|---|---|---|
| MTBudget | pharmagin-plannerview/legacy/src/components/MTBudget/ | 会议预算管理主组件 (Add/Edit/Delete/Copy/Summary/Download) |
| MTBudgetForm | pharmagin-plannerview/legacy/src/components/MTBudgetForm/ | 预算项编辑表单 |
| MTBudgetCopyForm | pharmagin-plannerview/legacy/src/components/MTBudgetCopyForm/ | 预算项跨版本复制表单 |
| MTBudgetSearchForm | pharmagin-plannerview/legacy/src/components/MTBudgetSeachForm/ | 预算搜索表单 (文件名拼写错误 "Seach") |
| MTSummary | pharmagin-plannerview/legacy/src/components/MTSummary/ | 跨版本预算汇总/打印 |
| BudgetCategories | pharmagin-plannerview/legacy/src/components/BudgetCategories/ | 预算类别管理 (Admin) |
| BudgetCategoryFormBC | pharmagin-plannerview/legacy/src/components/BudgetCategoryFormBC/ | 一级类别表单 |
| BudgetCategoryFormSC | pharmagin-plannerview/legacy/src/components/BudgetCategoryFormSC/ | 子类别表单 |
| BudgetTemplate | pharmagin-plannerview/legacy/src/components/BudgetTemplate/ | 预算模板管理 (Upload/View/Download) |
Salesview (Sales 端)
| 组件 | 路径 | 说明 |
|---|---|---|
| BudgetAllocation (主入口) | pharmagin-salesview/src/pages/BudgetAllocation/index.js | 预算分配主页面,含路由 |
| BudgetCapByLocale | pharmagin-salesview/src/pages/BudgetAllocation/List/BudgetCapByLocale.js | 区域级预算上限视图 |
| BudgetCapByLocaleTable | pharmagin-salesview/src/pages/BudgetAllocation/List/BudgetCapByLocaleTable.js | 区域预算表格 |
| ProgramTypeBudget | pharmagin-salesview/src/pages/BudgetAllocation/List/ProgramTypeBudget.js | 项目类型预算视图 |
| BrandBudgetAllocation | pharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetAllocation.js | 品牌预算分配页 |
| BrandBudgetTable | pharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetTable.js | 品牌预算表格 |
| BrandBudgetFilter | pharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetFilter.js | 品牌预算筛选器 |
| RegionBudgetAllocation | pharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetAllocation.js | Region 预算分配 |
| RegionBudgetTable | pharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetTable.js | Region 预算表格 |
| RegionBudgetFilter | pharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetFilter.js | Region 预算筛选 |
| DistrictBudgetAllocation | pharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetAllocation.js | District 预算分配 |
| DistrictBudgetTable | pharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetTable.js | District 预算表格 |
| DistrictBudgetFilter | pharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetFilter.js | District 预算筛选 |
| EnterBrandBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterBrandBudgetForm.js | 品牌预算录入 |
| EnterBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterBudgetForm.js | 预算录入 |
| EnterQtyForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterQtyForm.js | 数量录入 |
| AllocateBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/AllocateBudgetForm.js | 预算分配表单 |
| UnallocateBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/UnallocateBudgetForm.js | 取消分配表单 |
| MoveBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/MoveBudgetForm.js | 预算转移表单 |
| CapMethodForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/CapMethodForm.js | Cap 方法选择 |
| LocaleBudgetForm | pharmagin-salesview/src/pages/BudgetAllocation/Modal/LocaleBudgetForm.js | 区域预算表单 |
| MeetingBudget | pharmagin-salesview/src/pages/Programs/Modal/MeetingBudget.js | 会议预算弹窗 |
| ComplianceAudit/Budget | pharmagin-salesview/src/pages/Reports/ComplianceAudit/Budget.js | 合规审计预算报表 |
Speakerview (Speaker 端)
无预算相关组件 -- Speaker 端不涉及预算管理功能。
5.2 Redux State Structure
Plannerview - MTBudget
// Key: 'mtBudget' (injected per component lifecycle)
{
budgetLoading: boolean,
budgetCategories: [], // 预算类别列表
budgetVersions: [], // 版本列表 (SOW/EST/BILL/ACT/CXL)
budgetData: { // BudgetItemList response
meetingRequestId: number,
budgetVersionId: number,
budgetStatus: string,
productId: number,
programTypeId: number,
data: BudgetItemDTO[],
total: number
},
version: number, // 当前选中的版本
budgetVisible: boolean, // Modal 可见性
budgetModalType: string, // 'add'|'edit'|'copy'|'summary'
budgetWidth: number, // Modal 宽度
salesRepLogs: [],
}Plannerview - BudgetCategories
// Key: 'budgetCategories'
{
categories: BudgetCategoryDTO[],
loading: boolean,
}Salesview - BudgetAllocation
// Key: 'budgetAllocation'
{
budgetAllocation: [],
enterBudgetHistory: [], // cap items 录入历史
brandBudgetList: [], // region 品牌预算列表
districtBudgetList: [], // district 预算列表
brandBudgetHistory: [], // 品牌预算分配历史
regionBudget: {}, // 单个 region 预算
filters: {}, // 筛选条件
}5.3 Frontend Issues
组件文件名拼写错误:
MTBudgetSeachForm(应为MTBudgetSearchForm),路径pharmagin-plannerview/legacy/src/components/MTBudgetSeachForm/。Ant Design 3.x 过时 (
MTBudget/index.js): 使用了已废弃的Form.create()HOC 模式和componentWillReceiveProps生命周期。硬编码 productId (
MTBudget/index.js:557):budgetData.productId == 23硬编码产品 ID 来控制 Sales Rep Logs 的显示。硬编码 budgetVersionId (
MTBudget/index.js:102-103,137):budgetData.budgetVersionId == 4和version == 4硬编码 ACT 版本号来判断是否只读。使用
javascript:void(0)(MTBudget/index.js:143,BudgetCategories/index.js:171): 大量使用href="javaScript:void(0)",不符合现代 React 最佳实践。直接操作上传 URL 拼接 (
BudgetTemplate/index.js:85-86): 使用字符串模板直接拼接 API URL 和 token,绕过了统一的 API 调用层。Class 组件与过时模式 (
BudgetAllocation/index.js): 全部使用 Class 组件 +componentDidMount/componentWillReceiveProps模式,未使用 React Hooks。Salesview 中 BudgetAllocation 组件嵌套过深: List 目录下有 14 个子组件文件,结构复杂度高,各组件间的数据流和状态共享依赖 Redux,增加了维护成本。
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| # | 问题 | 位置 | 影响 |
|---|---|---|---|
| C1 | Strategy 单例线程安全 | RegionToDistrictStrategy.java:23, DistrictToDistrictStrategy.java:21 | 并发分配操作可能产生错误的 comment 和数据 |
| C2 | FiscalYear 无主键 | FiscalYear.java | 无法通过 ORM 正确执行 CRUD,依赖复合条件查询 |
| C3 | BudgetItem 删除逻辑矛盾 | BudgetItemService.java:143-144 | 设置 deleted=INACTIVE 后立即物理删除,软删除形同虚设 |
| C4 | 预算检查使用模板成本而非实际成本 | BudgetAllocationService.java:750-752 | hasEnoughBudget 用模板估算值,可能导致超支 |
| C5 | saveBudgetAllocationHistory 变量名交换 | BudgetAllocationService.java:463-464 | initialHistory=buildAdditionalHistory(), additionalHistory=buildInitialHistory(),逻辑颠倒 |
| C6 | DELETE 使用 RequestBody | BudgetItemController.java:99-102 | 部分 HTTP 客户端/代理不支持 DELETE body,导致删除失败 |
6.2 Design Defects (should improve)
| # | 问题 | 位置 | 建议 |
|---|---|---|---|
| D1 | BudgetItem 与 BudgetItemTemplate 大量重复字段 | Entity 层 | 提取公共基类 AbstractBudgetItem |
| D2 | vendorId/speakerId 双重存储 | BudgetItem.java | 统一为一个字段 |
| D3 | BudgetCapItem 双 FK (locale/dtl) | BudgetCapItem.java | 重构为明确的关联关系 |
| D4 | fiscalPeriod/fiscalYear 命名不一致 | 多处 DTO/Entity | 统一为 fiscalPeriod |
| D5 | enableStatus/deleteStatus 使用字符串 | 多处 Entity | 改用 Boolean 类型 |
| D6 | BudgetHelper 成本计算嵌套过深 | BudgetHelper.java:9-62 | 重构为多个清晰的子方法 |
| D7 | 硬编码 categoryId (9=Honoraria, 4=Speaker Expense) | BudgetItemTemplateService.java:168-169,184 | 使用枚举或配置 |
| D8 | 缺少数据库视图定义 | SQL 层依赖 v_budget_cap_locale, v_meeting_cost 等视图 | 需要记录视图定义 |
| D9 | API 路径风格不一致 | Controller 层 | 统一 RESTful 命名规范 |
| D10 | 无分页支持 | 所有列表 API | 添加分页参数 |
6.3 Technical Debt (nice to have)
| # | 问题 | 位置 | 建议 |
|---|---|---|---|
| T1 | BudgetCategory 未使用的字段 (resumeId, accountId, company) | BudgetCategory.java | 清理无用字段 |
| T2 | BudgetVersion Entity 未使用 Lombok | BudgetVersion.java | 统一使用 Lombok |
| T3 | BudgetCategory Entity 未使用 Lombok | BudgetCategory.java | 统一使用 Lombok |
| T4 | BudgetItemTemplate Entity 未使用 Lombok | BudgetItemTemplate.java | 统一使用 Lombok |
| T5 | 前端组件名拼写错误 | MTBudgetSeachForm | 修正命名 |
| T6 | 前端硬编码 productId/versionId | MTBudget 组件 | 提取为配置常量 |
| T7 | 前端 Class 组件 + 过时生命周期 | 所有 Budget 组件 | 迁移到 Function Component + Hooks |
| T8 | FiscalPeriodMigrationController 作为一次性迁移工具 | FiscalPeriodMigrationController.java | 迁移完成后应移除 |
| T9 | BudgetSummary 不使用 Lombok | BudgetSummary.java | 统一代码风格 |
| T10 | BigDecimal 使用 ROUND_HALF_UP (deprecated) | BudgetItemService.java:379 | 使用 RoundingMode.HALF_UP |
7. Rewrite Recommendations
7.1 Data Model 重构
统一预算项基类: 将
BudgetItem和BudgetItemTemplate的共同字段提取到AbstractBudgetLineItem,消除 20+ 字段的重复定义。FiscalYear 添加复合主键: 定义
(productId, fiscalYear)复合主键,或新增自增主键。消除 vendorId/speakerId 双重字段: 统一为
vendorId,并通过vendorCategoryId区分供应商类型。BudgetCapItem 明确 FK: 只保留
budgetLocaleDtlId作为外键,通过 dtl 关联到 locale。使用枚举类型和布尔字段: 将
enableStatus、deleteStatus改为 Boolean,taxMethod/gratuityMethod改为枚举。
7.2 Service 层重构
修复 Strategy 线程安全: 将
evenlyAllocated和regionId作为方法参数传递,而非实例状态。或者将 Strategy Bean 改为@Scope("prototype")。成本计算抽象: 将
BudgetHelper.calculateTotalCostByBudgetItem拆分为calculateBaseCost、calculateTaxCost、calculateGratuityCost三个清晰方法,并添加单元测试。统一预算检查逻辑:
hasEnoughBudget应该基于实际会议 BudgetItem 成本,而非模板估算。消除 N+1 查询: BudgetSummary 应使用批量查询,而不是循环内单条查询。
引入领域事件: 预算分配、取消、转移等操作应发布领域事件,便于审计和通知。
7.3 API 层重构
统一 RESTful 设计:
POST /v1/budget-allocations(分配)DELETE /v1/budget-allocations/{id}(取消分配)POST /v1/budget-transfers(转移)GET /v1/budget-caps?locale=region&fiscalYear=X(查询 cap)
添加分页: 所有列表 API 支持
page/size参数。DELETE 方法不使用 body: 改为
DELETE /v1/budgets/items?ids=1,2,3或POST /v1/budgets/items/batch-delete。移除迁移 Controller:
FiscalPeriodMigrationController应作为 DB migration 脚本。
7.4 Frontend 重构
迁移到 Function Component + Hooks: 所有 Class 组件迁移到 React Hooks,消除
componentWillReceiveProps。替换硬编码: 将 productId、versionId 等硬编码值提取到配置文件或 API 动态获取。
简化 Salesview BudgetAllocation: 14 个子组件拆分为更合理的模块划分,使用 Context 或 Zustand 代替 Redux 来管理组件间状态。
统一 API 调用层: 使用 axios interceptor 或 React Query 统一处理 API 请求,消除直接拼接 URL 的做法。
修正组件命名:
MTBudgetSeachForm->MTBudgetSearchForm。