feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)

Phase 0: wbx-spring-core 라이브러리 전환
- java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화
- 루트 settings.gradle + build.gradle (멀티모듈)

Phase 1: wtm-api 모듈 생성
- 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트
- Flyway V100~V107 MySQL 마이그레이션
- TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser

Phase 2: wtm-frontend (Vue 3 + PrimeVue 4)
- 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트
- 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar)
- BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트
- JWT 인터셉터, 역할 기반 메뉴 필터링

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-25 21:01:43 +09:00
부모 783865266b
커밋 df723f1d59
533개의 변경된 파일15528개의 추가작업 그리고 154개의 파일을 삭제

파일 보기

@@ -0,0 +1,24 @@
package kr.co.accura.wtm;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = {
"kr.co.accura.wbx.spring",
"kr.co.accura.wtm"
})
@EntityScan(basePackages = {
"kr.co.accura.wbx.spring",
"kr.co.accura.wtm"
})
@EnableJpaRepositories(basePackages = {
"kr.co.accura.wbx.spring",
"kr.co.accura.wtm"
})
public class WtmApplication {
public static void main(String[] args) {
SpringApplication.run(WtmApplication.class, args);
}
}

파일 보기

@@ -0,0 +1,75 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import kr.co.accura.wtm.domain.approval.dto.*;
import kr.co.accura.wtm.domain.approval.service.ApprovalService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/approvals")
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService approvalService;
@GetMapping("/pending")
public Page<ApprovalLineItemDto> pending(Pageable pageable) {
return approvalService.getPending(SecurityUtils.getCurrentUserId(), pageable);
}
@PostMapping("/{approvalId}/approve")
public ResponseEntity<Map<String, String>> approve(
@PathVariable Long approvalId,
@RequestBody(required = false) ApprovalActionRequest request) {
approvalService.approve(approvalId, SecurityUtils.getCurrentUserId(),
request != null ? request.comment() : null);
return ResponseEntity.ok(Map.of("message", "승인 완료"));
}
@PostMapping("/{approvalId}/reject")
public ResponseEntity<Map<String, String>> reject(
@PathVariable Long approvalId,
@RequestBody(required = false) ApprovalActionRequest request) {
approvalService.reject(approvalId, SecurityUtils.getCurrentUserId(),
request != null ? request.comment() : null);
return ResponseEntity.ok(Map.of("message", "반려 완료"));
}
@PostMapping("/batch-approve")
public ResponseEntity<Map<String, String>> batchApprove(
@RequestBody BatchApproveRequest request) {
approvalService.batchApprove(request.lineIds(), SecurityUtils.getCurrentUserId(), request.comment());
return ResponseEntity.ok(Map.of("message", "일괄 승인 완료"));
}
@PostMapping("/{approvalId}/comments")
public ResponseEntity<Map<String, String>> addComment(
@PathVariable Long approvalId,
@RequestBody ApprovalActionRequest request) {
approvalService.addComment(approvalId, SecurityUtils.getCurrentUserId(), request.comment());
return ResponseEntity.ok(Map.of("message", "코멘트 등록 완료"));
}
@GetMapping("/{approvalId}")
public ApprovalDto getDetail(@PathVariable Long approvalId) {
return approvalService.getApprovalDetail(approvalId);
}
@GetMapping("/history")
public Page<ApprovalLineItemDto> history(Pageable pageable) {
return approvalService.getHistory(SecurityUtils.getCurrentUserId(), pageable);
}
@GetMapping("/overdue")
public Page<ApprovalLineItemDto> overdue(Pageable pageable) {
// For now returns pending items — can be enhanced with date filter
return approvalService.getPending(SecurityUtils.getCurrentUserId(), pageable);
}
}

파일 보기

@@ -0,0 +1,67 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalLineRepository;
import kr.co.accura.wtm.domain.timesheet.TimesheetStatus;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/home")
@RequiredArgsConstructor
public class HomeController {
private final TimesheetRepository timesheetRepository;
private final TtApprovalLineRepository approvalLineRepository;
@GetMapping("/dashboard")
public Map<String, Object> dashboard() {
Long userId = SecurityUtils.getCurrentUserId();
Map<String, Object> data = new HashMap<>();
// Current week timesheet status
var recentTimesheets = timesheetRepository.findByUserId(userId, PageRequest.of(0, 5));
data.put("recentTimesheets", recentTimesheets.getContent().stream().map(ts ->
Map.of(
"id", ts.getId(),
"weekStartDate", ts.getWeekStartDate().toString(),
"status", ts.getStatus().name(),
"totalHours", ts.getTotalHours()
)).toList());
// Pending approvals count
var pendingApprovals = approvalLineRepository.findPendingByApproverId(
userId, PageRequest.of(0, 1));
data.put("pendingApprovalCount", pendingApprovals.getTotalElements());
return data;
}
@GetMapping("/notifications")
public Map<String, Object> notifications() {
Long userId = SecurityUtils.getCurrentUserId();
Map<String, Object> data = new HashMap<>();
// Pending approval notifications
var pendingLines = approvalLineRepository.findPendingByApproverId(
userId, PageRequest.of(0, 10));
data.put("items", pendingLines.getContent().stream().map(line ->
Map.of(
"type", "APPROVAL_REQUEST",
"title", "시수 결재 요청",
"message", "결재 대기 항목이 있습니다. (결재라인 #" + line.getId() + ")",
"createdAt", line.getCreatedAt() != null ? line.getCreatedAt().toString() : ""
)).toList());
data.put("total", pendingLines.getTotalElements());
return data;
}
}

파일 보기

@@ -0,0 +1,26 @@
package kr.co.accura.wtm.api;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/integration/hr")
@RequiredArgsConstructor
public class HrIntegrationController {
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> upload(@RequestParam("file") MultipartFile file) {
// TODO: Implement HR data upload from Excel/CSV
return ResponseEntity.ok(Map.of("message", "HR sync not yet implemented"));
}
@PostMapping("/sync")
public ResponseEntity<Map<String, String>> sync(@RequestBody Map<String, Object> request) {
// TODO: Implement HR system sync (SAP, etc.)
return ResponseEntity.ok(Map.of("message", "HR sync not yet implemented"));
}
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.config.dto.OverheadTypeDto;
import kr.co.accura.wtm.domain.config.service.OverheadTypeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/wtm/overhead-types")
@RequiredArgsConstructor
public class OverheadTypeController {
private final OverheadTypeService overheadTypeService;
@GetMapping
public List<OverheadTypeDto> list() {
return overheadTypeService.getAll();
}
@PostMapping
public OverheadTypeDto create(@RequestBody OverheadTypeDto dto) {
return overheadTypeService.create(dto);
}
@PutMapping("/{id}")
public OverheadTypeDto update(@PathVariable Long id, @RequestBody OverheadTypeDto dto) {
return overheadTypeService.update(id, dto);
}
}

파일 보기

@@ -0,0 +1,62 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.project.dto.*;
import kr.co.accura.wtm.domain.project.service.ProjectService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/projects")
@RequiredArgsConstructor
public class ProjectController {
private final ProjectService projectService;
@GetMapping
public ResponseEntity<Map<String, Object>> list(Pageable pageable) {
Page<ProjectDto> page = projectService.findAll(pageable);
return ResponseEntity.ok(Map.of(
"items", page.getContent(),
"total", page.getTotalElements()
));
}
@PostMapping
public ResponseEntity<ProjectDto> create(@RequestBody @Valid ProjectCreateRequest request) {
return ResponseEntity.ok(projectService.create(request));
}
@GetMapping("/{id}")
public ResponseEntity<ProjectDto> getById(@PathVariable Long id) {
return ResponseEntity.ok(projectService.findById(id));
}
@PutMapping("/{id}")
public ResponseEntity<ProjectDto> update(@PathVariable Long id,
@RequestBody @Valid ProjectCreateRequest request) {
return ResponseEntity.ok(projectService.update(id, request));
}
@GetMapping("/my")
public ResponseEntity<List<ProjectDto>> myProjects(@RequestParam Long userId) {
return ResponseEntity.ok(projectService.findMyProjects(userId));
}
@GetMapping("/{id}/members")
public ResponseEntity<List<ProjectAssignmentDto>> getMembers(@PathVariable Long id) {
return ResponseEntity.ok(projectService.getAssignments(id));
}
@PostMapping("/{id}/members")
public ResponseEntity<ProjectAssignmentDto> addMember(@PathVariable Long id,
@RequestBody @Valid AssignmentCreateRequest request) {
return ResponseEntity.ok(projectService.createAssignment(id, request));
}
}

파일 보기

@@ -0,0 +1,81 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.report.dto.ProjectHoursReport;
import kr.co.accura.wtm.domain.report.dto.ReportFilter;
import kr.co.accura.wtm.domain.report.dto.WbsHoursReport;
import kr.co.accura.wtm.domain.report.service.ReportService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/wtm/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
@GetMapping("/project-hours")
public ProjectHoursReport projectHours(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
@RequestParam(required = false) String groupBy) {
return reportService.getProjectHoursReport(
new ReportFilter(projectId, from, to, groupBy, null, null));
}
@GetMapping("/project-hours/export")
public ProjectHoursReport projectHoursExport(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
@RequestParam(required = false) String groupBy) {
// TODO: Return Excel byte[] with proper content type
return reportService.getProjectHoursReport(
new ReportFilter(projectId, from, to, groupBy, null, null));
}
@GetMapping("/wbs-hours")
public WbsHoursReport wbsHours(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) Integer wbsLevel,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return reportService.getWbsHoursReport(
new ReportFilter(projectId, from, to, null, wbsLevel, null));
}
@GetMapping("/wbs-hours/export")
public WbsHoursReport wbsHoursExport(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) Integer wbsLevel,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
// TODO: Return Excel byte[] with proper content type
return reportService.getWbsHoursReport(
new ReportFilter(projectId, from, to, null, wbsLevel, null));
}
@GetMapping("/phase-ratio")
public ProjectHoursReport phaseRatio(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
// TODO: Implement phase ratio logic
return reportService.getProjectHoursReport(
new ReportFilter(projectId, from, to, null, null, null));
}
@GetMapping("/np-ratio")
public ProjectHoursReport npRatio(
@RequestParam(required = false) String department,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
// TODO: Implement NP ratio logic
return reportService.getProjectHoursReport(
new ReportFilter(null, from, to, null, null, department));
}
}

파일 보기

@@ -0,0 +1,59 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.project.dto.*;
import kr.co.accura.wtm.domain.project.service.ProjectService;
import kr.co.accura.wtm.domain.user.dto.UserDto;
import kr.co.accura.wtm.domain.user.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/projects/{projectId}/assignments")
@RequiredArgsConstructor
public class ResourceAssignController {
private final ProjectService projectService;
@GetMapping
public ResponseEntity<List<ProjectAssignmentDto>> list(@PathVariable Long projectId) {
return ResponseEntity.ok(projectService.getAssignments(projectId));
}
@PostMapping
public ResponseEntity<ProjectAssignmentDto> create(@PathVariable Long projectId,
@RequestBody @Valid AssignmentCreateRequest request) {
return ResponseEntity.ok(projectService.createAssignment(projectId, request));
}
@PutMapping("/{assignId}")
public ResponseEntity<ProjectAssignmentDto> update(@PathVariable Long projectId,
@PathVariable Long assignId,
@RequestBody @Valid AssignmentCreateRequest request) {
return ResponseEntity.ok(projectService.updateAssignment(projectId, assignId, request));
}
@DeleteMapping("/{assignId}")
public ResponseEntity<Void> delete(@PathVariable Long projectId,
@PathVariable Long assignId) {
projectService.deleteAssignment(projectId, assignId);
return ResponseEntity.noContent().build();
}
@GetMapping("/available")
public ResponseEntity<Map<String, Object>> getAvailable(@PathVariable Long projectId,
Pageable pageable) {
Page<User> page = projectService.getAvailableUsers(projectId, pageable);
List<UserDto> users = page.getContent().stream().map(UserDto::from).toList();
return ResponseEntity.ok(Map.of(
"items", users,
"total", page.getTotalElements()
));
}
}

파일 보기

