- 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>
313 줄
11 KiB
Markdown
313 줄
11 KiB
Markdown
# 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 목록
|
|
```
|