파일
wbx-spring/HanwhaOCN/wtmgr/04-wbs-teal-module.md
accura0117 783865266b 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>
2026-03-25 19:52:15 +09:00

11 KiB

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 파일 파서

@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 버전 관리 서비스

@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) 관리

@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 목록