파일
wbx-spring/HanwhaOCN/wtmgr/02-database-schema.md
accura0117 783865266b docs: 한화오션 WTM 프로젝트 계획서 추가 (00~14)
- 00~11: WTM 시수관리 시스템 설계 문서 (아키텍처, DB스키마, API스펙 등)
- 12: BE 멀티프로젝트 플랫폼 구성 계획 (wbx-spring-core 라이브러리 전환)
- 13: FE Vue3+PrimeVue4 모듈 기반 구조 계획
- 14: 레이아웃 표준 및 디자인 시스템 (반응형, 하드코딩 제거)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:52:15 +09:00

14 KiB

02. DB 스키마 설계

ERD 개요

users ──┬── user_roles ──── roles ──── role_permissions
        │
        ├── org_hierarchy (조직 4레벨: BU/Division/Dept/Section)
        ├── hr_uploads (HR Master 업로드 이력)
        │
projects ──┬── project_assignments ──── users (인력 배정)
           ├── project_type_config (프로젝트 유형별 WBS 규칙)
           │
           ├── wbs_versions ──── wbs_nodes (P6 WBS 트리)
           ├── canonical_wbs ──── wbs_discipline_assignments
           │
           ├── teal_versions ──── teal_entries
           │
           ├── timesheets ──┬── timesheet_entries
           │                └── timesheet_uploads (Excel 일괄)
           │
           └── approvals ──── approval_lines ──── approval_comments

work_rules (근무 규칙: 일 8h, 주 52h, Location별)
overhead_types (Non-Project Overhead 유형, SA 관리)
sa_access_logs (SA 활동 로그)
reports (View/Materialized View 기반)

> 상세 보완: 11-requirements-supplement.md 참조
> (HR 필드, 조직계층, 권한매트릭스, Resource Assignment, 근무규칙 등)

핵심 테이블 설계

1. 사용자 / 권한

-- V1__init_users.sql

CREATE TABLE users (
    id              BIGINT IDENTITY PRIMARY KEY,
    employee_id     VARCHAR(50)  NOT NULL UNIQUE,  -- 사번
    email           VARCHAR(255) NOT NULL UNIQUE,
    username        VARCHAR(100) NOT NULL,
    full_name       VARCHAR(255),
    hashed_password VARCHAR(255),                   -- 내부 로그인용
    department      VARCHAR(100),
    discipline      VARCHAR(100),                   -- Engineering Discipline
    position_title  VARCHAR(100),
    location        VARCHAR(50),                    -- Onshore/Offshore
    employment_type VARCHAR(20) DEFAULT 'INTERNAL', -- INTERNAL, SUBCONTRACTOR
    is_active       BIT DEFAULT 1,
    is_locked       BIT DEFAULT 0,
    failed_attempts INT DEFAULT 0,
    last_login_at   DATETIME2,
    password_changed_at DATETIME2,
    azure_oid       VARCHAR(255),                   -- Azure Entra ID Object ID (SSO)
    mfa_enabled     BIT DEFAULT 0,
    created_at      DATETIME2 DEFAULT GETDATE(),
    updated_at      DATETIME2,
    created_by      BIGINT,
    updated_by      BIGINT
);

CREATE TABLE roles (
    id          BIGINT IDENTITY PRIMARY KEY,
    code        VARCHAR(20)  NOT NULL UNIQUE,  -- PM, PCM, PTK, DL, SA, USER
    name        VARCHAR(100) NOT NULL,
    description VARCHAR(500),
    level       INT DEFAULT 0  -- 권한 레벨 (SA=100, PM=80, DL=60 ...)
);

-- 사용자-역할 (다대다)
CREATE TABLE user_roles (
    id         BIGINT IDENTITY PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id),
    role_id    BIGINT NOT NULL REFERENCES roles(id),
    project_id BIGINT NULL REFERENCES projects(id),  -- 프로젝트별 역할 (PM, DL 등)
    granted_at DATETIME2 DEFAULT GETDATE(),
    granted_by BIGINT REFERENCES users(id),
    UNIQUE (user_id, role_id, project_id)
);

