From 349f95a9ca2db869299da52c5aa29d90c7e4c2b7 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 10 May 2026 22:52:35 +0900 Subject: [PATCH 1/2] Update payday calculation to consider public holidays --- .../kotlin/com/moa/entity/DailyEventType.kt | 2 +- src/main/kotlin/com/moa/entity/PaydayDay.kt | 54 ++++++++++++++----- .../com/moa/service/PublicHolidayService.kt | 10 ++++ .../kotlin/com/moa/service/WorkdayService.kt | 8 +-- .../PaydayNotificationBatchService.kt | 5 +- .../com/moa/entity/DailyEventTypeTest.kt | 16 +++++- .../kotlin/com/moa/entity/PaydayDayTest.kt | 34 ++++++++++++ 7 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/moa/entity/DailyEventType.kt b/src/main/kotlin/com/moa/entity/DailyEventType.kt index 3feb22d..ff5affc 100644 --- a/src/main/kotlin/com/moa/entity/DailyEventType.kt +++ b/src/main/kotlin/com/moa/entity/DailyEventType.kt @@ -28,7 +28,7 @@ enum class DailyEventType { ): List { val events = mutableListOf() - if (paydayDay.isPayday(date)) { + if (paydayDay.isPayday(date, publicHolidays)) { events += PAYDAY } if (date in publicHolidays) { diff --git a/src/main/kotlin/com/moa/entity/PaydayDay.kt b/src/main/kotlin/com/moa/entity/PaydayDay.kt index 77dabf8..881b5ff 100644 --- a/src/main/kotlin/com/moa/entity/PaydayDay.kt +++ b/src/main/kotlin/com/moa/entity/PaydayDay.kt @@ -10,7 +10,7 @@ import java.time.YearMonth * 사용자가 설정한 급여일을 표현하는 값 객체입니다. * * 급여일은 1일부터 31일 사이의 값만 허용하며, - * 실제 급여일 계산 시 월말 보정과 주말 보정 규칙을 함께 제공합니다. + * 실제 급여일 계산 시 월말 보정과 휴일 보정 규칙을 함께 제공합니다. */ @Embeddable data class PaydayDay( @@ -25,40 +25,70 @@ data class PaydayDay( * 특정 연월에 적용되는 실제 급여일을 계산합니다. * * 설정한 급여일이 해당 월에 없으면 말일로 보정하고, - * 보정된 날짜가 주말이면 직전 금요일로 당깁니다. + * 보정된 날짜가 주말 또는 공휴일이면 직전 영업일로 당깁니다. * * @param year 급여일을 계산할 연도 * @param month 급여일을 계산할 월 + * @param publicHolidays 공휴일 날짜 집합 * @return 실제 적용되는 급여일 */ - fun resolveEffectiveDate(year: Int, month: Int): LocalDate { + fun resolveEffectiveDate( + year: Int, + month: Int, + publicHolidays: Set = emptySet(), + ): LocalDate { val yearMonth = YearMonth.of(year, month) - val baseDate = yearMonth.atDay(minOf(value, yearMonth.lengthOfMonth())) + var date = yearMonth.atDay(minOf(value, yearMonth.lengthOfMonth())) - return when (baseDate.dayOfWeek) { - DayOfWeek.SATURDAY -> baseDate.minusDays(1) - DayOfWeek.SUNDAY -> baseDate.minusDays(2) - else -> baseDate + while (date.isHoliday(publicHolidays)) { + date = date.minusDays(1) } + + return date } /** * 특정 날짜가 이 급여일 설정의 실제 급여일인지 판정합니다. * * @param date 판정할 날짜 + * @param publicHolidays 공휴일 날짜 집합 * @return 해당 날짜가 실제 급여일이면 `true` */ - fun isPayday(date: LocalDate): Boolean = - resolveEffectiveDate(date.year, date.monthValue) == date + fun isPayday( + date: LocalDate, + publicHolidays: Set = emptySet(), + ): Boolean { + val currentMonth = YearMonth.from(date) + val candidateMonths = setOf(currentMonth, currentMonth.plusMonths(1)) + + return candidateMonths.any { + resolveEffectiveDate(it.year, it.monthValue, publicHolidays) == date + } + } companion object { /** * 특정 날짜에 실제 급여일로 귀결되는 모든 급여일 설정 값을 반환합니다. * * @param date 기준 날짜 + * @param publicHolidays 공휴일 날짜 집합 * @return 해당 날짜를 실제 급여일로 가지는 [PaydayDay] 집합 */ - fun resolvingTo(date: LocalDate): Set = - (1..31).map(::PaydayDay).filterTo(mutableSetOf()) { it.isPayday(date) } + fun resolvingTo( + date: LocalDate, + publicHolidays: Set = emptySet(), + ): Set { + val currentMonth = YearMonth.from(date) + val candidateMonths = setOf(currentMonth, currentMonth.plusMonths(1)) + + return (1..31).map(::PaydayDay).filterTo(mutableSetOf()) { paydayDay -> + candidateMonths.any { + paydayDay.resolveEffectiveDate(it.year, it.monthValue, publicHolidays) == date + } + } + } } + + private fun LocalDate.isHoliday(publicHolidays: Set): Boolean = + dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY || this in publicHolidays } diff --git a/src/main/kotlin/com/moa/service/PublicHolidayService.kt b/src/main/kotlin/com/moa/service/PublicHolidayService.kt index 3f335cb..032b48b 100644 --- a/src/main/kotlin/com/moa/service/PublicHolidayService.kt +++ b/src/main/kotlin/com/moa/service/PublicHolidayService.kt @@ -21,6 +21,16 @@ class PublicHolidayService( .toSet() } + @Transactional(readOnly = true) + fun getHolidayDatesForPaydayResolution(date: LocalDate): Set { + val start = date.withDayOfMonth(1) + val nextMonth = start.plusMonths(1) + val end = nextMonth.withDayOfMonth(nextMonth.lengthOfMonth()) + return publicHolidayRepository.findAllByDateBetween(start, end) + .map { it.date } + .toSet() + } + @Transactional(readOnly = true) fun isHoliday(date: LocalDate): Boolean = publicHolidayRepository.existsByDate(date) diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 34a416f..c63b981 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -41,7 +41,7 @@ class WorkdayService( val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) val paydayDay = resolvePaydayDay(memberId) - val publicHolidays = publicHolidayService.getHolidayDatesForMonth(year, month) + val publicHolidays = publicHolidayService.getHolidayDatesForPaydayResolution(start) return generateSequence(start) { it.plusDays(1) } .takeWhile { !it.isAfter(end) } @@ -156,7 +156,7 @@ class WorkdayService( ): WorkdayResponse { val saved = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue) - val publicHolidays = publicHolidayService.getHolidayDatesForMonth(date.year,date.monthValue) + val publicHolidays = publicHolidayService.getHolidayDatesForPaydayResolution(date) val schedule = resolveSchedule(saved, policy, date, publicHolidays) return createWorkdayResponse(memberId, date, schedule, policy, resolvePaydayDay(memberId), publicHolidays) } @@ -201,7 +201,7 @@ class WorkdayService( ) val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) - val publicHolidays = if (publicHolidayService.isHoliday(date)) setOf(date) else emptySet() + val publicHolidays = publicHolidayService.getHolidayDatesForPaydayResolution(date) return createWorkdayResponse( memberId, date, @@ -242,7 +242,7 @@ class WorkdayService( ) val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) - val publicHolidays = if (publicHolidayService.isHoliday(date)) setOf(date) else emptySet() + val publicHolidays = publicHolidayService.getHolidayDatesForPaydayResolution(date) return createWorkdayResponse( memberId, date, diff --git a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt index 20cde3c..f4c8fbe 100644 --- a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt @@ -7,6 +7,7 @@ import com.moa.entity.notification.NotificationSettingType import com.moa.entity.notification.NotificationType import com.moa.repository.NotificationLogRepository import com.moa.repository.ProfileRepository +import com.moa.service.PublicHolidayService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,6 +19,7 @@ class PaydayNotificationBatchService( private val profileRepository: ProfileRepository, private val notificationLogRepository: NotificationLogRepository, private val notificationEligibilityService: NotificationEligibilityService, + private val publicHolidayService: PublicHolidayService, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -49,7 +51,8 @@ class PaydayNotificationBatchService( } private fun findPaydayProfiles(date: LocalDate): List { - val candidatePaydayDays = PaydayDay.resolvingTo(date) + val publicHolidays = publicHolidayService.getHolidayDatesForPaydayResolution(date) + val candidatePaydayDays = PaydayDay.resolvingTo(date, publicHolidays) .map { it.value } if (candidatePaydayDays.isEmpty()) { diff --git a/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt b/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt index 9ba4dc6..2fc0ba9 100644 --- a/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt +++ b/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt @@ -61,6 +61,18 @@ class DailyEventTypeTest { assertThat(result).containsExactly(DailyEventType.PAYDAY) } + @Test + fun `설정한 급여일이 공휴일이면 직전 영업일을 급여일로 간주한다`() { + val holiday = LocalDate.of(2025, 6, 25) + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 6, 24), + paydayDay = PaydayDay(25), + publicHolidays = setOf(holiday), + ) + + assertThat(result).containsExactly(DailyEventType.PAYDAY) + } + @Test fun `공휴일이면 PUBLIC_HOLIDAY 이벤트를 반환한다`() { val holiday = LocalDate.of(2025, 5, 5) @@ -74,7 +86,7 @@ class DailyEventTypeTest { } @Test - fun `급여일과 공휴일이 겹치면 두 이벤트 모두 반환한다`() { + fun `설정한 급여일이 공휴일이면 해당 날짜는 공휴일 이벤트만 반환한다`() { val date = LocalDate.of(2025, 6, 25) val result = DailyEventType.resolve( date = date, @@ -82,7 +94,7 @@ class DailyEventTypeTest { publicHolidays = setOf(date), ) - assertThat(result).containsExactly(DailyEventType.PAYDAY, DailyEventType.PUBLIC_HOLIDAY) + assertThat(result).containsExactly(DailyEventType.PUBLIC_HOLIDAY) } @Test diff --git a/src/test/kotlin/com/moa/entity/PaydayDayTest.kt b/src/test/kotlin/com/moa/entity/PaydayDayTest.kt index 067d311..4b7f8a8 100644 --- a/src/test/kotlin/com/moa/entity/PaydayDayTest.kt +++ b/src/test/kotlin/com/moa/entity/PaydayDayTest.kt @@ -30,6 +30,24 @@ class PaydayDayTest { assertThat(result).isEqualTo(LocalDate.of(2025, 5, 30)) } + @Test + fun `실제 급여일이 공휴일이면 직전 영업일로 당긴다`() { + val holidays = setOf(LocalDate.of(2025, 6, 25)) + + val result = PaydayDay(25).resolveEffectiveDate(2025, 6, holidays) + + assertThat(result).isEqualTo(LocalDate.of(2025, 6, 24)) + } + + @Test + fun `주말과 공휴일이 연속되면 직전 영업일까지 반복해서 당긴다`() { + val holidays = setOf(LocalDate.of(2025, 5, 30)) + + val result = PaydayDay(31).resolveEffectiveDate(2025, 5, holidays) + + assertThat(result).isEqualTo(LocalDate.of(2025, 5, 29)) + } + @Test fun `특정 날짜가 실제 급여일인지 판정할 수 있다`() { val paydayDay = PaydayDay(25) @@ -37,4 +55,20 @@ class PaydayDayTest { assertThat(paydayDay.isPayday(LocalDate.of(2025, 6, 25))).isTrue() assertThat(paydayDay.isPayday(LocalDate.of(2025, 6, 24))).isFalse() } + + @Test + fun `다음 달 초 급여일이 전월 말로 당겨진 날짜도 급여일로 판정한다`() { + val paydayDay = PaydayDay(1) + + assertThat(paydayDay.isPayday(LocalDate.of(2025, 5, 30))).isTrue() + } + + @Test + fun `특정 날짜로 당겨지는 급여일 설정값을 공휴일까지 고려해 찾는다`() { + val holidays = setOf(LocalDate.of(2025, 6, 25)) + + val result = PaydayDay.resolvingTo(LocalDate.of(2025, 6, 24), holidays) + + assertThat(result).contains(PaydayDay(25)) + } } From 8772cc415906e5a836a2eb5421436bf403ec2d33 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 10 May 2026 23:02:50 +0900 Subject: [PATCH 2/2] Update public holidays documentation in payday calculations --- src/main/kotlin/com/moa/entity/DailyEventType.kt | 3 ++- src/main/kotlin/com/moa/entity/PaydayDay.kt | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/moa/entity/DailyEventType.kt b/src/main/kotlin/com/moa/entity/DailyEventType.kt index ff5affc..f089535 100644 --- a/src/main/kotlin/com/moa/entity/DailyEventType.kt +++ b/src/main/kotlin/com/moa/entity/DailyEventType.kt @@ -18,7 +18,8 @@ enum class DailyEventType { * * @param date 이벤트를 판정할 기준 일자 * @param paydayDay 사용자 설정 급여일 - * @param publicHolidays 해당 월의 공휴일 날짜 집합 + * @param publicHolidays 급여일 보정과 공휴일 이벤트 판정에 필요한 공휴일 날짜 집합. + * date가 속한 달과 다음 달의 공휴일을 포함해야 합니다. * @return 해당 일자에 적용되는 [DailyEventType] 목록 */ fun resolve( diff --git a/src/main/kotlin/com/moa/entity/PaydayDay.kt b/src/main/kotlin/com/moa/entity/PaydayDay.kt index 881b5ff..df20d7a 100644 --- a/src/main/kotlin/com/moa/entity/PaydayDay.kt +++ b/src/main/kotlin/com/moa/entity/PaydayDay.kt @@ -29,7 +29,7 @@ data class PaydayDay( * * @param year 급여일을 계산할 연도 * @param month 급여일을 계산할 월 - * @param publicHolidays 공휴일 날짜 집합 + * @param publicHolidays 해당 연월의 급여일 보정에 사용할 공휴일 날짜 집합 * @return 실제 적용되는 급여일 */ fun resolveEffectiveDate( @@ -51,7 +51,8 @@ data class PaydayDay( * 특정 날짜가 이 급여일 설정의 실제 급여일인지 판정합니다. * * @param date 판정할 날짜 - * @param publicHolidays 공휴일 날짜 집합 + * @param publicHolidays 급여일 보정에 필요한 공휴일 날짜 집합. + * date가 속한 달과 다음 달의 공휴일을 포함해야 합니다. * @return 해당 날짜가 실제 급여일이면 `true` */ fun isPayday( @@ -71,7 +72,8 @@ data class PaydayDay( * 특정 날짜에 실제 급여일로 귀결되는 모든 급여일 설정 값을 반환합니다. * * @param date 기준 날짜 - * @param publicHolidays 공휴일 날짜 집합 + * @param publicHolidays 급여일 보정에 필요한 공휴일 날짜 집합. + * date가 속한 달과 다음 달의 공휴일을 포함해야 합니다. * @return 해당 날짜를 실제 급여일로 가지는 [PaydayDay] 집합 */ fun resolvingTo(