# 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 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 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 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 nodesA = wbsNodeRepository.findByProjectIdAndVersion(projectId, versionA); List nodesB = wbsNodeRepository.findByProjectIdAndVersion(projectId, versionB); Map mapA = nodesA.stream() .collect(Collectors.toMap(WbsNode::getWbsCode, Function.identity())); Map 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 entries = parseTealFile(file); // 2. WBS 코드 검증 (canonical_wbs에 존재하는지) Set 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 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 목록 ```