feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)
Phase 0: wbx-spring-core 라이브러리 전환 - java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화 - 루트 settings.gradle + build.gradle (멀티모듈) Phase 1: wtm-api 모듈 생성 - 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트 - Flyway V100~V107 MySQL 마이그레이션 - TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser Phase 2: wtm-frontend (Vue 3 + PrimeVue 4) - 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트 - 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar) - BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트 - JWT 인터셉터, 역할 기반 메뉴 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
@@ -0,0 +1,422 @@
|
||||
# 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));
|
||||
}
|
||||
}
|
||||
```
|
||||
새 Issue에서 참조
사용자 차단