Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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;

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;
Expand All @@ -18,33 +24,53 @@
* 방어모드 기간 동안 근무하지 못해 발생할 예상 손실소득을 잡별로 계산한다.
*
* <p>잡 등록/수정 시점에 저장해둔 월 환산 예상 소득(monthly_expected_income)을
* 방어기간 일수만큼 일할 계산한다(30일 기준). 월 예상 소득을 계산할 수 없는
* 잡(PER_TASK 등)은 손실액을 null로 반환하며, 방어모드가 이를 계산 불가로 처리한다.</p>
* 방어기간 일수만큼 일할 계산한다(30일 기준). MONTHLY와 정기(스케줄 있는) HOURLY 잡은
* 등록/수정 시점에 이 값을 계산할 근거(고정 월급 또는 고정 스케줄×시급)가 있어 항상
* 채워져 있다. 반면 PER_TASK와 비정기(스케줄 없는) HOURLY 잡은 등록 시점에 확정할
* 근거가 없어 monthly_expected_income이 항상 null이므로, 대신 조회 시점 기준 최근
* 3개월(이번 달 제외, 완료된 달만) MATCHED 정산 실적을 데이터가 있는 달만으로 평균
* 내 사용한다. 최근 3개월 정산 이력이 전혀 없으면 다른 잡과 동일하게 손실액을
* null(계산 불가)로 반환한다.</p>
*/
@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<JobExpectedIncomeLossSummary> findExpectedIncomeLossByJob(
Long userId, LocalDate fromDate, LocalDate toDate) {
long days = ChronoUnit.DAYS.between(fromDate, toDate) + 1;

return jobService.findByUserId(userId).stream()
List<Job> 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<Job> jobsWithoutSnapshot = activeJobs.stream()
.filter(job -> job.getMonthlyExpectedIncome() == null)
.collect(Collectors.toList());
Map<Long, Long> 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<Long, Long> 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) {
Expand All @@ -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<Long, Long> calculateRecentAverageIncomeByJob(List<Job> jobsWithoutSnapshot) {
if (jobsWithoutSnapshot.isEmpty()) {
return Map.of();
}

List<Long> 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<Settlement> settlements = settlementMapper.findByJobIdInAndDepositDateRangeAndStatus(
jobIds, startDate, endDate, SettlementMatchStatus.MATCHED);

Map<Long, Map<YearMonth, Long>> monthlySumsByJob = new HashMap<>();
for (Settlement settlement : settlements) {
monthlySumsByJob
.computeIfAbsent(settlement.getJobId(), key -> new HashMap<>())
.merge(YearMonth.from(settlement.getDepositDate()), settlement.getActualAmount(), Long::sum);
}

Map<Long, Long> result = new HashMap<>();
for (Map.Entry<Long, Map<YearMonth, Long>> entry : monthlySumsByJob.entrySet()) {
Map<YearMonth, Long> monthlySums = entry.getValue();
long total = monthlySums.values().stream().mapToLong(Long::longValue).sum();
result.put(entry.getKey(), Math.round((double) total / monthlySums.size()));
}
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ List<Settlement> findByUserIdAndDepositDateRange(@Param("userId") Long userId,
List<Settlement> findByUserIdInAndDepositDateRange(@Param("userIds") List<Long> userIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);

/** PER_TASK 잡의 최근 N개월 평균 소득 계산용: 여러 잡의 SETTLEMENT를 한 번에 조회한다. 결과는 jobId로 그룹핑해서 써야 한다. */
List<Settlement> findByJobIdInAndDepositDateRangeAndStatus(@Param("jobIds") List<Long> jobIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("status") SettlementMatchStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,15 @@
AND deposit_date BETWEEN #{startDate} AND #{endDate}
</select>

<select id="findByJobIdInAndDepositDateRangeAndStatus" resultMap="settlementResultMap">
SELECT *
FROM SETTLEMENT
WHERE job_id IN
<foreach collection="jobIds" item="jobId" open="(" separator="," close=")">
#{jobId}
</foreach>
AND deposit_date BETWEEN #{startDate} AND #{endDate}
AND status = #{status}
</select>

</mapper>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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<JobExpectedIncomeLossSummary> 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<JobExpectedIncomeLossSummary> 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<JobExpectedIncomeLossSummary> 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<JobExpectedIncomeLossSummary> 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<JobExpectedIncomeLossSummary> 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<JobExpectedIncomeLossSummary> result = client.findExpectedIncomeLossByJob(USER_ID, from, to);

assertEquals(3000000L, result.get(0).getExpectedIncomeLoss());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ public List<Settlement> findByUserIdInAndDepositDateRange(List<Long> userIds, Lo
return result;
}

@Override
public List<Settlement> findByJobIdInAndDepositDateRangeAndStatus(List<Long> jobIds, LocalDate startDate,
LocalDate endDate, SettlementMatchStatus status) {
List<Settlement> 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<Settlement> findAll() {
return store;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,12 @@ public List<Settlement> findByUserIdInAndDepositDateRange(List<Long> userIds, Lo
.filter(s -> !s.getDepositDate().isBefore(startDate) && !s.getDepositDate().isAfter(endDate))
.toList();
}

@Override
public List<Settlement> findByJobIdInAndDepositDateRangeAndStatus(List<Long> jobIds, LocalDate startDate,
LocalDate endDate,
SettlementMatchStatus status) {
throw new UnsupportedOperationException("이 테스트 더블은 조회만 지원합니다.");
}
}
}
Loading