-- 초기 역할 데이터
-- V2__seed_roles.sql
INSERT INTO roles (code, name, level) VALUES
    ('SA',   'System Administrator',   100),
    ('PM',   'Project Manager',         80),
    ('PCM',  'Project Control Manager', 70),
    ('PTK',  'Project Timekeeper',      60),
    ('DL',   'Discipline Lead',         50),
    ('USER', 'General User',            10);

2. 프로젝트 / WBS / TEAL

-- V3__init_projects.sql

CREATE TABLE projects (
    id              BIGINT IDENTITY PRIMARY KEY,
    project_code    VARCHAR(50)  NOT NULL UNIQUE,   -- EPU 프로젝트 코드
    name            VARCHAR(255) NOT NULL,
    description     NVARCHAR(1000),
    project_type    VARCHAR(20) NOT NULL,            -- EPC, NON_PROJECT, OTHER
    status          VARCHAR(20) DEFAULT 'ACTIVE',    -- ACTIVE, CLOSED, HOLD
    start_date      DATE,
    end_date        DATE,
    pm_user_id      BIGINT REFERENCES users(id),
    created_at      DATETIME2 DEFAULT GETDATE(),
    updated_at      DATETIME2
);

-- WBS 버전 (P6에서 업로드되는 스냅샷)
CREATE TABLE wbs_versions (
    id              BIGINT IDENTITY PRIMARY KEY,
    project_id      BIGINT NOT NULL REFERENCES projects(id),
    version_number  INT NOT NULL,
    effective_date  DATE NOT NULL,
    source_type     VARCHAR(20) DEFAULT 'P6_UPLOAD',  -- P6_UPLOAD, MANUAL
    source_filename VARCHAR(500),
    description     NVARCHAR(500),
    status          VARCHAR(20) DEFAULT 'DRAFT',      -- DRAFT, ACTIVE, ARCHIVED
    uploaded_by     BIGINT REFERENCES users(id),
    created_at      DATETIME2 DEFAULT GETDATE(),
    UNIQUE (project_id, version_number)
);

-- WBS 노드 (Level 1~5 트리 구조)
CREATE TABLE wbs_nodes (
    id              BIGINT IDENTITY PRIMARY KEY,
    wbs_version_id  BIGINT NOT NULL REFERENCES wbs_versions(id),
    parent_id       BIGINT NULL REFERENCES wbs_nodes(id),  -- 트리 구조
    wbs_code        VARCHAR(100) NOT NULL,                  -- L1.L2.L3.L4.L5
    level           INT NOT NULL,                           -- 1~5
    name            NVARCHAR(500) NOT NULL,
    discipline      VARCHAR(50),
    planned_hours   DECIMAL(10,2),
    sort_order      INT DEFAULT 0,
    is_leaf         BIT DEFAULT 0,                          -- TEAL 선정 가능 여부
    UNIQUE (wbs_version_id, wbs_code)
);

-- Canonical WBS (정규화된 WBS 구조)
CREATE TABLE canonical_wbs (
    id              BIGINT IDENTITY PRIMARY KEY,
    project_id      BIGINT NOT NULL REFERENCES projects(id),
    wbs_code        VARCHAR(100) NOT NULL,
    level           INT NOT NULL,
    name            NVARCHAR(500) NOT NULL,
    parent_code     VARCHAR(100),
    discipline      VARCHAR(50),
    is_active       BIT DEFAULT 1,
    mapped_p6_code  VARCHAR(100),  -- P6 WBS와 매핑
    created_at      DATETIME2 DEFAULT GETDATE(),
    UNIQUE (project_id, wbs_code)
);

-- TEAL (Task Effective Activity List)
CREATE TABLE teal_versions (
    id              BIGINT IDENTITY PRIMARY KEY,
    project_id      BIGINT NOT NULL REFERENCES projects(id),
    version_number  INT NOT NULL,
    effective_date  DATE NOT NULL,
    description     NVARCHAR(500),
    status          VARCHAR(20) DEFAULT 'DRAFT',
    uploaded_by     BIGINT REFERENCES users(id),
    created_at      DATETIME2 DEFAULT GETDATE(),
    UNIQUE (project_id, version_number)
);

