基于业务生命周期的 Meeting Status 重新设计
一、真实的 Speaker Program 生命周期
在制药行业,一场 Speaker Program 的生命周期天然地包含了渐进式锁定的过程:
时间线 ──────────────────────────────────────────────────────────→
创建项目 开放注册 注册截止 活动当天 核查完成 财务结算 归档
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│DRAFT│→ │OPEN │→ │CLOSED│→ │EVENT │→ │RECON │→ │SETTLE│→ │LOCKED│
│ │ │ REG │ │ REG │ │ DAY │ │CILED │ │MENT │ │ │
└─────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
可编辑: 可编辑: 可编辑: 可编辑: 可编辑: 可编辑: 全部
项目信息 项目信息 项目信息 签到 HCP核查 预算 锁定
预算 预算 预算 HCP核查 预算 TOV
注册站 注册站 参会者 参会者 参会者
参会者 HCP核查
预算
不可改: 不可改: 不可改: 不可改: 不可改: 不可改: 不可改:
(无) (无) 新注册 新注册 新注册 新注册 所有
新参会者 新参会者 参会者
签到状态 HCP核查
签到状态核心洞察:每进入下一个阶段,可编辑的范围就缩小一层。这就是"渐进式锁定"。
二、当前实现 vs 业务需求对比
| 业务需求 | 当前实现 | 差距 |
|---|---|---|
| 注册截止后自动停止接受新注册 | policy_registration_deadline 仅用于显示,不校验 | 完全缺失 |
| 活动结束后不接受新参会者 | 仅前端 checkProgramCloseOutStatus() | 后端无保护 |
| 参会者列表关闭后不能修改签到状态 | 前端 attendeeListStatus==0 时弹提示,可绕过 | 后端无保护 |
| 核查完成后 HCP 信息锁定 | 无此机制 | 完全缺失 |
| 财务结算后预算锁定 | Budget API 无任何状态检查 | 完全缺失 |
| 项目关闭后全部锁定 | 仅 updateProgram() 和 saveSurveyResponse() 检查 | 大量漏洞 |
| Reopen 可以解锁 | REOPENED 状态存在,但解锁范围不明确 | 缺乏细粒度 |
三、重新设计 Meeting Status
3.1 新的状态体系
将 Meeting Status 重新设计为一个真正反映生命周期阶段的状态机,每个阶段天然包含"什么可以做、什么不可以做"的权限语义:
| Code | 状态 | 含义 | Budget Version | 阶段 |
|---|---|---|---|---|
| 0 | DRAFT | 草稿/初始创建 | SOW | 准备期 |
| 1 | WAITLISTED | 等待预算 | SOW | 准备期 |
| 2 | PENDING_APPROVAL | 等待审批 | SOW | 准备期 |
| 3 | DENIED | 审批拒绝 | — | 终止 |
| 4 | PLANNING | 策划中,注册站可配置 | EST | 策划期 |
| 5 | REGISTRATION_OPEN | 注册开放 | EST | 注册期 |
| 6 | REGISTRATION_CLOSED | 注册关闭,活动即将开始 | BILL | 执行期 |
| 7 | EVENT_COMPLETE | 活动结束,签到/核查中 | BILL | 核查期 |
| 8 | RECONCILED | 核查完成,财务结算中 | ACT | 结算期 |
| 9 | CLOSED | 全部锁定,归档 | ACT | 归档 |
| 10 | CANCELLED | 已取消 | BILL/CXL | 终止 |
| 11 | POSTPONED | 已推迟 | CXL | 暂停 |
| 12 | VOID | 作废 | — | 终止 |
| 13 | REOPENED | 管理员解锁 | ACT | 特殊 |
3.2 关键变化:吸收 Reg Site Status 和 Attendee List Status
Registration Site Status 不再需要独立存在。
之前分析说 Reg Site Status 是"独立生命周期"——但从业务角度看,这种独立性其实是一个 bug,不是 feature:
- 项目还在 DRAFT 阶段,注册站就不应该能被激活
- 项目进入 PLANNING 阶段,注册站可以配置但不应该对外开放
- 只有到了 REGISTRATION_OPEN 阶段,HCP 才能实际注册
- 项目进入 REGISTRATION_CLOSED 后,注册站自动停止接受新注册
Attendee List Status 也不再需要独立存在。
- REGISTRATION_OPEN 阶段 = 参会者列表开放
- REGISTRATION_CLOSED 阶段 = 不再接受新注册,但可以修改已有参会者
- EVENT_COMPLETE 阶段 = 不再接受新参会者,只能核查和修改签到
- RECONCILED 阶段 = 参会者信息冻结,只能做财务操作
- CLOSED 阶段 = 全部冻结
3.3 渐进式权限矩阵
这是整个设计的核心——每个 Meeting Status 阶段对应明确的操作权限:
操作 ╲ 状态 DRAFT PLAN REG REG EVENT RECON CLOSED RE-
NING OPEN CLOSED COMP. CILED OPENED
─────────────────────────────────────────────────────────────────────────────
修改项目基本信息 ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
修改预算 (Budget) ✅ ✅ ✅ ✅ ✅ ✅ ❌ ✅
配置注册站 ✅ ✅ ✅ ❌ ❌ ❌ ❌ ❌
HCP 自助注册 ❌ ❌ ✅ ❌ ❌ ❌ ❌ ❌
Planner 手动添加参会者 ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
修改参会者信息 ✅ ✅ ✅ ✅ ✅ ❌ ❌ ✅
删除参会者 ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
修改签到状态 ❌ ❌ ❌ ✅ ✅ ❌ ❌ ✅
HCP 核查 (Reconcile) ❌ ❌ ✅ ✅ ✅ ❌ ❌ ✅
TOV 计算 ❌ ❌ ❌ ❌ ❌ ✅ ❌ ✅
分配 Speaker ✅ ✅ ✅ ✅ ❌ ❌ ❌ ✅
发送邀请邮件 ❌ ❌ ✅ ✅ ❌ ❌ ❌ ❌
记录 Expense ❌ ❌ ❌ ✅ ✅ ✅ ❌ ✅3.4 完整状态机
┌─────────┐
│ DRAFT │ (SOW)
│ (0) │
└────┬────┘
│
┌─────────────┼──────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────┐
│ WAITLISTED │ │ PENDING │ │ VOID │
│ (1) │ │ APPROVAL │ │ (12) │
│ (SOW) │ │ (2) │ └──────────┘
└─────┬──────┘ │ (SOW) │
│ └──┬───┬───┘
│ Approve Deny
│ │ │
│ │ ▼
│ │ ┌──────────┐
│ │ │ DENIED │
│ │ │ (3) │
│ │ └──────────┘
▼ ▼
┌──────────────────────┐
│ PLANNING │ (EST)
│ (4) │
│ │
│ 可以: 配置注册站 │
│ 可以: 准备预算 │
│ 可以: 分配 Speaker │
│ 不可: 开放注册 │
└──────────┬───────────┘
│
│ (注册站配置完成,手动或自动开放)
▼
┌──────────────────────┐
│ REGISTRATION_OPEN │ (EST)
│ (5) │
│ │
│ 可以: HCP 自助注册 │
│ 可以: 发邀请邮件 │
│ 可以: 手动加参会者 │
│ 可以: HCP 核查 │
└──────────┬───────────┘
│
│ (注册截止 — 手动/定时/活动前自动)
▼
┌──────────────────────┐
│ REGISTRATION_CLOSED │ (BILL)
│ (6) │
│ │
│ 不可: 新注册 │
│ 可以: 修改已有参会者 │
│ 可以: 签到 │
│ 可以: HCP 核查 │
│ 可以: 修改预算 │
└──────────┬───────────┘
│
│ (活动日结束)
▼
┌──────────────────────┐
│ EVENT_COMPLETE │ (BILL)
│ (7) │
│ │
│ 不可: 新参会者 │
│ 不可: 删除参会者 │
│ 可以: 修改签到状态 │
│ 可以: HCP 核查 │
│ 可以: 修改预算 │
│ 可以: 记录 Expense │
└──────────┬───────────┘
│
│ (所有参会者已核查完毕)
▼
┌──────────────────────┐
│ RECONCILED │ (ACT)
│ (8) │
│ │
│ 不可: 修改参会者 │
│ 不可: 修改签到 │
│ 不可: 修改 HCP 核查 │
│ 可以: 修改预算/费用 │
│ 可以: TOV 计算 │
└──────────┬───────────┘
│
│ (TOV 完成 + 预算确认)
▼
┌──────────────────────┐
│ CLOSED │ (ACT)
│ (9) │
│ │
│ 全部锁定 │
│ 不可: 任何修改 │
└──────────┬───────────┘
│
│ (管理员解锁)
▼
┌──────────────────────┐
│ REOPENED │ (ACT)
│ (13) │
│ │
│ 恢复到 RECONCILED │
│ 级别的编辑权限 │
└──────────────────────┘
── 任意非终态 ──→ CANCELLED(10) / POSTPONED(11) / VOID(12)3.5 被吸收的状态字段
| 原独立字段 | 新设计中如何处理 |
|---|---|
budget_version_id | 从 Meeting Status 派生(见 3.1 表格) |
budget_status | 从 Meeting Status 派生 |
reg_site_status | 不再需要。PLANNING 以前 = 不可配置;PLANNING = 可配置未开放;REGISTRATION_OPEN = 已开放;REGISTRATION_CLOSED 以后 = 已关闭 |
attendee_list_status | 不再需要。REGISTRATION_OPEN/CLOSED = 列表开放;EVENT_COMPLETE 以后 = 不接受新参会者;RECONCILED 以后 = 全部冻结 |
attendee_list_closed_time | 记录为 Changelog(进入 EVENT_COMPLETE 的时间) |
四、后端权限执行层设计
4.1 统一拦截器(核心改动)
当前最大的问题是后端不执行任何基于状态的权限检查。新设计必须在后端强制执行:
/**
* 基于 Meeting Status 的操作权限检查器。
* 在每个需要校验的 Service 方法入口调用。
*/
public class MeetingPhaseGuard {
/**
* 操作类型枚举 — 每个业务操作映射到一个 Operation
*/
public enum Operation {
EDIT_PROGRAM_INFO, // 修改项目基本信息
EDIT_BUDGET, // 修改预算
CONFIGURE_REG_SITE, // 配置注册站
ACCEPT_REGISTRATION, // 接受新注册(HCP自助)
ADD_ATTENDEE, // Planner手动添加参会者
EDIT_ATTENDEE, // 修改参会者信息
DELETE_ATTENDEE, // 删除参会者
EDIT_SIGN_IN_STATUS, // 修改签到状态
RECONCILE_HCP, // HCP 核查
CALCULATE_TOV, // TOV 计算
ASSIGN_SPEAKER, // 分配 Speaker
SEND_INVITATION, // 发送邀请
RECORD_EXPENSE, // 记录费用
}
// 每个状态允许的操作集合
private static final Map<MeetingStatus, Set<Operation>> ALLOWED_OPERATIONS = Map.ofEntries(
entry(DRAFT, Set.of(
EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
)),
entry(PLANNING, Set.of(
EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
)),
entry(REGISTRATION_OPEN, Set.of(
EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
ACCEPT_REGISTRATION, ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE,
ASSIGN_SPEAKER, SEND_INVITATION, RECONCILE_HCP
)),
entry(REGISTRATION_CLOSED, Set.of(
EDIT_PROGRAM_INFO, EDIT_BUDGET,
ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE,
EDIT_SIGN_IN_STATUS, RECONCILE_HCP,
ASSIGN_SPEAKER, RECORD_EXPENSE
)),
entry(EVENT_COMPLETE, Set.of(
EDIT_BUDGET, EDIT_ATTENDEE,
EDIT_SIGN_IN_STATUS, RECONCILE_HCP, RECORD_EXPENSE
)),
entry(RECONCILED, Set.of(
EDIT_BUDGET, CALCULATE_TOV, RECORD_EXPENSE
)),
entry(CLOSED, Set.of(
// 空集 — 全部锁定
)),
entry(REOPENED, Set.of(
EDIT_BUDGET, EDIT_ATTENDEE,
EDIT_SIGN_IN_STATUS, RECONCILE_HCP,
CALCULATE_TOV, RECORD_EXPENSE,
ADD_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
))
);
/**
* 核心校验方法 — 每个 Service 方法入口调用
*/
public void checkPermission(Integer meetingRequestId, Operation operation) {
MeetingRequest mr = meetingRequestMapper.selectByPrimaryKey(meetingRequestId);
MeetingStatus status = MeetingStatus.valueOfCode(mr.getMeetingStatus());
Set<Operation> allowed = ALLOWED_OPERATIONS.getOrDefault(status, Set.of());
if (!allowed.contains(operation)) {
throw new ServiceException(
String.format("Operation [%s] is not allowed in status [%s]",
operation, status.getName()),
ErrorCode.FORBIDDEN
);
}
}
}4.2 Service 层改造示例
// AttendeeService.java — 改造前 vs 改造后
// ---- 改造前(无任何检查)----
public void updateAttendee(String id, AttendeeDTO dto) {
Attendee attendee = this.selectByPrimaryKey(id);
BeanUtil.copyPropertiesIgnoreNull(dto, attendee);
this.updateByPrimaryKeySelective(attendee);
}
// ---- 改造后(加入阶段权限检查)----
public void updateAttendee(String id, AttendeeDTO dto) {
Attendee attendee = this.selectByPrimaryKey(id);
MeetingRequest mr = getMeetingRequestByMeetingId(attendee.getMeetingId());
// 后端强制校验
phaseGuard.checkPermission(mr.getMeetingRequestId(), Operation.EDIT_ATTENDEE);
BeanUtil.copyPropertiesIgnoreNull(dto, attendee);
this.updateByPrimaryKeySelective(attendee);
}需要改造的 Service 方法清单:
| Service | 方法 | 操作类型 |
|---|---|---|
AttendeeService | updateAttendee() | EDIT_ATTENDEE |
AttendeeService | delete() / deleteAttendees() | DELETE_ATTENDEE |
AttendeeService | saveRegistration() | ACCEPT_REGISTRATION |
AttendeeService | updateSignInStatus() | EDIT_SIGN_IN_STATUS |
AttendeeService | reconcile() / reconcileAttendee() | RECONCILE_HCP |
BudgetItemService | saveBudgetItem() / updateBudgetItem() | EDIT_BUDGET |
BudgetItemService | deleteBudgetItem() | EDIT_BUDGET |
ProgramService | updateProgram() | EDIT_PROGRAM_INFO |
SiteService | activateRegSiteStatus() | CONFIGURE_REG_SITE |
ExpenseService | CRUD operations | RECORD_EXPENSE |
AttendeeTovService | allocateTov() | CALCULATE_TOV |
五、自动化状态转换
5.1 定时驱动的转换
某些状态转换应该可以自动触发,减少 Planner 的手动操作:
/**
* 定时检查并自动转换状态的 Cron Job
* 建议频率:每小时执行一次
*/
@Scheduled(cron = "0 0 * * * *")
public void autoAdvanceMeetingStatus() {
LocalDate today = LocalDate.now();
// 1. 注册截止:到达 registration_deadline 的 REGISTRATION_OPEN 项目 → REGISTRATION_CLOSED
List<MeetingRequest> regOpenMeetings = findByStatus(REGISTRATION_OPEN);
for (MeetingRequest mr : regOpenMeetings) {
Meeting meeting = meetingMapper.selectByPrimaryKey(mr.getMeetingId());
if (meeting.getPolicyRegistrationDeadline() != null
&& today.isAfter(toLocalDate(meeting.getPolicyRegistrationDeadline()))) {
advancePhase(mr.getMeetingRequestId(), REGISTRATION_CLOSED);
logAutoTransition(mr, REGISTRATION_OPEN, REGISTRATION_CLOSED, "Registration deadline reached");
}
}
// 2. 活动结束:活动 end_date 已过的 REGISTRATION_CLOSED 项目 → EVENT_COMPLETE
List<MeetingRequest> regClosedMeetings = findByStatus(REGISTRATION_CLOSED);
for (MeetingRequest mr : regClosedMeetings) {
Meeting meeting = meetingMapper.selectByPrimaryKey(mr.getMeetingId());
if (meeting.getEndDate() != null
&& today.isAfter(toLocalDate(meeting.getEndDate()))) {
advancePhase(mr.getMeetingRequestId(), EVENT_COMPLETE);
logAutoTransition(mr, REGISTRATION_CLOSED, EVENT_COMPLETE, "Event end date passed");
}
}
}5.2 条件驱动的转换建议
这些转换不自动执行,而是在条件满足时提示 Planner:
EVENT_COMPLETE → RECONCILED:
触发条件: 所有 hcp_status != NOT_RECONCILED (1) 的参会者
行为: 发通知给 Planner:"All attendees have been reconciled.
Ready to advance to Settlement phase?"
RECONCILED → CLOSED:
触发条件: TOV 计算完成 + 预算已在 ACT 版本
行为: 发通知给 Planner:"All close-out conditions met.
Ready to close the program?"5.3 注册截止的多种触发方式
注册关闭触发方式(可配置,不互斥):
1. 手动关闭: Planner 点击 "Close Registration" 按钮
2. 定时关闭: 到达 policy_registration_deadline 日期时自动关闭
3. 活动前关闭: 活动开始前 N 小时自动关闭(可配置 N)
4. 人数满关闭: 达到 expected_attendees 上限时自动关闭六、Changelog 统一设计
所有状态转换和关键数据变更都记录在统一的 Changelog 中:
6.1 增强 t_meeting_change_log
-- 在已有的 t_meeting_change_log 基础上,增加结构化字段
ALTER TABLE t_meeting_change_log ADD COLUMN change_type VARCHAR(50);
ALTER TABLE t_meeting_change_log ADD COLUMN change_detail JSONB;
-- change_type 枚举值:
-- STATUS_CHANGE : 状态变更
-- BUDGET_PHASE_CHANGE : 预算版本变更(由状态变更驱动)
-- ATTENDEE_LOCKED : 参会者列表锁定
-- REGISTRATION_CLOSED : 注册关闭
-- HCP_RECONCILED : HCP核查完成
-- TOV_CALCULATED : TOV计算完成
-- PROGRAM_CLOSED : 项目关闭
-- PROGRAM_REOPENED : 项目解锁6.2 自动记录示例
// 状态转换时自动记录
public void advancePhase(Integer meetingRequestId, MeetingStatus target) {
// ... 状态转换逻辑 ...
// 记录状态变更
changeLogService.log(MeetingChangeLog.builder()
.meetingRequestId(meetingRequestId)
.changeType("STATUS_CHANGE")
.fieldName("Meeting Status")
.oldValue(oldStatus.getName())
.newValue(target.getName())
.createdBy(currentUserId())
.build());
// 如果涉及 budget version 变更,额外记录
if (!oldBV.equals(newBV)) {
changeLogService.log(MeetingChangeLog.builder()
.meetingRequestId(meetingRequestId)
.changeType("BUDGET_PHASE_CHANGE")
.fieldName("Budget Version")
.oldValue(oldBV.name())
.newValue(newBV.name())
.changeDetail(budgetSnapshot) // JSON: 各 category 金额快照
.createdBy(currentUserId())
.build());
}
// 如果进入 REGISTRATION_CLOSED,记录注册关闭
if (target == REGISTRATION_CLOSED) {
changeLogService.log(MeetingChangeLog.builder()
.meetingRequestId(meetingRequestId)
.changeType("REGISTRATION_CLOSED")
.fieldName("Registration")
.oldValue("Open")
.newValue("Closed")
.changeDetail(attendeeCountSnapshot) // JSON: 各状态参会者数量
.createdBy(currentUserId())
.build());
}
// 如果进入 RECONCILED,记录参会者冻结
if (target == RECONCILED) {
changeLogService.log(MeetingChangeLog.builder()
.meetingRequestId(meetingRequestId)
.changeType("ATTENDEE_LOCKED")
.fieldName("Attendee List")
.oldValue("Editable")
.newValue("Locked")
.changeDetail(reconciliationSummary) // JSON: 核查统计
.createdBy(currentUserId())
.build());
}
}七、与前一版设计的对比
| 维度 | 前一版设计(docs/meeting-status-unification-design.md) | 本版设计 |
|---|---|---|
| Reg Site Status | 保持独立,不合并 | 合并。用 Meeting Status 阶段替代 |
| Attendee List Status | 保持为独立 flag | 合并。用 Meeting Status 阶段替代 |
| Budget Version | 合并,派生 | 合并,派生(不变) |
| 状态数量 | 12 个 | 14 个(多了 REGISTRATION_OPEN, REGISTRATION_CLOSED, EVENT_COMPLETE, RECONCILED) |
| 锁定粒度 | 仅区分"关闭"和"未关闭" | 渐进式锁定,6 级权限收窄 |
| 后端执行 | 未详细设计 | MeetingPhaseGuard 统一拦截 |
| 自动化 | 未涉及 | Cron Job 自动推进状态 |
为什么改变了对 Reg Site Status 的判断?
前一版从技术角度分析,认为 Reg Site Status 有"独立生命周期"。但从业务角度重新审视后发现:
- 注册站在项目还是 DRAFT 时就能被激活 → 这不是"灵活性",这是缺乏控制
- Planner 需要手动管理注册站的开关 → 这不是"独立性",这是自动化缺失
- 同一个 Meeting Status 下 Reg Site 可以处于任何状态 → 这不是"正交性",这是状态不一致
真正的业务需求是:注册站的状态应该跟随项目的生命周期阶段,而不是由 Planner 独立控制。
八、迁移策略
Phase 1:后端加固(不改状态体系,先堵漏洞)
优先级最高,风险最低。 在现有状态体系上增加 MeetingPhaseGuard:
1. 创建 MeetingPhaseGuard 类
2. 在 AttendeeService 的所有写操作中加入权限检查
3. 在 BudgetItemService 的所有写操作中加入权限检查
4. 在 ExpenseService 的所有写操作中加入权限检查
5. 使用现有的 isMeetingClosed() 判断,先覆盖最基本的"关闭后锁定"Phase 2:注册截止自动化(小范围改动)
1. 添加 Cron Job:检查 registration_deadline,自动将 reg_site_status 改为 Completed
2. 在 saveRegistration() 中增加后端校验:检查 reg_site_status 和 registration_deadline
3. 这一步不改变状态体系,只是让现有字段真正生效Phase 3:状态体系重构(大改动)
1. 引入新的 MeetingStatus 枚举(14 个状态)
2. 迁移数据:现有状态 → 新状态映射
3. 删除 budget_version_id, budget_status, reg_site_status, attendee_list_status
4. 前端适配新状态值
5. 全面测试数据迁移映射
-- 现有状态 → 新状态映射
-- 需要结合 reg_site_status 和 attendee_list_status 来判断目标状态
UPDATE t_meeting_request mr SET meeting_status =
CASE
-- 终止态直接映射
WHEN mr.meeting_status = 10 THEN 12 -- VOID → VOID
WHEN mr.meeting_status = 13 THEN 3 -- DENIED → DENIED
WHEN mr.meeting_status = 12 THEN 2 -- PENDING_APPROVAL → PENDING_APPROVAL
WHEN mr.meeting_status = 11 THEN 1 -- WAITLISTED → WAITLISTED
-- 关闭态
WHEN mr.meeting_status IN (4, 3, 8) THEN 9 -- CLOSED/CANCELLED_CLOSED/POSTPONED_CLOSED → CLOSED
WHEN mr.meeting_status = 2 THEN 10 -- CANCELLED → CANCELLED
WHEN mr.meeting_status = 7 THEN 11 -- POSTPONED → POSTPONED
WHEN mr.meeting_status = 14 THEN 13 -- REOPENED → REOPENED
-- 活跃态需要根据子状态判断
WHEN mr.meeting_status IN (0, 5, 9) THEN 0 -- Assigned/Estimate/Registered → DRAFT
WHEN mr.meeting_status = 6 THEN
CASE
WHEN m.reg_site_status = 1 THEN 5 -- Active → REGISTRATION_OPEN
WHEN m.reg_site_status = 3 AND m.attendee_list_status = 0 THEN 7 -- Completed + List Closed → EVENT_COMPLETE
WHEN m.reg_site_status = 3 THEN 6 -- Completed → REGISTRATION_CLOSED
ELSE 4 -- Draft/Suspend → PLANNING
END
WHEN mr.meeting_status = 1 THEN 6 -- Billing → REGISTRATION_CLOSED (或 EVENT_COMPLETE)
END
FROM ooto.t_meeting m
WHERE m.meeting_request_id = mr.meeting_request_id;九、总结
从业务角度重新审视后,Meeting Status 应该是项目生命周期的唯一驱动力,它天然地包含了:
- 财务阶段(Budget Version)— 已在前一版确认可合并
- 注册阶段(Registration Site Status)— 本版确认应该合并,因为注册开放/关闭是生命周期的自然阶段
- 参会者锁定(Attendee List Status)— 本版确认应该合并,因为"冻结参会者信息"是结算阶段的自然特征
- 渐进式权限— 这是前版完全缺失的,本版通过
MeetingPhaseGuard+ 权限矩阵实现
最关键的改进不是状态合并本身,而是引入后端强制执行的 MeetingPhaseGuard —— 即使不做状态体系重构,仅这一项改动就能解决当前最严重的合规性漏洞。