@@ -0,0 +1,46 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.teal.dto.TealEntryDto;
import kr.co.accura.wtm.domain.teal.dto.TealVersionDto;
import kr.co.accura.wtm.domain.teal.service.TealService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/wtm/projects/{projectId}/teal")
@RequiredArgsConstructor
public class TealController {
private final TealService tealService;
@PostMapping("/upload")
public ResponseEntity<TealVersionDto> upload(
@PathVariable Long projectId,
@RequestParam("file") MultipartFile file,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate effectiveDate,
@RequestParam(required = false) String description) {
return ResponseEntity.ok(tealService.uploadTeal(projectId, file, effectiveDate, description));
}
@GetMapping("/versions")
public ResponseEntity<List<TealVersionDto>> getVersions(@PathVariable Long projectId) {
return ResponseEntity.ok(tealService.getVersions(projectId));
}
@GetMapping("/active")
public ResponseEntity<List<TealEntryDto>> getActiveEntries(@PathVariable Long projectId) {
return ResponseEntity.ok(tealService.getActiveTealEntries(projectId));
}
@GetMapping("/by-wbs/{wbsId}")
public ResponseEntity<List<TealEntryDto>> getByWbs(@PathVariable Long projectId,
@PathVariable Long wbsId) {
return ResponseEntity.ok(tealService.getEntriesByWbs(projectId, wbsId));
}
}

파일 보기

@@ -0,0 +1,81 @@
package kr.co.accura.wtm.api;
import jakarta.validation.Valid;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import kr.co.accura.wtm.domain.timesheet.dto.*;
import kr.co.accura.wtm.domain.timesheet.service.TimesheetService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/wtm/timesheets")
@RequiredArgsConstructor
public class TimesheetController {
private final TimesheetService timesheetService;
@GetMapping("/week")
public TimesheetDto getWeekly(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate weekStart) {
return timesheetService.getOrCreateWeekly(
SecurityUtils.getCurrentUserId(), weekStart);
}
@PostMapping("/{timesheetId}/entries")
public TimesheetEntryDto saveEntry(
@PathVariable Long timesheetId,
@Valid @RequestBody TimesheetEntryRequest request) {
return timesheetService.saveEntry(timesheetId, request);
}
@PutMapping("/{timesheetId}/entries/batch")
public TimesheetDto saveBatch(
@PathVariable Long timesheetId,
@Valid @RequestBody List<TimesheetEntryRequest> entries) {
return timesheetService.saveBatch(timesheetId, entries);
}
@DeleteMapping("/{timesheetId}/entries/{entryId}")
public ResponseEntity<Void> deleteEntry(
@PathVariable Long timesheetId,
@PathVariable Long entryId) {
timesheetService.deleteEntry(timesheetId, entryId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{timesheetId}/submit")
public TimesheetDto submit(@PathVariable Long timesheetId) {
return timesheetService.submit(timesheetId);
}
@PostMapping("/upload")
public UploadResultDto uploadExcel(
@RequestParam("file") MultipartFile file,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate weekStart) {
return timesheetService.uploadExcel(
SecurityUtils.getCurrentUserId(), file, weekStart);
}
@GetMapping("/upload/template")
public ResponseEntity<Void> downloadTemplate() {
// TODO: Generate and return empty Excel template for timesheet upload
return ResponseEntity.status(501).build();
}
@GetMapping("/history")
public Page<TimesheetDto> history(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
Pageable pageable) {
return timesheetService.getHistory(
SecurityUtils.getCurrentUserId(), from, to, pageable);
}
}

파일 보기

@@ -0,0 +1,66 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.user.dto.*;
import kr.co.accura.wtm.domain.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.util.Map;
@RestController
@RequestMapping("/api/wtm/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Map<String, Object>> list(Pageable pageable) {
Page<UserDto> page = userService.findAll(pageable);
return ResponseEntity.ok(Map.of(
"items", page.getContent(),
"total", page.getTotalElements()
));
}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PutMapping("/{id}")
public ResponseEntity<UserDto> update(@PathVariable Long id,
@RequestBody @Valid UserUpdateRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@PutMapping("/{id}/roles")
public ResponseEntity<Void> assignRoles(@PathVariable Long id,
@RequestBody @Valid RoleAssignRequest request) {
userService.assignRoles(id, request);
return ResponseEntity.ok().build();
}
@PostMapping("/upload/internal")
public ResponseEntity<Map<String, Object>> uploadInternal(@RequestParam("file") MultipartFile file) {
// TODO: Implement Excel upload for internal employees
return ResponseEntity.ok(Map.of("message", "Internal user upload not yet implemented"));
}
@PostMapping("/upload/subcontractor")
public ResponseEntity<Map<String, Object>> uploadSubcontractor(@RequestParam("file") MultipartFile file) {
// TODO: Implement Excel upload for subcontractors
return ResponseEntity.ok(Map.of("message", "Subcontractor upload not yet implemented"));
}
@GetMapping("/upload/template")
public ResponseEntity<Void> downloadTemplate() {
// TODO: Implement template download
return ResponseEntity.noContent().build();
}
}

파일 보기

@@ -0,0 +1,75 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.wbs.dto.*;
import kr.co.accura.wtm.domain.wbs.dto.WbsDisciplineAssignmentDto;
import kr.co.accura.wtm.domain.wbs.service.WbsService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/wtm/projects/{projectId}")
@RequiredArgsConstructor
public class WbsController {
private final WbsService wbsService;
@PostMapping("/wbs/upload")
public ResponseEntity<WbsVersionDto> upload(
@PathVariable Long projectId,
@RequestParam("file") MultipartFile file,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate effectiveDate,
@RequestParam(required = false) String description) {
return ResponseEntity.ok(wbsService.uploadP6Wbs(projectId, file, effectiveDate, description));
}
@GetMapping("/wbs/versions")
public ResponseEntity<List<WbsVersionDto>> getVersions(@PathVariable Long projectId) {
return ResponseEntity.ok(wbsService.getVersions(projectId));
}
@GetMapping("/wbs/versions/{ver}")
public ResponseEntity<List<WbsNodeDto>> getVersionNodes(@PathVariable Long projectId,
@PathVariable("ver") Integer ver) {
return ResponseEntity.ok(wbsService.getVersionNodes(projectId, ver));
}
@PostMapping("/wbs/versions/{ver}/activate")
public ResponseEntity<Void> activateVersion(@PathVariable Long projectId,
@PathVariable("ver") Long ver) {
wbsService.activateVersion(ver);
return ResponseEntity.ok().build();
}
@GetMapping("/canonical-wbs")
public ResponseEntity<List<CanonicalWbsDto>> getCanonicalWbs(@PathVariable Long projectId) {
return ResponseEntity.ok(wbsService.getCanonicalWbs(projectId));
}
@GetMapping("/wbs/compare")
public ResponseEntity<WbsCompareResult> compare(@PathVariable Long projectId,
@RequestParam("a") int versionA,
@RequestParam("b") int versionB) {
return ResponseEntity.ok(wbsService.compareVersions(projectId, versionA, versionB));
}
/* ── WBS-Discipline assignments ── */
@GetMapping("/wbs-disciplines")
public ResponseEntity<List<WbsDisciplineAssignmentDto>> getWbsDisciplines(
@PathVariable Long projectId) {
return ResponseEntity.ok(wbsService.getWbsDisciplines(projectId));
}
@PutMapping("/wbs-disciplines")
public ResponseEntity<List<WbsDisciplineAssignmentDto>> saveWbsDisciplines(
@PathVariable Long projectId,
@RequestBody List<WbsDisciplineAssignmentDto> assignments) {
return ResponseEntity.ok(wbsService.saveWbsDisciplines(projectId, assignments));
}
}

파일 보기

@@ -0,0 +1,26 @@
package kr.co.accura.wtm.api;
import kr.co.accura.wtm.domain.config.dto.WorkRuleDto;
import kr.co.accura.wtm.domain.config.service.WorkRuleService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/wtm/work-rules")
@RequiredArgsConstructor
public class WorkRuleController {
private final WorkRuleService workRuleService;
@GetMapping
public List<WorkRuleDto> list() {
return workRuleService.getAll();
}
@PutMapping
public List<WorkRuleDto> update(@RequestBody List<WorkRuleDto> dtos) {
return workRuleService.saveAll(dtos);
}
}

파일 보기

@@ -0,0 +1,52 @@
package kr.co.accura.wtm.api;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* WTM-specific auth endpoints that supplement wbx-spring-core's AuthController.
* <p>
* wbx-spring-core already provides: /api/wtm/auth/login, /me, /refresh, /logout, /password/change.
* This controller adds only the MISSING endpoints: SSO and password-reset.
*/
@RestController
@RequiredArgsConstructor
public class WtmAuthController {
/**
* SSO initiation — redirects to OAuth2 authorization endpoint.
* Requires Azure Entra ID configuration.
*/
@GetMapping("/api/wtm/auth/sso")
public ResponseEntity<Map<String, String>> ssoInitiate() {
// TODO: Redirect to OAuth2 authorization URI once Azure Entra ID is configured
return ResponseEntity.status(501)
.body(Map.of("message", "SSO not yet configured. Azure Entra ID integration pending."));
}
/**
* SSO callback — handled by Spring Security OAuth2 filter chain.
* This endpoint exists for documentation; actual handling is done by SsoSuccessHandler.
*/
@GetMapping("/api/wtm/auth/sso/callback")
public ResponseEntity<Map<String, String>> ssoCallback() {
return ResponseEntity.status(501)
.body(Map.of("message", "SSO callback handled by Spring Security OAuth2."));
}
/**
* Password reset request — sends reset link to email.
*/
@PostMapping("/api/wtm/auth/password/reset")
public ResponseEntity<Map<String, String>> resetPassword(@RequestBody Map<String, String> request) {
// TODO: Implement password reset email flow
String email = request.get("email");
return ResponseEntity.ok(Map.of(
"message", "비밀번호 재설정 링크가 이메일로 전송되었습니다.",
"email", email != null ? email : ""
));
}
}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.config;
import org.springframework.context.annotation.Configuration;
/**
* WTM 전용 설정 클래스.
* WTM 프로젝트에 특화된 Bean 및 설정을 여기에 추가.
*/
@Configuration
public class WtmConfig {
}

파일 보기

@@ -0,0 +1,5 @@
package kr.co.accura.wtm.domain.approval.dto;
public record ApprovalActionRequest(
String comment
) {}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.domain.approval.dto;
import java.time.LocalDateTime;
public record ApprovalCommentDto(
Long id,
Long userId,
String comment,
String action,
LocalDateTime createdAt
) {}

파일 보기

@@ -0,0 +1,16 @@
package kr.co.accura.wtm.domain.approval.dto;
import java.time.LocalDateTime;
import java.util.List;
public record ApprovalDto(
Long id,
Long timesheetId,
Long requesterId,
Long projectId,
String status,
LocalDateTime submittedAt,
LocalDateTime completedAt,
List<ApprovalLineItemDto> lines,
List<ApprovalCommentDto> comments
) {}

파일 보기

@@ -0,0 +1,12 @@
package kr.co.accura.wtm.domain.approval.dto;
import java.time.LocalDateTime;
public record ApprovalLineItemDto(
Long id,
Long approverId,
int approvalOrder,
String roleCode,
String status,
LocalDateTime actedAt
) {}

파일 보기

@@ -0,0 +1,8 @@
package kr.co.accura.wtm.domain.approval.dto;
import java.util.List;
public record BatchApproveRequest(
List<Long> lineIds,
String comment
) {}

파일 보기

@@ -0,0 +1,61 @@
package kr.co.accura.wtm.domain.approval.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "approvals", uniqueConstraints = {
@UniqueConstraint(columnNames = {"timesheet_id"})
})
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TtApproval extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "timesheet_id", nullable = false)
private Long timesheetId;
@Column(name = "requester_id", nullable = false)
private Long requesterId;
@Column(name = "project_id")
private Long projectId;
@Column(length = 20)
@Builder.Default
private String status = "PENDING";
@Column(name = "submitted_at")
private LocalDateTime submittedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@OneToMany(mappedBy = "approval", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<TtApprovalLine> lines = new ArrayList<>();
@OneToMany(mappedBy = "approval", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<TtApprovalComment> comments = new ArrayList<>();
public void complete() {
this.status = "APPROVED";
this.completedAt = LocalDateTime.now();
}
public void reject() {
this.status = "REJECTED";
this.completedAt = LocalDateTime.now();
}
}

파일 보기

@@ -0,0 +1,33 @@
package kr.co.accura.wtm.domain.approval.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "approval_comments")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TtApprovalComment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "approval_id", nullable = false)
private TtApproval approval;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(length = 2000)
private String comment;
@Column(length = 20)
private String action;
}