CREATE TABLE teal_entries (
    id              BIGINT IDENTITY PRIMARY KEY,
    teal_version_id BIGINT NOT NULL REFERENCES teal_versions(id),
    canonical_wbs_id BIGINT REFERENCES canonical_wbs(id),
    activity_code   VARCHAR(100) NOT NULL,
    activity_name   NVARCHAR(500),
    discipline      VARCHAR(50),
    is_active       BIT DEFAULT 1
);

3. 시수 입력 (Timesheet)

-- V4__init_timesheets.sql

-- 시수 헤더 (주간 단위)
CREATE TABLE timesheets (
    id              BIGINT IDENTITY PRIMARY KEY,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    week_start_date DATE NOT NULL,                          -- 주 시작일 (월요일)
    week_end_date   DATE NOT NULL,
    status          VARCHAR(20) DEFAULT 'DRAFT',            -- DRAFT, SUBMITTED, DL_APPROVED, APPROVED, REJECTED
    total_hours     DECIMAL(10,2) DEFAULT 0,
    submitted_at    DATETIME2,
    created_at      DATETIME2 DEFAULT GETDATE(),
    updated_at      DATETIME2,
    UNIQUE (user_id, week_start_date)
);

-- 시수 상세 (일별 항목)
CREATE TABLE timesheet_entries (
    id              BIGINT IDENTITY PRIMARY KEY,
    timesheet_id    BIGINT NOT NULL REFERENCES timesheets(id),
    entry_type      VARCHAR(20) NOT NULL,           -- NON_PROJECT, OTHER_PROJECT, EPC
    entry_date      DATE NOT NULL,
    hours           DECIMAL(5,2) NOT NULL DEFAULT 0,

    -- NON_PROJECT: 카테고리만
    np_category     VARCHAR(100),                   -- Leave, Training, Admin 등

    -- OTHER_PROJECT: 프로젝트 + 카테고리
    other_project_id BIGINT NULL REFERENCES projects(id),
    other_category   VARCHAR(100),

    -- EPC: 프로젝트 + WBS + TEAL
    epc_project_id  BIGINT NULL REFERENCES projects(id),
    canonical_wbs_id BIGINT NULL REFERENCES canonical_wbs(id),
    teal_entry_id   BIGINT NULL REFERENCES teal_entries(id),
    revision_number INT DEFAULT 1,                  -- EPC Revision (PH1-2)

    remark          NVARCHAR(500),
    created_at      DATETIME2 DEFAULT GETDATE(),
    updated_at      DATETIME2,

    -- 일별 8시간, 주 52시간 제약은 애플리케이션 레벨에서 검증
    CONSTRAINT chk_hours CHECK (hours >= 0 AND hours <= 24)
);

CREATE INDEX idx_ts_entries_date ON timesheet_entries(entry_date);
CREATE INDEX idx_ts_entries_type ON timesheet_entries(entry_type);

-- Excel 일괄 업로드 이력
CREATE TABLE timesheet_uploads (
    id              BIGINT IDENTITY PRIMARY KEY,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    filename        VARCHAR(500),
    file_path       VARCHAR(1000),
    total_rows      INT,
    success_rows    INT,
    error_rows      INT,
    error_log       NVARCHAR(MAX),      -- JSON: [{row, field, error}]
    status          VARCHAR(20),         -- PROCESSING, COMPLETED, FAILED
    created_at      DATETIME2 DEFAULT GETDATE()
);

4. 결재 (Approval)

-- V5__init_approvals.sql

CREATE TABLE approvals (
    id              BIGINT IDENTITY PRIMARY KEY,
    timesheet_id    BIGINT NOT NULL REFERENCES timesheets(id),
    requester_id    BIGINT NOT NULL REFERENCES users(id),
    project_id      BIGINT REFERENCES projects(id),
    status          VARCHAR(20) DEFAULT 'PENDING',  -- PENDING, APPROVED, REJECTED
    submitted_at    DATETIME2 DEFAULT GETDATE(),
    completed_at    DATETIME2,
    UNIQUE (timesheet_id)
);

-- 결재 라인 (User → DL → PM 3단계)
CREATE TABLE approval_lines (
    id              BIGINT IDENTITY PRIMARY KEY,
    approval_id     BIGINT NOT NULL REFERENCES approvals(id),
    approver_id     BIGINT NOT NULL REFERENCES users(id),
    approval_order  INT NOT NULL,                    -- 1=DL, 2=PM
    role_code       VARCHAR(20),                     -- DL, PM
    status          VARCHAR(20) DEFAULT 'PENDING',   -- PENDING, APPROVED, REJECTED
    acted_at        DATETIME2,
    created_at      DATETIME2 DEFAULT GETDATE()
);

