Skip to content

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 BureauSpeaker 按 Brand 签合同——张医生是 Keytruda 的 speaker 不代表也是 Welireg 的
Compliance合规规则可以 Brand 级别定制(如 meal cap、frequency limit)
HonorariaSpeaker 费率按 Brand 不同(新药 launch 更高,成熟品种较低)
Program Type同一个 Brand 可能有多种 program 形式(dinner, webinar 等)
Indication一个 Brand 可能有多个 FDA 批准适应症,每个适应症有不同的 topic/presentation

Brand 的关键生命周期阶段:

阶段含义Speaker Program 特点
Pre-launchFDA 审批前只能做疾病教育(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 again

Product、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

证据链:

  1. t_product 包含 companycompany_idsalesforce_usernamesalesforce_security_token —— 租户级配置
  2. t_product 包含 product_config (JSON)、logo_path —— 客户白标配置
  3. 系统架构文档描述 "Each client runs on an independent application instance server" —— 单租户部署
  4. brandBudgetAllocationEnabled 作为配置开关存在 —— 说明 Brand 是可选功能而非核心概念
  5. 当前 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_idsales_force_id 两个冗余字段
  • t_territoryregion_iddistrict_id 两个父级引用,违反单一路径原则
  • 地理层级是「组织层级」而非「物理地理」,但两者在代码中混为一谈

2. Sales Force / Team(销售组织)

当前设计:

t_sales_team (SalesForce) — 地理组织维度
t_team (Team) — 功能组织维度,关联 Brand 和 ProgramType
优点问题
区分了地理组织和功能团队命名严重混乱t_sales_team 实际是 "Sales Force",不是 "Team"
Team 可以关联 Brand 和 ProgramTypet_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 allocationBrand 数据只有 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 methodService Type 上存了太多关注点(虚拟配置、空间配置等 JSON)
合同可按 ProgramType 限制ProgramType 和 Brand 是 N:1,但业务上可能需要 N:N

关键发现:

  • 当前有 6 个 Program Type 和对应的 Service Type
  • Approval 配置 (approvals JSONB) 虽然灵活但牺牲了可查询性
  • 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 维度HIGHBudget 只按 Region/District 分配,无法按 SalesForce 或 Team 聚合查看。多个 SalesForce 共享相同 Region 时无法区分
2Territory 层级无预算HIGH预算止于 District,Territory 被架空。Sales Rep 的预算可见性只能通过 District 间接获取
3无时间段粒度MEDIUM仅支持 Fiscal Year 粒度,不支持季度/月度预算分配和追踪
4locale_type 设计缺陷MEDIUM0=Region, 1=District 整数区分层级是 code smell,Region 和 District 的预算本质不同(Region 是池子,District 是分配)
5无预算审批工作流MEDIUM预算分配/修改无审批,仅靠角色权限控制
6Cap Method 可维护性差MEDIUM5 种整数枚举混合了「金额/数量」和「共享/独占」两个正交维度
7无预算滚转机制MEDIUM跨 Fiscal Period 无 carryover/rollover 支持
8Budget Alert 仅 Region 级LOWt_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

二、改进方案

设计原则

  1. 维度正交化:将预算分配的每个维度独立建模,通过组合实现灵活分配
  2. 层级泛化:用通用树结构替代硬编码层级
  3. 时间维度增强:支持多粒度时间周期
  4. 向后兼容:新设计可以通过视图层兼容现有 API

改进 1: 统一组织层级模型(Geography + SalesForce)

问题:当前 SalesForce、Region、District、Territory 是 4 张独立表,层级硬编码。

方案:引入通用的组织节点树(Org Node Tree),支持任意深度层级。

sql
-- 通用组织节点(替代 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 明确为「功能团队」,独立于组织层级。

sql
-- 功能团队(保留独立概念)
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"(药品品牌)。

问题

  1. Brand 模型过于扁平(仅 4 列),没有层级、没有元数据
  2. Brand 未作为预算第一维度——在医药行业,预算是先按 Brand 分,再按 Geography 分
  3. Brand 与 Sales Force 的从属关系未建模——每支 Sales Force 通常服务于特定 Brand

方案:Brand 成为三层层级结构(BU → Brand → Indication),保留 t_product 作为 Tenant 不变。

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

方案

sql
-- 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(预算池)

sql
-- 预算池:所有预算分配的核心实体
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(增强时间维度)

sql
-- 支持多粒度时间周期
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(预算交易记录)

sql
-- 统一的预算交易记录(替代 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(预算消耗追踪)

sql
-- 预算消耗记录(关联到会议)
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(增强告警)

sql
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 必须是第一维度?

  1. 预算审批:在药企中,Brand Team Lead / Brand Director 对该品牌的总预算负责。预算先确定每个 Brand 的总额,再由各 Brand 团队决定如何在地理区域间分配
  2. 组织架构:Sales Force 通常按 Brand 组建。Keytruda 的 East Region 和 Gardasil 的 East Region 可能是不同的人、不同的 Sales Force
  3. 合规报告:Sunshine Act / Open Payments 按 Brand(药品)报告转移价值,不按地理区域
  4. 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 上,可选(brandBudgetAllocationEnabledBrand 是预算第一维度,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 Method5 种整数枚举混合cap_type + allow_overspend 正交组合
审批Transaction 级别审批状态
滚转CARRYOVER transaction type
告警仅 Region 级任意 Pool 级别,多种告警类型
审计allocation_history + cap_item 分离统一 Transaction 记录
扩展性增加维度需改表结构新维度加 Pool 列或用 metadata JSONB

改进 6: 向后兼容策略

新模型不需要一次性替换旧模型。建议通过兼容视图平滑迁移:

sql
-- 兼容旧 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';

三、实施优先级建议

优先级改进项影响范围理由
P0Brand 重新定位为核心业务实体 + 层级增强Brand + Budget + Speaker + 全局最关键——Brand 是预算第一维度,是 Speaker Program 的核心组织单元。不做这一步,后续所有改进缺乏正确的业务基础
P0Budget Pool 模型(Brand-First) + Transaction 统一Budget 模块全部解决 12 个问题中的核心 7 个(#1,2,3,6,7,10,12),且必须基于新的 Brand 模型
P0Fiscal Period 增强(多粒度)Budget + Report解决 #3,为季度/月度报告打基础
P1OrgNode 通用树Geography + User + Budget解决层级硬编码,支持 Realignment
P2ProgramType-Brand 解耦(N:N)ProgramType + Budget更灵活的类型-品牌组合
P2Budget 审批工作流Budget解决 #5
P2Approval 规则表化ProgramType + Meeting替代 JSONB,支持报表查询
P3Team 模型清理Team + User解决命名混乱
P3Budget Alert 增强Alert + Notification解决 #8

四、总结

当前系统的核心问题可以归纳为 "概念错位、维度耦合、层级固化、时间单一"

  1. 概念错位:系统中的 Product 实际是 Tenant(租户),Brand 未按医药行业真实业务语义使用(当前仅 1 条记录且名字等于公司名)。在医药行业中,Brand(药品品牌)是 Speaker Program 商业运营的核心组织单元,是预算编列、Sales Force 组建、Speaker 签约、合规管理的第一维度
  2. 维度耦合:Budget 的地理、品牌、类型维度通过多表嵌套(locale → locale_dtl → cap_item)实现,新增维度极其困难。更关键的是,当前预算以 Geography 为第一维度,Brand 仅是可选附加标签——这与行业实践(Brand-First)相悖
  3. 层级固化:4 层固定组织层级无法适应不同客户的销售组织结构
  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)的各种预算管理需求,同时通过兼容视图实现平滑迁移。