파일 보기

@@ -0,0 +1,50 @@
package kr.co.accura.wtm.domain.approval.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "approval_lines")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TtApprovalLine extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "approval_id", nullable = false)
private TtApproval approval;
@Column(name = "approver_id", nullable = false)
private Long approverId;
@Column(name = "approval_order", nullable = false)
private int approvalOrder;
@Column(name = "role_code", length = 20)
private String roleCode;
@Column(length = 20)
@Builder.Default
private String status = "PENDING";
@Column(name = "acted_at")
private LocalDateTime actedAt;
public void approve() {
this.status = "APPROVED";
this.actedAt = LocalDateTime.now();
}
public void reject() {
this.status = "REJECTED";
this.actedAt = LocalDateTime.now();
}
}

파일 보기

@@ -0,0 +1,117 @@
package kr.co.accura.wtm.domain.approval.handler;
import kr.co.accura.wbx.spring.approval.*;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.approval.entity.TtApproval;
import kr.co.accura.wtm.domain.approval.entity.TtApprovalLine;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalLineRepository;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalRepository;
import kr.co.accura.wtm.domain.timesheet.TimesheetStatus;
import kr.co.accura.wtm.domain.timesheet.entity.Timesheet;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class TimesheetApprovalHandler implements ApprovalHandler {
private final TtApprovalRepository approvalRepository;
private final TtApprovalLineRepository lineRepository;
private final TimesheetRepository timesheetRepository;
private final ApplicationEventPublisher eventPublisher;
@Override
public String getTypeKey() {
return "timesheet";
}
@Override
public String getTypeDisplay() {
return "시수 결재";
}
@Override
@Transactional
public ApprovalResult approve(Long lineId, Long approverId, String comment) {
TtApprovalLine line = lineRepository.findById(lineId)
.orElseThrow(() -> new NotFoundException("결재 라인을 찾을 수 없습니다."));
line.approve();
lineRepository.save(line);
TtApproval approval = line.getApproval();
Timesheet ts = timesheetRepository.findById(approval.getTimesheetId())
.orElseThrow(() -> new NotFoundException("Timesheet not found"));
Optional<TtApprovalLine> next = lineRepository.findNextPending(
approval.getId(), line.getApprovalOrder());
if (next.isPresent()) {
ts.setStatus(TimesheetStatus.DL_APPROVED);
} else {
ts.setStatus(TimesheetStatus.APPROVED);
approval.complete();
approvalRepository.save(approval);
eventPublisher.publishEvent(new ApprovalCompletedEvent(
"timesheet", ts.getId(), approverId, approval));
}
timesheetRepository.save(ts);
return ApprovalResult.success("승인 완료");
}
@Override
@Transactional
public ApprovalResult reject(Long lineId, Long approverId, String comment) {
TtApprovalLine line = lineRepository.findById(lineId)
.orElseThrow(() -> new NotFoundException("결재 라인을 찾을 수 없습니다."));
line.reject();
lineRepository.save(line);
TtApproval approval = line.getApproval();
approval.reject();
approvalRepository.save(approval);
Timesheet ts = timesheetRepository.findById(approval.getTimesheetId())
.orElseThrow(() -> new NotFoundException("Timesheet not found"));
ts.setStatus(TimesheetStatus.REJECTED);
timesheetRepository.save(ts);
return ApprovalResult.success("반려 완료");
}
@Override
@Transactional(readOnly = true)
public ApprovalHistoryDto getApprovalHistory(Long timesheetId) {
TtApproval approval = approvalRepository.findByTimesheetId(timesheetId)
.orElseThrow(() -> new NotFoundException("결재 정보를 찾을 수 없습니다."));
List<ApprovalLineDto> lineDtos = approval.getLines().stream()
.map(l -> new ApprovalLineDto(
l.getId(), l.getApproverId(), null,
l.getApprovalOrder(), l.getRoleCode(), l.getStatus(),
null, l.getActedAt()))
.toList();
return new ApprovalHistoryDto(
timesheetId, "timesheet", approval.getStatus(),
null, approval.getSubmittedAt(), lineDtos);
}
@Override
@Transactional(readOnly = true)
public Page<ApprovalPendingDto> getPending(Long approverId, Pageable pageable) {
return lineRepository.findPendingByApproverId(approverId, pageable)
.map(line -> new ApprovalPendingDto(
line.getId(), "timesheet",
"시수 결재 #" + line.getApproval().getTimesheetId(),
null, line.getApproval().getSubmittedAt()));
}
}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.domain.approval.repository;
import kr.co.accura.wtm.domain.approval.entity.TtApprovalComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TtApprovalCommentRepository extends JpaRepository<TtApprovalComment, Long> {
List<TtApprovalComment> findByApproval_IdOrderByCreatedAtAsc(Long approvalId);
}

파일 보기

@@ -0,0 +1,29 @@
package kr.co.accura.wtm.domain.approval.repository;
import kr.co.accura.wtm.domain.approval.entity.TtApprovalLine;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface TtApprovalLineRepository extends JpaRepository<TtApprovalLine, Long> {
List<TtApprovalLine> findByApproval_IdOrderByApprovalOrder(Long approvalId);
@Query("SELECT l FROM TtApprovalLine l WHERE l.approval.id = :approvalId " +
"AND l.approvalOrder > :currentOrder AND l.status = 'PENDING' " +
"ORDER BY l.approvalOrder ASC")
Optional<TtApprovalLine> findNextPending(
@Param("approvalId") Long approvalId,
@Param("currentOrder") int currentOrder);
@Query("SELECT l FROM TtApprovalLine l WHERE l.approverId = :approverId AND l.status = 'PENDING'")
Page<TtApprovalLine> findPendingByApproverId(@Param("approverId") Long approverId, Pageable pageable);
@Query("SELECT l FROM TtApprovalLine l WHERE l.approverId = :approverId AND l.status <> 'PENDING'")
Page<TtApprovalLine> findHistoryByApproverId(@Param("approverId") Long approverId, Pageable pageable);
}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.domain.approval.repository;
import kr.co.accura.wtm.domain.approval.entity.TtApproval;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface TtApprovalRepository extends JpaRepository<TtApproval, Long> {
Optional<TtApproval> findByTimesheetId(Long timesheetId);
}

파일 보기

@@ -0,0 +1,145 @@
package kr.co.accura.wtm.domain.approval.service;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.approval.dto.*;
import kr.co.accura.wtm.domain.approval.entity.TtApproval;
import kr.co.accura.wtm.domain.approval.entity.TtApprovalComment;
import kr.co.accura.wtm.domain.approval.entity.TtApprovalLine;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalCommentRepository;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalLineRepository;
import kr.co.accura.wtm.domain.approval.repository.TtApprovalRepository;
import kr.co.accura.wtm.domain.timesheet.TimesheetStatus;
import kr.co.accura.wtm.domain.timesheet.entity.Timesheet;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class ApprovalService {
private final TtApprovalRepository approvalRepository;
private final TtApprovalLineRepository lineRepository;
private final TtApprovalCommentRepository commentRepository;
private final TimesheetRepository timesheetRepository;
@Transactional(readOnly = true)
public Page<ApprovalLineItemDto> getPending(Long approverId, Pageable pageable) {
return lineRepository.findPendingByApproverId(approverId, pageable)
.map(this::toLineDto);
}
public void approve(Long lineId, Long approverId, String comment) {
TtApprovalLine line = lineRepository.findById(lineId)
.orElseThrow(() -> new NotFoundException("결재 라인을 찾을 수 없습니다."));
line.approve();
lineRepository.save(line);
TtApproval approval = line.getApproval();
Timesheet ts = timesheetRepository.findById(approval.getTimesheetId())
.orElseThrow(() -> new NotFoundException("Timesheet not found"));
// Check for next pending approver
var next = lineRepository.findNextPending(approval.getId(), line.getApprovalOrder());
if (next.isPresent()) {
ts.setStatus(TimesheetStatus.DL_APPROVED);
} else {
ts.setStatus(TimesheetStatus.APPROVED);
approval.complete();
approvalRepository.save(approval);
}
timesheetRepository.save(ts);
if (comment != null && !comment.isBlank()) {
addComment(approval, approverId, comment, "APPROVE");
}
}
public void reject(Long lineId, Long approverId, String comment) {
TtApprovalLine line = lineRepository.findById(lineId)
.orElseThrow(() -> new NotFoundException("결재 라인을 찾을 수 없습니다."));
line.reject();
lineRepository.save(line);
TtApproval approval = line.getApproval();
approval.reject();
approvalRepository.save(approval);
Timesheet ts = timesheetRepository.findById(approval.getTimesheetId())
.orElseThrow(() -> new NotFoundException("Timesheet not found"));
ts.setStatus(TimesheetStatus.REJECTED);
timesheetRepository.save(ts);
if (comment != null && !comment.isBlank()) {
addComment(approval, approverId, comment, "REJECT");
}
}
public void batchApprove(List<Long> lineIds, Long approverId, String comment) {
for (Long lineId : lineIds) {
approve(lineId, approverId, comment);
}
}
public void addComment(Long approvalId, Long userId, String comment) {
TtApproval approval = approvalRepository.findById(approvalId)
.orElseThrow(() -> new NotFoundException("결재를 찾을 수 없습니다."));
addComment(approval, userId, comment, "COMMENT");
}
@Transactional(readOnly = true)
public ApprovalDto getApprovalDetail(Long approvalId) {
TtApproval approval = approvalRepository.findById(approvalId)
.orElseThrow(() -> new NotFoundException("결재를 찾을 수 없습니다."));
return toDto(approval);
}
@Transactional(readOnly = true)
public Page<ApprovalLineItemDto> getHistory(Long approverId, Pageable pageable) {
return lineRepository.findHistoryByApproverId(approverId, pageable)
.map(this::toLineDto);
}
@Transactional(readOnly = true)
public List<ApprovalCommentDto> getComments(Long approvalId) {
return commentRepository.findByApproval_IdOrderByCreatedAtAsc(approvalId).stream()
.map(this::toCommentDto)
.toList();
}
// --- Helper methods ---
private void addComment(TtApproval approval, Long userId, String comment, String action) {
TtApprovalComment c = TtApprovalComment.builder()
.approval(approval)
.userId(userId)
.comment(comment)
.action(action)
.build();
commentRepository.save(c);
}
private ApprovalDto toDto(TtApproval a) {
List<ApprovalLineItemDto> lines = a.getLines().stream().map(this::toLineDto).toList();
List<ApprovalCommentDto> comments = a.getComments().stream().map(this::toCommentDto).toList();
return new ApprovalDto(a.getId(), a.getTimesheetId(), a.getRequesterId(),
a.getProjectId(), a.getStatus(), a.getSubmittedAt(), a.getCompletedAt(),
lines, comments);
}
private ApprovalLineItemDto toLineDto(TtApprovalLine l) {
return new ApprovalLineItemDto(l.getId(), l.getApproverId(),
l.getApprovalOrder(), l.getRoleCode(), l.getStatus(), l.getActedAt());
}
private ApprovalCommentDto toCommentDto(TtApprovalComment c) {
return new ApprovalCommentDto(c.getId(), c.getUserId(),
c.getComment(), c.getAction(), c.getCreatedAt());
}
}

파일 보기

@@ -0,0 +1,39 @@
package kr.co.accura.wtm.domain.audit.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
@Entity
@Table(name = "sa_access_logs")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SaAccessLog extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, length = 50)
private String action;
@Column(length = 100)
private String resource;
@Column(name = "resource_id")
private Long resourceId;
@Column(name = "ip_address", length = 50)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(length = 2000)
private String detail;
}

