diff --git a/services/work-service/src/main/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClient.java b/services/work-service/src/main/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClient.java index 02afd043..35c62675 100644 --- a/services/work-service/src/main/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClient.java +++ b/services/work-service/src/main/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClient.java @@ -1,8 +1,11 @@ package com.ntropy.work.client; import java.time.LocalDate; +import java.time.YearMonth; import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.springframework.stereotype.Component; @@ -10,6 +13,9 @@ import com.ntropy.common.client.ExpectedIncomeLossQueryClient; import com.ntropy.common.dto.work.summary.JobExpectedIncomeLossSummary; import com.ntropy.work.domain.entity.Job; +import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.enums.SettlementMatchStatus; +import com.ntropy.work.mapper.SettlementMapper; import com.ntropy.work.service.JobService; import lombok.RequiredArgsConstructor; @@ -18,33 +24,53 @@ * 방어모드 기간 동안 근무하지 못해 발생할 예상 손실소득을 잡별로 계산한다. * *

잡 등록/수정 시점에 저장해둔 월 환산 예상 소득(monthly_expected_income)을 - * 방어기간 일수만큼 일할 계산한다(30일 기준). 월 예상 소득을 계산할 수 없는 - * 잡(PER_TASK 등)은 손실액을 null로 반환하며, 방어모드가 이를 계산 불가로 처리한다.

+ * 방어기간 일수만큼 일할 계산한다(30일 기준). MONTHLY와 정기(스케줄 있는) HOURLY 잡은 + * 등록/수정 시점에 이 값을 계산할 근거(고정 월급 또는 고정 스케줄×시급)가 있어 항상 + * 채워져 있다. 반면 PER_TASK와 비정기(스케줄 없는) HOURLY 잡은 등록 시점에 확정할 + * 근거가 없어 monthly_expected_income이 항상 null이므로, 대신 조회 시점 기준 최근 + * 3개월(이번 달 제외, 완료된 달만) MATCHED 정산 실적을 데이터가 있는 달만으로 평균 + * 내 사용한다. 최근 3개월 정산 이력이 전혀 없으면 다른 잡과 동일하게 손실액을 + * null(계산 불가)로 반환한다.

