From 9cb8162a38d6d606d420fecdbec42bb7e0bffcb1 Mon Sep 17 00:00:00 2001 From: JaeYoon Date: Wed, 19 Aug 2026 14:00:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B1=B4=EB=8B=B9=EC=A0=95?= =?UTF-8?q?=EC=82=B0(PER=5FTASK)=20=EC=9E=A1=20=EB=B0=A9=EC=96=B4=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=98=88=EC=83=81=20=EC=86=90=EC=8B=A4=EC=86=8C?= =?UTF-8?q?=EB=93=9D=20=EA=B3=84=EC=82=B0=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettlementMapper에 잡별 벌크 조회(findByJobIdInAndDepositDateRangeAndStatus) 추가 - LocalExpectedIncomeLossQueryClient: PER_TASK 잡은 조회 시점 기준 최근 3개월 (이번 달 제외) MATCHED 정산 평균으로 방어모드 예상 손실소득 계산 - 데이터 있는 달만으로 평균, 3개월 이력이 전혀 없으면 기존과 동일하게 null - JOB.monthly_expected_income 컬럼 자체는 변경 없음 (조회 시점 동적 계산) --- .../LocalExpectedIncomeLossQueryClient.java | 70 ++++++++++++-- .../ntropy/work/mapper/SettlementMapper.java | 6 ++ .../mapper/work/SettlementMapper.xml | 11 +++ ...ocalExpectedIncomeLossQueryClientTest.java | 96 ++++++++++++++++++- .../work/mapper/InMemorySettlementMapper.java | 15 +++ .../service/IncomeAnalysisServiceTest.java | 7 ++ 6 files changed, 198 insertions(+), 7 deletions(-) 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..40d6b64b 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,10 @@ 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.domain.enums.SettlementType; +import com.ntropy.work.mapper.SettlementMapper; import com.ntropy.work.service.JobService; import lombok.RequiredArgsConstructor; @@ -18,33 +25,49 @@ * 방어모드 기간 동안 근무하지 못해 발생할 예상 손실소득을 잡별로 계산한다. * *

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

+ * 방어기간 일수만큼 일할 계산한다(30일 기준). PER_TASK 잡은 등록/수정 시점에 확정할 + * 근거(고정 시급×시간도, 고정 월급도 아님)가 없어 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_PER_TASK_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()); + + List perTaskJobs = activeJobs.stream() + .filter(job -> job.getSettlementType() == SettlementType.PER_TASK) + .collect(Collectors.toList()); + Map perTaskAverageIncomeByJob = calculateRecentAverageIncomeByJob(perTaskJobs); + + return activeJobs.stream() + .map(job -> toSummary(job, days, perTaskAverageIncomeByJob)) .collect(Collectors.toList()); } - private JobExpectedIncomeLossSummary toSummary(Job job, long days) { + private JobExpectedIncomeLossSummary toSummary(Job job, long days, Map perTaskAverageIncomeByJob) { + Long monthlyIncome = job.getSettlementType() == SettlementType.PER_TASK + ? perTaskAverageIncomeByJob.get(job.getJobId()) + : job.getMonthlyExpectedIncome(); return new JobExpectedIncomeLossSummary( job.getJobId(), job.getJobName(), - calculateLoss(job.getMonthlyExpectedIncome(), days)); + calculateLoss(monthlyIncome, days)); } private Long calculateLoss(Long monthlyExpectedIncome, long days) { @@ -53,4 +76,39 @@ private Long calculateLoss(Long monthlyExpectedIncome, long days) { } return Math.round(monthlyExpectedIncome * ((double) days / DAYS_PER_MONTH)); } + + /** + * PER_TASK 잡들의 최근 3개월(이번 달 제외) 월 평균 실적 소득을 잡별로 계산한다. + * 데이터가 있는 달만으로 평균을 내며(예: 1개월치만 있으면 그 1개월로 평균), + * 최근 3개월 내 MATCHED 정산이 전혀 없는 잡은 결과 맵에 포함되지 않는다 + * (호출부에서 get() 시 null → 계산 불가로 처리됨). + */ + private Map calculateRecentAverageIncomeByJob(List perTaskJobs) { + if (perTaskJobs.isEmpty()) { + return Map.of(); + } + + List jobIds = perTaskJobs.stream().map(Job::getJobId).collect(Collectors.toList()); + YearMonth currentMonth = YearMonth.now(); + LocalDate startDate = currentMonth.minusMonths(RECENT_MONTHS_FOR_PER_TASK_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..f18389fc 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,82 @@ 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()); + } } 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("이 테스트 더블은 조회만 지원합니다."); + } } } From 24331a58ea4383cc3a471e64995dc57e6ac33b14 Mon Sep 17 00:00:00 2001 From: JaeYoon Date: Wed, 19 Aug 2026 14:11:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=B0=A9=EC=96=B4=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=98=88=EC=83=81=20=EC=86=90=EC=8B=A4=EC=86=8C?= =?UTF-8?q?=EB=93=9D=20=EA=B3=84=EC=82=B0=20-=20=EC=9B=94=20=EC=98=88?= =?UTF-8?q?=EC=83=81=EC=86=8C=EB=93=9D=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9E=A1=20=EC=B5=9C=EA=B7=BC=203?= =?UTF-8?q?=EA=B0=9C=EC=9B=94=20=EC=8B=A4=EC=A0=81=20=ED=8F=89=EA=B7=A0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalExpectedIncomeLossQueryClient: monthly_expected_income이 없는 잡 (PER_TASK, 비정기 HOURLY 등)은 조회 시점 기준 최근 3개월(이번 달 제외) MATCHED 정산 평균으로 방어모드 예상 손실소득 계산 - 분기 조건을 SettlementType이 아니라 job.getMonthlyExpectedIncome() == null로 일반화 (등록 시점에 계산 근거가 있는지가 진짜 기준) - 데이터 있는 달만으로 평균, 3개월 이력이 전혀 없으면 기존과 동일하게 null - JOB.monthly_expected_income 컬럼 자체는 변경 없음 (조회 시점 동적 계산) --- .../LocalExpectedIncomeLossQueryClient.java | 49 ++++++++++--------- ...ocalExpectedIncomeLossQueryClientTest.java | 38 ++++++++++++++ 2 files changed, 64 insertions(+), 23 deletions(-) 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 40d6b64b..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 @@ -15,7 +15,6 @@ 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.domain.enums.SettlementType; import com.ntropy.work.mapper.SettlementMapper; import com.ntropy.work.service.JobService; @@ -25,18 +24,20 @@ * 방어모드 기간 동안 근무하지 못해 발생할 예상 손실소득을 잡별로 계산한다. * *