파일 보기

@@ -0,0 +1,13 @@
package kr.co.accura.wtm.domain.audit.repository;
import kr.co.accura.wtm.domain.audit.entity.SaAccessLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SaAccessLogRepository extends JpaRepository<SaAccessLog, Long> {
Page<SaAccessLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
Page<SaAccessLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

파일 보기

@@ -0,0 +1,10 @@
package kr.co.accura.wtm.domain.config.dto;
public record OverheadTypeDto(
Long id,
String code,
String name,
String category,
Boolean isActive,
Integer sortOrder
) {}

파일 보기

@@ -0,0 +1,12 @@
package kr.co.accura.wtm.domain.config.dto;
import java.math.BigDecimal;
public record WorkRuleDto(
Long id,
String location,
BigDecimal minDailyHours,
BigDecimal maxDailyHours,
BigDecimal maxWeeklyHours,
Boolean isActive
) {}

파일 보기

@@ -0,0 +1,35 @@
package kr.co.accura.wtm.domain.config.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
@Entity
@Table(name = "overhead_types")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OverheadType extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String code;
@Column(nullable = false, length = 200)
private String name;
@Column(length = 50)
private String category;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@Column(name = "sort_order")
@Builder.Default
private Integer sortOrder = 0;
}

파일 보기

@@ -0,0 +1,39 @@
package kr.co.accura.wtm.domain.config.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
import java.math.BigDecimal;
@Entity
@Table(name = "work_rules")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class WorkRule extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50)
private String location;
@Column(name = "min_daily_hours", precision = 4, scale = 2)
@Builder.Default
private BigDecimal minDailyHours = new BigDecimal("8.00");
@Column(name = "max_daily_hours", precision = 4, scale = 2)
@Builder.Default
private BigDecimal maxDailyHours = new BigDecimal("12.00");
@Column(name = "max_weekly_hours", precision = 5, scale = 2)
@Builder.Default
private BigDecimal maxWeeklyHours = new BigDecimal("52.00");
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.domain.config.repository;
import kr.co.accura.wtm.domain.config.entity.OverheadType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OverheadTypeRepository extends JpaRepository<OverheadType, Long> {
List<OverheadType> findByIsActiveTrueOrderBySortOrderAsc();
}

파일 보기

@@ -0,0 +1,14 @@
package kr.co.accura.wtm.domain.config.repository;
import kr.co.accura.wtm.domain.config.entity.WorkRule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface WorkRuleRepository extends JpaRepository<WorkRule, Long> {
Optional<WorkRule> findByLocation(String location);
List<WorkRule> findByIsActiveTrue();
}

파일 보기

@@ -0,0 +1,53 @@
package kr.co.accura.wtm.domain.config.service;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.config.dto.OverheadTypeDto;
import kr.co.accura.wtm.domain.config.entity.OverheadType;
import kr.co.accura.wtm.domain.config.repository.OverheadTypeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class OverheadTypeService {
private final OverheadTypeRepository repository;
@Transactional(readOnly = true)
public List<OverheadTypeDto> getAll() {
return repository.findByIsActiveTrueOrderBySortOrderAsc().stream()
.map(this::toDto)
.toList();
}
public OverheadTypeDto create(OverheadTypeDto dto) {
OverheadType entity = OverheadType.builder()
.code(dto.code())
.name(dto.name())
.category(dto.category())
.isActive(dto.isActive() != null ? dto.isActive() : true)
.sortOrder(dto.sortOrder() != null ? dto.sortOrder() : 0)
.build();
return toDto(repository.save(entity));
}
public OverheadTypeDto update(Long id, OverheadTypeDto dto) {
OverheadType entity = repository.findById(id)
.orElseThrow(() -> new NotFoundException("Overhead type not found: " + id));
entity.setCode(dto.code());
entity.setName(dto.name());
entity.setCategory(dto.category());
if (dto.isActive() != null) entity.setIsActive(dto.isActive());
if (dto.sortOrder() != null) entity.setSortOrder(dto.sortOrder());
return toDto(repository.save(entity));
}
private OverheadTypeDto toDto(OverheadType e) {
return new OverheadTypeDto(e.getId(), e.getCode(), e.getName(),
e.getCategory(), e.getIsActive(), e.getSortOrder());
}
}

파일 보기

@@ -0,0 +1,54 @@
package kr.co.accura.wtm.domain.config.service;
import kr.co.accura.wtm.domain.config.dto.WorkRuleDto;
import kr.co.accura.wtm.domain.config.entity.WorkRule;
import kr.co.accura.wtm.domain.config.repository.WorkRuleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class WorkRuleService {
private final WorkRuleRepository repository;
@Transactional(readOnly = true)
public List<WorkRuleDto> getAll() {
return repository.findByIsActiveTrue().stream()
.map(this::toDto)
.toList();
}
public List<WorkRuleDto> saveAll(List<WorkRuleDto> dtos) {
List<WorkRule> rules = dtos.stream().map(dto -> {
WorkRule rule;
if (dto.id() != null) {
rule = repository.findById(dto.id()).orElse(new WorkRule());
} else if (dto.location() != null) {
rule = repository.findByLocation(dto.location()).orElse(new WorkRule());
} else {
rule = new WorkRule();
}
rule.setLocation(dto.location());
if (dto.minDailyHours() != null) rule.setMinDailyHours(dto.minDailyHours());
if (dto.maxDailyHours() != null) rule.setMaxDailyHours(dto.maxDailyHours());
if (dto.maxWeeklyHours() != null) rule.setMaxWeeklyHours(dto.maxWeeklyHours());
if (dto.isActive() != null) rule.setIsActive(dto.isActive());
return rule;
}).toList();
return repository.saveAll(rules).stream()
.map(this::toDto)
.toList();
}
private WorkRuleDto toDto(WorkRule e) {
return new WorkRuleDto(e.getId(), e.getLocation(),
e.getMinDailyHours(), e.getMaxDailyHours(),
e.getMaxWeeklyHours(), e.getIsActive());
}
}

파일 보기

@@ -0,0 +1,7 @@
package kr.co.accura.wtm.domain.project;
public enum ProjectStatus {
ACTIVE,
CLOSED,
HOLD
}

파일 보기

@@ -0,0 +1,7 @@
package kr.co.accura.wtm.domain.project;
public enum ProjectType {
EPC,
NON_PROJECT,
OTHER
}

파일 보기

@@ -0,0 +1,9 @@
package kr.co.accura.wtm.domain.project.dto;
import jakarta.validation.constraints.NotNull;
public record AssignmentCreateRequest(
@NotNull Long userId,
String role
) {
}

파일 보기

@@ -0,0 +1,27 @@
package kr.co.accura.wtm.domain.project.dto;
import kr.co.accura.wtm.domain.project.entity.ProjectAssignment;
import java.time.LocalDateTime;
public record ProjectAssignmentDto(
Long id,
Long projectId,
Long userId,
String userName,
String userEmail,
String role,
LocalDateTime assignedAt
) {
public static ProjectAssignmentDto from(ProjectAssignment assignment) {
return new ProjectAssignmentDto(
assignment.getId(),
assignment.getProject().getId(),
assignment.getUser().getId(),
assignment.getUser().getFullName(),
assignment.getUser().getEmail(),
assignment.getRole(),
assignment.getAssignedAt()
);
}
}

파일 보기

@@ -0,0 +1,16 @@
package kr.co.accura.wtm.domain.project.dto;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDate;
public record ProjectCreateRequest(
@NotBlank String projectCode,
@NotBlank String name,
String description,
@NotBlank String projectType,
LocalDate startDate,
LocalDate endDate,
Long pmUserId
) {
}

파일 보기

@@ -0,0 +1,33 @@
package kr.co.accura.wtm.domain.project.dto;
import kr.co.accura.wtm.domain.project.entity.Project;
import java.time.LocalDate;
public record ProjectDto(
Long id,
String projectCode,
String name,
String description,
String projectType,
String status,
LocalDate startDate,
LocalDate endDate,
Long pmUserId,
String pmUserName
) {
public static ProjectDto from(Project project) {
return new ProjectDto(
project.getId(),
project.getProjectCode(),
project.getName(),
project.getDescription(),
project.getProjectType() != null ? project.getProjectType().name() : null,
project.getStatus() != null ? project.getStatus().name() : null,
project.getStartDate(),
project.getEndDate(),
project.getPmUser() != null ? project.getPmUser().getId() : null,
project.getPmUser() != null ? project.getPmUser().getFullName() : null
);
}
}

파일 보기

@@ -0,0 +1,52 @@
package kr.co.accura.wtm.domain.project.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import kr.co.accura.wtm.domain.project.ProjectStatus;
import kr.co.accura.wtm.domain.project.ProjectType;
import kr.co.accura.wtm.domain.user.entity.User;
import lombok.*;
import java.time.LocalDate;
@Entity
@Table(name = "projects")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Project extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "project_code", unique = true, nullable = false, length = 50)
private String projectCode;
@Column(nullable = false, length = 255)
private String name;
@Column(length = 1000)
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "project_type", nullable = false, length = 20)
private ProjectType projectType;
@Enumerated(EnumType.STRING)
@Column(length = 20)
@Builder.Default
private ProjectStatus status = ProjectStatus.ACTIVE;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pm_user_id")
private User pmUser;
}

파일 보기

@@ -0,0 +1,39 @@
package kr.co.accura.wtm.domain.project.entity;
import jakarta.persistence.*;
import kr.co.accura.wtm.domain.user.entity.User;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "project_assignments")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProjectAssignment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(length = 50)
private String role;
@Column(name = "assigned_at")
@Builder.Default
private LocalDateTime assignedAt = LocalDateTime.now();
@Column(name = "assigned_by")
private Long assignedBy;
}

파일 보기

