# 03. 시수 입력 모듈 (3종) ## 시수 입력 유형 ``` ┌──────────────────────────────────────────────────────┐ │ 시수 입력 통합 UI (탭 전환) │ ├──────────────┬──────────────┬────────────────────────┤ │ Non-Project │ Other Project│ EPC Project │ │ 시수 입력 │ 시수 입력 │ 시수 입력 │ ├──────────────┼──────────────┼────────────────────────┤ │ • 카테고리 │ • 프로젝트 │ • 프로젝트 선택 │ │ 선택 │ 선택 │ • Canonical WBS 선택 │ │ • Leave │ • 카테고리 │ • TEAL Activity 선택 │ │ • Training │ 선택 │ • Revision 관리 (PH1-2)│ │ • Admin │ • 시간 입력 │ • 시간 입력 │ │ • 시간 입력 │ │ │ └──────────────┴──────────────┴────────────────────────┘ │ ┌──────┴──────┐ │ 규칙 엔진 │ │ 일 8h 제한 │ │ 주 52h 제한 │ └─────────────┘ ``` ## 핵심 도메인 모델 ```java // 시수 유형 Enum public enum TimesheetEntryType { NON_PROJECT, // 비프로젝트 (휴가, 교육, 행정) OTHER_PROJECT, // 타 프로젝트 EPC // EPC 프로젝트 (핵심) } // Non-Project 카테고리 public enum NonProjectCategory { ANNUAL_LEAVE("연차"), SICK_LEAVE("병가"), TRAINING("교육"), ADMIN("행정"), PUBLIC_HOLIDAY("공휴일"), OTHER("기타"); private final String displayName; } // Timesheet Entity (주간 단위) @Entity @Table(name = "timesheets") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Timesheet extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private LocalDate weekStartDate; @Column(nullable = false) private LocalDate weekEndDate; @Enumerated(EnumType.STRING) private TimesheetStatus status = TimesheetStatus.DRAFT; @Column(precision = 10, scale = 2) private BigDecimal totalHours = BigDecimal.ZERO; @OneToMany(mappedBy = "timesheet", cascade = CascadeType.ALL, orphanRemoval = true) private List entries = new ArrayList<>(); private LocalDateTime submittedAt; // 비즈니스 메서드 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(); } private void recalculateTotal() { this.totalHours = entries.stream() .map(TimesheetEntry::getHours) .reduce(BigDecimal.ZERO, BigDecimal::add); } } // TimesheetEntry (일별 상세) @Entity @Table(name = "timesheet_entries") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) 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(nullable = false, length = 20) private TimesheetEntryType entryType; @Column(nullable = false) private LocalDate entryDate; @Column(nullable = false, precision = 5, scale = 2) private BigDecimal hours; // Non-Project @Enumerated(EnumType.STRING) private NonProjectCategory npCategory; // Other Project @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "other_project_id") private Project otherProject; private String otherCategory; // EPC Project @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "epc_project_id") private Project epcProject; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "canonical_wbs_id") private CanonicalWbs canonicalWbs; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "teal_entry_id") private TealEntry tealEntry; private Integer revisionNumber = 1; private String remark; } ``` ## 규칙 엔진 (No.43~45) ```java @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"); /** * 시수 입력 전 규칙 검증 * @return 검증 결과 (errors + warnings) */ public ValidationResult validate(Timesheet timesheet) { var result = new ValidationResult(); // 1. 일별 시간 제한 (8h 기본, 10h 경고, 24h 하드 리밋) Map 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(new BigDecimal("24")) > 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. 주간 총 시간 제한 (52h) if (timesheet.getTotalHours().compareTo(MAX_WEEKLY_HOURS) > 0) { result.addError(null, String.format("주간 합계 %s시간 — 최대 %sh 초과", timesheet.getTotalHours(), MAX_WEEKLY_HOURS)); } // 3. EPC 시수 — WBS/TEAL 필수 timesheet.getEntries().stream() .filter(e -> e.getEntryType() == TimesheetEntryType.EPC) .forEach(e -> { if (e.getEpcProject() == null) result.addError(e.getEntryDate(), "EPC 시수 — 프로젝트 필수"); if (e.getCanonicalWbs() == null) result.addError(e.getEntryDate(), "EPC 시수 — WBS 선택 필수"); }); // 4. 미래 날짜 입력 불가 LocalDate today = LocalDate.now(); timesheet.getEntries().stream() .filter(e -> e.getEntryDate().isAfter(today)) .forEach(e -> result.addError(e.getEntryDate(), "미래 날짜에 시수를 입력할 수 없습니다.")); return result; } } ``` ## REST API ```java @RestController @RequestMapping("/api/timesheets") @RequiredArgsConstructor public class TimesheetController { private final TimesheetService timesheetService; // 주간 시수 조회 (생성 안 되어 있으면 자동 생성) @GetMapping("/week") public TimesheetDto getWeekly( @RequestParam @DateTimeFormat(iso = DATE) LocalDate weekStart) { return timesheetService.getOrCreateWeekly( SecurityUtils.getCurrentUserId(), weekStart); } // 시수 항목 저장 (Auto-save) @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 entries) { return timesheetService.saveBatch(timesheetId, entries); } // 시수 제출 (결재 요청) @PostMapping("/{timesheetId}/submit") public TimesheetDto submit(@PathVariable Long timesheetId) { return timesheetService.submit(timesheetId); } // Excel 일괄 업로드 @PostMapping("/upload") public UploadResultDto uploadExcel( @RequestParam("file") MultipartFile file, @RequestParam @DateTimeFormat(iso = DATE) LocalDate weekStart) { return timesheetService.uploadExcel( SecurityUtils.getCurrentUserId(), file, weekStart); } // 내 시수 이력 (페이징) @GetMapping("/history") public Page history( @RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate to, Pageable pageable) { return timesheetService.getHistory( SecurityUtils.getCurrentUserId(), from, to, pageable); } } ``` ## Excel 일괄 업로드 (Apache POI) ```java @Service @RequiredArgsConstructor public class TimesheetExcelService { private final TimesheetService timesheetService; /** * 표준 템플릿 기반 Excel 파싱 * 컬럼: Date | Type | Project | WBS | TEAL | Hours | Remark */ public UploadResult parseAndSave(Long userId, MultipartFile file, LocalDate weekStart) { var result = new UploadResult(); try (Workbook wb = new XSSFWorkbook(file.getInputStream())) { Sheet sheet = wb.getSheetAt(0); for (int i = 1; i <= sheet.getLastRowNum(); i++) { // 헤더 스킵 Row row = sheet.getRow(i); if (row == null) continue; try { TimesheetEntryRequest entry = parseRow(row, i); timesheetService.saveEntry(/* ... */); result.addSuccess(); } catch (Exception e) { result.addError(i, e.getMessage()); } } } return result; } } ``` ## 시수 입력 화면 구성 (프론트엔드 가이드) ``` ┌─────────────────────────────────────────────────────────┐ │ 시수 입력 2025-04-07 ~ 2025-04-11 │ │ ┌────────┬────────────┬──────────────┐ │ │ │Non-Proj│Other Proj │ EPC Project │ ← 탭 전환 │ │ └────────┴────────────┴──────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐│ │ │ Project: [EPU-2025-001 ▼] ││ │ │ WBS: [E.01.03 Piping Detail ▼] ││ │ │ TEAL: [Detail Engineering ▼] ││ │ ├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤│ │ │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Total ││ │ │ [8.0]│ [8.0]│ [8.0]│ [8.0]│ [4.0]│ [ ]│ 36.0h ││ │ └──────┴──────┴──────┴──────┴──────┴──────┴──────────┘│ │ │ │ [+ 행 추가] 주간 합계: 36.0 / 52h │ │ │ │ ⚠ 월요일: 기준(8h) 초과 경고 │ │ │ │ [임시 저장] [제출 (결재 요청)] │ └─────────────────────────────────────────────────────────┘ ```