Speaker Platform 架构分析与 Budget Allocation 改进方案
〇、核心概念澄清:Product 与 Brand 的业务语义
本节是整份文档的理解基础。 后续所有改进方案都建立在正确理解 Product/Brand 业务语义之上。
医药行业中 Brand 的真实含义
在美国医药行业中,Product 和 Brand 是同义词,都指一个上市药品的商品名:
"Our product Keytruda..." = "Our brand Keytruda..."
在 Veeva CRM(医药行业标准 CRM)中,
Product就是药品品牌。
| 术语 | 含义 | 举例 |
|---|---|---|
| Company | 药企公司 | Novartis, Pfizer, Merck, Noven |
| Business Unit (BU) | 业务单元 / 治疗领域 | Oncology BU, Cardiology BU |
| Brand / Product | 上市药品商品名(行业同义词) | Keytruda®, Humira®, Entresto® |
| Indication | 该药品的 FDA 批准适应症 | Keytruda for NSCLC, Keytruda for Melanoma |
一个大型药企的真实品牌结构:
Merck (Company)
├── Oncology BU
│ ├── Keytruda® (Brand/Product) ── 17 个适应症
│ │ ├── NSCLC (Indication) ── 有专门的 speaker topic 和 presentation
│ │ ├── Melanoma (Indication)
│ │ └── HNSCC (Indication)
│ └── Welireg® (Brand/Product) ── RCC
├── Vaccines BU
│ ├── Gardasil® (Brand/Product) ── HPV
│ └── Vaxneuvance® (Brand/Product) ── 肺炎球菌
└── Cardiovascular BU
└── Verquvo® (Brand/Product) ── 心衰Brand 是 Speaker Program 商业运营的核心组织单元
| 维度 | Brand 如何影响 |
|---|---|
| Sales Force | 每个 Brand 通常有独立的 Sales Force(或几个 Brand 共享一支) |
| Budget | 预算按 Brand 独立编列和追踪 |
| Speaker Bureau | Speaker 按 Brand 签合同——张医生是 Keytruda 的 speaker 不代表也是 Welireg 的 |
| Compliance | 合规规则可以 Brand 级别定制(如 meal cap、frequency limit) |
| Honoraria | Speaker 费率按 Brand 不同(新药 launch 更高,成熟品种较低) |
| Program Type | 同一个 Brand 可能有多种 program 形式(dinner, webinar 等) |
| Indication | 一个 Brand 可能有多个 FDA 批准适应症,每个适应症有不同的 topic/presentation |
Brand 的关键生命周期阶段:
| 阶段 | 含义 | Speaker Program 特点 |
|---|---|---|
| Pre-launch | FDA 审批前 | 只能做疾病教育(Disease Awareness),不能品牌推广 |
| Launch | 刚上市 | 预算最高,speaker program 最密集 |
| Growth | 市场渗透期 | 预算持续投入 |
| Mature | 市场稳定 | 预算逐步缩减 |
| LOE | 专利到期 / 仿制药进入 | 大幅削减或终止 speaker program |
当前系统的概念错位
当前数据库中的实际数据:
t_product: Noven (product_id=51, company=Noven) ← Company name
t_brand: Noven (brand_id=1, product_id=51) ← Company name again
t_sales_team: Noven (sales_team_id=1, product_id=51) ← Company name againProduct、Brand、SalesTeam 三者名字都是 "Noven"(公司名)——它们实际指向同一个东西。
当前系统的 t_product 不是医药行业中的 Product/Brand,它实际上是 Tenant(租户/客户实例):
| 系统中的概念 | 实际扮演的角色 | 医药行业正确术语 |
|---|---|---|
t_product (product_id=51, "Noven") | 客户部署实例 | Tenant / Client Instance |
t_brand (brand_id=1, "Noven") | 未被正确使用,形同虚设 | Brand / Product(药品) |
t_sales_team (sales_team_id=1, "Noven") | 销售组织 | Sales Force |
证据链:
t_product包含company、company_id、salesforce_username、salesforce_security_token—— 租户级配置t_product包含product_config(JSON)、logo_path—— 客户白标配置- 系统架构文档描述 "Each client runs on an independent application instance server" —— 单租户部署
brandBudgetAllocationEnabled作为配置开关存在 —— 说明 Brand 是可选功能而非核心概念- 当前
t_brand仅 1 条记录且名字与公司名相同 —— 未按真实业务语义使用
根本原因:系统最初的客户(如 Noven)是小型专科药企,只有 1 个品牌,所以 Company ≈ Product ≈ Brand。但这种模型无法扩展到大型药企。
新设计中 Product 与 Brand 的正确关系
┌─────────────────────────────────────────────────────────┐
│ Product (Tenant / Client Instance) │
│ 当前 t_product 的角色不变,明确其「租户」语义 │
│ 例: "Merck Speaker Platform Instance" │
│ 存储: 部署配置、SSO、Salesforce 集成、白标 logo │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Brand (type=BU) — 业务单元 [可选层级] │ │
│ │ 例: Oncology, Vaccines, Cardiovascular │ │
│ │ 作用: 组织分组、权限隔离、预算汇总 │ │
│ └───────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────▼───────────────────────────┐ │
│ │ Brand (type=BRAND) — 药品品牌 [核心层级] │ │
│ │ 例: Keytruda®, Gardasil® │ │
│ │ 作用: 预算、Sales Force、合同、合规的核心 │ │
│ │ 组织单元 │ │
│ └───────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────▼───────────────────────────┐ │
│ │ Brand (type=INDICATION) — 适应症 [可选] │ │
│ │ 例: NSCLC, Melanoma, HNSCC │ │
│ │ 作用: Topic、Presentation 分类 │ │
│ └───────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘Brand 成为核心业务枢纽,与其他实体的关系:
Brand (核心枢纽)
│
├──→ Sales Force (Org Node): 一个 Brand 有一支或多支 Sales Force
│ 例: Keytruda 有 Keytruda Field Team + Keytruda Virtual Team
│
├──→ Budget Pool: 预算按 Brand 独立编列
│ 例: Keytruda FY2026 = $5,000,000
│
├──→ Speaker Contract: Speaker 按 Brand 签合同
│ 例: Dr. Smith — Keytruda Speaker, $3,000/program
│
├──→ Program Type: Brand 可用哪些 program 形式 (N:N)
│ 例: Keytruda 支持 Dinner, Webinar, Lunch-and-Learn
│
├──→ Topic / Presentation: 按 Brand + Indication 组织
│ 例: Keytruda NSCLC Efficacy Talk
│
└──→ Compliance Rule: 合规规则可 Brand 级别定制
例: Keytruda 每位 HCP 每年最多参加 3 次 program预算分配的正确流向(Brand 为第一维度):
Total Budget (Tenant Level, FY2026)
│
├── Keytruda Brand Budget: $5,000,000
│ ├── East Region: $2,000,000
│ │ ├── District 1: $500,000
│ │ ├── District 2: $500,000
│ │ └── ...
│ ├── West Region: $2,000,000
│ └── National Reserve: $1,000,000
│
├── Welireg Brand Budget: $1,000,000
│ └── ...
│
└── Gardasil Brand Budget: $2,000,000
└── ...对于小型客户(如 Noven)的兼容:只有 1 个 Brand 时,数据和行为与当前完全一致,只是概念更清晰。
一、当前架构现状分析
1. Geography(地理层级)
当前设计:
SalesTeam(SalesForce) → Region → District → Territory| 优点 | 问题 |
|---|---|
| 4 层固定层级结构清晰 | 层级数量硬编码,无法适应不同客户组织结构 |
| 支持自定义 label(regionLabel 等) | Territory 是最低层级,但 Budget 只到 District,Territory 被架空 |
| 支持 Planner 级联继承 | 不支持矩阵组织(一个 Rep 属于多个层级) |
| Zone 计算服务(州际距离) | Zone 计算仅硬编码美国 50 州,无法国际化 |
| 支持 Region/District reassignment | 没有有效日期(effective date),无法支持年度 Realignment |
关键发现:
t_region同时存储sales_team_id和sales_force_id两个冗余字段t_territory有region_id和district_id两个父级引用,违反单一路径原则- 地理层级是「组织层级」而非「物理地理」,但两者在代码中混为一谈
2. Sales Force / Team(销售组织)
当前设计:
t_sales_team (SalesForce) — 地理组织维度
t_team (Team) — 功能组织维度,关联 Brand 和 ProgramType| 优点 | 问题 |
|---|---|
| 区分了地理组织和功能团队 | 命名严重混乱:t_sales_team 实际是 "Sales Force",不是 "Team" |
| Team 可以关联 Brand 和 ProgramType | t_team 冗余存储 sales_team_id + sales_force_id(相同含义) |
| 支持 Manager 映射(RM/DM) | SalesForce 和 Team 的关系不明确——Team 挂在 SalesForce 下但又独立管理 |
| UserProduct 关联用户到地理位置 | 没有组织变更版本控制,Realignment 会造成历史数据错乱 |
关键发现:
v_sales_force视图将sales_team_id映射为sales_force_id,说明系统内部已经意识到命名问题- Team 与 Brand 的关联 (
t_team_brand) 和 Team 与 ProgramType 的关联 (t_team_program_type) 是独立的多对多关系,但在业务上 Brand 和 ProgramType 本身已经有关联关系(t_meeting_program_type.brand_id),造成冗余交叉
3. Brand(品牌)
重要:关于 Brand 在医药行业中的真实业务含义,以及当前系统 Product/Brand 概念错位的详细分析,见 第〇节:核心概念澄清。
当前设计:
t_brand: brand_id, brand_name, product_id, status (仅 4 列)| 优点 | 问题 |
|---|---|
| 简洁明了 | 过于扁平——没有 BU / Therapeutic Area / Indication 层级 |
| 与 Product 关联清晰 | 没有任何元数据(Launch date, lifecycle stage, BU ownership, generic name 等) |
| 支持 brand-level budget allocation | Brand 数据只有 1 条记录("Noven"),与公司名相同,未被按真实业务语义使用 |
ProgramType 通过 brand_id 硬绑定 Brand,1 个 ProgramType 只能属于 1 个 Brand | |
| Brand 未作为预算第一维度——当前预算先按 Geography 分,Brand 只是附加维度 |
关键发现:
- 当前数据库中
t_brand仅有 1 条记录(brand_name="Noven"=公司名),说明 Brand 功能形同虚设 brandBudgetAllocationEnabled配置标志控制是否启用 Brand 维度预算,说明 Brand 被设计为可选附加维度而非核心维度——这与行业实践相悖- 缺少 Brand Group / Therapeutic Area / Indication 的概念,对于大型药企无法有效组织
t_product(当前名为 "Noven")实际扮演的是 Tenant(租户) 角色,而非医药行业中的 "Product"(药品)
4. Program Type(项目类型)
当前设计:
t_meeting_program_type → t_meeting_program_service_type (1:N)| 优点 | 问题 |
|---|---|
| 支持多种 Program Type + Service Option 两层结构 | meeting_id 列含义不清——类型表不应引用具体 meeting |
| Approval 规则可按 ProgramType 配置(JSON) | year 列违反设计原则——类型不应年度化 |
| 支持 Field Exhibit 特殊类型 | user_id(planner)混入类型定义,属于交叉关注点 |
| 虚拟会议类型(Zoom/Webinar/ThirdParty)完整 | Approval 存为 JSONB,不可高效查询和报表 |
| 每种类型有独立预算 cap method | Service Type 上存了太多关注点(虚拟配置、空间配置等 JSON) |
| 合同可按 ProgramType 限制 | ProgramType 和 Brand 是 N:1,但业务上可能需要 N:N |
关键发现:
- 当前有 6 个 Program Type 和对应的 Service Type
- Approval 配置 (
approvalsJSONB) 虽然灵活但牺牲了可查询性 - Topic 映射 (
t_mapping_topic_type) 增加了 Topic → ServiceType → ProgramType 的额外维度,但未在预算维度中使用
5. Budget Allocation(预算分配)— 重点分析
当前分配模型:
维度: Geography(Region/District) × Brand × ProgramType × FiscalYear
流向: Admin → Region → District核心表关系:
t_budget_cap_locale (product_id, region_id, district_id, brand_id, fiscal_year)
└── t_budget_cap_locale_dtl (program_type_id, cap_method, init/add bgt/qty)
└── t_budget_cap_item (budget_type, budget_amount)
t_budget_allocation_history (source → target, amount, allocation_type)5 种 Cap Method:
| Method | 含义 | 灵活性 |
|---|---|---|
| 0 - SharedAmountPool | 共享 $ 池(不分 ProgramType) | 最宽松 |
| 1 - ProgramType $ + Shared Pool | 类型限额 + 共享池混合 | 中等 |
| 2 - ProgramType Qty | 仅限数量,不限金额 | 数量控制 |
| 3 - ProgramType Qty + Shared Pool | 数量 + 共享池混合 | 中等 |
| 4 - ProgramType $ Cap | 每类型独立金额上限 | 最严格 |
Budget 问题汇总(共 12 项)
| # | 问题 | 严重程度 | 说明 |
|---|---|---|---|
| 1 | 缺少 SalesForce/Team 维度 | HIGH | Budget 只按 Region/District 分配,无法按 SalesForce 或 Team 聚合查看。多个 SalesForce 共享相同 Region 时无法区分 |
| 2 | Territory 层级无预算 | HIGH | 预算止于 District,Territory 被架空。Sales Rep 的预算可见性只能通过 District 间接获取 |
| 3 | 无时间段粒度 | MEDIUM | 仅支持 Fiscal Year 粒度,不支持季度/月度预算分配和追踪 |
| 4 | locale_type 设计缺陷 | MEDIUM | 用 0=Region, 1=District 整数区分层级是 code smell,Region 和 District 的预算本质不同(Region 是池子,District 是分配) |
| 5 | 无预算审批工作流 | MEDIUM | 预算分配/修改无审批,仅靠角色权限控制 |
| 6 | Cap Method 可维护性差 | MEDIUM | 5 种整数枚举混合了「金额/数量」和「共享/独占」两个正交维度 |
| 7 | 无预算滚转机制 | MEDIUM | 跨 Fiscal Period 无 carryover/rollover 支持 |
| 8 | Budget Alert 仅 Region 级 | LOW | t_budget_alert 只关联 region_id,无法 District 级别告警 |
| 9 | 无预测/趋势分析 | LOW | 缺少 Forecast vs. Actual 趋势对比能力 |
| 10 | 跨 Brand 视图缺失 | LOW | 每个 t_budget_cap_locale 记录绑定单一 brand_id,无法高效聚合跨 Brand 总预算 |
| 11 | 分配记录不可逆 | LOW | 虽然有 History 但 budget_cap_item 上的 delete_status 是 text 类型,软删除策略不一致 |
| 12 | 无 Budget Pool 共享 | HIGH | 无法定义跨 Region 或跨 Brand 的共享预算池,比如 National-level budget reserve |
二、改进方案
设计原则
- 维度正交化:将预算分配的每个维度独立建模,通过组合实现灵活分配
- 层级泛化:用通用树结构替代硬编码层级
- 时间维度增强:支持多粒度时间周期
- 向后兼容:新设计可以通过视图层兼容现有 API
改进 1: 统一组织层级模型(Geography + SalesForce)
问题:当前 SalesForce、Region、District、Territory 是 4 张独立表,层级硬编码。
方案:引入通用的组织节点树(Org Node Tree),支持任意深度层级。
-- 通用组织节点(替代 t_sales_team, t_region, t_district, t_territory)
CREATE TABLE t_org_node (
node_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
parent_node_id INTEGER REFERENCES t_org_node(node_id),
node_type VARCHAR(50) NOT NULL, -- 'SALES_FORCE', 'REGION', 'DISTRICT', 'TERRITORY', 或自定义
node_code VARCHAR(100),
node_name VARCHAR(255) NOT NULL,
node_level INTEGER NOT NULL, -- 层级深度(0=root, 1, 2, 3...)
node_path TEXT NOT NULL, -- 物化路径 e.g. '/1/5/12/34'
status INTEGER NOT NULL DEFAULT 1,
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
effective_to DATE, -- NULL = 当前有效
metadata JSONB, -- 扩展字段
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 索引支持快速层级查询
CREATE INDEX idx_org_node_path ON t_org_node USING gist (node_path gist_trgm_ops);
CREATE INDEX idx_org_node_parent ON t_org_node (parent_node_id);
CREATE INDEX idx_org_node_type ON t_org_node (product_id, node_type, status);
CREATE INDEX idx_org_node_effective ON t_org_node (effective_from, effective_to);
-- 节点类型定义(可配置)
CREATE TABLE t_org_node_type (
type_code VARCHAR(50) PRIMARY KEY,
type_label VARCHAR(100) NOT NULL, -- 可自定义显示名("Region" → "Area")
level_order INTEGER NOT NULL, -- 层级排序
product_id INTEGER, -- NULL = 全局,非空 = 产品专属
budget_enabled BOOLEAN DEFAULT FALSE, -- 此层级是否参与预算分配
manager_enabled BOOLEAN DEFAULT FALSE -- 此层级是否支持 Manager 分配
);
-- 节点管理者映射(替代 t_region_manager, t_district_manager)
CREATE TABLE t_org_node_manager (
node_id INTEGER NOT NULL REFERENCES t_org_node(node_id),
user_id INTEGER NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'MANAGER', -- MANAGER, PLANNER, etc.
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
effective_to DATE,
PRIMARY KEY (node_id, user_id, role)
);优势:
- 客户 A 可以有 5 层(National → Area → Region → District → Territory)
- 客户 B 可以只有 3 层(Region → District → Territory)
effective_from/to支持年度 Realignment,保留历史数据node_path物化路径支持高效的层级查询(查所有子节点、所有祖先)budget_enabled控制哪些层级参与预算分配
改进 2: 重新定义 Team 模型
问题:SalesTeam 和 Team 概念混淆;Team 与 Brand/ProgramType 的多对多关系与 Brand→ProgramType 的直接关系冲突。
方案:Team 明确为「功能团队」,独立于组织层级。
-- 功能团队(保留独立概念)
CREATE TABLE t_team_v2 (
team_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
team_name VARCHAR(255) NOT NULL,
team_type VARCHAR(50), -- 'BRAND_TEAM', 'CROSS_FUNCTIONAL', 'LAUNCH_TEAM'
org_node_id INTEGER REFERENCES t_org_node(node_id), -- 可选挂载到某个组织节点
status INTEGER NOT NULL DEFAULT 1,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
-- Team 成员
CREATE TABLE t_team_member (
team_id INTEGER NOT NULL REFERENCES t_team_v2(team_id),
user_id INTEGER NOT NULL,
role VARCHAR(50) DEFAULT 'MEMBER',
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
effective_to DATE,
PRIMARY KEY (team_id, user_id)
);
-- Team → Brand 关联(保留)
-- Team → ProgramType 关联(保留)改进 3: 重新定位 Brand 为核心业务实体
背景:参见 第〇节 的详细分析。 当前
t_product实际是 Tenant(租户),t_brand才应是医药行业 "Product/Brand"(药品品牌)。
问题:
- Brand 模型过于扁平(仅 4 列),没有层级、没有元数据
- Brand 未作为预算第一维度——在医药行业,预算是先按 Brand 分,再按 Geography 分
- Brand 与 Sales Force 的从属关系未建模——每支 Sales Force 通常服务于特定 Brand
方案:Brand 成为三层层级结构(BU → Brand → Indication),保留 t_product 作为 Tenant 不变。
CREATE TABLE t_brand_v2 (
brand_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL, -- FK → t_product(Tenant 角色)
parent_brand_id INTEGER REFERENCES t_brand_v2(brand_id), -- 支持层级
-- 品牌层级类型
brand_type VARCHAR(30) NOT NULL, -- 'BU', 'BRAND', 'INDICATION'
brand_level INTEGER NOT NULL DEFAULT 0, -- 0=BU, 1=Brand, 2=Indication
-- 核心属性
brand_name VARCHAR(255) NOT NULL,
brand_code VARCHAR(50), -- 内部代码
generic_name VARCHAR(255), -- 药品通用名(如 pembrolizumab for Keytruda)
therapeutic_area VARCHAR(255), -- 治疗领域标签
-- 生命周期
lifecycle_stage VARCHAR(30), -- PRE_LAUNCH, LAUNCH, GROWTH, MATURE, LOE
launch_date DATE, -- 上市日期
loe_date DATE, -- 专利到期日期(Loss of Exclusivity)
-- 商业归属
bu_name VARCHAR(255), -- 所属 BU 名称(冗余,便于查询)
status INTEGER NOT NULL DEFAULT 1,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);示例层级——大型药企(Merck):
Oncology (type=BU)
├── Keytruda® (type=BRAND, generic_name=pembrolizumab, lifecycle=GROWTH)
│ ├── NSCLC (type=INDICATION)
│ ├── Melanoma (type=INDICATION)
│ └── HNSCC (type=INDICATION)
└── Welireg® (type=BRAND, generic_name=belzutifan, lifecycle=LAUNCH)
└── RCC (type=INDICATION)
Vaccines (type=BU)
└── Gardasil® (type=BRAND, generic_name=HPV vaccine, lifecycle=MATURE)
└── HPV (type=INDICATION)示例层级——小型药企(Noven,与当前兼容):
Noven Pharmaceuticals (type=BU, 可选)
└── Minivelle® (type=BRAND, generic_name=estradiol patch, lifecycle=MATURE)与其他实体的关系(Brand 为核心枢纽):
Brand (核心业务枢纽)
│
├──→ Sales Force (Org Node): 一支 Sales Force 服务于一个或几个 Brand
│ 例: Keytruda Field Team, Keytruda Virtual Team
│
├──→ Budget Pool: 预算按 Brand 独立编列(Brand 是预算第一维度)
│ 例: Keytruda FY2026 = $5,000,000
│
├──→ Speaker Contract: Speaker 按 Brand 签合同
│ 例: Dr. Smith — Keytruda Speaker, $3,000/program
│
├──→ Program Type: Brand 可用哪些 program 形式 (N:N)
│ 例: Keytruda 支持 Dinner, Webinar, Lunch-and-Learn
│
├──→ Topic / Presentation: 按 Brand + Indication 组织
│ 例: Keytruda NSCLC Efficacy Talk
│
└──→ Compliance Rule: 合规规则可 Brand 级别定制
例: Keytruda 每位 HCP 每年最多参加 3 次 program改进 4: 解耦 ProgramType 与 Brand
问题:t_meeting_program_type.brand_id 是 N:1 硬绑定,现实中一个 Program Type(如 "Dinner Program")可以服务多个 Brand。
方案:
-- ProgramType 表清理多余列
-- 移除: meeting_id, year, user_id (这些不属于类型定义)
-- brand_id 改为多对多
CREATE TABLE t_brand_program_type (
brand_id INTEGER NOT NULL,
program_type_id INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (brand_id, program_type_id)
);
-- Approval 规则独立表(替代 JSONB)
CREATE TABLE t_approval_rule (
rule_id SERIAL PRIMARY KEY,
program_type_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
approver_role VARCHAR(50) NOT NULL, -- 'DM', 'RM', 'UM', 'RBD', 'MARKETING', 'LEGAL'
condition_type VARCHAR(20) NOT NULL, -- 'ALWAYS', 'COST_THRESHOLD'
threshold_amount NUMERIC,
sequence INTEGER NOT NULL, -- 审批顺序
status INTEGER NOT NULL DEFAULT 1
);改进 5: 全新 Budget Allocation 模型(核心)
这是最重要的改进。新模型的核心思想是维度正交化 + Budget Pool 概念。
5.1 Budget Pool(预算池)
-- 预算池:所有预算分配的核心实体
CREATE TABLE t_budget_pool (
pool_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
pool_name VARCHAR(255) NOT NULL,
pool_type VARCHAR(50) NOT NULL, -- 'MASTER', 'REGIONAL', 'DISTRICT', 'RESERVE', 'SHARED'
fiscal_period_id INTEGER NOT NULL REFERENCES t_fiscal_period(period_id),
parent_pool_id INTEGER REFERENCES t_budget_pool(pool_id), -- 层级分配来源
-- 多维度标签(每个维度可选,NULL = 不限定该维度)
org_node_id INTEGER REFERENCES t_org_node(node_id), -- 地理/组织维度
brand_id INTEGER, -- 品牌维度
program_type_id INTEGER, -- 项目类型维度
team_id INTEGER, -- 团队维度
-- 预算金额
initial_amount NUMERIC NOT NULL DEFAULT 0,
additional_amount NUMERIC NOT NULL DEFAULT 0,
-- 控制策略
cap_type VARCHAR(30) NOT NULL DEFAULT 'AMOUNT', -- 'AMOUNT', 'QUANTITY', 'BOTH'
initial_quantity INTEGER,
additional_quantity INTEGER,
allow_overspend BOOLEAN DEFAULT FALSE, -- 是否允许超支
overspend_threshold NUMERIC DEFAULT 0, -- 超支容忍度 (%)
-- 元数据
status INTEGER NOT NULL DEFAULT 1,
created_by INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 复合索引支持多维查询
CREATE INDEX idx_budget_pool_dims ON t_budget_pool
(product_id, fiscal_period_id, org_node_id, brand_id, program_type_id);
CREATE INDEX idx_budget_pool_parent ON t_budget_pool (parent_pool_id);5.2 Fiscal Period(增强时间维度)
-- 支持多粒度时间周期
CREATE TABLE t_fiscal_period (
period_id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
period_type VARCHAR(20) NOT NULL, -- 'YEAR', 'HALF', 'QUARTER', 'MONTH'
period_name VARCHAR(100) NOT NULL,
parent_period_id INTEGER REFERENCES t_fiscal_period(period_id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status INTEGER NOT NULL DEFAULT 1
);
-- 示例数据:
-- FY2026 (YEAR) → H1 2026 (HALF) → Q1 2026 (QUARTER) → Jan 2026 (MONTH)5.3 Budget Transaction(预算交易记录)
-- 统一的预算交易记录(替代 t_budget_cap_item + t_budget_allocation_history)
CREATE TABLE t_budget_transaction (
transaction_id SERIAL PRIMARY KEY,
transaction_type VARCHAR(30) NOT NULL, -- 'ALLOCATE', 'UNALLOCATE', 'TRANSFER', 'ADJUST', 'CARRYOVER'
source_pool_id INTEGER REFERENCES t_budget_pool(pool_id),
target_pool_id INTEGER REFERENCES t_budget_pool(pool_id),
amount NUMERIC NOT NULL,
amount_type VARCHAR(20) NOT NULL, -- 'INITIAL', 'ADDITIONAL', 'QUANTITY'
-- 审批
status VARCHAR(20) NOT NULL DEFAULT 'APPROVED', -- 'PENDING', 'APPROVED', 'REJECTED'
approved_by INTEGER,
approved_at TIMESTAMP,
-- 审计
comment TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);5.4 Budget Consumption(预算消耗追踪)
-- 预算消耗记录(关联到会议)
CREATE TABLE t_budget_consumption (
consumption_id SERIAL PRIMARY KEY,
pool_id INTEGER NOT NULL REFERENCES t_budget_pool(pool_id),
meeting_request_id INTEGER NOT NULL,
budget_version_id INTEGER NOT NULL, -- SOW/EST/BILL/ACT/CXL
amount NUMERIC NOT NULL DEFAULT 0,
consumption_status VARCHAR(20) NOT NULL, -- 'ESTIMATED', 'COMMITTED', 'ACTUAL', 'CANCELLED'
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 支持快速查询某个 Pool 的已消耗金额
CREATE INDEX idx_budget_consumption_pool ON t_budget_consumption (pool_id, consumption_status);5.5 Budget Alert(增强告警)
CREATE TABLE t_budget_alert_v2 (
alert_id SERIAL PRIMARY KEY,
pool_id INTEGER NOT NULL REFERENCES t_budget_pool(pool_id),
alert_type VARCHAR(30) NOT NULL, -- 'THRESHOLD_WARNING', 'OVERSPEND', 'APPROACHING_LIMIT'
threshold_pct NUMERIC, -- 触发阈值百分比 (e.g., 80, 90, 100)
alert_message TEXT,
is_read BOOLEAN DEFAULT FALSE,
notified_users INTEGER[], -- 通知的用户列表
created_at TIMESTAMP DEFAULT NOW()
);5.6 新模型的分配流向示意
核心变化:预算分配的第一维度从 Geography 变为 Brand。 这符合医药行业实践——预算先按药品品牌编列,再按销售地理区域下拨。
完整分配流向(Brand-First):
┌──────────────────────────────────────────────────────────────────────┐
│ TENANT TOTAL BUDGET (Merck Speaker Platform, FY2026) │
│ $10,000,000 │
└───────────────────────────────┬──────────────────────────────────────┘
│
┌─────────────────────┼────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ BRAND MASTER │ │ BRAND MASTER │ │ BRAND MASTER │
│ Keytruda® │ │ Welireg® │ │ Gardasil® │
│ FY2026 │ │ FY2026 │ │ FY2026 │
│ $5,000,000 │ │ $1,000,000 │ │ $2,000,000 │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
│ (Brand 内按 Geography 下拨) │
│ │
┌─────┼──────────────┐ ┌─────────┼──────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌──────┐┌──────┐ ┌──────────┐ ┌──────┐ ┌──────┐ ┌──────────┐
│East ││West │ │ RESERVE │ │East │ │West │ │ RESERVE │
│Region││Region│ │ National │ │Region│ │Region│ │ National │
│$2.0M ││$2.0M │ │ $1.0M │ │$0.8M │ │$0.8M │ │ $0.4M │
└──┬───┘└──┬───┘ └──────────┘ └──┬───┘ └──┬───┘ └──────────┘
│ │ │ │
│ │ │ (同样的层级...)
┌──┼──┐ ┌──┼──┐
▼ ▼ ▼ ▼ ▼ ▼
D1 D2 D3 D4 D5 D6 ← District 级别(可选继续到 Territory)对比旧模型流向:
旧: Admin → Region(+brand_id) → District ← Geography 先行,Brand 是附加标签
新: Tenant → Brand → Region → District ← Brand 先行,Geography 在 Brand 内分配为什么 Brand 必须是第一维度?
- 预算审批:在药企中,Brand Team Lead / Brand Director 对该品牌的总预算负责。预算先确定每个 Brand 的总额,再由各 Brand 团队决定如何在地理区域间分配
- 组织架构:Sales Force 通常按 Brand 组建。Keytruda 的 East Region 和 Gardasil 的 East Region 可能是不同的人、不同的 Sales Force
- 合规报告:Sunshine Act / Open Payments 按 Brand(药品)报告转移价值,不按地理区域
- P&L 归属:每个 Brand 有独立的 P&L,speaker program 支出归入 Brand 的营销预算
可选: 在 District 内按 ProgramType 再细分 Pool可选: Reserve Pool 可以被任何 Region 申请使用(通过 TRANSFER transaction)
5.7 新模型对比旧模型
| 维度 | 旧模型 | 新模型 |
|---|---|---|
| 分配优先级 | Geography 先行,Brand 是附加标签 | Brand 先行,Geography 在 Brand 内分配(符合行业实践) |
| 品牌维度 | brand_id 在 locale 上,可选(brandBudgetAllocationEnabled) | Brand 是预算第一维度,BRAND MASTER Pool 是起点 |
| 地理维度 | 仅 Region/District,2 层硬编码 | 任意 OrgNode 层级,通过 budget_enabled 控制 |
| 项目类型 | 通过 t_budget_cap_locale_dtl 子表 | Pool 上直接标记 program_type_id,更扁平 |
| 团队维度 | 不支持 | team_id 可选维度 |
| 时间维度 | 仅 Fiscal Year | 支持 Year/Half/Quarter/Month 层级 |
| 预算池 | 无共享池概念 | RESERVE / SHARED 类型池 |
| Cap Method | 5 种整数枚举混合 | cap_type + allow_overspend 正交组合 |
| 审批 | 无 | Transaction 级别审批状态 |
| 滚转 | 无 | CARRYOVER transaction type |
| 告警 | 仅 Region 级 | 任意 Pool 级别,多种告警类型 |
| 审计 | allocation_history + cap_item 分离 | 统一 Transaction 记录 |
| 扩展性 | 增加维度需改表结构 | 新维度加 Pool 列或用 metadata JSONB |
改进 6: 向后兼容策略
新模型不需要一次性替换旧模型。建议通过兼容视图平滑迁移:
-- 兼容旧 API 的 Region Budget 视图
CREATE VIEW v_legacy_region_budget AS
SELECT
bp.pool_id AS budget_locale_id,
bp.product_id,
on2.node_id AS region_id,
bp.brand_id,
bp.fiscal_period_id AS fiscal_year,
0 AS locale_type, -- Region
bp.initial_amount AS init_bgt,
bp.additional_amount AS add_bgt,
bp.created_at AS enter_date,
bp.created_by AS user_id
FROM t_budget_pool bp
JOIN t_org_node on2 ON on2.node_id = bp.org_node_id
WHERE bp.pool_type = 'REGIONAL'
AND on2.node_type = 'REGION';
-- 兼容旧 API 的 District Budget 视图
CREATE VIEW v_legacy_district_budget AS
SELECT
bp.pool_id AS budget_locale_id,
bp.product_id,
on_parent.node_id AS region_id,
on2.node_id AS district_id,
bp.brand_id,
bp.fiscal_period_id AS fiscal_year,
1 AS locale_type, -- District
bp.initial_amount AS init_bgt,
bp.additional_amount AS add_bgt
FROM t_budget_pool bp
JOIN t_org_node on2 ON on2.node_id = bp.org_node_id
JOIN t_org_node on_parent ON on_parent.node_id = on2.parent_node_id
WHERE bp.pool_type = 'DISTRICT'
AND on2.node_type = 'DISTRICT';三、实施优先级建议
| 优先级 | 改进项 | 影响范围 | 理由 |
|---|---|---|---|
| P0 | Brand 重新定位为核心业务实体 + 层级增强 | Brand + Budget + Speaker + 全局 | 最关键——Brand 是预算第一维度,是 Speaker Program 的核心组织单元。不做这一步,后续所有改进缺乏正确的业务基础 |
| P0 | Budget Pool 模型(Brand-First) + Transaction 统一 | Budget 模块全部 | 解决 12 个问题中的核心 7 个(#1,2,3,6,7,10,12),且必须基于新的 Brand 模型 |
| P0 | Fiscal Period 增强(多粒度) | Budget + Report | 解决 #3,为季度/月度报告打基础 |
| P1 | OrgNode 通用树 | Geography + User + Budget | 解决层级硬编码,支持 Realignment |
| P2 | ProgramType-Brand 解耦(N:N) | ProgramType + Budget | 更灵活的类型-品牌组合 |
| P2 | Budget 审批工作流 | Budget | 解决 #5 |
| P2 | Approval 规则表化 | ProgramType + Meeting | 替代 JSONB,支持报表查询 |
| P3 | Team 模型清理 | Team + User | 解决命名混乱 |
| P3 | Budget Alert 增强 | Alert + Notification | 解决 #8 |
四、总结
当前系统的核心问题可以归纳为 "概念错位、维度耦合、层级固化、时间单一":
- 概念错位:系统中的
Product实际是 Tenant(租户),Brand未按医药行业真实业务语义使用(当前仅 1 条记录且名字等于公司名)。在医药行业中,Brand(药品品牌)是 Speaker Program 商业运营的核心组织单元,是预算编列、Sales Force 组建、Speaker 签约、合规管理的第一维度 - 维度耦合:Budget 的地理、品牌、类型维度通过多表嵌套(locale → locale_dtl → cap_item)实现,新增维度极其困难。更关键的是,当前预算以 Geography 为第一维度,Brand 仅是可选附加标签——这与行业实践(Brand-First)相悖
- 层级固化:4 层固定组织层级无法适应不同客户的销售组织结构
- 时间单一:仅 Fiscal Year 粒度,不支持季度/月度周期管理
新方案的核心改进:
- Brand 重新定位:从可选附加维度提升为核心业务枢纽,支持 BU → Brand → Indication 三层层级
- Budget Pool(Brand-First):预算分配流向从
Admin → Region → District变为Tenant → Brand → Region → District,符合药企实际预算编列流程 - 通用组织树(OrgNode):替代 4 张硬编码层级表,支持任意深度,支持 Realignment
- 增强时间周期(Fiscal Period):支持 Year/Half/Quarter/Month 多粒度
这套设计可以满足从小型专科药企(如 Noven,仅 1 个 Brand)到大型跨国药企(如 Merck,数十个 Brand 跨多个 BU)的各种预算管理需求,同时通过兼容视图实现平滑迁移。