@@ -0,0 +1,44 @@
package kr.co.accura.wtm.domain.project.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "project_type_config",
uniqueConstraints = @UniqueConstraint(columnNames = {"project_type", "config_key"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProjectTypeConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "project_type", nullable = false, length = 20)
private String projectType;
@Column(name = "config_key", nullable = false, length = 100)
private String configKey;
@Column(name = "config_value", length = 500)
private String configValue;
@Column(length = 500)
private String description;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@Column(name = "created_at")
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.project.repository;
import kr.co.accura.wtm.domain.project.entity.ProjectAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProjectAssignmentRepository extends JpaRepository<ProjectAssignment, Long> {
List<ProjectAssignment> findByProject_Id(Long projectId);
List<ProjectAssignment> findByUser_Id(Long userId);
boolean existsByProject_IdAndUser_Id(Long projectId, Long userId);
}

파일 보기

@@ -0,0 +1,15 @@
package kr.co.accura.wtm.domain.project.repository;
import kr.co.accura.wtm.domain.project.entity.Project;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ProjectRepository extends JpaRepository<Project, Long> {
Optional<Project> findByProjectCode(String projectCode);
boolean existsByProjectCode(String projectCode);
}

파일 보기

@@ -0,0 +1,16 @@
package kr.co.accura.wtm.domain.project.repository;
import kr.co.accura.wtm.domain.project.entity.ProjectTypeConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProjectTypeConfigRepository extends JpaRepository<ProjectTypeConfig, Long> {
List<ProjectTypeConfig> findByProjectTypeAndIsActiveTrue(String projectType);
Optional<ProjectTypeConfig> findByProjectTypeAndConfigKey(String projectType, String configKey);
}

파일 보기

@@ -0,0 +1,144 @@
package kr.co.accura.wtm.domain.project.service;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.project.ProjectStatus;
import kr.co.accura.wtm.domain.project.ProjectType;
import kr.co.accura.wtm.domain.project.dto.*;
import kr.co.accura.wtm.domain.project.entity.Project;
import kr.co.accura.wtm.domain.project.entity.ProjectAssignment;
import kr.co.accura.wtm.domain.project.repository.ProjectAssignmentRepository;
import kr.co.accura.wtm.domain.project.repository.ProjectRepository;
import kr.co.accura.wtm.domain.user.entity.User;
import kr.co.accura.wtm.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository projectRepository;
private final ProjectAssignmentRepository assignmentRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Page<ProjectDto> findAll(Pageable pageable) {
return projectRepository.findAll(pageable).map(ProjectDto::from);
}
@Transactional(readOnly = true)
public ProjectDto findById(Long id) {
Project project = projectRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Project not found: " + id));
return ProjectDto.from(project);
}
public ProjectDto create(ProjectCreateRequest request) {
if (projectRepository.existsByProjectCode(request.projectCode())) {
throw new BusinessException("Project code already exists: " + request.projectCode());
}
Project project = Project.builder()
.projectCode(request.projectCode())
.name(request.name())
.description(request.description())
.projectType(ProjectType.valueOf(request.projectType()))
.startDate(request.startDate())
.endDate(request.endDate())
.build();
if (request.pmUserId() != null) {
User pmUser = userRepository.findById(request.pmUserId())
.orElseThrow(() -> new NotFoundException("PM user not found: " + request.pmUserId()));
project.setPmUser(pmUser);
}
projectRepository.save(project);
return ProjectDto.from(project);
}
public ProjectDto update(Long id, ProjectCreateRequest request) {
Project project = projectRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Project not found: " + id));
if (request.name() != null) project.setName(request.name());
if (request.description() != null) project.setDescription(request.description());
if (request.projectType() != null) project.setProjectType(ProjectType.valueOf(request.projectType()));
if (request.startDate() != null) project.setStartDate(request.startDate());
if (request.endDate() != null) project.setEndDate(request.endDate());
if (request.pmUserId() != null) {
User pmUser = userRepository.findById(request.pmUserId())
.orElseThrow(() -> new NotFoundException("PM user not found: " + request.pmUserId()));
project.setPmUser(pmUser);
}
projectRepository.save(project);
return ProjectDto.from(project);
}
@Transactional(readOnly = true)
public List<ProjectDto> findMyProjects(Long userId) {
List<ProjectAssignment> assignments = assignmentRepository.findByUser_Id(userId);
return assignments.stream()
.map(a -> ProjectDto.from(a.getProject()))
.toList();
}
// --- Assignment methods ---
@Transactional(readOnly = true)
public List<ProjectAssignmentDto> getAssignments(Long projectId) {
return assignmentRepository.findByProject_Id(projectId).stream()
.map(ProjectAssignmentDto::from)
.toList();
}
public ProjectAssignmentDto createAssignment(Long projectId, AssignmentCreateRequest request) {
Project project = projectRepository.findById(projectId)
.orElseThrow(() -> new NotFoundException("Project not found: " + projectId));
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new NotFoundException("User not found: " + request.userId()));
if (assignmentRepository.existsByProject_IdAndUser_Id(projectId, request.userId())) {
throw new BusinessException("User already assigned to this project");
}
ProjectAssignment assignment = ProjectAssignment.builder()
.project(project)
.user(user)
.role(request.role())
.build();
assignmentRepository.save(assignment);
return ProjectAssignmentDto.from(assignment);
}
public ProjectAssignmentDto updateAssignment(Long projectId, Long assignId, AssignmentCreateRequest request) {
ProjectAssignment assignment = assignmentRepository.findById(assignId)
.orElseThrow(() -> new NotFoundException("Assignment not found: " + assignId));
if (request.role() != null) assignment.setRole(request.role());
assignmentRepository.save(assignment);
return ProjectAssignmentDto.from(assignment);
}
public void deleteAssignment(Long projectId, Long assignId) {
ProjectAssignment assignment = assignmentRepository.findById(assignId)
.orElseThrow(() -> new NotFoundException("Assignment not found: " + assignId));
assignmentRepository.delete(assignment);
}
@Transactional(readOnly = true)
public Page<User> getAvailableUsers(Long projectId, Pageable pageable) {
// Returns all active users; filtering out already-assigned users can be refined later
return userRepository.findAll(pageable);
}
}

파일 보기

@@ -0,0 +1,22 @@
package kr.co.accura.wtm.domain.report.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public record ProjectHoursReport(
ReportFilter filter,
List<ProjectHoursRow> rows,
BigDecimal totalHours,
LocalDateTime generatedAt
) {
public record ProjectHoursRow(
Long projectId,
String projectCode,
String projectName,
String discipline,
LocalDate entryDate,
BigDecimal hours
) {}
}

파일 보기

@@ -0,0 +1,12 @@
package kr.co.accura.wtm.domain.report.dto;
import java.time.LocalDate;
public record ReportFilter(
Long projectId,
LocalDate from,
LocalDate to,
String groupBy,
Integer wbsLevel,
String department
) {}

파일 보기

@@ -0,0 +1,20 @@
package kr.co.accura.wtm.domain.report.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record WbsHoursReport(
ReportFilter filter,
List<WbsHoursRow> rows,
LocalDateTime generatedAt
) {
public record WbsHoursRow(
String wbsCode,
String wbsName,
int wbsLevel,
String discipline,
BigDecimal totalHours,
long userCount
) {}
}

파일 보기

@@ -0,0 +1,119 @@
package kr.co.accura.wtm.domain.report.service;
import kr.co.accura.wtm.domain.report.dto.ProjectHoursReport;
import kr.co.accura.wtm.domain.report.dto.ReportFilter;
import kr.co.accura.wtm.domain.report.dto.WbsHoursReport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ReportService {
private final EntityManager em;
public ProjectHoursReport getProjectHoursReport(ReportFilter filter) {
StringBuilder sql = new StringBuilder(
"SELECT te.epc_project_id, p.project_code, p.name, '' as discipline, " +
"te.entry_date, SUM(te.hours) " +
"FROM timesheet_entries te " +
"JOIN timesheets ts ON te.timesheet_id = ts.id " +
"LEFT JOIN projects p ON te.epc_project_id = p.id " +
"WHERE ts.status = 'APPROVED' ");
if (filter.projectId() != null) {
sql.append("AND te.epc_project_id = :projectId ");
}
if (filter.from() != null) {
sql.append("AND te.entry_date >= :fromDate ");
}
if (filter.to() != null) {
sql.append("AND te.entry_date <= :toDate ");
}
sql.append("GROUP BY te.epc_project_id, p.project_code, p.name, te.entry_date ");
sql.append("ORDER BY te.entry_date");
Query query = em.createNativeQuery(sql.toString());
if (filter.projectId() != null) query.setParameter("projectId", filter.projectId());
if (filter.from() != null) query.setParameter("fromDate", filter.from());
if (filter.to() != null) query.setParameter("toDate", filter.to());
@SuppressWarnings("unchecked")
List<Object[]> results = query.getResultList();
List<ProjectHoursReport.ProjectHoursRow> rows = new ArrayList<>();
BigDecimal totalHours = BigDecimal.ZERO;
for (Object[] row : results) {
BigDecimal hours = row[5] != null ? new BigDecimal(row[5].toString()) : BigDecimal.ZERO;
rows.add(new ProjectHoursReport.ProjectHoursRow(
row[0] != null ? ((Number) row[0]).longValue() : null,
row[1] != null ? row[1].toString() : null,
row[2] != null ? row[2].toString() : null,
row[3] != null ? row[3].toString() : null,
row[4] != null ? java.time.LocalDate.parse(row[4].toString()) : null,
hours
));
totalHours = totalHours.add(hours);
}
return new ProjectHoursReport(filter, rows, totalHours, LocalDateTime.now());
}
public WbsHoursReport getWbsHoursReport(ReportFilter filter) {
StringBuilder sql = new StringBuilder(
"SELECT cw.wbs_code, cw.name, cw.level, cw.discipline, " +
"SUM(te.hours), COUNT(DISTINCT ts.user_id) " +
"FROM timesheet_entries te " +
"JOIN timesheets ts ON te.timesheet_id = ts.id " +
"JOIN canonical_wbs cw ON te.canonical_wbs_id = cw.id " +
"WHERE ts.status = 'APPROVED' ");
if (filter.projectId() != null) {
sql.append("AND cw.project_id = :projectId ");
}
if (filter.wbsLevel() != null) {
sql.append("AND cw.level = :wbsLevel ");
}
if (filter.from() != null) {
sql.append("AND te.entry_date >= :fromDate ");
}
if (filter.to() != null) {
sql.append("AND te.entry_date <= :toDate ");
}
sql.append("GROUP BY cw.wbs_code, cw.name, cw.level, cw.discipline ");
sql.append("ORDER BY cw.wbs_code");
Query query = em.createNativeQuery(sql.toString());
if (filter.projectId() != null) query.setParameter("projectId", filter.projectId());
if (filter.wbsLevel() != null) query.setParameter("wbsLevel", filter.wbsLevel());
if (filter.from() != null) query.setParameter("fromDate", filter.from());
if (filter.to() != null) query.setParameter("toDate", filter.to());
@SuppressWarnings("unchecked")
List<Object[]> results = query.getResultList();
List<WbsHoursReport.WbsHoursRow> rows = new ArrayList<>();
for (Object[] row : results) {
rows.add(new WbsHoursReport.WbsHoursRow(
row[0] != null ? row[0].toString() : null,
row[1] != null ? row[1].toString() : null,
row[2] != null ? ((Number) row[2]).intValue() : 0,
row[3] != null ? row[3].toString() : null,
row[4] != null ? new BigDecimal(row[4].toString()) : BigDecimal.ZERO,
row[5] != null ? ((Number) row[5]).longValue() : 0
));
}
return new WbsHoursReport(filter, rows, LocalDateTime.now());
}
}

파일 보기

@@ -0,0 +1,32 @@
package kr.co.accura.wtm.domain.teal.dto;
import kr.co.accura.wtm.domain.teal.entity.TealEntry;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TealEntryDto {
private Long id;
private String wbsCode;
private Long canonicalWbsId;
private String activityCode;
private String activityName;
private String discipline;
private Boolean isActive;
public static TealEntryDto from(TealEntry entry) {
return TealEntryDto.builder()
.id(entry.getId())
.wbsCode(entry.getCanonicalWbs() != null ? entry.getCanonicalWbs().getWbsCode() : null)
.canonicalWbsId(entry.getCanonicalWbs() != null ? entry.getCanonicalWbs().getId() : null)
.activityCode(entry.getActivityCode())
.activityName(entry.getActivityName())
.discipline(entry.getDiscipline())
.isActive(entry.getIsActive())
.build();
}
}

파일 보기

@@ -0,0 +1,32 @@
package kr.co.accura.wtm.domain.teal.dto;
import kr.co.accura.wtm.domain.teal.entity.TealVersion;
import java.time.LocalDate;
import java.time.LocalDateTime;
public record TealVersionDto(
Long id,
Long projectId,
Integer versionNumber,
LocalDate effectiveDate,
String description,
String status,
Long uploadedBy,
LocalDateTime createdAt,
int entryCount
) {
public static TealVersionDto from(TealVersion version, int entryCount) {
return new TealVersionDto(
version.getId(),
version.getProjectId(),
version.getVersionNumber(),
version.getEffectiveDate(),
version.getDescription(),
version.getStatus(),
version.getUploadedBy(),
version.getCreatedAt(),
entryCount
);
}
}

파일 보기

@@ -0,0 +1,40 @@
package kr.co.accura.wtm.domain.teal.entity;
import jakarta.persistence.*;
import kr.co.accura.wtm.domain.wbs.entity.CanonicalWbs;
import lombok.*;
@Entity
@Table(name = "teal_entries")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TealEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "teal_version_id", nullable = false)
private TealVersion tealVersion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "canonical_wbs_id")
private CanonicalWbs canonicalWbs;
@Column(name = "activity_code", nullable = false, length = 100)
private String activityCode;
@Column(name = "activity_name", length = 500)
private String activityName;
@Column(length = 50)
private String discipline;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
}

파일 보기

