Meeting Status 统一化设计方案
一、核心问题
当前系统中存在多个并行的状态维度:
| 状态字段 | 存储位置 | 值数量 | 独立变更? |
|---|---|---|---|
| Meeting Status | t_meeting_request.meeting_status | 15 | 是 |
| Budget Version | t_meeting_request.budget_version_id + budget_status | 5 | 是(BudgetItemService.copy()) |
| Reg Site Status | ooto.t_meeting.reg_site_status | 4 | 是(SiteService) |
| Attendee List Status | ooto.t_meeting.attendee_list_status | 2 | 是(ProgramService) |
问题:能否用 Meeting Status 统一替代这些状态?
二、逐项分析
2.1 Budget Version → 可以合并
依据:
- 已存在 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)
...- 当前实现有一致性漏洞。
BudgetItemService.copy()可以在不改变 Meeting Status 的情况下独立修改 Budget Version,且没有任何校验:
// BudgetItemService.java:262-264 — 直接更新,无状态校验
meetingRequest.setBudgetVersionId(budgetVersionId);
meetingRequest.setBudgetStatus(Constants.BudgetVersion.getName(budgetVersionId));
meetingRequestMapper.updateByPrimaryKeySelective(meetingRequest);Budget Version 本质上描述的是 Meeting 所处的财务阶段,和 Meeting Status 描述的运营阶段是同一件事的两个面。合并后可以消除不一致的可能性。
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 → 不应合并
依据:
- 生命周期genuinely独立。 同一个 Meeting Status(如 Planning)下,Registration Site 可以处于任意状态:
| 场景 | Meeting Status | Reg Site Status |
|---|---|---|
| 刚创建项目,还没配置注册站 | Planning | Draft |
| 注册站已开放,正在收集报名 | Planning | Active |
| 临时暂停注册(如人数已满) | Planning | Suspend |
| 注册结束,但项目仍在策划中 | Planning | Completed |
不同的操作主体。 Meeting Status 由 Planner 通过审批/取消/关闭等业务操作驱动;Reg Site Status 由 Site Admin 通过注册站配置界面驱动。
存储在不同的表。
reg_site_status在ooto.t_meeting(注册站详情表),meeting_status在public.t_meeting_request(项目请求表)。它们的实体边界不同。如果强行合并,状态数量会爆炸。 15 × 4 = 60 种组合,完全不可维护。
结论:不合并。Registration Site Status 保持独立。
2.3 Attendee List Close Status → 不应合并,但应整合为状态机的 Gate
依据:
它是一个二值开关(Open/Closed),不是一个阶段性状态。 它更像一个"检查点"而非"状态"。在 Planning、Billing、甚至 Reopened 阶段都可能需要 Open/Close 参会者列表。
closeProgram() 已经把它作为前置条件检查:
// ProgramService.java:1416-1418
if (!Objects.equals(AttendeeListStatus.CLOSE.getCode(), programResponse.getAttendeeListStatus())) {
errors.add("Attendee List must be closed");
}如果合并成 Meeting Status 的一个阶段,会产生倒退问题。 例如引入
ATTENDEES_FINALIZED状态后,如果需要重新打开列表,就需要回退到之前的状态——但之前的状态是什么?Planning?Billing?需要额外字段记录"回退目标",反而增加复杂度。前端把它作为独立的可选列显示:
// 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 | 变更说明 |
|---|---|---|---|---|
| 0 | DRAFT | 草稿/初始分配 | SOW | 合并 Assigned + Estimate + Registered |
| 1 | WAITLISTED | 预算不足等待 | SOW | 保留,增加 waitlisted flag 也可 |
| 2 | PENDING_APPROVAL | 等待审批 | SOW | 保留 |
| 3 | DENIED | 审批拒绝 | N/A | 保留 |
| 4 | PLANNING | 策划中(含预算估算) | EST | 保留,吸收 Budget EST 语义 |
| 5 | CONFIRMED | 已确认/账单中 | BILL | 替换 Billing,语义更清晰 |
| 6 | COMPLETED | 已完成(待关闭) | ACT | 新增,表示活动已结束,财务结算中 |
| 7 | CLOSED | 已关闭归档 | ACT | 保留 |
| 8 | CANCELLED | 已取消 | BILL | 保留,合并 Cancelled + Cancelled Closed |
| 9 | POSTPONED | 已推迟 | CXL | 保留,合并 Postponed + Postponed Closed |
| 10 | VOID | 作废 | N/A | 保留 |
| 11 | REOPENED | 重新打开 | ACT | 保留 |
关键变更解释:
合并 Assigned(0) + Estimate(5) + Registered(9) → DRAFT(0)
- 数据库中 Estimate 和 Registered 从未使用
- 三个状态都在 SOW 阶段,实际语义相同:项目刚创建,尚未进入策划
Billing(1) → CONFIRMED(5)
- "Billing" 是财务视角的命名,对 Planner 不直观
- "Confirmed" 表示活动已确认,进入执行阶段
新增 COMPLETED(6)
- 当前从 BILLING 直接跳到 CLOSED,缺少"活动已结束但尚未关闭"的中间态
- COMPLETED 表示活动日已过,正在进行财务结算和 TOV 计算
- 关闭前置条件(Attendee List Closed + TOV Done)在此阶段检查
合并 Cancelled + Cancelled Closed → CANCELLED(8)
- 用
closedOut: booleanflag 区分是否已完成取消结算 - 同理 Postponed + Postponed Closed → POSTPONED(9)
- 用
Budget Version 派生规则
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)
| From | To | 触发条件 | Guard Conditions | 副作用 |
|---|---|---|---|---|
| (new) | DRAFT | 创建项目 + 预算充足 | - | 创建 SOW Budget Items |
| (new) | WAITLISTED | 创建项目 + 预算不足 | disallowProgramIfBudgetReached=false | 创建 SOW Budget Items |
| DRAFT/WAITLISTED | PENDING_APPROVAL | 创建项目 + 需要审批 | needApproval=true | 记录 oldMeetingStatus |
| PENDING_APPROVAL | DRAFT/WAITLISTED | 审批通过 | 所有审批级别通过 | 恢复 oldMeetingStatus |
| PENDING_APPROVAL | DENIED | 审批拒绝 | 任一级别拒绝 | - |
| DRAFT | PLANNING | 推进到策划阶段 | - | 自动 copy SOW→EST |
| PLANNING | CONFIRMED | 确认项目 | - | 自动 copy EST→BILL |
| CONFIRMED | COMPLETED | 活动结束 | - | 自动 copy BILL→ACT |
| COMPLETED | CLOSED | 关闭项目 | attendeeList=Closed AND TOV done | 归档 |
| CLOSED | REOPENED | 重新打开 | - | 可选重开 attendee list |
| REOPENED | CLOSED | 再次关闭 | attendeeList=Closed AND TOV done | 归档 |
| Any(非终态) | CANCELLED | 取消项目 | - | 记录 oldMeetingStatus |
| Any(非终态) | POSTPONED | 推迟项目 | - | 记录 oldMeetingStatus |
| Any(非终态) | VOID | 作废 | - | 软删除 |
| CANCELLED | CANCELLED | 取消结算完成 | - | 设置 closedOut=true |
| POSTPONED | POSTPONED | 推迟结算完成 | - | 设置 closedOut=true |
3.5 Budget Changelog 设计
当 Meeting Status 转换触发 Budget Version 变更时,自动记录详细的 Budget Changelog。
新增表 t_budget_change_log
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_id 和 budget_status 从 MeetingRequest
// 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()
// 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 示例
// 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 兼容层(向后兼容)
前端当前分别使用 meetingStatus 和 budgetStatus 字段。为了平滑迁移:
// 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 响应中的 budgetStatus 和 budgetVersionId 仍然存在,只是从计算得出而非从数据库直接读取。
3.7 数据库迁移
-- 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 Status | 否 | genuinely 独立的生命周期,强行合并导致状态爆炸 |
| Attendee List Close Status | 否 | 二值 flag 更适合做 Guard Condition,不适合做状态 |
核心改动范围:
MeetingStatus枚举重构(精简 + 增加deriveBudgetVersion())ProgramService新增advancePhase()方法,替代BudgetItemService.copy()的独立调用MeetingStateMachine新类,集中管理转换规则和 Guardt_budget_change_log新表,记录预算版本变更详情- API 响应层兼容(
budgetStatus改为计算属性) - 前端无需立即修改,平滑过渡
风险评估:
- 低风险: API 响应保持兼容,前端不感知变更
- 中风险: 需要全面测试 Budget copy 流程,确保自动 copy 与手动 copy 结果一致
- 需注意: 现有数据迁移前需要验证 meeting_status 和 budget_version_id 的一致性