잡 등록/수정 시점에 저장해둔 월 환산 예상 소득(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("이 테스트 더블은 조회만 지원합니다.");
+ }
}
}