@@ -0,0 +1,64 @@
package kr.co.accura.wtm.domain.teal.entity;
import jakarta.persistence.*;
import kr.co.accura.wtm.domain.project.entity.Project;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "teal_versions",
uniqueConstraints = @UniqueConstraint(columnNames = {"project_id", "version_number"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TealVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;
@Column(name = "version_number", nullable = false)
private Integer versionNumber;
@Column(name = "effective_date", nullable = false)
private LocalDate effectiveDate;
@Column(length = 500)
private String description;
@Column(length = 20)
@Builder.Default
private String status = "DRAFT";
@Column(name = "uploaded_by")
private Long uploadedBy;
@Column(name = "created_at")
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
public void activate() {
this.status = "ACTIVE";
}
public Long getProjectId() {
return project != null ? project.getId() : null;
}
public static TealVersion create(Project project, LocalDate effectiveDate, int versionNumber) {
return TealVersion.builder()
.project(project)
.versionNumber(versionNumber)
.effectiveDate(effectiveDate)
.status("DRAFT")
.build();
}
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.teal.repository;
import kr.co.accura.wtm.domain.teal.entity.TealEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TealEntryRepository extends JpaRepository<TealEntry, Long> {
List<TealEntry> findByTealVersion_Id(Long tealVersionId);
List<TealEntry> findByTealVersion_IdAndIsActiveTrue(Long tealVersionId);
List<TealEntry> findByCanonicalWbs_Id(Long canonicalWbsId);
}

파일 보기

@@ -0,0 +1,21 @@
package kr.co.accura.wtm.domain.teal.repository;
import kr.co.accura.wtm.domain.teal.entity.TealVersion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface TealVersionRepository extends JpaRepository<TealVersion, Long> {
List<TealVersion> findByProject_IdOrderByVersionNumberDesc(Long projectId);
@Query("SELECT MAX(v.versionNumber) FROM TealVersion v WHERE v.project.id = :projectId")
Optional<Integer> findMaxVersionByProjectId(@Param("projectId") Long projectId);
Optional<TealVersion> findByProject_IdAndStatus(Long projectId, String status);
}

파일 보기

@@ -0,0 +1,94 @@
package kr.co.accura.wtm.domain.teal.service;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.project.entity.Project;
import kr.co.accura.wtm.domain.project.repository.ProjectRepository;
import kr.co.accura.wtm.domain.teal.dto.TealEntryDto;
import kr.co.accura.wtm.domain.teal.dto.TealVersionDto;
import kr.co.accura.wtm.domain.teal.entity.TealEntry;
import kr.co.accura.wtm.domain.teal.entity.TealVersion;
import kr.co.accura.wtm.domain.teal.repository.TealEntryRepository;
import kr.co.accura.wtm.domain.teal.repository.TealVersionRepository;
import kr.co.accura.wtm.domain.wbs.repository.CanonicalWbsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class TealService {
private final TealVersionRepository tealVersionRepository;
private final TealEntryRepository tealEntryRepository;
private final CanonicalWbsRepository canonicalWbsRepository;
private final ProjectRepository projectRepository;
/**
* Upload TEAL file - Activity list linked to Canonical WBS.
*/
public TealVersionDto uploadTeal(Long projectId, MultipartFile file,
LocalDate effectiveDate, String description) {
Project project = projectRepository.findById(projectId)
.orElseThrow(() -> new NotFoundException("Project not found: " + projectId));
// TODO: Parse TEAL file (WBS Code | Activity Code | Activity Name | Discipline)
// For now, create an empty version as a stub
int nextVersion = tealVersionRepository
.findMaxVersionByProjectId(projectId)
.map(v -> v + 1).orElse(1);
TealVersion version = TealVersion.builder()
.project(project)
.versionNumber(nextVersion)
.effectiveDate(effectiveDate)
.description(description)
.status("DRAFT")
.build();
tealVersionRepository.save(version);
return TealVersionDto.from(version, 0);
}
/**
* Get TEAL versions for a project.
*/
@Transactional(readOnly = true)
public List<TealVersionDto> getVersions(Long projectId) {
return tealVersionRepository.findByProject_IdOrderByVersionNumberDesc(projectId).stream()
.map(v -> {
int count = tealEntryRepository.findByTealVersion_Id(v.getId()).size();
return TealVersionDto.from(v, count);
})
.toList();
}
/**
* Get active TEAL entries for timesheet input.
*/
@Transactional(readOnly = true)
public List<TealEntryDto> getActiveTealEntries(Long projectId) {
TealVersion activeVersion = tealVersionRepository.findByProject_IdAndStatus(projectId, "ACTIVE")
.orElseThrow(() -> new NotFoundException("No active TEAL version found for project: " + projectId));
return tealEntryRepository.findByTealVersion_IdAndIsActiveTrue(activeVersion.getId()).stream()
.map(TealEntryDto::from)
.toList();
}
/**
* Get TEAL entries by canonical WBS ID.
*/
@Transactional(readOnly = true)
public List<TealEntryDto> getEntriesByWbs(Long projectId, Long wbsId) {
return tealEntryRepository.findByCanonicalWbs_Id(wbsId).stream()
.map(TealEntryDto::from)
.toList();
}
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.timesheet;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum NonProjectCategory {
ANNUAL_LEAVE("연차"),
SICK_LEAVE("병가"),
TRAINING("교육"),
ADMIN("행정"),
PUBLIC_HOLIDAY("공휴일"),
OTHER("기타");
private final String displayName;
}

파일 보기

@@ -0,0 +1,7 @@
package kr.co.accura.wtm.domain.timesheet;
public enum TimesheetEntryType {
NON_PROJECT,
OTHER_PROJECT,
EPC
}

파일 보기

@@ -0,0 +1,9 @@
package kr.co.accura.wtm.domain.timesheet;
public enum TimesheetStatus {
DRAFT,
SUBMITTED,
DL_APPROVED,
APPROVED,
REJECTED
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.timesheet.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public record TimesheetDto(
Long id,
Long userId,
LocalDate weekStartDate,
LocalDate weekEndDate,
String status,
BigDecimal totalHours,
LocalDateTime submittedAt,
List<TimesheetEntryDto> entries
) {}

파일 보기

@@ -0,0 +1,19 @@
package kr.co.accura.wtm.domain.timesheet.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
public record TimesheetEntryDto(
Long id,
String entryType,
LocalDate entryDate,
BigDecimal hours,
String npCategory,
Long otherProjectId,
String otherCategory,
Long epcProjectId,
Long canonicalWbsId,
Long tealEntryId,
Integer revisionNumber,
String remark
) {}

파일 보기

@@ -0,0 +1,22 @@
package kr.co.accura.wtm.domain.timesheet.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
import java.time.LocalDate;
public record TimesheetEntryRequest(
Long id,
@NotNull String entryType,
@NotNull LocalDate entryDate,
@NotNull @PositiveOrZero BigDecimal hours,
String npCategory,
Long otherProjectId,
String otherCategory,
Long epcProjectId,
Long canonicalWbsId,
Long tealEntryId,
Integer revisionNumber,
String remark
) {}

파일 보기

@@ -0,0 +1,10 @@
package kr.co.accura.wtm.domain.timesheet.dto;
public record UploadResultDto(
Long uploadId,
int totalRows,
int successRows,
int errorRows,
String status,
String errorLog
) {}

파일 보기

@@ -0,0 +1,75 @@
package kr.co.accura.wtm.domain.timesheet.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wtm.domain.timesheet.TimesheetStatus;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "timesheets", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "week_start_date"})
})
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Timesheet extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "week_start_date", nullable = false)
private LocalDate weekStartDate;
@Column(name = "week_end_date", nullable = false)
private LocalDate weekEndDate;
@Enumerated(EnumType.STRING)
@Column(length = 20)
@Builder.Default
private TimesheetStatus status = TimesheetStatus.DRAFT;
@Column(name = "total_hours", precision = 10, scale = 2)
@Builder.Default
private BigDecimal totalHours = BigDecimal.ZERO;
@Column(name = "submitted_at")
private LocalDateTime submittedAt;
@OneToMany(mappedBy = "timesheet", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<TimesheetEntry> entries = new ArrayList<>();
// Business methods
public void addEntry(TimesheetEntry entry) {
entries.add(entry);
entry.setTimesheet(this);
recalculateTotal();
}
public void submit() {
if (status != TimesheetStatus.DRAFT && status != TimesheetStatus.REJECTED) {
throw new BusinessException("DRAFT 또는 REJECTED 상태에서만 제출 가능합니다.");
}
this.status = TimesheetStatus.SUBMITTED;
this.submittedAt = LocalDateTime.now();
}
public void recalculateTotal() {
this.totalHours = entries.stream()
.map(TimesheetEntry::getHours)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

파일 보기

@@ -0,0 +1,66 @@
package kr.co.accura.wtm.domain.timesheet.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import kr.co.accura.wtm.domain.timesheet.NonProjectCategory;
import kr.co.accura.wtm.domain.timesheet.TimesheetEntryType;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "timesheet_entries")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimesheetEntry extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "timesheet_id", nullable = false)
private Timesheet timesheet;
@Enumerated(EnumType.STRING)
@Column(name = "entry_type", nullable = false, length = 20)
private TimesheetEntryType entryType;
@Column(name = "entry_date", nullable = false)
private LocalDate entryDate;
@Column(nullable = false, precision = 5, scale = 2)
@Builder.Default
private BigDecimal hours = BigDecimal.ZERO;
// Non-Project fields
@Column(name = "np_category", length = 100)
private String npCategory;
// Other Project fields
@Column(name = "other_project_id")
private Long otherProjectId;
@Column(name = "other_category", length = 100)
private String otherCategory;
// EPC Project fields
@Column(name = "epc_project_id")
private Long epcProjectId;
@Column(name = "canonical_wbs_id")
private Long canonicalWbsId;
@Column(name = "teal_entry_id")
private Long tealEntryId;
@Column(name = "revision_number")
@Builder.Default
private Integer revisionNumber = 1;
@Column(length = 500)
private String remark;
}

파일 보기

@@ -0,0 +1,42 @@
package kr.co.accura.wtm.domain.timesheet.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
@Entity
@Table(name = "timesheet_uploads")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimesheetUpload extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(length = 500)
private String filename;
@Column(name = "file_path", length = 1000)
private String filePath;
@Column(name = "total_rows")
private Integer totalRows;
@Column(name = "success_rows")
private Integer successRows;
@Column(name = "error_rows")
private Integer errorRows;
@Column(name = "error_log", columnDefinition = "TEXT")
private String errorLog;
@Column(length = 20)
private String status;
}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wtm.domain.timesheet.repository;
import kr.co.accura.wtm.domain.timesheet.entity.TimesheetEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TimesheetEntryRepository extends JpaRepository<TimesheetEntry, Long> {
List<TimesheetEntry> findByTimesheet_Id(Long timesheetId);
}

파일 보기

@@ -0,0 +1,28 @@
package kr.co.accura.wtm.domain.timesheet.repository;
import kr.co.accura.wtm.domain.timesheet.entity.Timesheet;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Optional;
public interface TimesheetRepository extends JpaRepository<Timesheet, Long> {
Optional<Timesheet> findByUserIdAndWeekStartDate(Long userId, LocalDate weekStartDate);
Page<Timesheet> findByUserId(Long userId, Pageable pageable);
@Query("SELECT t FROM Timesheet t WHERE t.userId = :userId " +
"AND (:from IS NULL OR t.weekStartDate >= :from) " +
"AND (:to IS NULL OR t.weekStartDate <= :to) " +
"ORDER BY t.weekStartDate DESC")
Page<Timesheet> findByUserIdAndDateRange(
@Param("userId") Long userId,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
Pageable pageable);
}

파일 보기

@@ -0,0 +1,7 @@
package kr.co.accura.wtm.domain.timesheet.repository;
import kr.co.accura.wtm.domain.timesheet.entity.TimesheetUpload;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TimesheetUploadRepository extends JpaRepository<TimesheetUpload, Long> {
}

파일 보기

