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());
+ }
}