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은 다음에 포함되어 있습니다:
@@ -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에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기
새 Issue에서 참조
사용자 차단