CREATE TABLE approval_comments (
    id              BIGINT IDENTITY PRIMARY KEY,
    approval_id     BIGINT NOT NULL REFERENCES approvals(id),
    user_id         BIGINT NOT NULL REFERENCES users(id),
    comment         NVARCHAR(2000),
    action          VARCHAR(20),    -- APPROVE, REJECT, COMMENT
    created_at      DATETIME2 DEFAULT GETDATE()
);

5. 리포트 (View)

-- V6__init_report_views.sql

-- 프로젝트별 시수 집계 View
CREATE VIEW v_project_hours AS
SELECT
    te.epc_project_id AS project_id,
    p.project_code,
    p.name AS project_name,
    te.entry_date,
    DATEPART(YEAR, te.entry_date)  AS year,
    DATEPART(MONTH, te.entry_date) AS month,
    DATEPART(WEEK, te.entry_date)  AS week,
    u.discipline,
    u.department,
    te.entry_type,
    SUM(te.hours) AS total_hours,
    COUNT(DISTINCT te.timesheet_id) AS timesheet_count
FROM timesheet_entries te
JOIN timesheets ts ON te.timesheet_id = ts.id
JOIN users u ON ts.user_id = u.id
LEFT JOIN projects p ON te.epc_project_id = p.id
WHERE ts.status = 'APPROVED'
GROUP BY te.epc_project_id, p.project_code, p.name,
         te.entry_date, DATEPART(YEAR, te.entry_date),
         DATEPART(MONTH, te.entry_date), DATEPART(WEEK, te.entry_date),
         u.discipline, u.department, te.entry_type;

-- WBS Level별 시수 집계 View
CREATE VIEW v_wbs_hours AS
SELECT
    cw.project_id,
    cw.wbs_code,
    cw.level AS wbs_level,
    cw.name AS wbs_name,
    cw.discipline,
    SUM(te.hours) AS total_hours,
    COUNT(DISTINCT ts.user_id) AS user_count
FROM timesheet_entries te
JOIN timesheets ts ON te.timesheet_id = ts.id
JOIN canonical_wbs cw ON te.canonical_wbs_id = cw.id
WHERE ts.status = 'APPROVED'
GROUP BY cw.project_id, cw.wbs_code, cw.level, cw.name, cw.discipline;

6. 감사 / SA 로그 (PH1-2)

-- V7__init_audit.sql

CREATE TABLE sa_access_logs (
    id          BIGINT IDENTITY PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id),
    action      VARCHAR(50) NOT NULL,   -- LOGIN, VIEW, UPDATE, DELETE, EXPORT
    resource    VARCHAR(100),           -- users, projects, timesheets, ...
    resource_id BIGINT,
    ip_address  VARCHAR(50),
    user_agent  VARCHAR(500),
    detail      NVARCHAR(2000),
    created_at  DATETIME2 DEFAULT GETDATE()
);

CREATE INDEX idx_sa_log_user ON sa_access_logs(user_id, created_at);
CREATE INDEX idx_sa_log_action ON sa_access_logs(action, created_at);

JPA Entity 설계 패턴

// BaseEntity — 모든 엔티티 공통
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private Long createdBy;

    @LastModifiedBy
    private Long updatedBy;
}
// User Entity 예시
@Entity
@Table(name = "users")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 50)
    private String employeeId;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false, length = 100)
    private String username;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private EmploymentType employmentType = EmploymentType.INTERNAL;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<UserRole> userRoles = new ArrayList<>();

    // 비즈니스 메서드
    public boolean hasRole(String roleCode) {
        return userRoles.stream()
            .anyMatch(ur -> ur.getRole().getCode().equals(roleCode));
    }

    public boolean hasProjectRole(String roleCode, Long projectId) {
        return userRoles.stream()
            .anyMatch(ur -> ur.getRole().getCode().equals(roleCode)
                         && Objects.equals(ur.getProjectId(), projectId));
    }
}