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
@@ -0,0 +1,15 @@
package com.ntropy.common.client;

import java.time.LocalDate;

/**
* 정산 배치(SettlementService)를 사용자·날짜 단위로 수동 실행하기 위한 계약.
* 원래는 SettlementScheduler가 매일 자동으로만 실행하는데, 테스트/디버깅 목적으로
* 특정 사용자·날짜 하나만 즉시 돌려보고 싶을 때 이 인터페이스를 통해 work-service의
* SettlementService.processSettlement()를 그대로 호출한다.
*/
public interface SettlementBatchCommandClient {

/** processDate 하루치 입금 거래를 매칭해 SETTLEMENT를 생성한다. 새로 생성됐으면 true. */
boolean runForDate(Long userId, LocalDate processDate);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.ntropy.bff.controller.internal;

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.ntropy.bff.dto.common.ApiResponse;
import com.ntropy.common.client.SettlementBatchCommandClient;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;

/**
* [테스트용] 정산 배치(SettlementService)를 특정 사용자·날짜로 즉시 실행해보는 컨트롤러.
* 원래 정산은 SettlementScheduler가 매일 새벽 1시 30분에만 자동 실행하는데,
* 그걸 기다리지 않고 로컬/개발 환경에서 바로 결과를 확인하려고 만든 것.
* 검증 끝나면 이 클래스와 SecurityConfig의 "/internal/test/**" permitAll 줄, common의
* SettlementBatchCommandClient, work-service의 LocalSettlementBatchCommandClient까지
* 같이 지울 것.
*/
@Api(tags = "[테스트용] 정산 배치 수동 실행")
@RestController
@RequestMapping("/internal/test/settlement")
@RequiredArgsConstructor
public class SettlementTestController {

private final SettlementBatchCommandClient settlementBatchCommandClient;

@ApiOperation("특정 사용자의 특정 날짜 입금 거래를 지금 바로 정산 매칭 시도한다")
@GetMapping("/run")
public ApiResponse<String> run(
@RequestParam Long userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
) {
boolean created = settlementBatchCommandClient.runForDate(userId, date);
String message = created
? "새 SETTLEMENT가 생성됐습니다."
: "생성된 SETTLEMENT가 없습니다 (그 날짜에 매칭할 거래가 없거나, 이미 처리된 거래뿐입니다).";
return ApiResponse.success(200, message, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.ntropy.work.client;

import java.time.LocalDate;

import org.springframework.stereotype.Component;

import com.ntropy.common.client.SettlementBatchCommandClient;
import com.ntropy.work.service.SettlementService;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class LocalSettlementBatchCommandClient implements SettlementBatchCommandClient {

private final SettlementService settlementService;

@Override
public boolean runForDate(Long userId, LocalDate processDate) {
return settlementService.processSettlement(userId, processDate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
* offset일만큼 역산할 때 주말·공휴일을 건너뛴다. 주말 판정은 외부 데이터 없이 계산하고,
* 공휴일은 holidays 파라미터로 주입받는다 - 이 클래스는 순수 함수로 유지하고, 실제 공휴일
* 조회(홀리데이 API/캐시)는 호출부(SettlementService)의 책임으로 둔다.</p>
*
* <p>DAILY + BUSINESS_DAY 조합에서는 역산된 workDate 하루가 아니라, workDate부터 그 다음
* 영업일 직전까지를 기간으로 잡는다. 주말/공휴일은 오프셋 카운트에 안 들어가므로, 그 구간
* 안 어느 날에 일했든(예: 금·토·일 연속 근무, 또는 평일이지만 공휴일인 날) 순방향으로는
* 전부 같은 입금일로 수렴하기 때문이다 - workDate 하루만 조회하면 같은 날 입금될 다른 날의
* 근무일지를 놓친다.</p>
*
* <p>WEEKLY 정산은 PLATFORM.settlement_day_of_week(요일 고정, 예: 쿠팡이츠=FRI)가 있으면
* 실제 입금일이 아니라 그 요일 기준 "원래 예정된 정산일"로 오프셋을 계산한다. 실제로 쿠팡이츠는
* 정산 기간(화)과 정산일(금) 사이에 공휴일이 끼면 그 주 금요일 정산을 건너뛰고 다음 영업일(보통
* 다음 주 월요일)로 미룬다 - 실제 입금일 그대로 오프셋을 적용하면 밀린 만큼 정산 기간 전체가
* 같이 밀려버리므로, 항상 예정 요일부터 역산해야 한다.</p>
*/
public final class SettlementPeriodCalculator {

Expand All @@ -43,18 +55,58 @@ public static SettlementPeriod calculate(Platform platform, LocalDate paymentDat
private static SettlementPeriod dailyPeriod(Platform platform, LocalDate paymentDate, Set<LocalDate> holidays) {
int offset = platform.getSettlementOffsetDay() == null ? 0 : platform.getSettlementOffsetDay();
LocalDate workDate = subtractDays(paymentDate, offset, platform.getSettlementOffsetUnit(), holidays);
return new SettlementPeriod(workDate, workDate);
LocalDate periodEnd = extendThroughTrailingNonBusinessDays(workDate, platform.getSettlementOffsetUnit(), holidays);
return new SettlementPeriod(workDate, periodEnd);
}

private static SettlementPeriod weeklyPeriod(Platform platform, LocalDate paymentDate, Set<LocalDate> holidays) {
LocalDate scheduledPaymentDate = resolveScheduledPaymentDate(platform.getSettlementDayOfWeek(), paymentDate);
int offset = platform.getSettlementOffsetDay() == null
? DEFAULT_WEEKLY_OFFSET_DAY
: platform.getSettlementOffsetDay();
LocalDate periodEnd = subtractDays(paymentDate, offset, platform.getSettlementOffsetUnit(), holidays);
LocalDate periodEndAnchor = subtractDays(scheduledPaymentDate, offset, platform.getSettlementOffsetUnit(), holidays);
// periodEnd 경계일 자체가 공휴일이면 subtractDays가 그 전 영업일로 밀어버려서, 정작
// 그 경계일(공휴일)에 일한 근무일지가 7일 범위 밖으로 빠진다 - DAILY와 같은 이유로
// 같은 헬퍼로 경계일을 다시 확장해준다.
LocalDate periodEnd = extendThroughTrailingNonBusinessDays(
periodEndAnchor, platform.getSettlementOffsetUnit(), holidays);
LocalDate periodStart = periodEnd.minusDays(6);
return new SettlementPeriod(periodStart, periodEnd);
}

/**
* settlement_day_of_week가 설정돼 있으면, 실제 입금일이 그 요일이 아닐 때(정산 요일과
* 다음 정산 요일 사이에 낀 공휴일 때문에 다음 영업일로 밀린 경우 등) 입금일 이전의
* 가장 최근 그 요일을 "원래 예정된 정산일"로 되돌린다. 오프셋은 항상 이 예정일 기준으로
* 계산해야 실제 입금이 밀려도 정산 기간(예: 쿠팡이츠의 전주 수~금주 화) 자체는 흔들리지
* 않는다 - 실제 입금일 그대로 오프셋을 적용하면 밀린 만큼 기간 전체가 같이 밀려버린다.
* 값이 없으면(플랫폼별로 아직 확인 안 된 경우) 기존처럼 실제 입금일을 그대로 쓴다.
*/
private static LocalDate resolveScheduledPaymentDate(String settlementDayOfWeek, LocalDate actualPaymentDate) {
if (settlementDayOfWeek == null) {
return actualPaymentDate;
}
DayOfWeek scheduled = parseDayOfWeek(settlementDayOfWeek);
LocalDate date = actualPaymentDate;
while (date.getDayOfWeek() != scheduled) {
date = date.minusDays(1);
}
return date;
}

private static DayOfWeek parseDayOfWeek(String abbreviation) {
return switch (abbreviation.toUpperCase(java.util.Locale.ROOT)) {
case "MON" -> DayOfWeek.MONDAY;
case "TUE" -> DayOfWeek.TUESDAY;
case "WED" -> DayOfWeek.WEDNESDAY;
case "THU" -> DayOfWeek.THURSDAY;
case "FRI" -> DayOfWeek.FRIDAY;
case "SAT" -> DayOfWeek.SATURDAY;
case "SUN" -> DayOfWeek.SUNDAY;
default -> throw new IllegalArgumentException("알 수 없는 settlement_day_of_week 값입니다: " + abbreviation);
};
}

private static SettlementPeriod monthlyPeriod(LocalDate paymentDate) {
YearMonth previousMonth = YearMonth.from(paymentDate).minusMonths(1);
return new SettlementPeriod(previousMonth.atDay(1), previousMonth.atEndOfMonth());
Expand All @@ -79,6 +131,26 @@ private static LocalDate subtractDays(LocalDate date, int days, String unit, Set
return result;
}

/**
* BUSINESS_DAY 오프셋에서는 순방향으로 보면 "start부터 그 다음 영업일 직전까지"의
* 비영업일(주말/공휴일) 근무도 전부 같은 입금일로 수렴한다 - 영업일이 아닌 날은
* 오프셋 카운트에 안 들어가서, 그 구간 어느 날에 일했든 "그 다음 offset번째 영업일"은
* 똑같이 계산되기 때문이다(예: 금·토·일 연속 근무는 셋 다 같은 날 입금됨).
* start만 단일 조회 대상으로 삼으면 이 구간의 다른 날 근무일지를 놓치므로, start부터
* 다음 영업일 바로 전날까지로 기간을 넓힌다. CALENDAR_DAY는 애초에 하루 단위로 1:1
* 대응되므로 그대로 start를 반환한다.
*/
private static LocalDate extendThroughTrailingNonBusinessDays(LocalDate start, String unit, Set<LocalDate> holidays) {
if (!BUSINESS_DAY.equals(unit)) {
return start;
}
LocalDate end = start;
while (isWeekend(end.plusDays(1)) || holidays.contains(end.plusDays(1))) {
end = end.plusDays(1);
}
return end;
}

private static boolean isWeekend(LocalDate date) {
DayOfWeek dayOfWeek = date.getDayOfWeek();
return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,35 @@ void calculate_businessDayUnit_skipsInjectedHoliday() {
assertEquals(LocalDate.of(2026, 8, 13), period.start());
}

@Test
@DisplayName("DAILY+BUSINESS_DAY: 금·토·일 연속 근무는 전부 같은 입금일로 수렴하므로 기간이 셋을 다 포함해야 한다")
void calculate_dailyBusinessDay_periodCoversWeekendRun() {
// 금(08/14)에 일하고 3영업일 후 입금 = 월/화/수(08/17~19) 지나 수요일(08/19) 입금
Platform platform = Platform.builder()
.settlementCycle("DAILY").settlementOffsetDay(3).settlementOffsetUnit("BUSINESS_DAY").build();

SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, LocalDate.of(2026, 8, 19), Set.of());

// workDate(금, 08/14)부터 다음 영업일(월, 08/17) 직전인 일요일(08/16)까지 - 금/토/일 전부 포함
assertEquals(LocalDate.of(2026, 8, 14), period.start());
assertEquals(LocalDate.of(2026, 8, 16), period.end());
}

@Test
@DisplayName("DAILY+BUSINESS_DAY: 공휴일이 껴도 기간이 그 공휴일까지 포함해야 한다 (8/17 대체공휴일 실사례)")
void calculate_dailyBusinessDay_periodCoversHolidayRun() {
// 목(08/20) 입금, 3영업일 전 역산: 수(08/19)-1, 화(08/18)-2, 월(08/17,공휴일)은 건너뛰고 금(08/14)-3
Platform platform = Platform.builder()
.settlementCycle("DAILY").settlementOffsetDay(3).settlementOffsetUnit("BUSINESS_DAY").build();
Set<LocalDate> holidays = Set.of(LocalDate.of(2026, 8, 17));

SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, LocalDate.of(2026, 8, 20), holidays);

// workDate(금, 08/14)부터 다음 영업일(화, 08/18) 직전인 08/17(월, 공휴일)까지 - 금/토/일/월(공휴일) 다 포함
assertEquals(LocalDate.of(2026, 8, 14), period.start());
assertEquals(LocalDate.of(2026, 8, 17), period.end());
}

@Test
@DisplayName("WEEKLY + BUSINESS_DAY도 offset 역산 시 주말을 건너뛴다 (쿠팡이츠 배달파트너 실제 offset=3)")
void calculate_weeklyBusinessDay_skipsWeekendForPeriodEnd() {
Expand All @@ -127,4 +156,53 @@ void calculate_weeklyBusinessDay_skipsWeekendForPeriodEnd() {
assertEquals(LocalDate.of(2026, 8, 5), period.end());
assertEquals(LocalDate.of(2026, 7, 30), period.start());
}

@Test
@DisplayName("WEEKLY + BUSINESS_DAY: periodEnd 경계일 자체가 공휴일이어도 그 경계일이 그대로 기간에 포함돼야 한다 (쿠팡이츠 화요일 마감일이 공휴일인 경우)")
void calculate_weeklyBusinessDay_periodEndBoundaryIsHoliday() {
// 금요일(08/14) 정산, offset=3이면 원래 화요일(08/11)이 periodEnd여야 하는데
// 08/11을 공휴일로 주입하면 subtractDays는 그 전 영업일(월, 08/10)로 밀어버린다.
// 경계일 확장 보정이 없으면 정작 화요일(08/11, 공휴일)에 일한 근무일지가 기간 밖으로 빠진다.
Platform platform = Platform.builder()
.settlementCycle("WEEKLY").settlementOffsetDay(3).settlementOffsetUnit("BUSINESS_DAY").build();
Set<LocalDate> holidays = Set.of(LocalDate.of(2026, 8, 11));

SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, LocalDate.of(2026, 8, 14), holidays);

// 공휴일이 없을 때와 동일하게 화(08/11)~그 7일 전 수(08/05)여야 한다
assertEquals(LocalDate.of(2026, 8, 5), period.start());
assertEquals(LocalDate.of(2026, 8, 11), period.end());
}

@Test
@DisplayName("WEEKLY: settlement_day_of_week가 있으면 정산 기간이 화~금 사이 공휴일로 실제 입금이 다음 영업일로 밀려도 흔들리지 않는다 (쿠팡이츠 실제 사례)")
void calculate_weekly_dayOfWeekSet_ignoresActualDelayFromHoliday() {
// 원래는 금요일(08/21) 정산이어야 하는데, 화~금 사이 공휴일 때문에 실제로는
// 다음 주 월요일(08/24)에 입금됐다고 가정. settlement_day_of_week=FRI가 있으면
// 실제 입금일(월)이 아니라 그 이전 가장 최근 금요일(08/21)을 기준으로 오프셋을 계산해야
// 정산 기간(전주 수~금주 화)이 밀리지 않는다.
Platform platform = Platform.builder()
.settlementCycle("WEEKLY").settlementOffsetDay(3).settlementOffsetUnit("BUSINESS_DAY")
.settlementDayOfWeek("FRI").build();

SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, LocalDate.of(2026, 8, 24), Set.of());

// 원래 예정일(금, 08/21) 기준 3영업일 전 = 화(08/18), 그 7일 전 = 수(08/12)
assertEquals(LocalDate.of(2026, 8, 12), period.start());
assertEquals(LocalDate.of(2026, 8, 18), period.end());
}

@Test
@DisplayName("WEEKLY: 실제 입금일이 이미 예정 요일과 같으면(정상 케이스) settlement_day_of_week가 있어도 결과가 그대로다")
void calculate_weekly_dayOfWeekSet_noDelay_matchesUnsetBehavior() {
Platform platform = Platform.builder()
.settlementCycle("WEEKLY").settlementOffsetDay(3).settlementOffsetUnit("BUSINESS_DAY")
.settlementDayOfWeek("FRI").build();

// 08/14은 이미 금요일이라 실제 입금일 == 예정 요일
SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, LocalDate.of(2026, 8, 14), Set.of());

assertEquals(LocalDate.of(2026, 8, 5), period.start());
assertEquals(LocalDate.of(2026, 8, 11), period.end());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,13 @@ void processSettlement_onDemandPlatform_settlesToJobWithoutTouchingWorkLog() {
}

@Test
@DisplayName("BUSINESS_DAY 플랫폼은 HolidayService에서 조회한 공휴일까지 건너뛰고 기간을 계산한다")
void processSettlement_businessDayPlatform_skipsInjectedHoliday() {
@DisplayName("BUSINESS_DAY 플랫폼은 공휴일까지 건너뛰어 workDate를 역산하고, 그 공휴일에 일한 근무일지도 같은 기간으로 매칭한다")
void processSettlement_businessDayPlatform_skipsInjectedHolidayAndCoversItInPeriod() {
Long businessDayPlatformId = 40L;
LocalDate paymentDate = LocalDate.of(2026, 8, 17); // 월요일
LocalDate holiday = LocalDate.of(2026, 8, 14); // 금요일 - 공휴일로 주입
LocalDate expectedWorkDate = LocalDate.of(2026, 8, 13); // 목요일 - 공휴일까지 건너뛴 결과
LocalDate expectedWorkDate = LocalDate.of(2026, 8, 13); // 목요일 - 공휴일까지 건너뛴 역산 결과
LocalDate expectedPeriodEnd = LocalDate.of(2026, 8, 16); // 일요일 - 다음 영업일(월, 8/17) 직전까지 확장

platformMapper.seed(Platform.builder()
.platformId(businessDayPlatformId)
Expand All @@ -270,22 +271,25 @@ void processSettlement_businessDayPlatform_skipsInjectedHoliday() {
holidayMapperForTest.seed(Holiday.builder()
.holidayDate(holiday).name("임시공휴일").build());

WorkLog expectedLog = workLog(expectedWorkDate, "CONFIRMED", 40_000L);
WorkLog fridayLog = workLog(holiday, "CONFIRMED", 40_000L);
workLogMapper.insert(expectedLog);
workLogMapper.insert(fridayLog);
insertIncome(expectedLog, businessDayPlatformId, 40_000L, SettlementStatus.PENDING);
WorkLog thursdayLog = workLog(expectedWorkDate, "CONFIRMED", 40_000L);
// 공휴일(8/14, 금)에 실제로 일한 근무일지 - 기간이 확장되면서 같이 매칭돼야 한다
WorkLog holidayWorkLog = workLog(holiday, "CONFIRMED", 25_000L);
workLogMapper.insert(thursdayLog);
workLogMapper.insert(holidayWorkLog);
insertIncome(thursdayLog, businessDayPlatformId, 40_000L, SettlementStatus.PENDING);
insertIncome(holidayWorkLog, businessDayPlatformId, 25_000L, SettlementStatus.PENDING);
incomingTransactionQueryClient.transactions = List.of(
new NormalizedIncomingTransaction(1L, paymentDate, LocalTime.NOON, "배민커넥트테스트", BigDecimal.valueOf(40_000L))
new NormalizedIncomingTransaction(1L, paymentDate, LocalTime.NOON, "배민커넥트테스트", BigDecimal.valueOf(65_000L))
);

service.processSettlement(USER_ID, paymentDate);

Settlement settlement = settlementMapper.findAll().get(0);
assertEquals(expectedWorkDate, settlement.getPeriodStart());
assertEquals(expectedWorkDate, settlement.getPeriodEnd());
assertEquals(SettlementStatus.COMPLETED, workLogMapper.findById(expectedLog.getLogId()).getSettlementStatus());
assertEquals(SettlementStatus.NONE, workLogMapper.findById(fridayLog.getLogId()).getSettlementStatus());
assertEquals(expectedPeriodEnd, settlement.getPeriodEnd());
assertEquals(65_000L, settlement.getExpectedAmount());
assertEquals(SettlementStatus.COMPLETED, workLogMapper.findById(thursdayLog.getLogId()).getSettlementStatus());
assertEquals(SettlementStatus.COMPLETED, workLogMapper.findById(holidayWorkLog.getLogId()).getSettlementStatus());
}

@Test
Expand Down
Loading