diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarWorkBrief.java b/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarWorkBrief.java index 178c7348..8c79b721 100644 --- a/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarWorkBrief.java +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarWorkBrief.java @@ -17,4 +17,6 @@ public class CalendarWorkBrief { private LocalTime startTime; private LocalTime endTime; private String status; + private Long taskCount; + private Long fatigue; } diff --git a/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java b/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java index cb5a970d..53d99f21 100644 --- a/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java +++ b/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java @@ -30,6 +30,7 @@ public enum WorkErrorCode implements ServiceErrorCode { WORK_LOG_ACCESS_DENIED(403, "본인 소유가 아닌 근무일지입니다."), INVALID_WORK_TIME_RANGE(400, "시작 시간과 종료 시간이 같을 수 없습니다."), WORK_LOG_ALREADY_CONFIRMED(409, "이미 확정된 근무일지입니다."), + WORK_LOG_SETTLEMENT_IN_PROGRESS(409, "정산이 진행 중이거나 완료된 근무일지는 수정할 수 없습니다."), CATEGORY_NOT_FOUND(404, "존재하지 않는 카테고리입니다."), PLATFORM_NOT_FOUND(404, "존재하지 않는 플랫폼입니다."), diff --git a/services/work-service/src/main/java/com/ntropy/work/service/CalendarService.java b/services/work-service/src/main/java/com/ntropy/work/service/CalendarService.java index 29ceaf07..f50fd4b4 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/CalendarService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/CalendarService.java @@ -111,7 +111,7 @@ public CalendarDailySummary getDailySummary(Long userId, LocalDate date, Double List works = workLogs.stream() .map(w -> new CalendarWorkBrief(w.getLogId(), w.getJobId(), jobNames.get(w.getJobId()), - w.getStartTime(), w.getEndTime(), w.getStatus())) + w.getStartTime(), w.getEndTime(), w.getStatus(), w.getTaskCount(), w.getFatigue())) .collect(Collectors.toList()); String dayOfWeek = KOREAN_DAY_OF_WEEK.get(date.getDayOfWeek()); diff --git a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java index 4727f4d2..0bfbb2b3 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java @@ -89,8 +89,11 @@ public WorkLog registerActual(WorkLog workLog) { } /** - * 근무일지 수정. PLANNED 상태에서만 가능하다. 확정(CONFIRMED) 이후에는 - * WORK_LOG_PLATFORM_INCOME과의 정합성이 깨질 수 있어 수정할 수 없고, 삭제만 가능하다. + * 근무일지 수정. PLANNED 상태는 항상 가능하다. CONFIRMED 상태는 아직 어느 플랫폼도 + * 정산되지 않은 경우(settlementStatus == PENDING)에 한해 허용한다 - 하나라도 + * 실제 입금과 매칭(PARTIAL/COMPLETED)된 이후에는 그 기록을 보존해야 하므로 여전히 + * 수정할 수 없고 삭제만 가능하다. CONFIRMED 상태를 수정하면 소득이 바뀔 수 있어 + * WORK_LOG_PLATFORM_INCOME을 지우고 다시 배분한다. * 넘어온 필드만 덮어쓰고 나머지는 기존 값을 유지한다. * * @param requesterUserId 요청자 userId. 근무일지 소유자와 다르면 예외 @@ -99,18 +102,30 @@ public WorkLog registerActual(WorkLog workLog) { public WorkLog editWorkLog(Long requesterUserId, Long logId, WorkLog patch) { WorkLog existing = findById(logId); verifyOwnership(requesterUserId, existing.getUserId(), logId); - if (STATUS_CONFIRMED.equals(existing.getStatus())) { - throw new ServiceException(WorkErrorCode.WORK_LOG_ALREADY_CONFIRMED, "logId=" + logId); + boolean wasConfirmed = STATUS_CONFIRMED.equals(existing.getStatus()); + if (wasConfirmed && existing.getSettlementStatus() != SettlementStatus.PENDING) { + throw new ServiceException(WorkErrorCode.WORK_LOG_SETTLEMENT_IN_PROGRESS, "logId=" + logId); } applyPatch(existing, patch); validateNoOverlap(existing.getUserId(), existing.getWorkDate(), existing.getStartTime(), existing.getEndTime(), existing.getLogId()); Job job = jobService.findById(existing.getJobId()); + if (wasConfirmed) { + validateTaskCountIfPerTask(job, existing.getTaskCount()); + } existing.setEstimatedIncome( calculateEstimatedIncome(job, existing.getStartTime(), existing.getEndTime(), existing.getTaskCount())); - workLogMapper.update(existing); + if (wasConfirmed) { + workLogPlatformIncomeMapper.deleteByLogId(existing.getLogId()); + List incomes = resolvePlatformIncomes(job.getJobId(), existing.getEstimatedIncome()); + existing.setSettlementStatus(WorkLogSettlementStatusCalculator.calculate(incomes)); + workLogMapper.update(existing); + saveIncomes(existing.getLogId(), incomes); + } else { + workLogMapper.update(existing); + } return existing; } diff --git a/services/work-service/src/test/java/com/ntropy/work/service/CalendarServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/CalendarServiceTest.java index 5aa59de9..e6903161 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/CalendarServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/CalendarServiceTest.java @@ -255,14 +255,17 @@ void monthlySummary_savingGoalForOtherMonth_targetAmountIsNull() { @DisplayName("일간 요약은 근무 목록과 요일 한글 표기를 채운다") void dailySummary_returnsWorksAndKoreanDayOfWeek() { LocalDate monday = LocalDate.of(2026, 8, 3); - seedWorkLog(JOB_A, monday, LocalTime.of(18, 0), LocalTime.of(22, 0), + WorkLog log = seedWorkLog(JOB_A, monday, LocalTime.of(18, 0), LocalTime.of(22, 0), "PLANNED", SettlementStatus.NONE, 48000L); + log.setFatigue(3L); + workLogMapper.update(log); CalendarDailySummary summary = calendarService.getDailySummary(USER_ID, monday, null, null); assertEquals("월", summary.getDayOfWeek()); assertEquals(1, summary.getWorks().size()); assertEquals("배민 배달", summary.getWorks().get(0).getJobName()); + assertEquals(3L, summary.getWorks().get(0).getFatigue()); } @Test diff --git a/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java index 957904f4..61c1bc44 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java @@ -377,10 +377,10 @@ void editWorkLog_notOwner_throws() { } @Test - @DisplayName("확정된 근무일지는 수정할 수 없다") - void editWorkLog_confirmed_throws() { - Job job = hourlyJob(); - WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + @DisplayName("정산이 진행된(COMPLETED) 확정 근무일지는 수정할 수 없다") + void editWorkLog_settlementCompleted_throws() { + Job job = onDemandJob(); // ON_DEMAND 플랫폼이라 확정 즉시 COMPLETED + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(20, 0), LocalTime.of(22, 0))); workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build()); WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); @@ -388,6 +388,39 @@ void editWorkLog_confirmed_throws() { assertThrows(ServiceException.class, () -> workLogService.editWorkLog(USER_ID, plan.getLogId(), patch)); } + @Test + @DisplayName("확정됐지만 아직 정산이 시작되지 않은(PENDING) 근무일지는 수정할 수 있다") + void editWorkLog_confirmedButPending_isAllowed() { + Job job = hourlyJob(); // 플랫폼 매핑이 없어 확정해도 PENDING 유지 + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build()); + + WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); + WorkLog result = workLogService.editWorkLog(USER_ID, plan.getLogId(), patch); + + assertEquals("CONFIRMED", result.getStatus()); + assertEquals(LocalTime.of(23, 0), result.getEndTime()); + assertEquals(60000L, result.getEstimatedIncome()); + } + + @Test + @DisplayName("자동정산 플랫폼 잡을 확정 후(PENDING) 수정하면 income 행이 새 소득 기준으로 재생성된다") + void editWorkLog_autoPlatformPending_regeneratesIncomeRows() { + Job job = multiPlatformJob(); // AUTO 트리거 플랫폼 2개 매핑, 확정해도 PENDING 유지 + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + WorkLog confirmed = workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build()); + assertEquals(SettlementStatus.PENDING, confirmed.getSettlementStatus()); + + WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); // 4시간 -> 5시간, 40000 -> 50000 + WorkLog result = workLogService.editWorkLog(USER_ID, plan.getLogId(), patch); + + assertEquals(50000L, result.getEstimatedIncome()); + List incomes = workLogPlatformIncomeMapper.findByLogId(result.getLogId()); + assertEquals(2, incomes.size()); + long total = incomes.stream().mapToLong(WorkLogPlatformIncome::getExpectedAmount).sum(); + assertEquals(50000L, total); + } + @Test @DisplayName("확정하면 PLANNED에서 CONFIRMED로 전환된다") void confirmWorkLog_transitionsPlannedToConfirmed() {