@@ -0,0 +1,73 @@
package kr.co.accura.wtm.domain.timesheet.rule;
import kr.co.accura.wtm.domain.timesheet.TimesheetEntryType;
import kr.co.accura.wtm.domain.timesheet.entity.Timesheet;
import kr.co.accura.wtm.domain.timesheet.entity.TimesheetEntry;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class TimesheetRuleEngine {
private static final BigDecimal MAX_DAILY_HOURS = new BigDecimal("8");
private static final BigDecimal MAX_WEEKLY_HOURS = new BigDecimal("52");
private static final BigDecimal WARN_DAILY_HOURS = new BigDecimal("10");
private static final BigDecimal HARD_DAILY_LIMIT = new BigDecimal("24");
/**
* Validate timesheet entries against business rules.
*/
public ValidationResult validate(Timesheet timesheet) {
var result = new ValidationResult();
// 1. Daily hour limits
Map<LocalDate, BigDecimal> dailyTotals = timesheet.getEntries().stream()
.collect(Collectors.groupingBy(
TimesheetEntry::getEntryDate,
Collectors.reducing(BigDecimal.ZERO, TimesheetEntry::getHours, BigDecimal::add)
));
for (var entry : dailyTotals.entrySet()) {
LocalDate date = entry.getKey();
BigDecimal total = entry.getValue();
if (total.compareTo(HARD_DAILY_LIMIT) > 0) {
result.addError(date, "일 최대 24시간을 초과할 수 없습니다.");
} else if (total.compareTo(WARN_DAILY_HOURS) > 0) {
result.addWarning(date,
String.format("일 %s시간 입력 — 기준(%sh) 초과", total, MAX_DAILY_HOURS));
}
}
// 2. Weekly total limit (52h)
if (timesheet.getTotalHours().compareTo(MAX_WEEKLY_HOURS) > 0) {
result.addError(null,
String.format("주간 합계 %s시간 — 최대 %sh 초과",
timesheet.getTotalHours(), MAX_WEEKLY_HOURS));
}
// 3. EPC entries require project and WBS
timesheet.getEntries().stream()
.filter(e -> e.getEntryType() == TimesheetEntryType.EPC)
.forEach(e -> {
if (e.getEpcProjectId() == null) {
result.addError(e.getEntryDate(), "EPC 시수 — 프로젝트 필수");
}
if (e.getCanonicalWbsId() == null) {
result.addError(e.getEntryDate(), "EPC 시수 — WBS 선택 필수");
}
});
// 4. No future dates
LocalDate today = LocalDate.now();
timesheet.getEntries().stream()
.filter(e -> e.getEntryDate().isAfter(today))
.forEach(e -> result.addError(e.getEntryDate(), "미래 날짜에 시수를 입력할 수 없습니다."));
return result;
}
}

파일 보기

@@ -0,0 +1,32 @@
package kr.co.accura.wtm.domain.timesheet.rule;
import lombok.Getter;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Getter
public class ValidationResult {
private final List<ValidationMessage> errors = new ArrayList<>();
private final List<ValidationMessage> warnings = new ArrayList<>();
public void addError(LocalDate date, String message) {
errors.add(new ValidationMessage(date, message));
}
public void addWarning(LocalDate date, String message) {
warnings.add(new ValidationMessage(date, message));
}
public boolean hasErrors() {
return !errors.isEmpty();
}
public boolean hasWarnings() {
return !warnings.isEmpty();
}
public record ValidationMessage(LocalDate date, String message) {}
}

파일 보기

@@ -0,0 +1,209 @@
package kr.co.accura.wtm.domain.timesheet.service;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.timesheet.TimesheetEntryType;
import kr.co.accura.wtm.domain.timesheet.TimesheetStatus;
import kr.co.accura.wtm.domain.timesheet.dto.*;
import kr.co.accura.wtm.domain.timesheet.entity.Timesheet;
import kr.co.accura.wtm.domain.timesheet.entity.TimesheetEntry;
import kr.co.accura.wtm.domain.timesheet.entity.TimesheetUpload;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetEntryRepository;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetRepository;
import kr.co.accura.wtm.domain.timesheet.repository.TimesheetUploadRepository;
import kr.co.accura.wtm.domain.timesheet.rule.TimesheetRuleEngine;
import kr.co.accura.wtm.domain.timesheet.rule.ValidationResult;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class TimesheetService {
private final TimesheetRepository timesheetRepository;
private final TimesheetEntryRepository timesheetEntryRepository;
private final TimesheetUploadRepository timesheetUploadRepository;
private final TimesheetRuleEngine ruleEngine;
public TimesheetDto getOrCreateWeekly(Long userId, LocalDate weekStart) {
LocalDate weekEnd = weekStart.plusDays(6);
Timesheet ts = timesheetRepository.findByUserIdAndWeekStartDate(userId, weekStart)
.orElseGet(() -> {
Timesheet newTs = Timesheet.builder()
.userId(userId)
.weekStartDate(weekStart)
.weekEndDate(weekEnd)
.build();
return timesheetRepository.save(newTs);
});
return toDto(ts);
}
public TimesheetEntryDto saveEntry(Long timesheetId, TimesheetEntryRequest request) {
Timesheet ts = findTimesheet(timesheetId);
ensureEditable(ts);
TimesheetEntry entry;
if (request.id() != null) {
entry = timesheetEntryRepository.findById(request.id())
.orElseThrow(() -> new NotFoundException("시수 항목을 찾을 수 없습니다."));
updateEntry(entry, request);
} else {
entry = createEntry(ts, request);
ts.addEntry(entry);
}
timesheetEntryRepository.save(entry);
ts.recalculateTotal();
timesheetRepository.save(ts);
return toEntryDto(entry);
}
public TimesheetDto saveBatch(Long timesheetId, List<TimesheetEntryRequest> requests) {
Timesheet ts = findTimesheet(timesheetId);
ensureEditable(ts);
// Clear existing entries and replace
ts.getEntries().clear();
for (TimesheetEntryRequest request : requests) {
TimesheetEntry entry = createEntry(ts, request);
ts.addEntry(entry);
}
ts.recalculateTotal();
timesheetRepository.save(ts);
return toDto(ts);
}
public TimesheetDto submit(Long timesheetId) {
Timesheet ts = findTimesheet(timesheetId);
// Validate before submission
ValidationResult validation = ruleEngine.validate(ts);
if (validation.hasErrors()) {
String errorMsg = validation.getErrors().stream()
.map(e -> e.message())
.reduce((a, b) -> a + "; " + b)
.orElse("검증 오류");
throw new BusinessException(errorMsg);
}
ts.submit();
timesheetRepository.save(ts);
return toDto(ts);
}
@Transactional(readOnly = true)
public Page<TimesheetDto> getHistory(Long userId, LocalDate from, LocalDate to, Pageable pageable) {
return timesheetRepository.findByUserIdAndDateRange(userId, from, to, pageable)
.map(this::toDto);
}
public UploadResultDto uploadExcel(Long userId, MultipartFile file, LocalDate weekStart) {
TimesheetUpload upload = TimesheetUpload.builder()
.userId(userId)
.filename(file.getOriginalFilename())
.status("PROCESSING")
.totalRows(0)
.successRows(0)
.errorRows(0)
.build();
upload = timesheetUploadRepository.save(upload);
// TODO: Implement Excel parsing with Apache POI
upload.setStatus("COMPLETED");
timesheetUploadRepository.save(upload);
return new UploadResultDto(
upload.getId(),
upload.getTotalRows() != null ? upload.getTotalRows() : 0,
upload.getSuccessRows() != null ? upload.getSuccessRows() : 0,
upload.getErrorRows() != null ? upload.getErrorRows() : 0,
upload.getStatus(),
upload.getErrorLog()
);
}
public void deleteEntry(Long timesheetId, Long entryId) {
Timesheet ts = findTimesheet(timesheetId);
ensureEditable(ts);
ts.getEntries().removeIf(e -> e.getId().equals(entryId));
ts.recalculateTotal();
timesheetRepository.save(ts);
}
// --- Helper methods ---
private Timesheet findTimesheet(Long id) {
return timesheetRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Timesheet not found: " + id));
}
private void ensureEditable(Timesheet ts) {
if (ts.getStatus() != TimesheetStatus.DRAFT && ts.getStatus() != TimesheetStatus.REJECTED) {
throw new BusinessException("수정 가능한 상태가 아닙니다 (현재: " + ts.getStatus() + ")");
}
}
private TimesheetEntry createEntry(Timesheet ts, TimesheetEntryRequest req) {
return TimesheetEntry.builder()
.timesheet(ts)
.entryType(TimesheetEntryType.valueOf(req.entryType()))
.entryDate(req.entryDate())
.hours(req.hours())
.npCategory(req.npCategory())
.otherProjectId(req.otherProjectId())
.otherCategory(req.otherCategory())
.epcProjectId(req.epcProjectId())
.canonicalWbsId(req.canonicalWbsId())
.tealEntryId(req.tealEntryId())
.revisionNumber(req.revisionNumber() != null ? req.revisionNumber() : 1)
.remark(req.remark())
.build();
}
private void updateEntry(TimesheetEntry entry, TimesheetEntryRequest req) {
entry.setEntryType(TimesheetEntryType.valueOf(req.entryType()));
entry.setEntryDate(req.entryDate());
entry.setHours(req.hours());
entry.setNpCategory(req.npCategory());
entry.setOtherProjectId(req.otherProjectId());
entry.setOtherCategory(req.otherCategory());
entry.setEpcProjectId(req.epcProjectId());
entry.setCanonicalWbsId(req.canonicalWbsId());
entry.setTealEntryId(req.tealEntryId());
entry.setRevisionNumber(req.revisionNumber() != null ? req.revisionNumber() : 1);
entry.setRemark(req.remark());
}
private TimesheetDto toDto(Timesheet ts) {
List<TimesheetEntryDto> entryDtos = ts.getEntries().stream()
.map(this::toEntryDto)
.toList();
return new TimesheetDto(
ts.getId(), ts.getUserId(),
ts.getWeekStartDate(), ts.getWeekEndDate(),
ts.getStatus().name(), ts.getTotalHours(),
ts.getSubmittedAt(), entryDtos
);
}
private TimesheetEntryDto toEntryDto(TimesheetEntry e) {
return new TimesheetEntryDto(
e.getId(), e.getEntryType().name(), e.getEntryDate(),
e.getHours(), e.getNpCategory(),
e.getOtherProjectId(), e.getOtherCategory(),
e.getEpcProjectId(), e.getCanonicalWbsId(),
e.getTealEntryId(), e.getRevisionNumber(), e.getRemark()
);
}
}

파일 보기

@@ -0,0 +1,6 @@
package kr.co.accura.wtm.domain.user;
public enum EmploymentType {
INTERNAL,
SUBCONTRACTOR
}

파일 보기

@@ -0,0 +1,13 @@
package kr.co.accura.wtm.domain.user.dto;
import java.util.List;
public record RoleAssignRequest(
List<RoleEntry> roles
) {
public record RoleEntry(
String roleCode,
Long projectId
) {
}
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.user.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record UserCreateRequest(
@NotBlank String employeeId,
@NotBlank @Email String email,
@NotBlank String username,
String fullName,
String department,
String discipline,
String positionTitle,
String location,
String employmentType
) {
}

파일 보기

@@ -0,0 +1,40 @@
package kr.co.accura.wtm.domain.user.dto;
import kr.co.accura.wtm.domain.user.entity.User;
import java.util.List;
public record UserDto(
Long id,
String employeeId,
String email,
String username,
String fullName,
String department,
String discipline,
String positionTitle,
String location,
String employmentType,
Boolean isActive,
List<String> roles
) {
public static UserDto from(User user) {
List<String> roleNames = user.getUserRoles().stream()
.map(ur -> ur.getRole().getCode())
.toList();
return new UserDto(
user.getId(),
user.getEmployeeId(),
user.getEmail(),
user.getUsername(),
user.getFullName(),
user.getDepartment(),
user.getDiscipline(),
user.getPositionTitle(),
user.getLocation(),
user.getEmploymentType() != null ? user.getEmploymentType().name() : null,
user.getIsActive(),
roleNames
);
}
}

파일 보기

@@ -0,0 +1,13 @@
package kr.co.accura.wtm.domain.user.dto;
public record UserUpdateRequest(
String username,
String fullName,
String department,
String discipline,
String positionTitle,
String location,
String employmentType,
Boolean isActive
) {
}

파일 보기

@@ -0,0 +1,52 @@
package kr.co.accura.wtm.domain.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "hr_uploads")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class HrUpload {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(length = 500)
private String filename;
@Column(name = "file_path", length = 1000)
private String filePath;
@Column(name = "total_rows")
private Integer totalRows;
@Column(name = "success_rows")
private Integer successRows;
@Column(name = "error_rows")
private Integer errorRows;
@Column(name = "error_log", columnDefinition = "TEXT")
private String errorLog;
@Column(length = 20)
private String status;
@Column(name = "sync_source", length = 50)
private String syncSource;
@Column(name = "created_at")
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,48 @@
package kr.co.accura.wtm.domain.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "org_hierarchy")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OrgHierarchy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String name;
@Column(length = 50)
private String code;
@Column(nullable = false)
private Integer level;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private OrgHierarchy parent;
@Column(name = "sort_order")
@Builder.Default
private Integer sortOrder = 0;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@Column(name = "created_at")
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wtm.domain.user.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "roles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 20)
private String code;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 500)
private String description;
@Column
@Builder.Default
private Integer level = 0;
}

