Skip to content

Meeting Status 统一化设计方案

一、核心问题

当前系统中存在多个并行的状态维度:

状态字段存储位置值数量独立变更?
Meeting Statust_meeting_request.meeting_status15
Budget Versiont_meeting_request.budget_version_id + budget_status5是(BudgetItemService.copy()
Reg Site Statusooto.t_meeting.reg_site_status4是(SiteService
Attendee List Statusooto.t_meeting.attendee_list_status2是(ProgramService

问题:能否用 Meeting Status 统一替代这些状态?

二、逐项分析

2.1 Budget Version → 可以合并

依据:

  1. 已存在 1:1 映射。 t_meeting_request_status 表明确定义了每个 Meeting Status 对应的 Budget Version:
ASSIGNED(0)          → SOW(1)
ESTIMATE(5)          → SOW(1)
PLANNING(6)          → EST(2)
BILLING(1)           → BILL(3)
CLOSED(4)            → ACT(4)
CANCELLED_CLOSED(3)  → ACT(4)
REOPENED(14)         → ACT(4)
POSTPONED(7)         → CXL(5)
...
  1. 当前实现有一致性漏洞。 BudgetItemService.copy() 可以在不改变 Meeting Status 的情况下独立修改 Budget Version,且没有任何校验:
java
// BudgetItemService.java:262-264 — 直接更新,无状态校验
meetingRequest.setBudgetVersionId(budgetVersionId);
meetingRequest.setBudgetStatus(Constants.BudgetVersion.getName(budgetVersionId));
meetingRequestMapper.updateByPrimaryKeySelective(meetingRequest);
  1. Budget Version 本质上描述的是 Meeting 所处的财务阶段,和 Meeting Status 描述的运营阶段是同一件事的两个面。合并后可以消除不一致的可能性。

  2. Budget Items 本身仍然保留版本标识。 每个 t_budget_item 都有自己的 budget_version_id,用于区分 SOW/EST/BILL/ACT/CXL 版本的数据。合并只是去掉 t_meeting_request 上的冗余字段,不影响预算明细的多版本存储。

结论:合并。Budget Version 由 Meeting Status 派生,不再独立存储。


2.2 Registration Site Status → 不应合并

依据:

  1. 生命周期genuinely独立。 同一个 Meeting Status(如 Planning)下,Registration Site 可以处于任意状态:
场景Meeting StatusReg Site Status
刚创建项目,还没配置注册站PlanningDraft
注册站已开放,正在收集报名PlanningActive
临时暂停注册(如人数已满)PlanningSuspend
注册结束,但项目仍在策划中PlanningCompleted
  1. 不同的操作主体。 Meeting Status 由 Planner 通过审批/取消/关闭等业务操作驱动;Reg Site Status 由 Site Admin 通过注册站配置界面驱动。

  2. 存储在不同的表。 reg_site_statusooto.t_meeting(注册站详情表),meeting_statuspublic.t_meeting_request(项目请求表)。它们的实体边界不同。

  3. 如果强行合并,状态数量会爆炸。 15 × 4 = 60 种组合,完全不可维护。

结论:不合并。Registration Site Status 保持独立。


2.3 Attendee List Close Status → 不应合并,但应整合为状态机的 Gate

依据:

  1. 它是一个二值开关(Open/Closed),不是一个阶段性状态。 它更像一个"检查点"而非"状态"。在 Planning、Billing、甚至 Reopened 阶段都可能需要 Open/Close 参会者列表。

  2. closeProgram() 已经把它作为前置条件检查:

java
// ProgramService.java:1416-1418
if (!Objects.equals(AttendeeListStatus.CLOSE.getCode(), programResponse.getAttendeeListStatus())) {
    errors.add("Attendee List must be closed");
}
  1. 如果合并成 Meeting Status 的一个阶段,会产生倒退问题。 例如引入 ATTENDEES_FINALIZED 状态后,如果需要重新打开列表,就需要回退到之前的状态——但之前的状态是什么?Planning?Billing?需要额外字段记录"回退目标",反而增加复杂度。

  2. 前端把它作为独立的可选列显示:

javascript
// MeetingTable/index.js:616-625 — 可选字段
if (optionalMeetingFields.includes('attendeeListStatus')) {
    columns.push({ title: 'Attendee List Closed', ... });
}

结论:不合并。保持为独立 flag,但纳入状态机的 Guard Condition。


三、统一设计方案

3.1 核心原则

┌──────────────────────────────────────────────────────────┐
│  Meeting Status = 唯一的项目生命周期状态(含财务阶段语义) │
│  Budget Version = 从 Meeting Status 派生的计算属性        │
│  Reg Site Status = 独立维度,保持不变                     │
│  Attendee List Status = 独立 flag,作为状态转换的 Guard   │
└──────────────────────────────────────────────────────────┘

3.2 重新设计 Meeting Status

当前 15 个状态 → 精简为 12 个

Code新状态含义派生 Budget Version变更说明
0DRAFT草稿/初始分配SOW合并 Assigned + Estimate + Registered
1WAITLISTED预算不足等待SOW保留,增加 waitlisted flag 也可
2PENDING_APPROVAL等待审批SOW保留
3DENIED审批拒绝N/A保留
4PLANNING策划中(含预算估算)EST保留,吸收 Budget EST 语义
5CONFIRMED已确认/账单中BILL替换 Billing,语义更清晰
6COMPLETED已完成(待关闭)ACT新增,表示活动已结束,财务结算中
7CLOSED已关闭归档ACT保留
8CANCELLED已取消BILL保留,合并 Cancelled + Cancelled Closed
9POSTPONED已推迟CXL保留,合并 Postponed + Postponed Closed
10VOID作废N/A保留
11REOPENED重新打开ACT保留

关键变更解释:

  1. 合并 Assigned(0) + Estimate(5) + Registered(9) → DRAFT(0)

    • 数据库中 Estimate 和 Registered 从未使用
    • 三个状态都在 SOW 阶段,实际语义相同:项目刚创建,尚未进入策划
  2. Billing(1) → CONFIRMED(5)

    • "Billing" 是财务视角的命名,对 Planner 不直观
    • "Confirmed" 表示活动已确认,进入执行阶段
  3. 新增 COMPLETED(6)

    • 当前从 BILLING 直接跳到 CLOSED,缺少"活动已结束但尚未关闭"的中间态
    • COMPLETED 表示活动日已过,正在进行财务结算和 TOV 计算
    • 关闭前置条件(Attendee List Closed + TOV Done)在此阶段检查
  4. 合并 Cancelled + Cancelled Closed → CANCELLED(8)

    • closedOut: boolean flag 区分是否已完成取消结算
    • 同理 Postponed + Postponed Closed → POSTPONED(9)

Budget Version 派生规则

java
public static BudgetVersion deriveBudgetVersion(MeetingStatus status) {
    switch (status) {
        case DRAFT:
        case WAITLISTED:
        case PENDING_APPROVAL:
            return BudgetVersion.SOW;
        case PLANNING:
            return BudgetVersion.EST;
        case CONFIRMED:
        case CANCELLED:  // 取消结算仍在 BILL 阶段
            return BudgetVersion.BILL;
        case COMPLETED:
        case CLOSED:
        case REOPENED:
            return BudgetVersion.ACT;
        case POSTPONED:
            return BudgetVersion.CXL;
        case DENIED:
        case VOID:
            return null; // 不适用
        default:
            throw new IllegalStateException("Unknown status: " + status);
    }
}

3.3 统一状态机定义

                          ┌─────────┐
                          │  DRAFT  │ (SOW)
                          │   (0)   │
                          └────┬────┘

                 ┌─────────────┼──────────────┐
                 │             │              │
                 ▼             ▼              ▼
          ┌────────────┐ ┌──────────┐  ┌──────────┐
          │ WAITLISTED │ │ PENDING  │  │   VOID   │
          │    (1)     │ │ APPROVAL │  │   (10)   │
          │   (SOW)    │ │   (2)    │  └──────────┘
          └─────┬──────┘ │  (SOW)   │
                │        └──┬───┬───┘
                │           │   │
                │     Approve   Deny
                │           │   │
                │           ▼   ▼
                │           │ ┌──────────┐
                │           │ │  DENIED  │
                │           │ │   (3)    │
                │           │ └──────────┘
                │           │
                ▼           ▼
          ┌──────────────────────┐
          │      PLANNING        │ (EST)
          │        (4)           │
          │                      │
          │  Budget: SOW → EST   │
          │  (auto-copy on       │
          │   status transition) │
          └──────────┬───────────┘

            ┌────────┼─────────┐
            │        │         │
            ▼        ▼         ▼
     ┌───────────┐         ┌───────────┐
     │ CANCELLED │         │ POSTPONED │
     │    (8)    │         │    (9)    │
     │  (BILL)   │         │   (CXL)  │
     └───────────┘         └───────────┘


          ┌──────────────────────┐
          │     CONFIRMED        │ (BILL)
          │       (5)            │
          │                      │
          │  Budget: EST → BILL  │
          └──────────┬───────────┘


          ┌──────────────────────┐
          │     COMPLETED        │ (ACT)
          │       (6)            │
          │                      │
          │  Budget: BILL → ACT  │
          │                      │
          │  Gates:              │
          │  ☐ Attendee List     │
          │    Closed            │
          │  ☐ TOV Calculated    │
          └──────────┬───────────┘

                     │  (all gates passed)

          ┌──────────────────────┐
          │      CLOSED          │ (ACT)
          │       (7)            │
          └──────────┬───────────┘

                     │  (explicit reopen)

          ┌──────────────────────┐
          │     REOPENED         │ (ACT)
          │      (11)            │
          └──────────────────────┘

3.4 状态转换规则(State Transition Table)

FromTo触发条件Guard Conditions副作用
(new)DRAFT创建项目 + 预算充足-创建 SOW Budget Items
(new)WAITLISTED创建项目 + 预算不足disallowProgramIfBudgetReached=false创建 SOW Budget Items
DRAFT/WAITLISTEDPENDING_APPROVAL创建项目 + 需要审批needApproval=true记录 oldMeetingStatus
PENDING_APPROVALDRAFT/WAITLISTED审批通过所有审批级别通过恢复 oldMeetingStatus
PENDING_APPROVALDENIED审批拒绝任一级别拒绝-
DRAFTPLANNING推进到策划阶段-自动 copy SOW→EST
PLANNINGCONFIRMED确认项目-自动 copy EST→BILL
CONFIRMEDCOMPLETED活动结束-自动 copy BILL→ACT
COMPLETEDCLOSED关闭项目attendeeList=Closed AND TOV done归档
CLOSEDREOPENED重新打开-可选重开 attendee list
REOPENEDCLOSED再次关闭attendeeList=Closed AND TOV done归档
Any(非终态)CANCELLED取消项目-记录 oldMeetingStatus
Any(非终态)POSTPONED推迟项目-记录 oldMeetingStatus
Any(非终态)VOID作废-软删除
CANCELLEDCANCELLED取消结算完成-设置 closedOut=true
POSTPONEDPOSTPONED推迟结算完成-设置 closedOut=true

3.5 Budget Changelog 设计

当 Meeting Status 转换触发 Budget Version 变更时,自动记录详细的 Budget Changelog。

新增表 t_budget_change_log

sql
CREATE TABLE t_budget_change_log (
    id                  SERIAL PRIMARY KEY,
    meeting_request_id  INTEGER NOT NULL,

    -- 状态变更
    old_meeting_status  INTEGER NOT NULL,
    new_meeting_status  INTEGER NOT NULL,
    old_budget_version  VARCHAR(10) NOT NULL,  -- SOW/EST/BILL/ACT/CXL
    new_budget_version  VARCHAR(10) NOT NULL,

    -- 预算快照(每次版本变更时记录总额)
    old_version_total   DECIMAL(12,2),  -- 旧版本总预算
    new_version_total   DECIMAL(12,2),  -- 新版本总预算(copy后的初始值)

    -- 明细变更(JSON存储每个category的变更)
    budget_items_snapshot JSONB,
    /*
    格式:
    [
      {
        "categoryName": "Honoraria",
        "oldVersionAmount": 5000.00,
        "newVersionAmount": 5000.00,
        "delta": 0.00
      },
      {
        "categoryName": "Venue",
        "oldVersionAmount": 3000.00,
        "newVersionAmount": 2800.00,
        "delta": -200.00
      }
    ]
    */

    -- 审计
    created_by          INTEGER NOT NULL,
    created_at          TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    comment             VARCHAR(500)  -- 可选的变更说明
);

CREATE INDEX idx_budget_changelog_meeting ON t_budget_change_log(meeting_request_id);

Changelog 记录时机

状态转换                    记录内容
─────────────────────────────────────────────────────────
DRAFT → PLANNING           SOW→EST copy, 记录 SOW 总额和 EST 初始总额
PLANNING → CONFIRMED       EST→BILL copy, 记录 EST 总额和 BILL 初始总额
CONFIRMED → COMPLETED      BILL→ACT copy, 记录 BILL 总额和 ACT 初始总额
Any → CANCELLED            记录当前版本总额,切换到 BILL 阶段
Any → POSTPONED            记录当前版本总额,切换到 CXL 阶段

预算版本内的修改 Changelog

在同一个 Budget Version 阶段内修改预算明细(如在 EST 阶段调整某个 line item),使用现有的 t_meeting_change_log 记录字段级变更:

字段变更日志(已有机制):
- fieldName: "Budget Item - Honoraria - Unit Cost"
- oldValue: "5000.00"
- newValue: "5500.00"

3.6 代码实现方案

3.6.1 移除 budget_version_idbudget_status 从 MeetingRequest

java
// MeetingRequest.java — 移除这两个字段
// @Column(name = "budget_version_id")
// private Integer budgetVersionId;       ← 删除

// @Column(name = "budget_status")
// private String budgetStatus;           ← 删除

// 新增派生方法
public Integer getBudgetVersionId() {
    return MeetingStatus.deriveBudgetVersion(this.meetingStatus);
}

public String getBudgetStatus() {
    BudgetVersion bv = MeetingStatus.deriveBudgetVersion(this.meetingStatus);
    return bv != null ? bv.name() : "N/A";
}

3.6.2 重构 BudgetItemService.copy() → ProgramService.advancePhase()

java
// ProgramService.java — 新增方法

/**
 * 推进项目到下一个阶段,同时自动 copy 预算到对应版本。
 * 替代原来的 BudgetItemService.copy() 独立调用。
 */
@Transactional
public void advancePhase(Integer meetingRequestId, MeetingStatus targetStatus) {
    MeetingRequest mr = meetingRequestMapper.selectByPrimaryKey(meetingRequestId);
    MeetingStatus currentStatus = MeetingStatus.valueOfCode(mr.getMeetingStatus());

    // 1. 校验转换合法性
    if (!stateMachine.canTransition(currentStatus, targetStatus)) {
        throw new ServiceException(
            "Cannot transition from " + currentStatus + " to " + targetStatus,
            ErrorCode.FORBIDDEN
        );
    }

    // 2. 确定 budget version 变更
    BudgetVersion oldBV = MeetingStatus.deriveBudgetVersion(currentStatus);
    BudgetVersion newBV = MeetingStatus.deriveBudgetVersion(targetStatus);

    // 3. 如果 budget version 发生变更,执行 copy
    if (oldBV != null && newBV != null && !oldBV.equals(newBV)) {
        BudgetChangeLog changelog = budgetItemService.copyWithChangelog(
            meetingRequestId, oldBV, newBV
        );
        budgetChangeLogMapper.insert(changelog);
    }

    // 4. 更新 meeting status
    Integer oldStatus = mr.getMeetingStatus();
    mr.setMeetingStatus(targetStatus.getCode());
    meetingRequestMapper.updateByPrimaryKeySelective(mr);

    // 5. 发布事件
    publisher.publishEvent(new ProgramStatusChangedEvent(oldStatus, mr));
}

3.6.3 状态机 Guard 示例

java
// MeetingStateMachine.java — 新增类

public class MeetingStateMachine {

    private static final Map<MeetingStatus, Set<MeetingStatus>> TRANSITIONS = Map.ofEntries(
        entry(DRAFT,            Set.of(PLANNING, PENDING_APPROVAL, CANCELLED, POSTPONED, VOID)),
        entry(WAITLISTED,       Set.of(PLANNING, PENDING_APPROVAL, CANCELLED, VOID)),
        entry(PENDING_APPROVAL, Set.of(DRAFT, WAITLISTED, DENIED)),
        entry(DENIED,           Set.of(DRAFT)),  // 可以重新提交
        entry(PLANNING,         Set.of(CONFIRMED, CANCELLED, POSTPONED, VOID)),
        entry(CONFIRMED,        Set.of(COMPLETED, CANCELLED, POSTPONED)),
        entry(COMPLETED,        Set.of(CLOSED, REOPENED, CANCELLED)),
        entry(CLOSED,           Set.of(REOPENED)),
        entry(REOPENED,         Set.of(CLOSED, CANCELLED)),
        entry(CANCELLED,        Set.of()),  // 终态
        entry(POSTPONED,        Set.of()),  // 终态
        entry(VOID,             Set.of())   // 终态
    );

    public boolean canTransition(MeetingStatus from, MeetingStatus to) {
        Set<MeetingStatus> allowed = TRANSITIONS.get(from);
        return allowed != null && allowed.contains(to);
    }

    /**
     * 检查 Guard Conditions(关闭项目时的前置条件)
     */
    public List<String> checkGuards(MeetingStatus target, ProgramResponse program) {
        List<String> errors = new ArrayList<>();

        if (target == CLOSED) {
            if (program.getAttendeeListStatus() != AttendeeListStatus.CLOSE.getCode()) {
                errors.add("Attendee List must be closed");
            }
            if (program.getAllocationTov() == null) {
                errors.add("Transfer of Value calculation must be completed");
            }
        }

        return errors;
    }
}

3.6.4 API 兼容层(向后兼容)

前端当前分别使用 meetingStatusbudgetStatus 字段。为了平滑迁移:

java
// ProgramResponse.java — API 响应仍然返回 budgetStatus

public class ProgramResponse {
    private Integer meetingStatus;
    private String meetingStatusName;

    // 计算属性,保持 API 兼容
    public Integer getBudgetVersionId() {
        return MeetingStatus.deriveBudgetVersion(this.meetingStatus);
    }

    public String getBudgetStatus() {
        BudgetVersion bv = MeetingStatus.deriveBudgetVersion(this.meetingStatus);
        return bv != null ? bv.name() : "N/A";
    }

    // 独立维度,保持不变
    private Integer regSiteStatus;
    private Integer attendeeListStatus;
}

前端不需要立即修改——API 响应中的 budgetStatusbudgetVersionId 仍然存在,只是从计算得出而非从数据库直接读取。

3.7 数据库迁移

sql
-- Phase 1: 添加新字段(如果需要 closedOut flag)
ALTER TABLE t_meeting_request ADD COLUMN closed_out BOOLEAN DEFAULT FALSE;

-- Phase 2: 迁移 Cancelled Closed → Cancelled + closedOut=true
UPDATE t_meeting_request
SET meeting_status = 8,  -- 新的 CANCELLED code
    closed_out = TRUE
WHERE meeting_status = 3; -- 旧的 CANCELLED_CLOSED

-- Phase 3: 迁移 Postponed Closed → Postponed + closedOut=true
UPDATE t_meeting_request
SET meeting_status = 9,  -- 新的 POSTPONED code
    closed_out = TRUE
WHERE meeting_status = 8; -- 旧的 POSTPONED_CLOSED

-- Phase 4: 合并 Assigned/Estimate/Registered → DRAFT
UPDATE t_meeting_request
SET meeting_status = 0   -- DRAFT
WHERE meeting_status IN (5, 9); -- Estimate, Registered

-- Phase 5: 创建 budget_change_log 表(见 3.5 节 DDL)

-- Phase 6: 验证数据一致性
SELECT meeting_status, budget_version_id,
    CASE
        WHEN meeting_status IN (0, 1, 2) THEN 1     -- SOW
        WHEN meeting_status = 4 THEN 2               -- EST
        WHEN meeting_status IN (5, 8) THEN 3         -- BILL
        WHEN meeting_status IN (6, 7, 11) THEN 4     -- ACT
        WHEN meeting_status = 9 THEN 5               -- CXL
        ELSE -1
    END as expected_budget_version,
    CASE
        WHEN budget_version_id = CASE
            WHEN meeting_status IN (0, 1, 2) THEN 1
            WHEN meeting_status = 4 THEN 2
            WHEN meeting_status IN (5, 8) THEN 3
            WHEN meeting_status IN (6, 7, 11) THEN 4
            WHEN meeting_status = 9 THEN 5
            ELSE -1 END
        THEN 'CONSISTENT'
        ELSE 'INCONSISTENT'
    END as check_result
FROM t_meeting_request
WHERE meeting_status NOT IN (3, 10);  -- 排除 DENIED 和 VOID

-- Phase 7: 确认一致后,可选择性地删除冗余列
-- ALTER TABLE t_meeting_request DROP COLUMN budget_version_id;
-- ALTER TABLE t_meeting_request DROP COLUMN budget_status;
-- 注意:建议先保留列但标记为 deprecated,给前端迁移时间

四、前后对比

4.1 当前架构

┌─────────────┐   ┌──────────────┐   ┌───────────────┐   ┌──────────────┐
│  Meeting    │   │  Budget      │   │  Reg Site     │   │  Attendee    │
│  Status     │   │  Version     │   │  Status       │   │  List Status │
│             │   │              │   │               │   │              │
│  15 values  │   │  5 values    │   │  4 values     │   │  2 values    │
│  独立变更    │   │  独立变更    │   │  独立变更      │   │  独立变更    │
│  无校验      │   │  无校验      │   │  有前置条件    │   │  有前置条件  │
└──────┬──────┘   └──────┬───────┘   └───────────────┘   └──────┬───────┘
       │                 │                                       │
       │    "应该一致"    │         "独立"                   "关闭的Gate"
       │   (但无保证)     │                                       │
       └────────┬────────┘                                       │
                │                                                │
         closeProgram() 同时检查两者 ──────────────────────────────┘

问题: Budget Version 和 Meeting Status "应该一致"但没有机制保证。

4.2 新架构

┌──────────────────────────────────┐   ┌───────────────┐   ┌──────────────┐
│  Meeting Status                  │   │  Reg Site     │   │  Attendee    │
│  (唯一状态源,含财务阶段语义)     │   │  Status       │   │  List Status │
│                                  │   │               │   │              │
│  12 values                       │   │  4 values     │   │  2 values    │
│  状态机驱动,带 Guard Conditions  │   │  独立生命周期  │   │  Guard Flag  │
│  自动派生 Budget Version          │   │               │   │              │
│                                  │   │               │   │              │
│  转换时自动:                      │   │               │   │              │
│  ✓ Copy Budget Items             │   │               │   │              │
│  ✓ 记录 Budget Changelog         │   │               │   │              │
│  ✓ 触发通知                      │   │               │   │              │
└──────────┬───────────────────────┘   └───────────────┘   └──────┬───────┘
           │                                                      │
           │           COMPLETED → CLOSED 时检查                   │
           └──────────────────────────────────────────────────────┘

改进: Budget Version 不再是独立状态,而是 Meeting Status 的派生属性。状态机保证一致性。

4.3 消除的问题

问题现状改进后
Budget 和 Meeting Status 不一致可能发生,无校验不可能,派生关系
Budget copy 不触发状态变更copy 是独立操作copy 是状态转换的副作用
15 个状态含未使用值Estimate(5), Registered(9) 从未使用精简为 12 个活跃状态
Cancelled 有两个阶段Cancelled + Cancelled Closed单一 CANCELLED + closedOut flag
关闭前置条件分散检查硬编码在 closeProgram()状态机 Guard 统一管理
缺少预算变更审计仅记录版本名变更完整记录金额快照

五、总结

候选状态合并?原因
Budget Version与 Meeting Status 已有 1:1 映射,合并消除不一致风险
Registration Site Statusgenuinely 独立的生命周期,强行合并导致状态爆炸
Attendee List Close Status二值 flag 更适合做 Guard Condition,不适合做状态

核心改动范围:

  1. MeetingStatus 枚举重构(精简 + 增加 deriveBudgetVersion()
  2. ProgramService 新增 advancePhase() 方法,替代 BudgetItemService.copy() 的独立调用
  3. MeetingStateMachine 新类,集中管理转换规则和 Guard
  4. t_budget_change_log 新表,记录预算版本变更详情
  5. API 响应层兼容(budgetStatus 改为计算属性)
  6. 前端无需立即修改,平滑过渡

风险评估:

  • 低风险: API 响应保持兼容,前端不感知变更
  • 中风险: 需要全面测试 Budget copy 流程,确保自动 copy 与手动 copy 结果一致
  • 需注意: 现有数据迁移前需要验证 meeting_status 和 budget_version_id 的一致性