Skip to content

Budget & Financial Domain - Deep Dive Analysis

1. Domain Overview

1.1 领域职责描述

Budget & Financial 领域负责整个医药演讲者平台的预算管理和财务控制。核心职责包括:

  1. 预算层级管理 (Budget Cap Allocation): 管理从 Admin -> Region -> District 的预算逐级分配
  2. 品牌预算分配 (Brand Budget Allocation): 按品牌(Brand)维度管理预算的录入、分配、取消分配和转移
  3. 会议级预算管理 (Meeting Budget Items): 管理单个会议/项目的预算行项目(line items),包含 SOW/EST/BILL/ACT/CXL 五个版本的成本跟踪
  4. 预算模板管理 (Budget Templates): 按项目类型和服务类型管理可复用的预算模板
  5. 预算类别管理 (Budget Categories): 管理预算的分类和子分类体系
  6. 财务年度管理 (Fiscal Year/Period): 管理客户的财务年度定义,包括起止日期
  7. 预算上限控制 (Budget Cap): 支持三种 Cap 方法: Shared $ Pool, Program Type $ Cap, Program Type Qty Cap
  8. 预算告警 (Budget Alert): 预算变更时生成告警通知

1.2 涉及的后端模块和包

包路径说明
com.pharmagin.modules.v1.budget.controller6 个 Controller
com.pharmagin.modules.v1.budget.service10 个 Service
com.pharmagin.modules.v1.budget.model37 个 Request/Response/DTO
com.pharmagin.modules.v1.budget.constant3 个枚举常量
com.pharmagin.modules.v1.budget.strategy策略模式:4 种分配历史记录策略
com.pharmagin.modules.v1.budget.utilBudgetHelper 工具类
com.pharmagin.common.persistence.entity10 个 Budget 相关 Entity
com.pharmagin.common.constant.BudgetConstant4 个预算常量枚举

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)。

字段名类型注解说明
budgetItemIdInteger@Id, @GeneratedValue主键,序列 s_budget_item
categoryIdInteger预算类别 ID (外键 -> BudgetCategory)
subCategoryIdInteger子类别 ID (外键 -> BudgetCategory)
itemNameString预算项名称
quantityBigDecimal数量
unitCostBigDecimal单价
extCostBigDecimal扩展成本 (quantity * unitCost)
taxBigDecimal税额
gratuityBigDecimal小费/服务费
vendorIdInteger供应商/Speaker ID
productIdInteger产品 ID
meetingIdInteger会议 ID
meetingRequestIdInteger会议请求 ID (外键 -> MeetingRequest)
gratuityIsTaxedInteger小费是否含税 (0/1)
reconciledInteger是否已核销 (0/1)
invoiceNumberString发票号
currencyString货币类型
notesString备注
taxMethodInteger税额计算方式: 1=固定金额, 0=百分比
gratuityMethodInteger小费计算方式: 1=固定金额, 0=百分比
parentBudgetItemIdInteger父预算项 ID(SOW 版本的 ID 作为其他版本的 parent)
budgetStatusString预算状态名 (SOW/EST/BILL/ACT/CXL)
dateDate日期
statusInteger状态
deletedInteger删除标志 (0=有效, 1=已删除)
speakerIdInteger演讲者 ID (与 vendorId 重复)
budgetVersionIdInteger预算版本 ID (1=SOW,2=EST,3=BILL,4=ACT,5=CXL)
vendorCategoryIdInteger供应商类型: 0=Speaker, 1=PLID, 2=Other
savingsTotalBigDecimal节省总额
allocateTypeInteger分配类型
checkNumberString支票号
checkIssueDateDate支票签发日期
plidStringPLID 标识
otherVendorString其他供应商名称

2.1.2 BudgetCategory (t_budget_category)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCategory.java

预算类别表,支持两级分类(Category / SubCategory 通过 parentCategoryId 实现自关联)。

字段名类型注解说明
categoryIdInteger@Id, @GeneratedValue主键,序列 s_budget_category
categoryNameString类别名称
productIdInteger产品 ID
categoryTypeInteger类别类型
resumeIdInteger简历 ID (用途不明确)
parentCategoryIdInteger父类别 ID (0=顶级类别)
accountIdInteger账户 ID
speakerIdInteger演讲者 ID
companyString公司名称
categorySequenceInteger排序序号

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 五个版本。