파일 보기

@@ -0,0 +1,97 @@
package kr.co.accura.wtm.domain.user.entity;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import kr.co.accura.wtm.domain.user.EmploymentType;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "employee_id", unique = true, nullable = false, length = 50)
private String employeeId;
@Column(unique = true, nullable = false, length = 255)
private String email;
@Column(nullable = false, length = 100)
private String username;
@Column(name = "full_name", length = 255)
private String fullName;
@Column(name = "hashed_password", length = 255)
private String hashedPassword;
@Column(length = 100)
private String department;
@Column(length = 100)
private String discipline;
@Column(name = "position_title", length = 100)
private String positionTitle;
@Column(length = 50)
private String location;
@Enumerated(EnumType.STRING)
@Column(name = "employment_type", length = 20)
@Builder.Default
private EmploymentType employmentType = EmploymentType.INTERNAL;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@Column(name = "is_locked")
@Builder.Default
private Boolean isLocked = false;
@Column(name = "failed_attempts")
@Builder.Default
private Integer failedAttempts = 0;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "password_changed_at")
private LocalDateTime passwordChangedAt;
@Column(name = "azure_oid", length = 255)
private String azureOid;
@Column(name = "mfa_enabled")
@Builder.Default
private Boolean mfaEnabled = false;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@Builder.Default
private List<UserRole> userRoles = new ArrayList<>();
public boolean hasRole(String roleCode) {
return userRoles.stream()
.anyMatch(ur -> ur.getRole().getCode().equals(roleCode));
}
public boolean hasProjectRole(String roleCode, Long projectId) {
return userRoles.stream()
.anyMatch(ur -> ur.getRole().getCode().equals(roleCode)
&& Objects.equals(ur.getProjectId(), projectId));
}
}

파일 보기

@@ -0,0 +1,39 @@
package kr.co.accura.wtm.domain.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_roles",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id", "project_id"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(name = "project_id")
private Long projectId;
@Column(name = "granted_at")
@Builder.Default
private LocalDateTime grantedAt = LocalDateTime.now();
@Column(name = "granted_by")
private Long grantedBy;
}

파일 보기

@@ -0,0 +1,15 @@
package kr.co.accura.wtm.domain.user.repository;
import kr.co.accura.wtm.domain.user.entity.HrUpload;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface HrUploadRepository extends JpaRepository<HrUpload, Long> {
List<HrUpload> findByUser_IdOrderByCreatedAtDesc(Long userId);
List<HrUpload> findByStatus(String status);
}

파일 보기

@@ -0,0 +1,20 @@
package kr.co.accura.wtm.domain.user.repository;
import kr.co.accura.wtm.domain.user.entity.OrgHierarchy;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface OrgHierarchyRepository extends JpaRepository<OrgHierarchy, Long> {
List<OrgHierarchy> findByParent_Id(Long parentId);
List<OrgHierarchy> findByLevel(Integer level);
List<OrgHierarchy> findByIsActiveTrueOrderBySortOrder();
Optional<OrgHierarchy> findByCode(String code);
}

파일 보기

@@ -0,0 +1,13 @@
package kr.co.accura.wtm.domain.user.repository;
import kr.co.accura.wtm.domain.user.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByCode(String code);
}

파일 보기

@@ -0,0 +1,19 @@
package kr.co.accura.wtm.domain.user.repository;
import kr.co.accura.wtm.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByEmployeeId(String employeeId);
boolean existsByEmail(String email);
boolean existsByEmployeeId(String employeeId);
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wtm.domain.user.repository;
import kr.co.accura.wtm.domain.user.entity.UserRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Long> {
List<UserRole> findByUser_Id(Long userId);
List<UserRole> findByUser_IdAndProjectId(Long userId, Long projectId);
void deleteByUser_IdAndProjectId(Long userId, Long projectId);
}

파일 보기

@@ -0,0 +1,119 @@
package kr.co.accura.wtm.domain.user.service;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wtm.domain.user.EmploymentType;
import kr.co.accura.wtm.domain.user.dto.*;
import kr.co.accura.wtm.domain.user.entity.Role;
import kr.co.accura.wtm.domain.user.entity.User;
import kr.co.accura.wtm.domain.user.entity.UserRole;
import kr.co.accura.wtm.domain.user.repository.RoleRepository;
import kr.co.accura.wtm.domain.user.repository.UserRepository;
import kr.co.accura.wtm.domain.user.repository.UserRoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final UserRoleRepository userRoleRepository;
@Transactional(readOnly = true)
public Page<UserDto> findAll(Pageable pageable) {
return userRepository.findAll(pageable).map(UserDto::from);
}
@Transactional(readOnly = true)
public UserDto findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found: " + id));
return UserDto.from(user);
}
@Transactional(readOnly = true)
public UserDto findByEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new NotFoundException("User not found: " + email));
return UserDto.from(user);
}
public UserDto create(UserCreateRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new BusinessException("Email already exists: " + request.email());
}
if (userRepository.existsByEmployeeId(request.employeeId())) {
throw new BusinessException("Employee ID already exists: " + request.employeeId());
}
User user = User.builder()
.employeeId(request.employeeId())
.email(request.email())
.username(request.username())
.fullName(request.fullName())
.department(request.department())
.discipline(request.discipline())
.positionTitle(request.positionTitle())
.location(request.location())
.employmentType(request.employmentType() != null
? EmploymentType.valueOf(request.employmentType())
: EmploymentType.INTERNAL)
.build();
userRepository.save(user);
return UserDto.from(user);
}
public UserDto update(Long id, UserUpdateRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found: " + id));
if (request.username() != null) user.setUsername(request.username());
if (request.fullName() != null) user.setFullName(request.fullName());
if (request.department() != null) user.setDepartment(request.department());
if (request.discipline() != null) user.setDiscipline(request.discipline());
if (request.positionTitle() != null) user.setPositionTitle(request.positionTitle());
if (request.location() != null) user.setLocation(request.location());
if (request.employmentType() != null) {
user.setEmploymentType(EmploymentType.valueOf(request.employmentType()));
}
if (request.isActive() != null) user.setIsActive(request.isActive());
userRepository.save(user);
return UserDto.from(user);
}
public void assignRoles(Long userId, RoleAssignRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("User not found: " + userId));
// Remove existing roles
userRoleRepository.deleteByUser_IdAndProjectId(userId, null);
// Assign new roles
for (RoleAssignRequest.RoleEntry entry : request.roles()) {
Role role = roleRepository.findByCode(entry.roleCode())
.orElseThrow(() -> new NotFoundException("Role not found: " + entry.roleCode()));
UserRole userRole = UserRole.builder()
.user(user)
.role(role)
.projectId(entry.projectId())
.build();
userRoleRepository.save(userRole);
}
}
@Transactional(readOnly = true)
public List<UserRole> getUserRoles(Long userId) {
return userRoleRepository.findByUser_Id(userId);
}
}

파일 보기

@@ -0,0 +1,29 @@
package kr.co.accura.wtm.domain.wbs.dto;
import kr.co.accura.wtm.domain.wbs.entity.CanonicalWbs;
public record CanonicalWbsDto(
Long id,
Long projectId,
String wbsCode,
Integer level,
String name,
String parentCode,
String discipline,
Boolean isActive,
String mappedP6Code
) {
public static CanonicalWbsDto from(CanonicalWbs cw) {
return new CanonicalWbsDto(
cw.getId(),
cw.getProjectId(),
cw.getWbsCode(),
cw.getLevel(),
cw.getName(),
cw.getParentCode(),
cw.getDiscipline(),
cw.getIsActive(),
cw.getMappedP6Code()
);
}
}

파일 보기

@@ -0,0 +1,30 @@
package kr.co.accura.wtm.domain.wbs.dto;
import kr.co.accura.wtm.domain.wbs.entity.WbsNode;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
@Getter
public class WbsCompareResult {
private final List<WbsNodeDto> added = new ArrayList<>();
private final List<WbsNodeDto> removed = new ArrayList<>();
private final List<ModifiedEntry> modified = new ArrayList<>();
public void addAdded(WbsNode node) {
added.add(WbsNodeDto.from(node));
}
public void addRemoved(WbsNode node) {
removed.add(WbsNodeDto.from(node));
}
public void addModified(WbsNode before, WbsNode after) {
modified.add(new ModifiedEntry(WbsNodeDto.from(before), WbsNodeDto.from(after)));
}
public record ModifiedEntry(WbsNodeDto before, WbsNodeDto after) {
}
}

파일 보기

@@ -0,0 +1,26 @@
package kr.co.accura.wtm.domain.wbs.dto;
import kr.co.accura.wtm.domain.wbs.entity.WbsDisciplineAssignment;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class WbsDisciplineAssignmentDto {
private Long id;
private Long canonicalWbsId;
private String discipline;
private Boolean isActive;
public static WbsDisciplineAssignmentDto from(WbsDisciplineAssignment entity) {
return WbsDisciplineAssignmentDto.builder()
.id(entity.getId())
.canonicalWbsId(entity.getCanonicalWbs().getId())
.discipline(entity.getDiscipline())
.isActive(entity.getIsActive())
.build();
}
}

파일 보기

@@ -0,0 +1,36 @@
package kr.co.accura.wtm.domain.wbs.dto;
import kr.co.accura.wtm.domain.wbs.entity.WbsNode;
import lombok.*;
import java.math.BigDecimal;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class WbsNodeDto {
private Long id;
private String wbsCode;
private String name;
private Integer level;
private String parentCode;
private String discipline;
private BigDecimal plannedHours;
private Boolean isLeaf;
public static WbsNodeDto from(WbsNode node) {
return WbsNodeDto.builder()
.id(node.getId())
.wbsCode(node.getWbsCode())
.name(node.getName())
.level(node.getLevel())
.parentCode(node.getParent() != null ? node.getParent().getWbsCode() : null)
.discipline(node.getDiscipline())
.plannedHours(node.getPlannedHours())
.isLeaf(node.getIsLeaf())
.build();
}
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wtm.domain.wbs.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
public class WbsParseResult {
private final List<WbsNodeDto> nodes = new ArrayList<>();
private final List<String> errors = new ArrayList<>();
public void addNode(WbsNodeDto node) {
nodes.add(node);
}
public void addError(String error) {
errors.add(error);
}
public boolean hasErrors() {
return !errors.isEmpty();
}
public void setError(String error) {
errors.add(error);
}
}

파일 보기

@@ -0,0 +1,36 @@
package kr.co.accura.wtm.domain.wbs.dto;
import kr.co.accura.wtm.domain.wbs.entity.WbsVersion;
import java.time.LocalDate;
import java.time.LocalDateTime;
public record WbsVersionDto(
Long id,
Long projectId,
Integer versionNumber,
LocalDate effectiveDate,
String sourceType,
String sourceFilename,
String description,
String status,
Long uploadedBy,
LocalDateTime createdAt,
int nodeCount
) {
public static WbsVersionDto from(WbsVersion version, int nodeCount) {
return new WbsVersionDto(
version.getId(),
version.getProjectId(),
version.getVersionNumber(),
version.getEffectiveDate(),
version.getSourceType(),
version.getSourceFilename(),
version.getDescription(),
version.getStatus(),
version.getUploadedBy(),
version.getCreatedAt(),
nodeCount
);
}
}

이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기