# PlanitAI KPI - 데이터 모델 설계 **시리즈**: PlanitAI KPI 개발기 v5/16 **작성일**: 2025-12-07 **작성자**: GemEgg Dev Team --- ## 1. 데이터 모델 개요 ### 1.1 핵심 엔티티 ``` ┌─────────────────────────────────────────────────────────────┐ │ Core Entity Relationships │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ Organization │ │ │ │ (조직) │ │ │ └──────┬───────┘ │ │ ┌─────────────┼─────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ User │ │ KPITree │ │ Report │ │ │ │ (사용자) │ │ (KPI트리) │ │ (리포트) │ │ │ └──────────┘ └────┬─────┘ └──────────┘ │ │ │ │ │ ┌───────────┼───────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ KPINode │ │ KPIData │ │KPIRelation│ │ │ │ (KPI노드) │ │ (데이터) │ │ (관계) │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### 1.2 설계 원칙 1. **유연성**: 다양한 KPI 구조 지원 2. **이력 관리**: 모든 변경 추적 3. **계산 효율**: 트리 순회 최적화 4. **확장성**: 새로운 KPI 타입 추가 용이 --- ## 2. 핵심 엔티티 상세 ### 2.1 Organization (조직) ```python from dataclasses import dataclass from datetime import datetime from typing import Optional from enum import Enum class FiscalYearStart(Enum): JANUARY = 1 APRIL = 4 JULY = 7 OCTOBER = 10 @dataclass class Organization: """조직 엔티티""" id: str # org_001 name: str # 株式会社GemEgg slug: str # gemegg # 회계 설정 fiscal_year_start: FiscalYearStart # 회계연도 시작월 currency: str = "JPY" # 통화 timezone: str = "Asia/Tokyo" # 시간대 # 구독 정보 plan: str = "free" # free, starter, growth, scale # 메타데이터 created_at: datetime updated_at: datetime is_active: bool = True ``` ### 2.2 User (사용자) ```python class UserRole(Enum): OWNER = "owner" # 오너 (전체 권한) ADMIN = "admin" # 관리자 (설정 변경) EDITOR = "editor" # 편집자 (KPI 편집) VIEWER = "viewer" # 뷰어 (조회만) @dataclass class User: """사용자 엔티티""" id: str # user_001 email: str name: str # 권한 organization_id: str role: UserRole # 인증 auth_provider: str # google, email auth_provider_id: str # 설정 preferences: dict # UI 설정 등 # 메타데이터 created_at: datetime last_login_at: Optional[datetime] is_active: bool = True ``` ### 2.3 KPITree (KPI 트리) ```python @dataclass class KPITree: """KPI 트리 엔티티 - 전체 KPI 구조를 담는 컨테이너""" id: str # tree_001 organization_id: str # 기본 정보 name: str # "2025年度 営業KPI" description: Optional[str] # 트리 설정 root_node_id: str # 루트 KGI 노드 ID fiscal_year: str # "2025" # 상태 status: str = "active" # active, archived version: int = 1 # 버전 관리 # 메타데이터 created_at: datetime updated_at: datetime created_by: str # user_id ``` ### 2.4 KPINode (KPI 노드) ```python class KPICategory(Enum): """KPI 카테고리""" FINANCE = "finance" # 재무 SALES = "sales" # 영업 MARKETING = "marketing" # 마케팅 HR = "hr" # 인력 PRODUCT = "product" # 제품 CUSTOMER = "customer" # 고객 OPERATION = "operation" # 운영 CUSTOM = "custom" # 사용자 정의 class KPIUnit(Enum): """KPI 단위""" CURRENCY = "currency" # 통화 (円, $) PERCENTAGE = "percentage" # 퍼센트 (%) COUNT = "count" # 개수 (件, 人) RATIO = "ratio" # 비율 (x.xx) DAYS = "days" # 일수 HOURS = "hours" # 시간 CUSTOM = "custom" # 사용자 정의 class KPINodeType(Enum): """KPI 노드 타입""" KGI = "kgi" # 최종 목표 (매출 등) KPI = "kpi" # 핵심 지표 METRIC = "metric" # 일반 지표 INPUT = "input" # 입력값 (리프 노드) @dataclass class KPINode: """KPI 노드 엔티티""" id: str # kpi_001 tree_id: str # 소속 트리 # 기본 정보 name: str # "売上高" name_en: Optional[str] # "Revenue" description: Optional[str] # 분류 node_type: KPINodeType # KGI, KPI, METRIC, INPUT category: KPICategory # 카테고리 # 단위 unit: KPIUnit unit_label: str # "円", "%", "件" decimal_places: int = 0 # 소수점 자릿수 # 계산식 (리프 노드는 None) formula: Optional[str] # "contract_count * contract_price" formula_type: Optional[str] # "multiply", "add", "divide", "subtract" # 트리 구조 parent_id: Optional[str] # 부모 노드 ID children_ids: list[str] # 자식 노드 IDs level: int # 트리 깊이 (0 = root) order: int # 같은 레벨에서 순서 # 목표 설정 target_direction: str = "higher_is_better" # higher_is_better, lower_is_better # 담당 owner_user_id: Optional[str] # 담당자 department: Optional[str] # 담당 부서 # 메타데이터 created_at: datetime updated_at: datetime is_active: bool = True ``` ### 2.5 KPIData (KPI 데이터) ```python class DataType(Enum): """데이터 타입""" PLAN = "plan" # 계획 ACTUAL = "actual" # 실적 FORECAST = "forecast" # 예측 BUDGET = "budget" # 예산 @dataclass class KPIData: """KPI 데이터 엔티티 - 시계열 데이터""" id: str # data_001 node_id: str # KPI 노드 ID # 기간 period_type: str # "monthly", "quarterly", "yearly" period_start: datetime # 기간 시작일 period_end: datetime # 기간 종료일 fiscal_year: str # "2025" fiscal_month: int # 1-12 # 데이터 data_type: DataType # plan, actual, forecast value: float # 실제 값 # 계산된 필드 (캐시) variance: Optional[float] # 차이 (actual - plan) variance_rate: Optional[float] # 차이율 (%) achievement_rate: Optional[float] # 달성률 (%) # 입력 정보 source: str # "manual", "google_sheets", "freee", "api" source_reference: Optional[str] # 소스 참조 (시트 셀 주소 등) # 메타데이터 created_at: datetime updated_at: datetime created_by: str # user_id ``` ### 2.6 KPIRelation (KPI 관계) ```python class RelationType(Enum): """관계 타입""" PARENT_CHILD = "parent_child" # 부모-자식 (트리 구조) FORMULA = "formula" # 계산식 관계 CORRELATION = "correlation" # 상관관계 (AI 분석) @dataclass class KPIRelation: """KPI 간 관계 엔티티""" id: str tree_id: str # 관계 source_node_id: str # 소스 노드 target_node_id: str # 타겟 노드 relation_type: RelationType # 계산식 관계의 경우 operator: Optional[str] # "multiply", "add", "divide", "subtract" weight: Optional[float] # 가중치 (합계 계산 시) # 메타데이터 created_at: datetime ``` --- ## 3. 데이터베이스 스키마 ### 3.1 PostgreSQL DDL ```sql -- 조직 테이블 CREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, slug VARCHAR(100) UNIQUE NOT NULL, fiscal_year_start INTEGER DEFAULT 4, currency VARCHAR(10) DEFAULT 'JPY', timezone VARCHAR(50) DEFAULT 'Asia/Tokyo', plan VARCHAR(50) DEFAULT 'free', settings JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), is_active BOOLEAN DEFAULT TRUE ); -- 사용자 테이블 CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, organization_id UUID REFERENCES organizations(id), role VARCHAR(50) NOT NULL DEFAULT 'viewer', auth_provider VARCHAR(50) NOT NULL, auth_provider_id VARCHAR(255), preferences JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), last_login_at TIMESTAMPTZ, is_active BOOLEAN DEFAULT TRUE ); -- KPI 트리 테이블 CREATE TABLE kpi_trees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id), name VARCHAR(255) NOT NULL, description TEXT, root_node_id UUID, fiscal_year VARCHAR(10) NOT NULL, status VARCHAR(50) DEFAULT 'active', version INTEGER DEFAULT 1, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by UUID REFERENCES users(id) ); -- KPI 노드 테이블 CREATE TABLE kpi_nodes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tree_id UUID NOT NULL REFERENCES kpi_trees(id) ON DELETE CASCADE, -- 기본 정보 name VARCHAR(255) NOT NULL, name_en VARCHAR(255), description TEXT, -- 분류 node_type VARCHAR(50) NOT NULL, category VARCHAR(50) NOT NULL, -- 단위 unit VARCHAR(50) NOT NULL, unit_label VARCHAR(20) NOT NULL, decimal_places INTEGER DEFAULT 0, -- 계산식 formula TEXT, formula_type VARCHAR(50), -- 트리 구조 parent_id UUID REFERENCES kpi_nodes(id), level INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, -- 목표 방향 target_direction VARCHAR(50) DEFAULT 'higher_is_better', -- 담당 owner_user_id UUID REFERENCES users(id), department VARCHAR(100), -- 메타데이터 metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), is_active BOOLEAN DEFAULT TRUE ); -- KPI 데이터 테이블 CREATE TABLE kpi_data ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), node_id UUID NOT NULL REFERENCES kpi_nodes(id) ON DELETE CASCADE, -- 기간 period_type VARCHAR(20) NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, fiscal_year VARCHAR(10) NOT NULL, fiscal_month INTEGER, -- 데이터 data_type VARCHAR(20) NOT NULL, value DECIMAL(20, 4) NOT NULL, -- 계산 필드 variance DECIMAL(20, 4), variance_rate DECIMAL(10, 4), achievement_rate DECIMAL(10, 4), -- 소스 source VARCHAR(50) NOT NULL DEFAULT 'manual', source_reference TEXT, -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by UUID REFERENCES users(id), -- 유니크 제약 UNIQUE(node_id, period_start, data_type) ); -- 인덱스 CREATE INDEX idx_kpi_nodes_tree ON kpi_nodes(tree_id); CREATE INDEX idx_kpi_nodes_parent ON kpi_nodes(parent_id); CREATE INDEX idx_kpi_nodes_category ON kpi_nodes(category); CREATE INDEX idx_kpi_data_node ON kpi_data(node_id); CREATE INDEX idx_kpi_data_period ON kpi_data(period_start, period_end); CREATE INDEX idx_kpi_data_fiscal ON kpi_data(fiscal_year, fiscal_month); ``` --- ## 4. 샘플 데이터 ### 4.1 KPI 트리 예시 (영업) ```json { "tree": { "id": "tree_001", "name": "2025年度 営業KPI", "fiscal_year": "2025", "root_node_id": "kpi_001" }, "nodes": [ { "id": "kpi_001", "name": "売上高", "node_type": "kgi", "category": "finance", "unit": "currency", "unit_label": "円", "formula": "kpi_002 * kpi_003", "formula_type": "multiply", "level": 0 }, { "id": "kpi_002", "name": "契約数", "node_type": "kpi", "category": "sales", "unit": "count", "unit_label": "件", "formula": "kpi_004 * kpi_005", "parent_id": "kpi_001", "level": 1 }, { "id": "kpi_003", "name": "契約単価", "node_type": "kpi", "category": "sales", "unit": "currency", "unit_label": "円", "formula": null, "parent_id": "kpi_001", "level": 1 }, { "id": "kpi_004", "name": "成約率", "node_type": "kpi", "category": "sales", "unit": "percentage", "unit_label": "%", "parent_id": "kpi_002", "level": 2 }, { "id": "kpi_005", "name": "商談数", "node_type": "kpi", "category": "sales", "unit": "count", "unit_label": "件", "formula": "kpi_006 * kpi_007", "parent_id": "kpi_002", "level": 2 }, { "id": "kpi_006", "name": "商談化率", "node_type": "kpi", "category": "marketing", "unit": "percentage", "unit_label": "%", "parent_id": "kpi_005", "level": 3 }, { "id": "kpi_007", "name": "リード数", "node_type": "input", "category": "marketing", "unit": "count", "unit_label": "件", "parent_id": "kpi_005", "level": 3 } ] } ``` ### 4.2 KPI 데이터 예시 ```json { "kpi_data": [ { "node_id": "kpi_001", "fiscal_year": "2025", "fiscal_month": 4, "period_start": "2025-04-01", "period_end": "2025-04-30", "data_type": "plan", "value": 2000000 }, { "node_id": "kpi_001", "fiscal_year": "2025", "fiscal_month": 4, "period_start": "2025-04-01", "period_end": "2025-04-30", "data_type": "actual", "value": 1800000, "variance": -200000, "variance_rate": -10.0, "achievement_rate": 90.0 }, { "node_id": "kpi_007", "fiscal_year": "2025", "fiscal_month": 4, "data_type": "plan", "value": 400 }, { "node_id": "kpi_007", "fiscal_year": "2025", "fiscal_month": 4, "data_type": "actual", "value": 380 } ] } ``` --- ## 5. Python 데이터 클래스 (전체) ```python # src/data_models.py from dataclasses import dataclass, field from datetime import datetime from typing import Optional, List, Dict, Any from enum import Enum # === Enums === class FiscalYearStart(Enum): JANUARY = 1 APRIL = 4 JULY = 7 OCTOBER = 10 class UserRole(Enum): OWNER = "owner" ADMIN = "admin" EDITOR = "editor" VIEWER = "viewer" class KPICategory(Enum): FINANCE = "finance" SALES = "sales" MARKETING = "marketing" HR = "hr" PRODUCT = "product" CUSTOMER = "customer" OPERATION = "operation" CUSTOM = "custom" class KPIUnit(Enum): CURRENCY = "currency" PERCENTAGE = "percentage" COUNT = "count" RATIO = "ratio" DAYS = "days" HOURS = "hours" CUSTOM = "custom" class KPINodeType(Enum): KGI = "kgi" KPI = "kpi" METRIC = "metric" INPUT = "input" class DataType(Enum): PLAN = "plan" ACTUAL = "actual" FORECAST = "forecast" BUDGET = "budget" # === Core Entities === @dataclass class Organization: id: str name: str slug: str fiscal_year_start: FiscalYearStart = FiscalYearStart.APRIL currency: str = "JPY" timezone: str = "Asia/Tokyo" plan: str = "free" settings: Dict[str, Any] = field(default_factory=dict) created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) is_active: bool = True @dataclass class KPINode: id: str tree_id: str name: str node_type: KPINodeType category: KPICategory unit: KPIUnit unit_label: str name_en: Optional[str] = None description: Optional[str] = None decimal_places: int = 0 formula: Optional[str] = None formula_type: Optional[str] = None parent_id: Optional[str] = None children_ids: List[str] = field(default_factory=list) level: int = 0 order: int = 0 target_direction: str = "higher_is_better" owner_user_id: Optional[str] = None department: Optional[str] = None metadata: Dict[str, Any] = field(default_factory=dict) created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) is_active: bool = True @dataclass class KPIData: id: str node_id: str period_type: str period_start: datetime period_end: datetime fiscal_year: str data_type: DataType value: float fiscal_month: Optional[int] = None variance: Optional[float] = None variance_rate: Optional[float] = None achievement_rate: Optional[float] = None source: str = "manual" source_reference: Optional[str] = None created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) created_by: Optional[str] = None @dataclass class KPITree: id: str organization_id: str name: str fiscal_year: str root_node_id: Optional[str] = None description: Optional[str] = None status: str = "active" version: int = 1 nodes: Dict[str, KPINode] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict) created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) created_by: Optional[str] = None def get_node(self, node_id: str) -> Optional[KPINode]: """노드 ID로 노드 조회""" return self.nodes.get(node_id) def get_children(self, node_id: str) -> List[KPINode]: """자식 노드 목록 반환""" node = self.get_node(node_id) if not node: return [] return [self.nodes[cid] for cid in node.children_ids if cid in self.nodes] def get_ancestors(self, node_id: str) -> List[KPINode]: """조상 노드 목록 반환 (루트까지)""" ancestors = [] node = self.get_node(node_id) while node and node.parent_id: parent = self.get_node(node.parent_id) if parent: ancestors.append(parent) node = parent else: break return ancestors ``` --- ## 6. 결론 ### 데이터 모델 요약 | 엔티티 | 역할 | 핵심 필드 | |--------|------|-----------| | Organization | 조직/테넌트 | fiscal_year_start, plan | | KPITree | KPI 트리 컨테이너 | root_node_id, fiscal_year | | KPINode | 개별 KPI | formula, category, unit | | KPIData | 시계열 데이터 | value, data_type, period | ### 설계 특징 1. **트리 구조**: parent_id + children_ids로 양방향 탐색 2. **유연한 수식**: formula 필드로 커스텀 계산 3. **시계열 지원**: 월별/분기별/연간 데이터 4. **멀티 테넌트**: organization_id로 격리 --- **다음 편: [v6] KPI 트리 엔진 설계** *KPI 트리의 계산 로직, 수식 파싱, 순환 참조 검출 등 핵심 엔진 설계를 다룹니다.*