字段名类型注解说明
budgetVersionIdInteger@Id, @GeneratedValue主键,序列 s_budget_version
descriptionString版本描述
nameString版本名称 (SOW/EST/BILL/ACT/CXL)
resumeIdInteger简历 ID
productIdInteger产品 ID
accountIdInteger账户 ID
sequenceInteger排序序号
companyString公司名称

2.1.4 BudgetCapLocale (t_budget_cap_locale)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCapLocale.java

预算上限区域配置表,定义 Region/District 级别的预算上限设置。

字段名类型注解说明
budgetLocaleIdInteger@Id, @GeneratedValue主键,序列 s_budget_cap_locale
productIdInteger产品 ID
regionIdInteger区域 ID
districtIdInteger地区 ID (0=Region 级别)
localeTypeInteger区域类型: 0=Region, 1=District
fiscalYearInteger财务年度
initBgtBigDecimal初始预算
addBgtBigDecimal追加预算
enterDateDate录入日期
portalUserIdInteger门户用户 ID
enableStatusString启用状态: "Y"=启用, "N"=禁用
userIdInteger用户 ID
brandIdInteger品牌 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 细分预算。

字段名类型注解说明
budgetLocaleDtlIdInteger@Id, @GeneratedValue主键,序列 s_budget_cap_locale_dtl
budgetLocaleIdInteger外键 -> BudgetCapLocale
programTypeIdInteger项目类型 ID
initBgtBigDecimal初始预算
addBgtBigDecimal追加预算
initQtyBigDecimal初始数量
addQtyBigDecimal追加数量
enterDateDate录入日期
portalUserIdInteger门户用户 ID
enableStatusString启用状态: "Y"/"N"
capMethodIntegerCap 方法: 0=SharedAmountPool, 2=ProgramTypeQty, 4=ProgramTypeCap
userIdInteger用户 ID
estimatedBudgetPerProgramBigDecimal每个项目的预估预算

2.1.6 BudgetCapItem (t_budget_cap_item)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetCapItem.java

预算上限变更明细记录,记录每次预算金额的增减。

字段名类型注解说明
budgetCapItemIdInteger@Id, @GeneratedValue主键,序列 s_budget_cap_item
budgetLocaleDtlIdInteger外键 -> BudgetCapLocaleDtl
budgetLocaleIdInteger外键 -> BudgetCapLocale
budgetTypeInteger预算类型: 0=InitialBudget, 1=AdditionalBudget, 2=InitialQty, 3=AdditionalQty
budgetAmountBigDecimal预算金额
portalUserIdInteger门户用户 ID
deleteStatusString删除状态: "Y"=已删除, "N"=有效
enterDateDate录入日期
lastUpdateDateDate最后更新日期
userIdInteger用户 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 的预算分配操作。

字段名类型注解说明
idInteger@Id, @GeneratedValue主键,序列 s_budget_allocation_history
fiscalPeriodInteger财务周期
brandIdInteger品牌 ID
sourceIdInteger来源 ID (region_id 或 district_id)
sourceNameString来源名称
targetIdInteger目标 ID (region_id 或 district_id)
targetNameString目标名称
allocationTypeInteger分配类型: 0=Admin->Region, 1=Region->District, 2=District->District, 3=District->Region
amountBigDecimal分配金额
amountTypeInteger金额类型: 0=Initial, 1=Additional
regionIdInteger区域 ID
commentString操作备注
delFlagInteger删除标志: 0=有效, 1=已删除
createdByString创建人
createdAtDate创建时间

2.1.8 BudgetAlert (t_budget_alert)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetAlert.java

预算告警表,在 Region 级别录入品牌预算时创建。

字段名类型注解说明
idInteger@Id, @GeneratedValue主键,序列 s_budget_alert
fiscalPeriodInteger财务周期
amountBigDecimal告警金额
regionIdInteger区域 ID
createdAtDate创建时间

2.1.9 BudgetAlertViewHistory (t_budget_alert_view_history)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/BudgetAlertViewHistory.java

预算告警查看记录表。

