- WBS/TEAL 화면 실제 구현 (TreeTable, FileUpload, 버전관리) - 시수이력/결재이력 화면 구현 (DataTable, Filter, Timeline) - 비밀번호변경 화면 추가 - 로그인 snake_case 응답 매핑 수정 - Vite 프록시 8081 포트 수정 - auth guard에서 fetchMe 자동 호출 - V108 샘플 데이터 (10명 사용자, 4주 시수 215건, 결재 9건) - 배너 추가 (WBX Spring) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 KiB
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 목록