*/ @Component @RequiredArgsConstructor public class LocalExpectedIncomeLossQueryClient implements ExpectedIncomeLossQueryClient { private static final int DAYS_PER_MONTH = 30; + private static final int RECENT_MONTHS_FOR_AVERAGE = 3; private final JobService jobService; + private final SettlementMapper settlementMapper; @Override public List findExpectedIncomeLossByJob( Long userId, LocalDate fromDate, LocalDate toDate) { long days = ChronoUnit.DAYS.between(fromDate, toDate) + 1; - return jobService.findByUserId(userId).stream() + List activeJobs = jobService.findByUserId(userId).stream() .filter(job -> Boolean.TRUE.equals(job.getIsActive())) - .map(job -> toSummary(job, days)) + .collect(Collectors.toList()); + + // monthly_expected_income이 등록 시점에 계산되지 않은 잡(PER_TASK, 비정기 HOURLY 등)만 + // 최근 실적 평균 대상이 된다. + List jobsWithoutSnapshot = activeJobs.stream() + .filter(job -> job.getMonthlyExpectedIncome() == null) + .collect(Collectors.toList()); + Map recentAverageIncomeByJob = calculateRecentAverageIncomeByJob(jobsWithoutSnapshot); + + return activeJobs.stream() + .map(job -> toSummary(job, days, recentAverageIncomeByJob)) .collect(Collectors.toList()); } - private JobExpectedIncomeLossSummary toSummary(Job job, long days) { + private JobExpectedIncomeLossSummary toSummary(Job job, long days, Map recentAverageIncomeByJob) { + Long monthlyIncome = job.getMonthlyExpectedIncome() != null + ? job.getMonthlyExpectedIncome() + : recentAverageIncomeByJob.get(job.getJobId()); return new JobExpectedIncomeLossSummary( job.getJobId(), job.getJobName(), - calculateLoss(job.getMonthlyExpectedIncome(), days)); + calculateLoss(monthlyIncome, days)); } private Long calculateLoss(Long monthlyExpectedIncome, long days) { @@ -53,4 +79,39 @@ private Long calculateLoss(Long monthlyExpectedIncome, long days) { } return Math.round(monthlyExpectedIncome * ((double) days / DAYS_PER_MONTH)); } + + /** + * monthly_expected_income이 없는 잡들의 최근 3개월(이번 달 제외) 월 평균 실적 + * 소득을 잡별로 계산한다. 데이터가 있는 달만으로 평균을 내며(예: 1개월치만 + * 있으면 그 1개월로 평균), 최근 3개월 내 MATCHED 정산이 전혀 없는 잡은 결과 + * 맵에 포함되지 않는다(호출부에서 get() 시 null → 계산 불가로 처리됨). + */ + private Map calculateRecentAverageIncomeByJob(List jobsWithoutSnapshot) { + if (jobsWithoutSnapshot.isEmpty()) { + return Map.of(); + } + + List jobIds = jobsWithoutSnapshot.stream().map(Job::getJobId).collect(Collectors.toList()); + YearMonth currentMonth = YearMonth.now(); + LocalDate startDate = currentMonth.minusMonths(RECENT_MONTHS_FOR_AVERAGE).atDay(1); + LocalDate endDate = currentMonth.minusMonths(1).atEndOfMonth(); + + List settlements = settlementMapper.findByJobIdInAndDepositDateRangeAndStatus( + jobIds, startDate, endDate, SettlementMatchStatus.MATCHED); + + Map> monthlySumsByJob = new HashMap<>(); + for (Settlement settlement : settlements) { + monthlySumsByJob + .computeIfAbsent(settlement.getJobId(), key -> new HashMap<>()) + .merge(YearMonth.from(settlement.getDepositDate()), settlement.getActualAmount(), Long::sum); + } + + Map result = new HashMap<>(); + for (Map.Entry> entry : monthlySumsByJob.entrySet()) { + Map monthlySums = entry.getValue(); + long total = monthlySums.values().stream().mapToLong(Long::longValue).sum(); + result.put(entry.getKey(), Math.round((double) total / monthlySums.size())); + } + return result; + } } diff --git a/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java b/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java index 4b5e2929..7ada6b98 100644 --- a/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java +++ b/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java @@ -30,4 +30,10 @@ List findByUserIdAndDepositDateRange(@Param("userId") Long userId, List findByUserIdInAndDepositDateRange(@Param("userIds") List userIds, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + /** PER_TASK 잡의 최근 N개월 평균 소득 계산용: 여러 잡의 SETTLEMENT를 한 번에 조회한다. 결과는 jobId로 그룹핑해서 써야 한다. */ + List findByJobIdInAndDepositDateRangeAndStatus(@Param("jobIds") List jobIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("status") SettlementMatchStatus status); } diff --git a/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml b/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml index 954b9783..e484e4b3 100644 --- a/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml +++ b/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml @@ -59,4 +59,15 @@ AND deposit_date BETWEEN #{startDate} AND #{endDate} + + diff --git a/services/work-service/src/test/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClientTest.java b/services/work-service/src/test/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClientTest.java index aa9d8cbe..e888337e 100644 --- a/services/work-service/src/test/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClientTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/client/LocalExpectedIncomeLossQueryClientTest.java @@ -5,9 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; +import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.enums.SettlementMatchStatus; import com.ntropy.work.mapper.InMemoryAllocationGoalMapper; +import com.ntropy.work.mapper.InMemorySettlementMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -30,11 +34,13 @@ class LocalExpectedIncomeLossQueryClientTest { private static final Long USER_ID = 1L; private InMemoryJobMapper jobMapper; + private InMemorySettlementMapper settlementMapper; private LocalExpectedIncomeLossQueryClient client; @BeforeEach void setUp() { jobMapper = new InMemoryJobMapper(); + settlementMapper = new InMemorySettlementMapper(); InMemoryCategoryMapper categoryMapper = new InMemoryCategoryMapper(); categoryMapper.seed(Category.builder().categoryId(1L).name("배달").build()); InMemoryAllocationGoalMapper allocationGoalMapper = new InMemoryAllocationGoalMapper(); @@ -44,7 +50,7 @@ void setUp() { JobService jobService = new JobService( jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(categoryMapper), allocationGoalMapper, recommendedWorkHoursService); - client = new LocalExpectedIncomeLossQueryClient(jobService); + client = new LocalExpectedIncomeLossQueryClient(jobService, settlementMapper); } private Job.JobBuilder baseJob() { @@ -59,6 +65,16 @@ private Job.JobBuilder baseJob() { .isActive(true); } + private Settlement.SettlementBuilder matchedSettlement(Long jobId, LocalDate depositDate, long actualAmount) { + return Settlement.builder() + .userId(USER_ID) + .jobId(jobId) + .status(SettlementMatchStatus.MATCHED) + .depositDate(depositDate) + .actualAmount(actualAmount) + .transactionCount(1); + } + @Test @DisplayName("방어기간 30일이면 월 예상 소득 전액이 손실로 계산된다") void findExpectedIncomeLossByJob_fullMonth_returnsFullIncome() { @@ -139,4 +155,120 @@ void findExpectedIncomeLossByJob_noJobs_returnsEmpty() { assertTrue(result.isEmpty()); } + + @Test + @DisplayName("건당정산 잡은 최근 3개월(이번 달 제외) MATCHED 정산 평균으로 손실을 계산한다") + void findExpectedIncomeLossByJob_perTask_usesRecentThreeMonthAverage() { + jobMapper.seed(baseJob().jobId(1L).jobName("쿠팡플렉스") + .settlementType(SettlementType.PER_TASK).monthlyWage(null).perTaskWage(3000).taskPerHour(3.5f) + .monthlyExpectedIncome(null).build()); + + YearMonth thisMonth = YearMonth.now(); + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(1).atDay(10), 500000L).build()); + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(2).atDay(10), 700000L).build()); + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(3).atDay(10), 600000L).build()); + // 이번 달 정산은 아직 진행 중이라 평균 계산 대상에서 제외되어야 한다 + settlementMapper.insert(matchedSettlement(1L, thisMonth.atDay(1), 999999L).build()); + + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(29); // 30일 + + List result = client.findExpectedIncomeLossByJob(USER_ID, from, to); + + // 평균 = (500000+700000+600000)/3 = 600000, 30일 방어기간이면 전액 손실 + assertEquals(1, result.size()); + assertEquals(600000L, result.get(0).getExpectedIncomeLoss()); + } + + @Test + @DisplayName("건당정산 잡이 최근 3개월 중 일부만 정산 이력이 있으면 있는 달만으로 평균낸다") + void findExpectedIncomeLossByJob_perTask_partialHistory_averagesOverAvailableMonthsOnly() { + jobMapper.seed(baseJob().jobId(1L).jobName("쿠팡플렉스") + .settlementType(SettlementType.PER_TASK).monthlyWage(null).perTaskWage(3000).taskPerHour(3.5f) + .monthlyExpectedIncome(null).build()); + + YearMonth thisMonth = YearMonth.now(); + // 최근 3개월 중 지난달 하나만 이력 존재 (신규 잡 등) + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(1).atDay(10), 450000L).build()); + + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(29); + + List result = client.findExpectedIncomeLossByJob(USER_ID, from, to); + + assertEquals(450000L, result.get(0).getExpectedIncomeLoss()); + } + + @Test + @DisplayName("건당정산 잡이 최근 3개월 정산 이력이 전혀 없으면 손실액이 null로 반환된다") + void findExpectedIncomeLossByJob_perTask_noHistory_returnsNullLoss() { + jobMapper.seed(baseJob().jobId(1L).jobName("쿠팡플렉스") + .settlementType(SettlementType.PER_TASK).monthlyWage(null).perTaskWage(3000).taskPerHour(3.5f) + .monthlyExpectedIncome(null).build()); + + List result = client.findExpectedIncomeLossByJob( + USER_ID, LocalDate.now(), LocalDate.now().plusDays(29)); + + assertEquals(1, result.size()); + assertNull(result.get(0).getExpectedIncomeLoss()); + } + + @Test + @DisplayName("건당정산 잡과 다른 정산방식 잡이 섞여 있어도 각자의 방식으로 계산된다") + void findExpectedIncomeLossByJob_mixedSettlementTypes_eachUsesOwnCalculation() { + jobMapper.seed(baseJob().jobId(1L).jobName("본업").monthlyExpectedIncome(3000000L).build()); + jobMapper.seed(baseJob().jobId(2L).jobName("쿠팡플렉스") + .settlementType(SettlementType.PER_TASK).monthlyWage(null).perTaskWage(3000).taskPerHour(3.5f) + .monthlyExpectedIncome(null).build()); + + YearMonth thisMonth = YearMonth.now(); + settlementMapper.insert(matchedSettlement(2L, thisMonth.minusMonths(1).atDay(10), 500000L).build()); + + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(29); + + List result = client.findExpectedIncomeLossByJob(USER_ID, from, to); + + assertEquals(2, result.size()); + assertEquals(3000000L, result.get(0).getExpectedIncomeLoss()); + assertEquals(500000L, result.get(1).getExpectedIncomeLoss()); + } + + @Test + @DisplayName("비정기(스케줄 없는) 시급 잡도 monthly_expected_income이 null이라 최근 3개월 평균으로 계산된다") + void findExpectedIncomeLossByJob_irregularHourly_usesRecentThreeMonthAverage() { + jobMapper.seed(baseJob().jobId(1L).jobName("단기 물류센터") + .settlementType(SettlementType.HOURLY).monthlyWage(null).hourlyWage(12000) + .monthlyExpectedIncome(null).build()); + + YearMonth thisMonth = YearMonth.now(); + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(1).atDay(10), 400000L).build()); + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(2).atDay(10), 800000L).build()); + + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(29); + + List result = client.findExpectedIncomeLossByJob(USER_ID, from, to); + + // 평균 = (400000+800000)/2 = 600000 + assertEquals(1, result.size()); + assertEquals(600000L, result.get(0).getExpectedIncomeLoss()); + } + + @Test + @DisplayName("monthly_expected_income이 이미 있는 잡은 정산 이력이 있어도 스냅샷 값을 그대로 쓴다") + void findExpectedIncomeLossByJob_hasSnapshot_ignoresSettlementHistory() { + jobMapper.seed(baseJob().jobId(1L).jobName("본업").monthlyExpectedIncome(3000000L).build()); + + YearMonth thisMonth = YearMonth.now(); + // 스냅샷이 있는 잡인데 정산 이력이 있어도 평균 계산에 쓰이면 안 된다 + settlementMapper.insert(matchedSettlement(1L, thisMonth.minusMonths(1).atDay(10), 100L).build()); + + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(29); + + List result = client.findExpectedIncomeLossByJob(USER_ID, from, to); + + assertEquals(3000000L, result.get(0).getExpectedIncomeLoss()); + } } diff --git a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java index 9ffc648c..1e349446 100644 --- a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java +++ b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java @@ -64,6 +64,21 @@ public List findByUserIdInAndDepositDateRange(List userIds, Lo return result; } + @Override + public List findByJobIdInAndDepositDateRangeAndStatus(List jobIds, LocalDate startDate, + LocalDate endDate, SettlementMatchStatus status) { + List result = new ArrayList<>(); + for (Settlement settlement : store) { + if (jobIds.contains(settlement.getJobId()) + && !settlement.getDepositDate().isBefore(startDate) + && !settlement.getDepositDate().isAfter(endDate) + && settlement.getStatus() == status) { + result.add(settlement); + } + } + return result; + } + public List findAll() { return store; } diff --git a/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java index ec398762..bcb8b9dd 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java @@ -387,5 +387,12 @@ public List findByUserIdInAndDepositDateRange(List userIds, Lo .filter(s -> !s.getDepositDate().isBefore(startDate) && !s.getDepositDate().isAfter(endDate)) .toList(); } + + @Override + public List findByJobIdInAndDepositDateRangeAndStatus(List jobIds, LocalDate startDate, + LocalDate endDate, + SettlementMatchStatus status) { + throw new UnsupportedOperationException("이 테스트 더블은 조회만 지원합니다."); + } } }