字段名类型注解说明
idInteger@Id, @GeneratedValue主键,序列 s_budget_alert_view_history
budgetAlertIdInteger外键 -> BudgetAlert
portalUserIdInteger门户用户 ID
createdAtDate查看时间
userIdInteger用户 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 维度存储可复用的预算项模板。

字段名类型注解说明
budgetItemTemplateIdInteger@Id, @GeneratedValue主键,序列 s_budget_item_template
categoryIdInteger类别 ID
subCategoryIdInteger子类别 ID
itemNameString项目名称
quantityBigDecimal数量
unitCostBigDecimal单价
extCostBigDecimal扩展成本
taxBigDecimal税额
gratuityBigDecimal小费
vendorIdInteger供应商 ID
productIdInteger产品 ID
gratuityIsTaxedInteger小费是否含税
reconciledInteger是否核销
invoiceNumberString发票号
currencyString货币
notesString备注
taxMethodInteger税额计算方式
gratuityMethodInteger小费计算方式
parentBudgetItemIdInteger父预算项 ID
budgetStatusString预算状态
dateDate日期
deletedInteger删除标志
speakerIdInteger演讲者 ID
meetingProgramTypeInteger项目类型 ID
isSpeakerInteger是否为演讲者: 1=Speaker, 2=Speaker2
budgetVersionIdInteger预算版本 ID
programServiceTypeInteger服务类型 ID
vendorCategoryIdInteger供应商类别 ID
savingsTotalBigDecimal节省总额
allocateTypeInteger分配类型

2.1.11 FiscalYear (t_fiscal_year)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/FiscalYear.java

财务年度定义表。注意: 该表没有 @Id 注解。

字段名类型注解说明
fiscalYearInteger财务年度编号
productIdInteger产品 ID
startDateDate开始日期
endDateDate结束日期
fiscalPeriodNameString财务周期名称

2.1.12 Brand (t_brand)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/Brand.java

品牌表。

字段名类型注解说明
brandIdInteger@Id, @GeneratedValue主键,序列 s_brand
brandNameString品牌名称
productIdInteger产品 ID
statusInteger状态

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 使用情况。

字段名类型注解说明
idInteger@Id, @GeneratedValue主键,序列 s_allocation_tov
meetingRequestIdInteger会议请求 ID
usedTovBigDecimal已使用 TOV
allocationHeadcountInteger分配人数
tovChangeReasonStringTOV 变更原因
headcountChangeReasonString人数变更原因

2.1.14 TeamBrand (t_team_brand)

文件: pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/TeamBrand.java

团队-品牌关联表(联合主键)。

字段名类型注解说明
teamIdInteger@Id团队 ID
brandIdInteger@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

  1. FiscalYear 缺少主键 (FiscalYear.java:22-38): t_fiscal_year 表没有 @Id 注解,缺少主键定义。实际使用 (productId, fiscalYear) 复合键查询,但未在 JPA 层面声明复合主键。

  2. BudgetItem 与 BudgetItemTemplate 高度重复 (BudgetItem.java vs BudgetItemTemplate.java): 两个 Entity 有 20+ 个相同字段,BudgetItemTemplate 多了 meetingProgramTypeisSpeakerprogramServiceType 三个字段。代码中频繁使用 BeanUtil.map() 在两者之间转换,应该提取公共基类。

  3. vendorId 与 speakerId 语义混乱 (BudgetItem.java:53-54,101-102): BudgetItem 同时有 vendorIdspeakerId 两个字段,在 service 层频繁出现 item.setSpeakerId(vendorId) 的赋值(BudgetItemService.java:99,560),说明两个字段实际指向同一个值。

  4. BudgetCapItem 的双 FK 设计 (BudgetCapItem.java:31-35): 同时有 budgetLocaleDtlIdbudgetLocaleId 两个外键,但只使用其一(根据是 locale 级别还是 dtl 级别的预算),违反了数据库范式。

  5. enableStatus/deleteStatus 使用字符串而非布尔 (BudgetCapLocale.java:57, BudgetCapItem.java:47): 使用 "Y"/"N" 字符串而非数据库布尔类型。

  6. BudgetItemStatusFlag 语义反转 (BudgetItemStatusFlag.java:5): YES(0, "Y") 表示不删除/启用,但源码有 TODO 注释 // TODO: toggle value,说明 value 和 code 的语义存在已知问题。YES.getCode() 返回 "Y" 但 YES.getValue() 返回 0,在不同上下文使用不同的方式很容易混淆。

  7. BudgetAllocationHistory 冗余存储 (BudgetAllocationHistory.java:41-54): 同时存储了 sourceId/targetId (ID) 和 sourceName/targetName (名称),名称可能会因 Region/District 重命名而不一致。

  8. 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 > 0

