From 4d3b633b38f1fa514e01c92a19310b86c0882c89 Mon Sep 17 00:00:00 2001 From: JaeYoon Date: Wed, 19 Aug 2026 17:46:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B7=BC=EB=AC=B4=EC=8B=9C=EA=B0=84/=EC=86=8C=EB=93=9D?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A9=ED=91=9C=C2=B7=ED=99=95=EC=A0=95=C2=B7?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkLog.status 문자열을 WorkLogStatus 공유 상수로 추출 (WorkLogService, WorkLogReminderService, CalendarService) - CalendarMonthlyHours: plannedHours/actualHours → goalHours/confirmedHours/scheduledHours로 분리 - CalendarMonthlyHours: WORK_LOG_PLATFORM_INCOME 기준 expectedSettlementIncome(예상 정산 소득) 추가 - DashboardHoursResponse: confirmedHours/scheduledHours/goalHours 3필드로 변경 - DashboardIncomeResponse: actualIncome(SETTLEMENT 기반 실입금액)/expectedSettlementIncome/goalIncome 3필드로 변경 - 미사용 DashboardSettlementResponse 삭제, DashboardResponse.settlement 필드 제거 - DashboardController: getDashboard()에서 실입금액은 IncomeAnalysisQueryClient로 별도 조회 --- .../work/summary/CalendarMonthlyHours.java | 16 ++- .../dashboard/DashboardController.java | 6 +- .../response/DashboardHoursResponse.java | 5 +- .../response/DashboardIncomeResponse.java | 10 +- .../dashboard/response/DashboardResponse.java | 1 - .../response/DashboardSettlementResponse.java | 37 ------- .../com/ntropy/work/domain/WorkLogStatus.java | 14 +++ .../ntropy/work/service/CalendarService.java | 45 ++++++--- .../work/service/WorkLogReminderService.java | 3 +- .../ntropy/work/service/WorkLogService.java | 5 +- .../work/service/CalendarServiceTest.java | 98 +++++++++++++++---- 11 files changed, 156 insertions(+), 84 deletions(-) delete mode 100644 services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardSettlementResponse.java create mode 100644 services/work-service/src/main/java/com/ntropy/work/domain/WorkLogStatus.java diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarMonthlyHours.java b/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarMonthlyHours.java index ed07643b..7d86798b 100644 --- a/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarMonthlyHours.java +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/CalendarMonthlyHours.java @@ -5,9 +5,13 @@ import lombok.NoArgsConstructor; /** - * plannedHours: 해당 월 ALLOCATION_GOAL(잡별 추천 근무시간) 합 - * actualHours: 해당 월 WORK_LOG 전체(PLANNED+CONFIRMED) 근무시간 합 - * expectedIncome: 전체 WORK_LOG의 estimated_income 합 + * goalHours: 해당 월 ALLOCATION_GOAL(잡별 추천 근무시간) 합 + * confirmedHours/scheduledHours: 해당 월 WORK_LOG를 status(CONFIRMED/PLANNED) 기준으로 나눈 근무시간 합 + * expectedIncome: 전체 WORK_LOG의 estimated_income 합 (status/settlementStatus 무관) + * expectedSettlementIncome: 예상 정산 소득. WORK_LOG_PLATFORM_INCOME 기준으로 아직 COMPLETED가 안 된 + * 플랫폼 몫 + PLANNED WORK_LOG 전체(estimated_income). 실제 입금액(actualIncome)은 SETTLEMENT + * 테이블 기반이라 WorkLog 데이터만으로는 계산할 수 없어 이 DTO에는 포함하지 않는다 - 필요하면 + * IncomeAnalysisQueryClient(§ SETTLEMENT 집계)를 별도로 조회해야 한다. * targetAmount: 해당 월 SAVING_GOAL.target_amount. 저축 목표를 등록하지 않은 달은 null */ @Getter @@ -15,8 +19,10 @@ @AllArgsConstructor public class CalendarMonthlyHours { - private int plannedHours; - private int actualHours; + private int goalHours; + private int confirmedHours; + private int scheduledHours; private Long expectedIncome; + private Long expectedSettlementIncome; private Long targetAmount; } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/controller/dashboard/DashboardController.java b/services/bff-service/src/main/java/com/ntropy/bff/controller/dashboard/DashboardController.java index 97098167..f46ce3f8 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/controller/dashboard/DashboardController.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/controller/dashboard/DashboardController.java @@ -15,7 +15,6 @@ import com.ntropy.bff.dto.dashboard.response.DashboardHoursResponse; import com.ntropy.bff.dto.dashboard.response.DashboardIncomeResponse; import com.ntropy.bff.dto.dashboard.response.DashboardResponse; -import com.ntropy.bff.dto.dashboard.response.DashboardSettlementResponse; import com.ntropy.bff.dto.dashboard.response.JobRecommendationsResponse; import com.ntropy.bff.security.AuthenticatedUserIdResolver; import com.ntropy.common.client.CalendarQueryClient; @@ -72,9 +71,8 @@ public ApiResponse getDashboard(@ApiParam(hidden = true) Auth DashboardResponse response = new DashboardResponse( user.name(), - new DashboardHoursResponse(hours.getActualHours(), hours.getPlannedHours()), - new DashboardIncomeResponse(incomeAnalysis.getTotalIncome(), hours.getTargetAmount()), - DashboardSettlementResponse.from(incomeAnalysis.getEarnedDepositComparisons()), + new DashboardHoursResponse(hours.getConfirmedHours(), hours.getScheduledHours(), hours.getGoalHours()), + new DashboardIncomeResponse(incomeAnalysis.getTotalIncome(), hours.getExpectedSettlementIncome(), hours.getTargetAmount()), fatigueScore ); return ApiResponse.success(response); diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardHoursResponse.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardHoursResponse.java index b291911c..04bbf810 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardHoursResponse.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardHoursResponse.java @@ -7,6 +7,7 @@ @AllArgsConstructor public class DashboardHoursResponse { - private int current; - private int target; + private int confirmedHours; + private int scheduledHours; + private int goalHours; } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardIncomeResponse.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardIncomeResponse.java index 59634469..0164fecd 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardIncomeResponse.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardIncomeResponse.java @@ -3,10 +3,16 @@ import lombok.AllArgsConstructor; import lombok.Getter; +/** + * actualIncome: 이번 달 실제 입금액. SETTLEMENT(MATCHED, deposit_date 기준) 합계 - 진짜 통장에 들어온 돈. + * expectedSettlementIncome: 예상 정산 소득. WORK_LOG/WORK_LOG_PLATFORM_INCOME 기준 아직 정산 안 된 예상치. + * goalIncome: 이번 달 SAVING_GOAL.target_amount. + */ @Getter @AllArgsConstructor public class DashboardIncomeResponse { - private Long amount; - private Long target; + private Long actualIncome; + private Long expectedSettlementIncome; + private Long goalIncome; } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardResponse.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardResponse.java index efb9a331..a4e702fb 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardResponse.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardResponse.java @@ -10,6 +10,5 @@ public class DashboardResponse { private String greetingName; private DashboardHoursResponse goalHours; private DashboardIncomeResponse goalIncome; - private DashboardSettlementResponse settlement; private Integer fatigueScore; } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardSettlementResponse.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardSettlementResponse.java deleted file mode 100644 index 280d0aa8..00000000 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/dashboard/response/DashboardSettlementResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.ntropy.bff.dto.dashboard.response; - -import java.util.List; - -import com.ntropy.common.dto.work.summary.EarnedDepositComparison; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -/** - * 이번 달 발생소득 대비 실입금소득. - * earned는 확정 근무일지 기준 발생소득, deposited는 매칭된 입금 거래 기준 실입금소득이다. - */ -@Getter -@AllArgsConstructor -public class DashboardSettlementResponse { - - private Long deposited; - private Long earned; - - public static DashboardSettlementResponse from(List comparisons) { - if (comparisons == null || comparisons.isEmpty()) { - return new DashboardSettlementResponse(0L, 0L); - } - long deposited = comparisons.stream() - .mapToLong(comparison -> nullToZero(comparison.getDepositedIncome())) - .sum(); - long earned = comparisons.stream() - .mapToLong(comparison -> nullToZero(comparison.getEarnedIncome())) - .sum(); - return new DashboardSettlementResponse(deposited, earned); - } - - private static long nullToZero(Long value) { - return value == null ? 0L : value; - } -} diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/WorkLogStatus.java b/services/work-service/src/main/java/com/ntropy/work/domain/WorkLogStatus.java new file mode 100644 index 00000000..6f307430 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/domain/WorkLogStatus.java @@ -0,0 +1,14 @@ +package com.ntropy.work.domain; + +/** + * WorkLog.status 값. WorkLogService, WorkLogReminderService, CalendarService 등 + * 여러 서비스에서 공통으로 참조하는 문자열 상수다. + */ +public final class WorkLogStatus { + + public static final String PLANNED = "PLANNED"; + public static final String CONFIRMED = "CONFIRMED"; + + private WorkLogStatus() { + } +} 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 32d38e18..29ceaf07 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 @@ -23,14 +23,17 @@ import com.ntropy.common.dto.work.summary.CalendarWorkBrief; import com.ntropy.common.dto.work.summary.WeatherForecast; import com.ntropy.common.dto.work.summary.WeatherForecastList; +import com.ntropy.work.domain.WorkLogStatus; import com.ntropy.work.domain.entity.AllocationGoal; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.SavingGoal; import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.entity.WorkLogPlatformIncome; import com.ntropy.work.domain.enums.SettlementStatus; import com.ntropy.work.mapper.AllocationGoalMapper; import com.ntropy.work.mapper.SavingGoalMapper; import com.ntropy.work.mapper.WorkLogMapper; +import com.ntropy.work.mapper.WorkLogPlatformIncomeMapper; import com.ntropy.work.util.WorkTimeUtils; import lombok.RequiredArgsConstructor; @@ -56,6 +59,7 @@ public class CalendarService { } private final WorkLogMapper workLogMapper; + private final WorkLogPlatformIncomeMapper workLogPlatformIncomeMapper; private final AllocationGoalMapper allocationGoalMapper; private final SavingGoalMapper savingGoalMapper; private final JobService jobService; @@ -74,6 +78,8 @@ public CalendarMonthlySummary getMonthlySummary(Long userId, int year, int month List jobIds = jobs.stream().map(Job::getJobId).collect(Collectors.toList()); List workLogs = workLogMapper.findByUserIdAndDateRange(userId, startDate, endDate); + List confirmedIncomes = + workLogPlatformIncomeMapper.findConfirmedByUserIdAndDateRange(userId, startDate, endDate); List allocationGoals = jobIds.isEmpty() ? List.of() : allocationGoalMapper.findByJobIdsAndTargetMonth(jobIds, targetMonth); @@ -81,7 +87,7 @@ public CalendarMonthlySummary getMonthlySummary(Long userId, int year, int month Map weatherByDate = weatherByDate(latitude, longitude); - CalendarMonthlyHours hours = summarizeHours(workLogs, allocationGoals, savingGoal); + CalendarMonthlyHours hours = summarizeHours(workLogs, confirmedIncomes, allocationGoals, savingGoal); List days = summarizeDays(workLogs, jobNames, weatherByDate); return new CalendarMonthlySummary(year, month, hours, days); @@ -134,27 +140,44 @@ private Map weatherByDate(Double latitude, Double lo } /** - * plannedHours: 해당 월 ALLOCATION_GOAL(잡별 추천 근무시간) 합 - * actualHours: 해당 월 WORK_LOG 전체(PLANNED+CONFIRMED) 근무시간 합 + * goalHours: 해당 월 ALLOCATION_GOAL(잡별 추천 근무시간) 합 + * confirmedHours/scheduledHours: 해당 월 WORK_LOG를 status(CONFIRMED/PLANNED) 기준으로 나눈 근무시간 합 + * expectedSettlementIncome: expectedIncome(전체 예상소득) - CONFIRMED 근무일지에 딸린 + * WORK_LOG_PLATFORM_INCOME 행 중 이미 COMPLETED로 정산 완료된 행들의 expectedAmount 합. + * 플랫폼별 income 행 단위로 계산하므로, 여러 플랫폼 중 일부만 정산된 PARTIAL 근무일지도 + * 완료된 플랫폼 몫만 정확히 제외된다. 실제 입금액(actualIncome)은 SETTLEMENT 테이블 + * 기반이라 여기서는 계산하지 않는다 - IncomeAnalysisQueryClient를 따로 써야 한다. * targetAmount: 해당 월 SAVING_GOAL이 없으면 null (달성률 미표시는 프론트 책임) */ - private CalendarMonthlyHours summarizeHours(List workLogs, List allocationGoals, - SavingGoal savingGoal) { - int plannedHours = allocationGoals.stream() + private CalendarMonthlyHours summarizeHours(List workLogs, List confirmedIncomes, + List allocationGoals, SavingGoal savingGoal) { + int goalHours = allocationGoals.stream() .mapToInt(goal -> goal.getRecommendHour() == null ? 0 : goal.getRecommendHour().intValue()) .sum(); - int actualHours = 0; + int confirmedHours = 0; + int scheduledHours = 0; long expectedIncome = 0; for (WorkLog workLog : workLogs) { - actualHours += WorkTimeUtils.durationHours(workLog.getStartTime(), workLog.getEndTime()); - if (workLog.getEstimatedIncome() != null) { - expectedIncome += workLog.getEstimatedIncome(); + int hours = WorkTimeUtils.durationHours(workLog.getStartTime(), workLog.getEndTime()); + long income = workLog.getEstimatedIncome() == null ? 0 : workLog.getEstimatedIncome(); + if (WorkLogStatus.CONFIRMED.equals(workLog.getStatus())) { + confirmedHours += hours; + } else if (WorkLogStatus.PLANNED.equals(workLog.getStatus())) { + scheduledHours += hours; } + expectedIncome += income; } + long completedFromPlatforms = confirmedIncomes.stream() + .filter(income -> SettlementStatus.COMPLETED.equals(income.getSettlementStatus())) + .mapToLong(income -> income.getExpectedAmount() == null ? 0 : income.getExpectedAmount()) + .sum(); + long expectedSettlementIncome = expectedIncome - completedFromPlatforms; + Long targetAmount = savingGoal == null ? null : savingGoal.getTargetAmount(); - return new CalendarMonthlyHours(plannedHours, actualHours, expectedIncome, targetAmount); + return new CalendarMonthlyHours(goalHours, confirmedHours, scheduledHours, expectedIncome, + expectedSettlementIncome, targetAmount); } private List summarizeDays(List workLogs, Map jobNames, diff --git a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogReminderService.java b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogReminderService.java index f4aa5370..11c53d98 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogReminderService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogReminderService.java @@ -11,6 +11,7 @@ import com.ntropy.common.client.NotificationCommandClient; import com.ntropy.common.dto.notification.NotificationCreateCommand; import com.ntropy.work.config.WorkReminderBatchUserScopeProperties; +import com.ntropy.work.domain.WorkLogStatus; import com.ntropy.work.domain.entity.WorkLog; import com.ntropy.work.mapper.WorkLogMapper; @@ -27,7 +28,7 @@ @RequiredArgsConstructor public class WorkLogReminderService { - private static final String STATUS_PLANNED = "PLANNED"; + private static final String STATUS_PLANNED = WorkLogStatus.PLANNED; private static final int UNCONFIRMED_REMINDER_DELAY_MINUTES = 60; private final ActiveUserQueryClient activeUserQueryClient; 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 2068bbbe..4727f4d2 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 @@ -10,6 +10,7 @@ import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.WorkLogSettlementStatusCalculator; +import com.ntropy.work.domain.WorkLogStatus; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.JobPlatformMapping; import com.ntropy.work.domain.entity.Platform; @@ -30,8 +31,8 @@ @RequiredArgsConstructor public class WorkLogService { - private static final String STATUS_PLANNED = "PLANNED"; - private static final String STATUS_CONFIRMED = "CONFIRMED"; + private static final String STATUS_PLANNED = WorkLogStatus.PLANNED; + private static final String STATUS_CONFIRMED = WorkLogStatus.CONFIRMED; private static final String TRIGGER_ON_DEMAND = "ON_DEMAND"; private final WorkLogMapper workLogMapper; 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 e918652e..5aa59de9 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 @@ -22,6 +22,7 @@ import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.SavingGoal; import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.entity.WorkLogPlatformIncome; import com.ntropy.work.domain.enums.SettlementStatus; import com.ntropy.work.domain.enums.SettlementType; import com.ntropy.work.mapper.InMemoryAllocationGoalMapper; @@ -30,15 +31,19 @@ import com.ntropy.work.mapper.InMemoryJobScheduleMapper; import com.ntropy.work.mapper.InMemorySavingGoalMapper; import com.ntropy.work.mapper.InMemoryWorkLogMapper; +import com.ntropy.work.mapper.InMemoryWorkLogPlatformIncomeMapper; class CalendarServiceTest { private static final Long USER_ID = 1L; private static final Long JOB_A = 10L; private static final Long JOB_B = 20L; + private static final Long PLATFORM_A = 100L; + private static final Long PLATFORM_B = 200L; private static final String TARGET_MONTH = "2026-08"; private InMemoryWorkLogMapper workLogMapper; + private InMemoryWorkLogPlatformIncomeMapper workLogPlatformIncomeMapper; private InMemoryAllocationGoalMapper allocationGoalMapper; private InMemorySavingGoalMapper savingGoalMapper; private JobService jobService; @@ -51,6 +56,7 @@ void setUp() { jobMapper.seed(job(JOB_B, "쿠팡이츠 배달")); workLogMapper = new InMemoryWorkLogMapper(); + workLogPlatformIncomeMapper = new InMemoryWorkLogPlatformIncomeMapper(workLogMapper); allocationGoalMapper = new InMemoryAllocationGoalMapper(); savingGoalMapper = new InMemorySavingGoalMapper(); SavingGoalService savingGoalService = new SavingGoalService(savingGoalMapper, allocationGoalMapper); @@ -62,8 +68,8 @@ jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(new InMemoryCate ); calendarService = new CalendarService( - workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(null), - new StubWeatherQueryClient(List.of()) + workLogMapper, workLogPlatformIncomeMapper, allocationGoalMapper, savingGoalMapper, jobService, + new StubFatigueService(null), new StubWeatherQueryClient(List.of()) ); } @@ -82,9 +88,9 @@ private Job job(Long jobId, String jobName) { return job; } - private void seedWorkLog(Long jobId, LocalDate date, LocalTime start, LocalTime end, - String status, SettlementStatus settlementStatus, Long estimatedIncome) { - workLogMapper.insert(WorkLog.builder() + private WorkLog seedWorkLog(Long jobId, LocalDate date, LocalTime start, LocalTime end, + String status, SettlementStatus settlementStatus, Long estimatedIncome) { + WorkLog workLog = WorkLog.builder() .userId(USER_ID) .jobId(jobId) .workDate(date) @@ -93,11 +99,23 @@ private void seedWorkLog(Long jobId, LocalDate date, LocalTime start, LocalTime .status(status) .settlementStatus(settlementStatus) .estimatedIncome(estimatedIncome) + .build(); + workLogMapper.insert(workLog); + return workLog; + } + + private void seedIncome(Long logId, Long platformId, long expectedAmount, SettlementStatus settlementStatus) { + workLogPlatformIncomeMapper.insert(WorkLogPlatformIncome.builder() + .logId(logId) + .platformId(platformId) + .expectedAmount(expectedAmount) + .settlementStatus(settlementStatus) .build()); } + @Test - @DisplayName("월간 요약은 목표시간 합/근무시간 합/예상수입 합/저축 목표 금액을 계산한다") + @DisplayName("월간 요약은 목표시간 합/예상수입 합/저축 목표 금액을 계산한다") void monthlySummary_aggregatesPlannedActualAndIncome() { allocationGoalMapper.insert(AllocationGoal.builder().jobId(JOB_A).targetMonth(TARGET_MONTH).recommendHour(20L).build()); allocationGoalMapper.insert(AllocationGoal.builder().jobId(JOB_B).targetMonth(TARGET_MONTH).recommendHour(10L).build()); @@ -110,12 +128,52 @@ void monthlySummary_aggregatesPlannedActualAndIncome() { CalendarMonthlySummary summary = calendarService.getMonthlySummary(USER_ID, 2026, 8, null, null); - assertEquals(30, summary.getSummary().getPlannedHours()); - assertEquals(8, summary.getSummary().getActualHours()); + assertEquals(30, summary.getSummary().getGoalHours()); assertEquals(68000L, summary.getSummary().getExpectedIncome()); assertEquals(2_500_000L, summary.getSummary().getTargetAmount()); } + @Test + @DisplayName("월간 요약의 근무시간은 status 기준으로 confirmedHours/scheduledHours에 나뉘어 집계된다") + void monthlySummary_splitsHoursByStatus() { + seedWorkLog(JOB_A, LocalDate.of(2026, 8, 3), LocalTime.of(18, 0), LocalTime.of(22, 0), + "PLANNED", SettlementStatus.NONE, 48000L); + seedWorkLog(JOB_B, LocalDate.of(2026, 8, 5), LocalTime.of(10, 0), LocalTime.of(14, 0), + "CONFIRMED", SettlementStatus.PENDING, 20000L); + + CalendarMonthlySummary summary = calendarService.getMonthlySummary(USER_ID, 2026, 8, null, null); + + assertEquals(4, summary.getSummary().getConfirmedHours()); + assertEquals(4, summary.getSummary().getScheduledHours()); + } + + @Test + @DisplayName("예상 정산 소득은 PLANNED 근무와, CONFIRMED 근무일지 중 아직 COMPLETED 안 된 플랫폼 몫을 합산한다") + void monthlySummary_expectedSettlementIncome_includesPlannedAndConfirmedNotCompleted() { + WorkLog completed = seedWorkLog(JOB_A, LocalDate.of(2026, 8, 3), LocalTime.of(9, 0), LocalTime.of(12, 0), + "CONFIRMED", SettlementStatus.COMPLETED, 36000L); + seedIncome(completed.getLogId(), PLATFORM_A, 36000L, SettlementStatus.COMPLETED); + + WorkLog pending = seedWorkLog(JOB_A, LocalDate.of(2026, 8, 5), LocalTime.of(9, 0), LocalTime.of(12, 0), + "CONFIRMED", SettlementStatus.PENDING, 30000L); + seedIncome(pending.getLogId(), PLATFORM_A, 30000L, SettlementStatus.PENDING); + + // 플랫폼 2개짜리 잡: 하나만 완료된 PARTIAL 근무. COMPLETED 몫(12000)은 정산 완료 처리되어 + // 나머지 PENDING 몫(8000)만 expectedSettlementIncome으로 들어가야 한다. + WorkLog partial = seedWorkLog(JOB_A, LocalDate.of(2026, 8, 7), LocalTime.of(9, 0), LocalTime.of(12, 0), + "CONFIRMED", SettlementStatus.PARTIAL, 20000L); + seedIncome(partial.getLogId(), PLATFORM_A, 12000L, SettlementStatus.COMPLETED); + seedIncome(partial.getLogId(), PLATFORM_B, 8000L, SettlementStatus.PENDING); + + seedWorkLog(JOB_B, LocalDate.of(2026, 8, 10), LocalTime.of(18, 0), LocalTime.of(22, 0), + "PLANNED", SettlementStatus.NONE, 48000L); + + CalendarMonthlySummary summary = calendarService.getMonthlySummary(USER_ID, 2026, 8, null, null); + + // expectedSettlementIncome = 30000(pending) + 8000(partial의 미완료 몫) + 48000(planned) = 86000 + assertEquals(86000L, summary.getSummary().getExpectedSettlementIncome()); + } + @Test @DisplayName("월 범위 밖의 근무일지는 집계에서 제외된다") void monthlySummary_excludesLogsOutsideDateRange() { @@ -124,7 +182,8 @@ void monthlySummary_excludesLogsOutsideDateRange() { CalendarMonthlySummary summary = calendarService.getMonthlySummary(USER_ID, 2026, 8, null, null); - assertEquals(0, summary.getSummary().getActualHours()); + assertEquals(0, summary.getSummary().getConfirmedHours()); + assertEquals(0, summary.getSummary().getScheduledHours()); assertTrue(summary.getDays().isEmpty()); } @@ -166,8 +225,9 @@ void monthlySummary_jobBriefsAreDeduplicatedPerDay() { void monthlySummary_noDataReturnsZeroedSummary() { CalendarMonthlySummary summary = calendarService.getMonthlySummary(USER_ID, 2026, 8, null, null); - assertEquals(0, summary.getSummary().getPlannedHours()); - assertEquals(0, summary.getSummary().getActualHours()); + assertEquals(0, summary.getSummary().getGoalHours()); + assertEquals(0, summary.getSummary().getConfirmedHours()); + assertEquals(0, summary.getSummary().getScheduledHours()); assertEquals(0L, summary.getSummary().getExpectedIncome()); assertTrue(summary.getDays().isEmpty()); } @@ -220,8 +280,8 @@ freshJobMapper, new InMemoryJobScheduleMapper(), new CategoryService(new InMemor freshAllocationGoalMapper, freshRecommendedWorkHoursService ); CalendarService service = new CalendarService( - workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(gauge), - new StubWeatherQueryClient(List.of()) + workLogMapper, workLogPlatformIncomeMapper, allocationGoalMapper, savingGoalMapper, jobService, + new StubFatigueService(gauge), new StubWeatherQueryClient(List.of()) ); CalendarDailySummary summary = service.getDailySummary(USER_ID, LocalDate.of(2026, 8, 3), null, null); @@ -236,8 +296,8 @@ void monthlySummary_attachesWeatherOnlyForForecastDates() { LocalDate noForecastDate = LocalDate.of(2026, 8, 10); WeatherForecast forecast = new WeatherForecast(forecastDate, "맑음", "없음", false, 28); CalendarService service = new CalendarService( - workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(null), - new StubWeatherQueryClient(List.of(forecast)) + workLogMapper, workLogPlatformIncomeMapper, allocationGoalMapper, savingGoalMapper, jobService, + new StubFatigueService(null), new StubWeatherQueryClient(List.of(forecast)) ); seedWorkLog(JOB_A, forecastDate, LocalTime.of(9, 0), LocalTime.of(12, 0), "CONFIRMED", SettlementStatus.COMPLETED, 36000L); @@ -255,8 +315,8 @@ workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatig void monthlySummary_weatherQueryFails_degradesToNullWeather() { LocalDate date = LocalDate.of(2026, 8, 3); CalendarService service = new CalendarService( - workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(null), - new ThrowingWeatherQueryClient() + workLogMapper, workLogPlatformIncomeMapper, allocationGoalMapper, savingGoalMapper, jobService, + new StubFatigueService(null), new ThrowingWeatherQueryClient() ); seedWorkLog(JOB_A, date, LocalTime.of(9, 0), LocalTime.of(12, 0), "CONFIRMED", SettlementStatus.COMPLETED, 36000L); @@ -271,8 +331,8 @@ workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatig void dailySummary_forecastListIsNull_returnsNullWeather() { LocalDate date = LocalDate.of(2026, 8, 3); CalendarService service = new CalendarService( - workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(null), - new StubWeatherQueryClient(null) + workLogMapper, workLogPlatformIncomeMapper, allocationGoalMapper, savingGoalMapper, jobService, + new StubFatigueService(null), new StubWeatherQueryClient(null) ); seedWorkLog(JOB_A, date, LocalTime.of(9, 0), LocalTime.of(12, 0), "PLANNED", SettlementStatus.NONE, 48000L);