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