hasEnoughBudget 检查流程 (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 > 0

3.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 下、版本号 >= 当前版本的 items

3.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 / 100

3.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 unallocatedBudgetBudgetAllocationService.java:230
取消分配releaseBudget <= District budgetBudgetAllocationService.java:257-258
转移预算allocatedBudget <= source District balanceBudgetAllocationService.java:299-300
预算充足检查根据 capMethod 执行不同检查BudgetAllocationService.java:738-745
创建类别同层级不允许同名类别BudgetCategoryService.java:121-131
删除类别类别被 BudgetItem 或 Template 使用时不允许删除BudgetCategoryService.java:68-73
创建预算项meetingRequestId 必须对应存在的 MeetingRequestBudgetItemService.java:87-89
FiscalYear同一 productId 不允许多个重叠的 fiscal yearFiscalYearService.java:52-54
预算录入amount != 0 才创建 cap itemBudgetAllocationService.java:640-642

3.3 Business Logic Issues

  1. saveBudgetAllocationHistory 变量名错误 (BudgetAllocationService.java:462-472): 变量 initialHistory 赋值的是 buildAdditionalHistory() 的返回值,additionalHistory 赋值的是 buildInitialHistory()。名称完全颠倒,虽然不影响功能(两者都会保存),但降低了代码可读性。

  2. RegionToDistrictStrategy 中 evenlyAllocated 是实例级状态 (RegionToDistrictStrategy.java:23): evenlyAllocated 是实例变量,由于 Strategy 是 Spring 单例 Bean,在并发场景下会存在线程安全问题。DistrictToDistrictStrategy.regionId 同理 (DistrictToDistrictStrategy.java:21)。

  3. 平均分配余数处理 (BudgetAllocationService.java:373-375): 使用 divideAndRemainder 计算平均分配,余数全部加到第一个 District,但没有记录哪个 District 得到了额外的余数,可能导致审计困难。

  4. Budget Summary 中 N+1 查询问题 (BudgetItemService.java:519-535): getSummary() 方法遍历每个 DTO 时,对每个 currentItem 都调用 convertToDto(currentItem),而 convertToDto 内部会单独查询 BudgetCategory 和 Speaker。应该使用批量查询版本 convertToDtos

  5. BudgetVersion 硬编码 (Constants.java:221-244): 版本号(1-5)在代码中大量硬编码(如 BudgetVersion.SOW.getValue() == versionId),如果需要添加新版本或调整顺序,改动面很大。

  6. MeetingCost 计算依赖 Template 而非实际项 (BudgetAllocationService.java:750-752): getMeetingCost 方法使用 Template 的 totalCost 而非会议实际 BudgetItem 的成本来判断预算是否充足,这意味着预算检查使用的是模板估算值而非实际花费。

  7. BudgetItem 删除使用 delete 而非软删除 (BudgetItemService.java:143-144): 虽然设置了 deleted = STATUS_INACTIVE,但随后调用了 this.delete(budgetItem) 进行物理删除,而不是 update。软删除标志设置后立即物理删除,逻辑矛盾。

  8. 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)

