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