잡 등록/수정 시점에 저장해둔 월 환산 예상 소득(monthly_expected_income)을 - * 방어기간 일수만큼 일할 계산한다(30일 기준). PER_TASK 잡은 등록/수정 시점에 확정할 - * 근거(고정 시급×시간도, 고정 월급도 아님)가 없어 monthly_expected_income이 항상 - * null이므로, 대신 조회 시점 기준 최근 3개월(이번 달 제외, 완료된 달만) MATCHED - * 정산 실적을 데이터가 있는 달만으로 평균 내 사용한다. 최근 3개월 정산 이력이 전혀 - * 없으면 다른 정산방식과 동일하게 손실액을 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_PER_TASK_AVERAGE = 3; + private static final int RECENT_MONTHS_FOR_AVERAGE = 3; private final JobService jobService; private final SettlementMapper settlementMapper; @@ -50,20 +51,22 @@ public List findExpectedIncomeLossByJob( .filter(job -> Boolean.TRUE.equals(job.getIsActive())) .collect(Collectors.toList()); - List perTaskJobs = activeJobs.stream() - .filter(job -> job.getSettlementType() == SettlementType.PER_TASK) + // monthly_expected_income이 등록 시점에 계산되지 않은 잡(PER_TASK, 비정기 HOURLY 등)만 + // 최근 실적 평균 대상이 된다. + List jobsWithoutSnapshot = activeJobs.stream() + .filter(job -> job.getMonthlyExpectedIncome() == null) .collect(Collectors.toList()); - Map perTaskAverageIncomeByJob = calculateRecentAverageIncomeByJob(perTaskJobs); + Map recentAverageIncomeByJob = calculateRecentAverageIncomeByJob(jobsWithoutSnapshot); return activeJobs.stream() - .map(job -> toSummary(job, days, perTaskAverageIncomeByJob)) + .map(job -> toSummary(job, days, recentAverageIncomeByJob)) .collect(Collectors.toList()); } - private JobExpectedIncomeLossSummary toSummary(Job job, long days, Map perTaskAverageIncomeByJob) { - Long monthlyIncome = job.getSettlementType() == SettlementType.PER_TASK - ? perTaskAverageIncomeByJob.get(job.getJobId()) - : job.getMonthlyExpectedIncome(); + 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(), @@ -78,19 +81,19 @@ private Long calculateLoss(Long monthlyExpectedIncome, long days) { } /** - * PER_TASK 잡들의 최근 3개월(이번 달 제외) 월 평균 실적 소득을 잡별로 계산한다. - * 데이터가 있는 달만으로 평균을 내며(예: 1개월치만 있으면 그 1개월로 평균), - * 최근 3개월 내 MATCHED 정산이 전혀 없는 잡은 결과 맵에 포함되지 않는다 - * (호출부에서 get() 시 null → 계산 불가로 처리됨). + * monthly_expected_income이 없는 잡들의 최근 3개월(이번 달 제외) 월 평균 실적 + * 소득을 잡별로 계산한다. 데이터가 있는 달만으로 평균을 내며(예: 1개월치만 + * 있으면 그 1개월로 평균), 최근 3개월 내 MATCHED 정산이 전혀 없는 잡은 결과 + * 맵에 포함되지 않는다(호출부에서 get() 시 null → 계산 불가로 처리됨). */ - private Map calculateRecentAverageIncomeByJob(List perTaskJobs) { - if (perTaskJobs.isEmpty()) { + private Map calculateRecentAverageIncomeByJob(List jobsWithoutSnapshot) { + if (jobsWithoutSnapshot.isEmpty()) { return Map.of(); } - List jobIds = perTaskJobs.stream().map(Job::getJobId).collect(Collectors.toList()); + List jobIds = jobsWithoutSnapshot.stream().map(Job::getJobId).collect(Collectors.toList()); YearMonth currentMonth = YearMonth.now(); - LocalDate startDate = currentMonth.minusMonths(RECENT_MONTHS_FOR_PER_TASK_AVERAGE).atDay(1); + LocalDate startDate = currentMonth.minusMonths(RECENT_MONTHS_FOR_AVERAGE).atDay(1); LocalDate endDate = currentMonth.minusMonths(1).atEndOfMonth(); List settlements = settlementMapper.findByJobIdInAndDepositDateRangeAndStatus( 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 f18389fc..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 @@ -233,4 +233,42 @@ void findExpectedIncomeLossByJob_mixedSettlementTypes_eachUsesOwnCalculation() { 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()); + } }