MethodPathParametersRequest BodyResponseDescription
GET/locale-budgetLocaleBudgetQuery (productId*, brandId, salesForceId, regionId, fiscalYear)-List<LocaleBudgetsResponse>查询区域级预算列表
PUT/locale-budget/locale-type-SetLocaleTypeRequest (productId*, fiscalYear, regionId, localeType, brandId)void设置预算上限的区域类型 (Region/District)
GET/program-type-budgetProgramTypeBudgetQuery (productId, brandId, regionId, fiscalYear, programTypeId, districtId)-List<ProgramTypeBudgetResponse>查询项目类型级预算列表
PUT/program-type-budget/cap-method-CapMethodRequest (budgetLocaleId, programTypeId, productId, capMethod)void设置 Cap 方法
GET/cap-itemsBudgetCapItemsRequest (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-budgetRegionBudgetRequest (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-itemsregionId + RegionBudgetRequest-List<RegionBrandBudgetCapItem>获取 Region 品牌预算的 Cap 明细
POST/brand-budget/region-budget-BudgetEnterRequest (productId*, regionId*, fiscalPeriod*, brandId*, initialBudget, additionalBudget)void录入 Region 品牌预算
GET/brand-budget/region-budget/downloadDownloadBrandBudgetRequest (productId, fiscalPeriod, brandId, regionId)-Excel File下载 Region 品牌预算报表
GET/brand-budget/district-budgetDistrictBudgetRequest (productId, fiscalPeriod, brandId, regionId, districtId, status)-List<DistrictBudgetResponse>获取 District 级品牌预算
POST/brand-budget/district-budget-BudgetAllocateRequest (productId*, regionId*, fiscalPeriod*, brandId*, allocationType*, allocatedBudget*, districtId)voidRegion->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)voidDistrict 间转移预算

BudgetCategoryController (v1/budgets/categories)

MethodPathParametersRequest BodyResponseDescription
GET/--List<BudgetCategoryDTO>获取所有预算类别
POST/-BudgetCategoryDTOBudgetCategoryDTO创建类别
PUT/{id}idBudgetCategoryDTOBudgetCategoryDTO更新类别
GET/{id}id-BudgetCategoryDTO获取单个类别
DELETE/{id}id-void (204)删除类别

BudgetItemController (v1/budgets/items)

MethodPathParametersRequest BodyResponseDescription
GET/meetingRequestId*, budgetVersionId, budgetCategoryId, reconciled-BudgetItemList获取会议预算项列表
GET/{id}id-BudgetItemDTO获取单个预算项
POST/-BudgetItemDTOBudgetItemDTO (201)创建预算项 (同时创建所有版本)
PUT/{id}idBudgetItemDTOBudgetItemDTO更新预算项
DELETE/-List<Object> idsvoid (204)批量删除预算项
PUT/copy-BudgetItemCopyDTO (budgetVersionId, meetingRequestId, ids)List<BudgetItemDTO>复制预算项到另一版本
GET/summarymeetingRequestId-BudgetSummary预算汇总 (跨版本比较)
GET/downloadmeetingRequestId, budgetVersionId-Excel File下载预算项

BudgetTemplateController (v1/budgets/templates)

MethodPathParametersRequest BodyResponseDescription
GET/productId-List<BudgetItemTemplateDTO>获取预算模板列表
POST/uploadfile (MultipartFile), serviceTypeId, programTypeId-void上传预算模板 Excel
GET/downloadserviceTypeId, programTypeId-Excel File下载预算模板

BudgetVersionController (v1/budgets/versions)

MethodPathParametersRequest BodyResponseDescription
GET/--List<BudgetVersion>获取所有预算版本

FiscalYearController (v1/products/{productId})

MethodPathParametersRequest BodyResponseDescription
GET/fiscal-yearsproductId-List<FiscalYearResponse>获取财务年度列表
POST/fiscal-yearsproductIdFiscalYearRequest (fiscalYear*, startDate*, endDate*, fiscalPeriodName)void创建/更新财务年度
GET/fiscal-years/{fiscalYear}productId, fiscalYear-FiscalYearResponse获取单个财务年度

FiscalPeriodMigrationController (v1/fiscal-periods-migration)

MethodPathParametersRequest BodyResponseDescription
GET/--void迁移 fiscal period name (一次性工具)

4.2 API Design Issues

  1. DELETE 方法接收 RequestBody (BudgetItemController.java:99-102): DELETE /v1/budgets/items 使用 @RequestBody List<Object> ids,HTTP 标准不保证 DELETE 请求会传递 body,某些代理/网关会移除 DELETE body。应改为 query parameter 或 POST 方法。

  2. GET 方法产生副作用 (FiscalPeriodMigrationController.java:15-18): GET /v1/fiscal-periods-migration 实际执行数据迁移操作,违反了 HTTP GET 的幂等性原则。

  3. API 路径风格不一致:

    • v1/budget/allocation (单数)
    • v1/budgets/categories (复数)
    • v1/budgets/items (复数)
    • v1/products/{productId}/fiscal-years (RESTful 嵌套)
  4. 返回 null 而非 404 (BudgetAllocationController.java:94-98): getRegionBudget 在数据为空时返回 null 而非抛出 404 异常。

  5. 缺少分页 (BudgetAllocationController.java): 所有列表接口返回 List 而非分页结果,大数据量下存在性能隐患。

  6. 原始类型返回 (BudgetVersionController.java:22-26): 直接返回 Entity(List<BudgetVersion>)而非 DTO,暴露了内部数据模型。

  7. 参数命名不一致: fiscalPeriodfiscalYear 在不同 API 中指代同一概念,令人困惑。如 BudgetEnterRequestfiscalPeriodSetLocaleTypeRequestfiscalYearFiscalYearRequest 也用 fiscalYear,但 BudgetAllocationHistory 表字段是 fiscal_period


5. Frontend Analysis

5.1 Pages & Components

Plannerview (Planner 端)

组件路径说明
MTBudgetpharmagin-plannerview/legacy/src/components/MTBudget/会议预算管理主组件 (Add/Edit/Delete/Copy/Summary/Download)
MTBudgetFormpharmagin-plannerview/legacy/src/components/MTBudgetForm/预算项编辑表单
MTBudgetCopyFormpharmagin-plannerview/legacy/src/components/MTBudgetCopyForm/预算项跨版本复制表单
MTBudgetSearchFormpharmagin-plannerview/legacy/src/components/MTBudgetSeachForm/预算搜索表单 (文件名拼写错误 "Seach")
MTSummarypharmagin-plannerview/legacy/src/components/MTSummary/跨版本预算汇总/打印
BudgetCategoriespharmagin-plannerview/legacy/src/components/BudgetCategories/预算类别管理 (Admin)
BudgetCategoryFormBCpharmagin-plannerview/legacy/src/components/BudgetCategoryFormBC/一级类别表单
BudgetCategoryFormSCpharmagin-plannerview/legacy/src/components/BudgetCategoryFormSC/子类别表单
BudgetTemplatepharmagin-plannerview/legacy/src/components/BudgetTemplate/预算模板管理 (Upload/View/Download)

Salesview (Sales 端)

组件路径说明
BudgetAllocation (主入口)pharmagin-salesview/src/pages/BudgetAllocation/index.js预算分配主页面,含路由
BudgetCapByLocalepharmagin-salesview/src/pages/BudgetAllocation/List/BudgetCapByLocale.js区域级预算上限视图
BudgetCapByLocaleTablepharmagin-salesview/src/pages/BudgetAllocation/List/BudgetCapByLocaleTable.js区域预算表格
ProgramTypeBudgetpharmagin-salesview/src/pages/BudgetAllocation/List/ProgramTypeBudget.js项目类型预算视图
BrandBudgetAllocationpharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetAllocation.js品牌预算分配页
BrandBudgetTablepharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetTable.js品牌预算表格
BrandBudgetFilterpharmagin-salesview/src/pages/BudgetAllocation/List/BrandBudgetFilter.js品牌预算筛选器
RegionBudgetAllocationpharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetAllocation.jsRegion 预算分配
RegionBudgetTablepharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetTable.jsRegion 预算表格
RegionBudgetFilterpharmagin-salesview/src/pages/BudgetAllocation/List/RegionBudgetFilter.jsRegion 预算筛选
DistrictBudgetAllocationpharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetAllocation.jsDistrict 预算分配
DistrictBudgetTablepharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetTable.jsDistrict 预算表格
DistrictBudgetFilterpharmagin-salesview/src/pages/BudgetAllocation/List/DistrictBudgetFilter.jsDistrict 预算筛选
EnterBrandBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterBrandBudgetForm.js品牌预算录入
EnterBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterBudgetForm.js预算录入
EnterQtyFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/EnterQtyForm.js数量录入
AllocateBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/AllocateBudgetForm.js预算分配表单
UnallocateBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/UnallocateBudgetForm.js取消分配表单
MoveBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/MoveBudgetForm.js预算转移表单
CapMethodFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/CapMethodForm.jsCap 方法选择
LocaleBudgetFormpharmagin-salesview/src/pages/BudgetAllocation/Modal/LocaleBudgetForm.js区域预算表单
MeetingBudgetpharmagin-salesview/src/pages/Programs/Modal/MeetingBudget.js会议预算弹窗
ComplianceAudit/Budgetpharmagin-salesview/src/pages/Reports/ComplianceAudit/Budget.js合规审计预算报表

Speakerview (Speaker 端)

无预算相关组件 -- Speaker 端不涉及预算管理功能。

5.2 Redux State Structure

Plannerview - MTBudget

javascript
// 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

javascript
// Key: 'budgetCategories'
{
  categories: BudgetCategoryDTO[],
  loading: boolean,
}

Salesview - BudgetAllocation

javascript
// Key: 'budgetAllocation'
{
  budgetAllocation: [],
  enterBudgetHistory: [],    // cap items 录入历史
  brandBudgetList: [],       // region 品牌预算列表
  districtBudgetList: [],    // district 预算列表
  brandBudgetHistory: [],    // 品牌预算分配历史
  regionBudget: {},          // 单个 region 预算
  filters: {},               // 筛选条件
}

5.3 Frontend Issues

  1. 组件文件名拼写错误: MTBudgetSeachForm (应为 MTBudgetSearchForm),路径 pharmagin-plannerview/legacy/src/components/MTBudgetSeachForm/

  2. Ant Design 3.x 过时 (MTBudget/index.js): 使用了已废弃的 Form.create() HOC 模式和 componentWillReceiveProps 生命周期。

  3. 硬编码 productId (MTBudget/index.js:557): budgetData.productId == 23 硬编码产品 ID 来控制 Sales Rep Logs 的显示。

  4. 硬编码 budgetVersionId (MTBudget/index.js:102-103,137): budgetData.budgetVersionId == 4version == 4 硬编码 ACT 版本号来判断是否只读。

  5. 使用 javascript:void(0) (MTBudget/index.js:143, BudgetCategories/index.js:171): 大量使用 href="javaScript:void(0)",不符合现代 React 最佳实践。

  6. 直接操作上传 URL 拼接 (BudgetTemplate/index.js:85-86): 使用字符串模板直接拼接 API URL 和 token,绕过了统一的 API 调用层。

  7. Class 组件与过时模式 (BudgetAllocation/index.js): 全部使用 Class 组件 + componentDidMount/componentWillReceiveProps 模式,未使用 React Hooks。

  8. Salesview 中 BudgetAllocation 组件嵌套过深: List 目录下有 14 个子组件文件,结构复杂度高,各组件间的数据流和状态共享依赖 Redux,增加了维护成本。


6. Problem Summary

6.1 Critical Issues (must fix in rewrite)

#问题位置影响
C1Strategy 单例线程安全RegionToDistrictStrategy.java:23, DistrictToDistrictStrategy.java:21并发分配操作可能产生错误的 comment 和数据
C2FiscalYear 无主键FiscalYear.java无法通过 ORM 正确执行 CRUD,依赖复合条件查询
C3BudgetItem 删除逻辑矛盾BudgetItemService.java:143-144设置 deleted=INACTIVE 后立即物理删除,软删除形同虚设
C4预算检查使用模板成本而非实际成本BudgetAllocationService.java:750-752hasEnoughBudget 用模板估算值,可能导致超支
C5saveBudgetAllocationHistory 变量名交换BudgetAllocationService.java:463-464initialHistory=buildAdditionalHistory(), additionalHistory=buildInitialHistory(),逻辑颠倒
C6DELETE 使用 RequestBodyBudgetItemController.java:99-102部分 HTTP 客户端/代理不支持 DELETE body,导致删除失败

6.2 Design Defects (should improve)

#问题位置建议
D1BudgetItem 与 BudgetItemTemplate 大量重复字段Entity 层提取公共基类 AbstractBudgetItem
D2vendorId/speakerId 双重存储BudgetItem.java统一为一个字段
D3BudgetCapItem 双 FK (locale/dtl)BudgetCapItem.java重构为明确的关联关系
D4fiscalPeriod/fiscalYear 命名不一致多处 DTO/Entity统一为 fiscalPeriod
D5enableStatus/deleteStatus 使用字符串多处 Entity改用 Boolean 类型
D6BudgetHelper 成本计算嵌套过深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 等视图需要记录视图定义
D9API 路径风格不一致Controller 层统一 RESTful 命名规范
D10无分页支持所有列表 API添加分页参数

6.3 Technical Debt (nice to have)

#问题位置建议
T1BudgetCategory 未使用的字段 (resumeId, accountId, company)BudgetCategory.java清理无用字段
T2BudgetVersion Entity 未使用 LombokBudgetVersion.java统一使用 Lombok
T3BudgetCategory Entity 未使用 LombokBudgetCategory.java统一使用 Lombok
T4BudgetItemTemplate Entity 未使用 LombokBudgetItemTemplate.java统一使用 Lombok
T5前端组件名拼写错误MTBudgetSeachForm修正命名
T6前端硬编码 productId/versionIdMTBudget 组件提取为配置常量
T7前端 Class 组件 + 过时生命周期所有 Budget 组件迁移到 Function Component + Hooks
T8FiscalPeriodMigrationController 作为一次性迁移工具FiscalPeriodMigrationController.java迁移完成后应移除
T9BudgetSummary 不使用 LombokBudgetSummary.java统一代码风格
T10BigDecimal 使用 ROUND_HALF_UP (deprecated)BudgetItemService.java:379使用 RoundingMode.HALF_UP

7. Rewrite Recommendations

7.1 Data Model 重构

  1. 统一预算项基类: 将 BudgetItemBudgetItemTemplate 的共同字段提取到 AbstractBudgetLineItem,消除 20+ 字段的重复定义。

  2. FiscalYear 添加复合主键: 定义 (productId, fiscalYear) 复合主键,或新增自增主键。

  3. 消除 vendorId/speakerId 双重字段: 统一为 vendorId,并通过 vendorCategoryId 区分供应商类型。

  4. BudgetCapItem 明确 FK: 只保留 budgetLocaleDtlId 作为外键,通过 dtl 关联到 locale。

  5. 使用枚举类型和布尔字段: 将 enableStatusdeleteStatus 改为 Boolean,taxMethod/gratuityMethod 改为枚举。

7.2 Service 层重构

  1. 修复 Strategy 线程安全: 将 evenlyAllocatedregionId 作为方法参数传递,而非实例状态。或者将 Strategy Bean 改为 @Scope("prototype")

  2. 成本计算抽象: 将 BudgetHelper.calculateTotalCostByBudgetItem 拆分为 calculateBaseCostcalculateTaxCostcalculateGratuityCost 三个清晰方法,并添加单元测试。

  3. 统一预算检查逻辑: hasEnoughBudget 应该基于实际会议 BudgetItem 成本,而非模板估算。

  4. 消除 N+1 查询: BudgetSummary 应使用批量查询,而不是循环内单条查询。

  5. 引入领域事件: 预算分配、取消、转移等操作应发布领域事件,便于审计和通知。

7.3 API 层重构

  1. 统一 RESTful 设计:

    • POST /v1/budget-allocations (分配)
    • DELETE /v1/budget-allocations/{id} (取消分配)
    • POST /v1/budget-transfers (转移)
    • GET /v1/budget-caps?locale=region&fiscalYear=X (查询 cap)
  2. 添加分页: 所有列表 API 支持 page/size 参数。

  3. DELETE 方法不使用 body: 改为 DELETE /v1/budgets/items?ids=1,2,3POST /v1/budgets/items/batch-delete

  4. 移除迁移 Controller: FiscalPeriodMigrationController 应作为 DB migration 脚本。

7.4 Frontend 重构

  1. 迁移到 Function Component + Hooks: 所有 Class 组件迁移到 React Hooks,消除 componentWillReceiveProps

  2. 替换硬编码: 将 productId、versionId 等硬编码值提取到配置文件或 API 动态获取。

  3. 简化 Salesview BudgetAllocation: 14 个子组件拆分为更合理的模块划分,使用 Context 或 Zustand 代替 Redux 来管理组件间状态。

  4. 统一 API 调用层: 使用 axios interceptor 或 React Query 统一处理 API 请求,消除直接拼接 URL 的做法。

  5. 修正组件命名: MTBudgetSeachForm -> MTBudgetSearchForm