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>
이 Commit은 다음에 포함되어 있습니다:
312
HanwhaOCN/wtmgr/04-wbs-teal-module.md
일반 파일
312
HanwhaOCN/wtmgr/04-wbs-teal-module.md
일반 파일
@@ -0,0 +1,312 @@
|
||||
# 04. WBS · TEAL 관리 모듈
|
||||
|
||||
## Canonical WBS 레벨 구조 (No.34)
|
||||
|
||||
```
|
||||
Level 1: Project
|
||||
Level 2: Phase (Engineering, Procurement, Construction, Commissioning, etc.)
|
||||
Level 3: Asset or Area
|
||||
Level 4: Work or Discipline
|
||||
Level 5: Deliverable, Package, or Material (Engineering & SCM only)
|
||||
```
|
||||
|
||||
> **업로드 주체**: PM이 P6 WBS 파일 업로드 (No.27) → PCM이 승인 (No.35)
|
||||
> **Canonical WBS는 프로젝트별 수정 불가** (No.33: 한화오션 표준 구조)
|
||||
|
||||
## WBS 데이터 흐름
|
||||
|
||||
```
|
||||
P6 Export File (.xls/.csv)
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ P6 WBS 파서 │ Level 1~5 파싱
|
||||
│ (Apache POI) │ wbs_code 생성
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ wbs_versions │ ───▶ │ wbs_nodes │
|
||||
│ (스냅샷 단위) │ │ (트리 구조) │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
│
|
||||
▼ 매핑
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ canonical_wbs │ ◀─── │ TEAL 관리 │
|
||||
│ (정규 WBS 구조) │ │ (Activity List) │
|
||||
└───────┬───────────┘ └───────────────────┘
|
||||
│
|
||||
▼ 시수 입력 시 선택
|
||||
┌───────────────────┐
|
||||
│ timesheet_entries│
|
||||
│ (EPC 시수) │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
## P6 WBS 파일 파서
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class P6WbsParser {
|
||||
|
||||
/**
|
||||
* P6 Export 파일을 파싱하여 WBS 트리 구조 생성
|
||||
* Level 1~5 계층 구조 지원
|
||||
*
|
||||
* P6 컬럼 예상:
|
||||
* Activity ID | Activity Name | WBS Code | WBS Name | Level | Planned Hours
|
||||
*/
|
||||
public WbsParseResult parse(MultipartFile file) {
|
||||
var result = new WbsParseResult();
|
||||
Map<String, WbsNodeDto> nodeMap = new LinkedHashMap<>();
|
||||
|
||||
try (Workbook wb = WorkbookFactory.create(file.getInputStream())) {
|
||||
Sheet sheet = wb.getSheetAt(0);
|
||||
|
||||
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null) continue;
|
||||
|
||||
String wbsCode = getCellString(row, 2); // WBS Code (L1.L2.L3.L4.L5)
|
||||
String wbsName = getCellString(row, 3);
|
||||
int level = (int) getCellNumeric(row, 4);
|
||||
double plannedHours = getCellNumeric(row, 5);
|
||||
|
||||
var node = WbsNodeDto.builder()
|
||||
.wbsCode(wbsCode)
|
||||
.name(wbsName)
|
||||
.level(level)
|
||||
.plannedHours(BigDecimal.valueOf(plannedHours))
|
||||
.parentCode(deriveParentCode(wbsCode, level))
|
||||
.build();
|
||||
|
||||
nodeMap.put(wbsCode, node);
|
||||
result.addNode(node);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.setError("파일 파싱 실패: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 부모-자식 관계 검증
|
||||
validateHierarchy(nodeMap, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WBS 코드에서 부모 코드 추출
|
||||
* 예: "E.01.03.02.01" (Level 5) → "E.01.03.02" (Level 4)
|
||||
*/
|
||||
private String deriveParentCode(String wbsCode, int level) {
|
||||
if (level <= 1) return null;
|
||||
int lastDot = wbsCode.lastIndexOf('.');
|
||||
return lastDot > 0 ? wbsCode.substring(0, lastDot) : null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WBS 버전 관리 서비스
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class WbsService {
|
||||
|
||||
private final WbsVersionRepository wbsVersionRepository;
|
||||
private final WbsNodeRepository wbsNodeRepository;
|
||||
private final CanonicalWbsRepository canonicalWbsRepository;
|
||||
private final P6WbsParser p6Parser;
|
||||
|
||||
/**
|
||||
* P6 WBS 파일 업로드 → 새 버전 생성
|
||||
*/
|
||||
public WbsVersionDto uploadP6Wbs(Long projectId, MultipartFile file,
|
||||
LocalDate effectiveDate, String description) {
|
||||
// 1. 파일 파싱
|
||||
WbsParseResult parseResult = p6Parser.parse(file);
|
||||
if (parseResult.hasErrors()) {
|
||||
throw new BusinessException("WBS 파싱 오류: " + parseResult.getErrors());
|
||||
}
|
||||
|
||||
// 2. 버전 번호 자동 증가
|
||||
int nextVersion = wbsVersionRepository
|
||||
.findMaxVersionByProjectId(projectId)
|
||||
.map(v -> v + 1).orElse(1);
|
||||
|
||||
// 3. WBS 버전 저장
|
||||
WbsVersion version = WbsVersion.builder()
|
||||
.projectId(projectId)
|
||||
.versionNumber(nextVersion)
|
||||
.effectiveDate(effectiveDate)
|
||||
.sourceType(WbsSourceType.P6_UPLOAD)
|
||||
.sourceFilename(file.getOriginalFilename())
|
||||
.description(description)
|
||||
.status(WbsVersionStatus.DRAFT)
|
||||
.build();
|
||||
wbsVersionRepository.save(version);
|
||||
|
||||
// 4. WBS 노드 벌크 저장
|
||||
List<WbsNode> nodes = parseResult.getNodes().stream()
|
||||
.map(dto -> WbsNode.builder()
|
||||
.wbsVersion(version)
|
||||
.wbsCode(dto.getWbsCode())
|
||||
.level(dto.getLevel())
|
||||
.name(dto.getName())
|
||||
.discipline(dto.getDiscipline())
|
||||
.plannedHours(dto.getPlannedHours())
|
||||
.isLeaf(dto.getLevel() == 5)
|
||||
.build())
|
||||
.toList();
|
||||
wbsNodeRepository.saveAll(nodes);
|
||||
|
||||
// 5. 부모-자식 관계 설정
|
||||
setParentReferences(version.getId(), nodes);
|
||||
|
||||
return WbsVersionDto.from(version, nodes.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* WBS 버전 활성화 (DRAFT → ACTIVE)
|
||||
* 기존 ACTIVE 버전은 ARCHIVED로 변경
|
||||
*/
|
||||
public void activateVersion(Long versionId) {
|
||||
WbsVersion version = wbsVersionRepository.findById(versionId)
|
||||
.orElseThrow(() -> new NotFoundException("WBS 버전을 찾을 수 없습니다."));
|
||||
|
||||
// 기존 ACTIVE → ARCHIVED
|
||||
wbsVersionRepository.archiveActiveVersions(version.getProjectId());
|
||||
|
||||
version.activate();
|
||||
wbsVersionRepository.save(version);
|
||||
|
||||
// Canonical WBS 동기화
|
||||
syncCanonicalWbs(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical WBS 동기화
|
||||
* 활성 WBS 버전의 노드를 canonical_wbs에 반영
|
||||
*/
|
||||
private void syncCanonicalWbs(WbsVersion version) {
|
||||
List<WbsNode> nodes = wbsNodeRepository.findByWbsVersionId(version.getId());
|
||||
|
||||
for (WbsNode node : nodes) {
|
||||
canonicalWbsRepository.findByProjectIdAndWbsCode(
|
||||
version.getProjectId(), node.getWbsCode()
|
||||
).ifPresentOrElse(
|
||||
// 기존 → 업데이트
|
||||
existing -> {
|
||||
existing.updateFrom(node);
|
||||
existing.setMappedP6Code(node.getWbsCode());
|
||||
},
|
||||
// 신규 → 생성
|
||||
() -> canonicalWbsRepository.save(
|
||||
CanonicalWbs.fromWbsNode(version.getProjectId(), node))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WBS 버전 비교 (PH1-2)
|
||||
*/
|
||||
public WbsCompareResult compareVersions(Long projectId, int versionA, int versionB) {
|
||||
List<WbsNode> nodesA = wbsNodeRepository.findByProjectIdAndVersion(projectId, versionA);
|
||||
List<WbsNode> nodesB = wbsNodeRepository.findByProjectIdAndVersion(projectId, versionB);
|
||||
|
||||
Map<String, WbsNode> mapA = nodesA.stream()
|
||||
.collect(Collectors.toMap(WbsNode::getWbsCode, Function.identity()));
|
||||
Map<String, WbsNode> mapB = nodesB.stream()
|
||||
.collect(Collectors.toMap(WbsNode::getWbsCode, Function.identity()));
|
||||
|
||||
var result = new WbsCompareResult();
|
||||
|
||||
// Added in B
|
||||
mapB.keySet().stream()
|
||||
.filter(code -> !mapA.containsKey(code))
|
||||
.forEach(code -> result.addAdded(mapB.get(code)));
|
||||
|
||||
// Removed from A
|
||||
mapA.keySet().stream()
|
||||
.filter(code -> !mapB.containsKey(code))
|
||||
.forEach(code -> result.addRemoved(mapA.get(code)));
|
||||
|
||||
// Modified
|
||||
mapA.keySet().stream()
|
||||
.filter(mapB::containsKey)
|
||||
.filter(code -> !mapA.get(code).contentEquals(mapB.get(code)))
|
||||
.forEach(code -> result.addModified(mapA.get(code), mapB.get(code)));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TEAL (Task Effective Activity List) 관리
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class TealService {
|
||||
|
||||
/**
|
||||
* TEAL 업로드 — Canonical WBS에 연결된 Activity 목록
|
||||
*/
|
||||
public TealVersionDto uploadTeal(Long projectId, MultipartFile file,
|
||||
LocalDate effectiveDate) {
|
||||
// 1. 파일 파싱 (WBS Code | Activity Code | Activity Name | Discipline)
|
||||
List<TealEntryDto> entries = parseTealFile(file);
|
||||
|
||||
// 2. WBS 코드 검증 (canonical_wbs에 존재하는지)
|
||||
Set<String> validWbsCodes = canonicalWbsRepository
|
||||
.findActiveCodesByProjectId(projectId);
|
||||
|
||||
for (TealEntryDto entry : entries) {
|
||||
if (!validWbsCodes.contains(entry.getWbsCode())) {
|
||||
throw new BusinessException(
|
||||
"WBS 코드 '" + entry.getWbsCode() + "'가 Canonical WBS에 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 버전 생성 및 저장
|
||||
TealVersion version = TealVersion.create(projectId, effectiveDate);
|
||||
tealVersionRepository.save(version);
|
||||
|
||||
List<TealEntry> tealEntries = entries.stream()
|
||||
.map(dto -> TealEntry.builder()
|
||||
.tealVersion(version)
|
||||
.canonicalWbs(canonicalWbsRepository
|
||||
.findByProjectIdAndWbsCode(projectId, dto.getWbsCode()).orElseThrow())
|
||||
.activityCode(dto.getActivityCode())
|
||||
.activityName(dto.getActivityName())
|
||||
.discipline(dto.getDiscipline())
|
||||
.build())
|
||||
.toList();
|
||||
tealEntryRepository.saveAll(tealEntries);
|
||||
|
||||
return TealVersionDto.from(version, tealEntries.size());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
```
|
||||
# WBS
|
||||
POST /api/projects/{projectId}/wbs/upload P6 WBS 파일 업로드
|
||||
GET /api/projects/{projectId}/wbs/versions 버전 목록
|
||||
GET /api/projects/{projectId}/wbs/versions/{ver} 버전 상세 (트리)
|
||||
POST /api/projects/{projectId}/wbs/versions/{ver}/activate 버전 활성화
|
||||
GET /api/projects/{projectId}/wbs/compare?a=1&b=2 버전 비교 (PH1-2)
|
||||
|
||||
# Canonical WBS
|
||||
GET /api/projects/{projectId}/canonical-wbs 정규 WBS 트리 조회
|
||||
GET /api/projects/{projectId}/canonical-wbs/flat 플랫 목록 (시수 입력 드롭다운용)
|
||||
|
||||
# TEAL
|
||||
POST /api/projects/{projectId}/teal/upload TEAL 파일 업로드
|
||||
GET /api/projects/{projectId}/teal/versions 버전 목록
|
||||
GET /api/projects/{projectId}/teal/active 활성 TEAL 목록 (시수 입력용)
|
||||
GET /api/projects/{projectId}/teal/by-wbs/{wbsId} WBS별 TEAL Activity 목록
|
||||
```
|
||||
새 Issue에서 참조
사용자 차단