파일
wbx-spring/plans/wtmgr/02-database-schema.md
accura0117 9707a6eeb1 feat: FE 화면 구현 완료 + 샘플 데이터 + 결재라인 연동
- WBS/TEAL 화면 실제 구현 (TreeTable, FileUpload, 버전관리)
- 시수이력/결재이력 화면 구현 (DataTable, Filter, Timeline)
- 비밀번호변경 화면 추가
- 로그인 snake_case 응답 매핑 수정
- Vite 프록시 8081 포트 수정
- auth guard에서 fetchMe 자동 호출
- V108 샘플 데이터 (10명 사용자, 4주 시수 215건, 결재 9건)
- 배너 추가 (WBX Spring)

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

423 줄
14 KiB
Markdown

# 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. 사용자 / 권한
```sql
-- 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
```sql
-- 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)
```sql
-- 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)
```sql
-- 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)
```sql
-- 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)
```sql
-- 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 설계 패턴
```java
// 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;
}
```
```java
// 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));
}
}
```