파일
wbx-spring/HanwhaOCN/wtmgr/03-timesheet-module.md
accura0117 783865266b docs: 한화오션 WTM 프로젝트 계획서 추가 (00~14)
- 00~11: WTM 시수관리 시스템 설계 문서 (아키텍처, DB스키마, API스펙 등)
- 12: BE 멀티프로젝트 플랫폼 구성 계획 (wbx-spring-core 라이브러리 전환)
- 13: FE Vue3+PrimeVue4 모듈 기반 구조 계획
- 14: 레이아웃 표준 및 디자인 시스템 (반응형, 하드코딩 제거)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:52:15 +09:00

13 KiB

03. 시수 입력 모듈 (3종)

시수 입력 유형

┌──────────────────────────────────────────────────────┐
│               시수 입력 통합 UI (탭 전환)               │
├──────────────┬──────────────┬────────────────────────┤
│ Non-Project  │ Other Project│    EPC Project         │
│ 시수 입력    │ 시수 입력    │    시수 입력           │
├──────────────┼──────────────┼────────────────────────┤
│ • 카테고리   │ • 프로젝트   │ • 프로젝트 선택        │
│   선택       │   선택       │ • Canonical WBS 선택   │
│ • Leave      │ • 카테고리   │ • TEAL Activity 선택   │
│ • Training   │   선택       │ • Revision 관리 (PH1-2)│
│ • Admin      │ • 시간 입력  │ • 시간 입력            │
│ • 시간 입력  │              │                        │
└──────────────┴──────────────┴────────────────────────┘
                       │
                ┌──────┴──────┐
                │ 규칙 엔진   │
                │ 일 8h 제한  │
                │ 주 52h 제한 │
                └─────────────┘

핵심 도메인 모델

// 시수 유형 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<TimesheetEntry> 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)

@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<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(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

@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<TimesheetEntryRequest> 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<TimesheetSummaryDto> 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)

@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) 초과 경고                           │
│                                                         │
│  [임시 저장]  [제출 (결재 요청)]                         │
└─────────────────────────────────────────────────────────┘