diff --git a/src/main/kotlin/com/moa/entity/DailyEventType.kt b/src/main/kotlin/com/moa/entity/DailyEventType.kt index 897acdb..ce70312 100644 --- a/src/main/kotlin/com/moa/entity/DailyEventType.kt +++ b/src/main/kotlin/com/moa/entity/DailyEventType.kt @@ -1,6 +1,32 @@ package com.moa.entity +import java.time.LocalDate + +/** + * 일자별로 표시할 부가 이벤트를 정의하는 열거형입니다. + */ enum class DailyEventType { - PAYDAY, - HOLIDAY, + /** 급여일에 해당하는 경우 */ + PAYDAY; + + companion object { + /** + * 특정 일자와 급여일 설정을 바탕으로 해당 일자에 표시할 이벤트를 판정합니다. + * + * 현재는 급여일([PAYDAY])만 지원하며, 추후 공휴일 등 다른 이벤트가 추가될 수 있습니다. + * + * @param date 이벤트를 판정할 기준 일자 + * @param paydayDay 사용자 설정 급여일 + * @return 해당 일자에 적용되는 [DailyEventType] 목록 + */ + fun resolve(date: LocalDate, paydayDay: PaydayDay): List { + val events = mutableListOf() + + if (paydayDay.isPayday(date)) { + events += PAYDAY + } + + return events + } + } } diff --git a/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt b/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt index ea20144..1a59a51 100644 --- a/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt +++ b/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt @@ -1,7 +1,55 @@ package com.moa.entity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * 일자별 근무 진행 상태를 정의하는 열거형입니다. + */ enum class DailyWorkStatusType { + /** 근무 일정이 없거나 상태를 판정할 수 없는 경우 */ NONE, + + /** 아직 근무 종료 시각이 지나지 않은 경우 */ SCHEDULED, - COMPLETED, + + /** 근무 종료 시각이 지나 완료된 경우 */ + COMPLETED; + + companion object { + /** + * 특정 일자의 근무 스케줄 정보를 바탕으로 근무 상태를 판정합니다. + * + * 근무 유형이 [DailyWorkScheduleType.NONE]이거나, 출근/퇴근 시간 중 하나라도 없으면 + * 상태를 판정할 수 없으므로 [NONE]을 반환합니다. + * 퇴근 시간이 출근 시간보다 이른 경우에는 자정을 넘기는 근무로 간주하여 익일 종료 시각 기준으로 계산합니다. + * + * @param date 근무 상태를 판정할 기준 일자 + * @param scheduleType 해당 일자의 근무 일정 유형 + * @param clockIn 출근 시간 + * @param clockOut 퇴근 시간 + * @param now 상태 판정에 사용할 현재 시각. 기본값은 [LocalDateTime.now] 입니다. + * @return 판정된 [DailyWorkStatusType]. 종료 시각 이전이면 [SCHEDULED], 이후면 [COMPLETED]를 반환합니다. + */ + fun resolve( + date: LocalDate, + scheduleType: DailyWorkScheduleType, + clockIn: LocalTime?, + clockOut: LocalTime?, + now: LocalDateTime = LocalDateTime.now(), + ): DailyWorkStatusType { + if (scheduleType == DailyWorkScheduleType.NONE) return NONE + + val resolvedClockIn = clockIn ?: return NONE + val resolvedClockOut = clockOut ?: return NONE + val endAt = if (!resolvedClockOut.isBefore(resolvedClockIn)) { + date.atTime(resolvedClockOut) + } else { + date.plusDays(1).atTime(resolvedClockOut) + } + + return if (now.isBefore(endAt)) SCHEDULED else COMPLETED + } + } } diff --git a/src/main/kotlin/com/moa/entity/PaydayDay.kt b/src/main/kotlin/com/moa/entity/PaydayDay.kt new file mode 100644 index 0000000..e3798a5 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/PaydayDay.kt @@ -0,0 +1,64 @@ +package com.moa.entity + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +/** + * 사용자가 설정한 급여일을 표현하는 Value Object 입니다. + * + * 급여일은 1일부터 31일 사이의 값만 허용하며, + * 실제 급여일 계산 시 월말 보정 및 주말 보정 규칙을 함께 제공합니다. + */ +@Embeddable +data class PaydayDay( + @Column(name = "payday_day", nullable = false) + var value: Int = 25, +) { + init { + require(value in 1..31) { "paydayDay must be between 1 and 31" } + } + + /** + * 특정 연월에 적용되는 실제 급여일을 계산합니다. + * + * 설정한 급여일이 해당 월에 없으면 말일로 보정하고, + * 보정된 날짜가 주말이면 직전 금요일로 당깁니다. + * + * @param year 급여일을 계산할 연도 + * @param month 급여일을 계산할 월 + * @return 실제 적용되는 급여일 + */ + fun resolveEffectiveDate(year: Int, month: Int): LocalDate { + val yearMonth = YearMonth.of(year, month) + val baseDate = yearMonth.atDay(minOf(value, yearMonth.lengthOfMonth())) + + return when (baseDate.dayOfWeek) { + DayOfWeek.SATURDAY -> baseDate.minusDays(1) + DayOfWeek.SUNDAY -> baseDate.minusDays(2) + else -> baseDate + } + } + + /** + * 특정 날짜가 이 급여일 설정의 실제 급여일인지 판정합니다. + * + * @param date 판정할 날짜 + * @return 해당 날짜가 실제 급여일이면 `true` + */ + fun isPayday(date: LocalDate): Boolean = + resolveEffectiveDate(date.year, date.monthValue) == date + + companion object { + /** + * 특정 날짜에 실제 급여일로 귀결되는 모든 급여일 설정 값을 반환합니다. + * + * @param date 기준 날짜 + * @return 해당 날짜를 실제 급여일로 가지는 [PaydayDay] 집합 + */ + fun resolvingTo(date: LocalDate): Set = + (1..31).map(::PaydayDay).filterTo(mutableSetOf()) { it.isPayday(date) } + } +} diff --git a/src/main/kotlin/com/moa/entity/PayrollVersion.kt b/src/main/kotlin/com/moa/entity/PayrollVersion.kt index 76eaf97..213001c 100644 --- a/src/main/kotlin/com/moa/entity/PayrollVersion.kt +++ b/src/main/kotlin/com/moa/entity/PayrollVersion.kt @@ -23,7 +23,3 @@ class PayrollVersion( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0 } - -enum class SalaryInputType { - ANNUAL, MONTHLY -} diff --git a/src/main/kotlin/com/moa/entity/Profile.kt b/src/main/kotlin/com/moa/entity/Profile.kt index 086d14d..903319c 100644 --- a/src/main/kotlin/com/moa/entity/Profile.kt +++ b/src/main/kotlin/com/moa/entity/Profile.kt @@ -13,8 +13,8 @@ class Profile( @Column(nullable = true) var workplace: String? = null, - @Column(nullable = false) - var paydayDay: Int = 25, + @Embedded + var paydayDay: PaydayDay = PaydayDay(), ) : BaseEntity() { @Id diff --git a/src/main/kotlin/com/moa/entity/SalaryInputType.kt b/src/main/kotlin/com/moa/entity/SalaryInputType.kt new file mode 100644 index 0000000..2ba5a60 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/SalaryInputType.kt @@ -0,0 +1,6 @@ +package com.moa.entity + +enum class SalaryInputType { + ANNUAL, + MONTHLY, +} diff --git a/src/main/kotlin/com/moa/entity/NotificationLog.kt b/src/main/kotlin/com/moa/entity/notification/NotificationLog.kt similarity index 92% rename from src/main/kotlin/com/moa/entity/NotificationLog.kt rename to src/main/kotlin/com/moa/entity/notification/NotificationLog.kt index 2ee6971..7f93ddf 100644 --- a/src/main/kotlin/com/moa/entity/NotificationLog.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationLog.kt @@ -1,5 +1,6 @@ -package com.moa.entity +package com.moa.entity.notification +import com.moa.entity.BaseEntity import jakarta.persistence.* import java.time.LocalDate import java.time.LocalTime diff --git a/src/main/kotlin/com/moa/entity/NotificationSetting.kt b/src/main/kotlin/com/moa/entity/notification/NotificationSetting.kt similarity index 94% rename from src/main/kotlin/com/moa/entity/NotificationSetting.kt rename to src/main/kotlin/com/moa/entity/notification/NotificationSetting.kt index cfe2f3e..eba777e 100644 --- a/src/main/kotlin/com/moa/entity/NotificationSetting.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationSetting.kt @@ -1,5 +1,6 @@ -package com.moa.entity +package com.moa.entity.notification +import com.moa.entity.BaseEntity import jakarta.persistence.* @Entity diff --git a/src/main/kotlin/com/moa/entity/NotificationSettingType.kt b/src/main/kotlin/com/moa/entity/notification/NotificationSettingType.kt similarity index 88% rename from src/main/kotlin/com/moa/entity/NotificationSettingType.kt rename to src/main/kotlin/com/moa/entity/notification/NotificationSettingType.kt index de718bc..43a834a 100644 --- a/src/main/kotlin/com/moa/entity/NotificationSettingType.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationSettingType.kt @@ -1,4 +1,4 @@ -package com.moa.entity +package com.moa.entity.notification enum class NotificationSettingType( val category: String, diff --git a/src/main/kotlin/com/moa/entity/NotificationStatus.kt b/src/main/kotlin/com/moa/entity/notification/NotificationStatus.kt similarity index 70% rename from src/main/kotlin/com/moa/entity/NotificationStatus.kt rename to src/main/kotlin/com/moa/entity/notification/NotificationStatus.kt index c527921..25cc1ae 100644 --- a/src/main/kotlin/com/moa/entity/NotificationStatus.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationStatus.kt @@ -1,4 +1,4 @@ -package com.moa.entity +package com.moa.entity.notification enum class NotificationStatus { PENDING, diff --git a/src/main/kotlin/com/moa/entity/NotificationType.kt b/src/main/kotlin/com/moa/entity/notification/NotificationType.kt similarity index 91% rename from src/main/kotlin/com/moa/entity/NotificationType.kt rename to src/main/kotlin/com/moa/entity/notification/NotificationType.kt index 920ec70..8c1a529 100644 --- a/src/main/kotlin/com/moa/entity/NotificationType.kt +++ b/src/main/kotlin/com/moa/entity/notification/NotificationType.kt @@ -1,4 +1,4 @@ -package com.moa.entity +package com.moa.entity.notification enum class NotificationType( val title: String, diff --git a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt index e8d415c..cf36564 100644 --- a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt +++ b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt @@ -1,8 +1,8 @@ package com.moa.repository -import com.moa.entity.NotificationLog -import com.moa.entity.NotificationStatus -import com.moa.entity.NotificationType +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType import org.springframework.data.jpa.repository.JpaRepository import java.time.LocalDate import java.time.LocalTime diff --git a/src/main/kotlin/com/moa/repository/NotificationSettingRepository.kt b/src/main/kotlin/com/moa/repository/NotificationSettingRepository.kt index 893fa99..a111d08 100644 --- a/src/main/kotlin/com/moa/repository/NotificationSettingRepository.kt +++ b/src/main/kotlin/com/moa/repository/NotificationSettingRepository.kt @@ -1,6 +1,6 @@ package com.moa.repository -import com.moa.entity.NotificationSetting +import com.moa.entity.notification.NotificationSetting import org.springframework.data.jpa.repository.JpaRepository interface NotificationSettingRepository : JpaRepository { diff --git a/src/main/kotlin/com/moa/repository/ProfileRepository.kt b/src/main/kotlin/com/moa/repository/ProfileRepository.kt index 6764dc8..13c873e 100644 --- a/src/main/kotlin/com/moa/repository/ProfileRepository.kt +++ b/src/main/kotlin/com/moa/repository/ProfileRepository.kt @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface ProfileRepository : JpaRepository { fun findByMemberId(memberId: Long): Profile? - fun findAllByPaydayDayIn(paydayDays: Collection): List + fun findAllByPaydayDay_ValueIn(paydayDayValues: Collection): List } diff --git a/src/main/kotlin/com/moa/service/OnboardingStatusService.kt b/src/main/kotlin/com/moa/service/OnboardingStatusService.kt index 15b3207..60e4480 100644 --- a/src/main/kotlin/com/moa/service/OnboardingStatusService.kt +++ b/src/main/kotlin/com/moa/service/OnboardingStatusService.kt @@ -27,7 +27,7 @@ class OnboardingStatusService( ProfileResponse( nickname = it.nickname, workplace = it.workplace, - paydayDay = it.paydayDay, + paydayDay = it.paydayDay.value, ) } diff --git a/src/main/kotlin/com/moa/service/PaydayResolver.kt b/src/main/kotlin/com/moa/service/PaydayResolver.kt deleted file mode 100644 index 51bfb38..0000000 --- a/src/main/kotlin/com/moa/service/PaydayResolver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.moa.service - -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.YearMonth - -// 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. -fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { - val yearMonth = YearMonth.of(year, month) - val baseDate = yearMonth.atDay(minOf(paydayDay, yearMonth.lengthOfMonth())) - - return when (baseDate.dayOfWeek) { - DayOfWeek.SATURDAY -> baseDate.minusDays(1) - DayOfWeek.SUNDAY -> baseDate.minusDays(2) - else -> baseDate - } -} diff --git a/src/main/kotlin/com/moa/service/ProfileService.kt b/src/main/kotlin/com/moa/service/ProfileService.kt index 31a7bae..00b3e22 100644 --- a/src/main/kotlin/com/moa/service/ProfileService.kt +++ b/src/main/kotlin/com/moa/service/ProfileService.kt @@ -3,6 +3,7 @@ package com.moa.service import com.moa.common.exception.BadRequestException import com.moa.common.exception.ErrorCode import com.moa.common.exception.NotFoundException +import com.moa.entity.PaydayDay import com.moa.entity.Profile import com.moa.repository.ProfileRepository import com.moa.service.dto.NicknameUpdateRequest @@ -25,7 +26,7 @@ class ProfileService( return ProfileResponse( nickname = profile.nickname, workplace = profile.workplace, - paydayDay = profile.paydayDay, + paydayDay = profile.paydayDay.value, ) } @@ -45,7 +46,7 @@ class ProfileService( return ProfileResponse( nickname = profile.nickname, workplace = profile.workplace, - paydayDay = profile.paydayDay, + paydayDay = profile.paydayDay.value, ) } @@ -59,7 +60,7 @@ class ProfileService( return ProfileResponse( nickname = profile.nickname, workplace = profile.workplace, - paydayDay = profile.paydayDay, + paydayDay = profile.paydayDay.value, ) } @@ -73,25 +74,25 @@ class ProfileService( return ProfileResponse( nickname = profile.nickname, workplace = profile.workplace, - paydayDay = profile.paydayDay, + paydayDay = profile.paydayDay.value, ) } @Transactional fun updatePayday(memberId: Long, req: PaydayUpdateRequest): ProfileResponse { - if (req.paydayDay !in 1..31) { - throw BadRequestException(ErrorCode.INVALID_PAYDAY_INPUT) - } - val profile = profileRepository.findByMemberId(memberId) ?: throw NotFoundException() - profile.paydayDay = req.paydayDay + profile.paydayDay = try { + PaydayDay(req.paydayDay) + } catch (_: IllegalArgumentException) { + throw BadRequestException(ErrorCode.INVALID_PAYDAY_INPUT) + } return ProfileResponse( nickname = profile.nickname, workplace = profile.workplace, - paydayDay = profile.paydayDay, + paydayDay = profile.paydayDay.value, ) } } diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 382383f..bd11f69 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -7,13 +7,14 @@ import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository +import com.moa.service.calculator.EarningsCalculator +import com.moa.service.calculator.SalaryCalculator import com.moa.service.dto.* import com.moa.service.notification.NotificationSyncService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDate -import java.time.LocalDateTime import java.time.LocalTime @Service @@ -23,6 +24,7 @@ class WorkdayService( private val profileRepository: ProfileRepository, private val notificationSyncService: NotificationSyncService, private val earningsCalculator: EarningsCalculator, + private val salaryCalculator: SalaryCalculator, ) { @Transactional(readOnly = true) @@ -69,12 +71,12 @@ class WorkdayService( ) } - val policyDailyMinutes = SalaryCalculator.calculateWorkMinutes( + val policyDailyMinutes = salaryCalculator.calculateWorkMinutes( monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, ) val policyWorkDayOfWeeks = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() - val workDaysInMonth = SalaryCalculator.getWorkDaysInPeriod( + val workDaysInMonth = salaryCalculator.getWorkDaysInPeriod( start = start, end = end.plusDays(1), workDays = policyWorkDayOfWeeks @@ -99,16 +101,18 @@ class WorkdayService( var date = start while (!date.isAfter(lastCalculableDate)) { val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) - val status = resolveDailyWorkStatus(date, schedule) + val status = DailyWorkStatusType.resolve( + date = date, + scheduleType = schedule.type, + clockIn = schedule.clockIn, + clockOut = schedule.clockOut, + ) val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) val isCompletedWork = status == DailyWorkStatusType.COMPLETED && (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) if (isCompletedWork && schedule.clockIn != null && adjustedClockOut != null) { - workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, adjustedClockOut) - } - - if (isCompletedWork) { + workedMinutes += salaryCalculator.calculateWorkMinutes(schedule.clockIn, adjustedClockOut) val dailyEarnings = earningsCalculator.calculateDailyEarnings( memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, adjustedClockOut, ) @@ -292,9 +296,9 @@ class WorkdayService( date: LocalDate, schedule: ResolvedSchedule, policy: WorkPolicyVersion?, - paydayDay: Int, + paydayDay: PaydayDay, ): WorkdayResponse { - val events = resolveDailyEvents(date, paydayDay) + val events = DailyEventType.resolve(date, paydayDay) if (schedule.type == DailyWorkScheduleType.NONE) { return WorkdayResponse( @@ -308,7 +312,12 @@ class WorkdayService( val resolvedPolicy = policy ?: return WorkdayResponse( date = date, type = schedule.type, - status = resolveDailyWorkStatus(date, schedule), + status = DailyWorkStatusType.resolve( + date = date, + scheduleType = schedule.type, + clockIn = schedule.clockIn, + clockOut = schedule.clockOut, + ), events = events, dailyPay = 0, clockInTime = schedule.clockIn, @@ -320,7 +329,12 @@ class WorkdayService( return WorkdayResponse( date = date, type = schedule.type, - status = resolveDailyWorkStatus(date, schedule), + status = DailyWorkStatusType.resolve( + date = date, + scheduleType = schedule.type, + clockIn = schedule.clockIn, + clockOut = schedule.clockOut, + ), events = events, dailyPay = earnings?.toInt() ?: 0, clockInTime = schedule.clockIn, @@ -344,43 +358,9 @@ class WorkdayService( ) } - private fun resolvePaydayDay(memberId: Long): Int = + private fun resolvePaydayDay(memberId: Long): PaydayDay = profileRepository.findByMemberId(memberId)?.paydayDay ?: throw NotFoundException() - private fun resolveDailyEvents(date: LocalDate, paydayDay: Int): List { - val events = mutableListOf() - - if (isPayday(date, paydayDay)) { - events += DailyEventType.PAYDAY - } - - // TODO: Add holiday event resolution when holiday data is available. - - return events - } - - private fun isPayday(date: LocalDate, paydayDay: Int): Boolean { - return resolveEffectivePayday(date.year, date.monthValue, paydayDay) == date - } - - private fun resolveDailyWorkStatus( - date: LocalDate, - schedule: ResolvedSchedule, - ): DailyWorkStatusType { - if (schedule.type == DailyWorkScheduleType.NONE) return DailyWorkStatusType.NONE - - val clockIn = schedule.clockIn ?: return DailyWorkStatusType.NONE - val clockOut = schedule.clockOut ?: return DailyWorkStatusType.NONE - val now = LocalDateTime.now() - val endAt = if (!clockOut.isBefore(clockIn)) { - date.atTime(clockOut) - } else { - date.plusDays(1).atTime(clockOut) - } - - return if (now.isBefore(endAt)) DailyWorkStatusType.SCHEDULED else DailyWorkStatusType.COMPLETED - } - private fun resolveClockOutForEarnings( targetDate: LocalDate, today: LocalDate, diff --git a/src/main/kotlin/com/moa/service/EarningsCalculator.kt b/src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt similarity index 79% rename from src/main/kotlin/com/moa/service/EarningsCalculator.kt rename to src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt index 8cc3b10..37c6ac0 100644 --- a/src/main/kotlin/com/moa/service/EarningsCalculator.kt +++ b/src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt @@ -1,8 +1,7 @@ -package com.moa.service +package com.moa.service.calculator import com.moa.entity.DailyWorkScheduleType -import com.moa.entity.SalaryCalculator -import com.moa.entity.SalaryType +import com.moa.entity.SalaryInputType import com.moa.entity.WorkPolicyVersion import com.moa.repository.PayrollVersionRepository import org.springframework.stereotype.Service @@ -19,13 +18,14 @@ import java.time.YearMonth @Service class EarningsCalculator( private val payrollVersionRepository: PayrollVersionRepository, + private val salaryCalculator: SalaryCalculator, ) { /** * 지정된 날짜가 속한 월을 기준으로 직원의 기본 월급을 계산합니다. * * 주어진 날짜가 속한 달의 마지막 날을 기준으로, 해당 시점에 유효한 가장 최근의 급여 정보를 바탕으로 계산합니다. - * 급여 유형이 연봉([SalaryType.YEARLY])인 경우 12로 나눈 후 소수점 첫째 자리에서 반올림(HALF_UP)한 값을 반환하며, - * 월급([SalaryType.MONTHLY])인 경우 계약된 금액을 그대로 반환합니다. + * 급여 유형이 연봉([com.moa.entity.SalaryInputType.ANNUAL])인 경우 12로 나눈 후 소수점 첫째 자리에서 반올림(HALF_UP)한 값을 반환하며, + * 월급([com.moa.entity.SalaryInputType.MONTHLY])인 경우 계약된 금액을 그대로 반환합니다. * * @param memberId 직원의 고유 식별자 * @param date 기준 날짜 (이 날짜가 속한 월의 마지막 날을 기준으로 유효한 급여 정책을 적용합니다) @@ -38,11 +38,11 @@ class EarningsCalculator( memberId, lastDayOfMonth, ) ?: return null - return when (SalaryType.from(payroll.salaryInputType)) { - SalaryType.YEARLY -> payroll.salaryAmount.toBigDecimal() + return when (payroll.salaryInputType) { + SalaryInputType.ANNUAL -> payroll.salaryAmount.toBigDecimal() .divide(BigDecimal(12), 0, RoundingMode.HALF_UP).toLong() - SalaryType.MONTHLY -> payroll.salaryAmount + SalaryInputType.MONTHLY -> payroll.salaryAmount } } @@ -51,7 +51,7 @@ class EarningsCalculator( * * 이 메서드는 직원의 근무 정책과 실제 출퇴근 시간을 비교하여 일일 소득을 산출합니다. * 다음과 같은 규칙이 적용됩니다: - * - 근무 일정이 없는 경우([DailyWorkScheduleType.NONE]) 소득은 `0`으로 계산됩니다. + * - 근무 일정이 없는 경우([com.moa.entity.DailyWorkScheduleType.NONE]) 소득은 `0`으로 계산됩니다. * - 실제 출근 시간([clockInTime])과 퇴근 시간([clockOutTime])이 모두 제공된 경우, 정책상의 소정 근로 시간과 * 실제 근로 시간을 비율로 산정하여 급여를 일할 계산합니다. * - 출퇴근 시간 중 하나라도 누락된 경우, 해당 일자의 기본 일급(Daily Rate)을 그대로 반환합니다. @@ -80,20 +80,20 @@ class EarningsCalculator( memberId, lastDayOfMonth, ) ?: return null - val dailyRate = SalaryCalculator.calculateDailyRate( + val dailyRate = salaryCalculator.calculateDailyRate( targetDate = date, - salaryType = SalaryType.from(payroll.salaryInputType), + salaryType = payroll.salaryInputType, salaryAmount = payroll.salaryAmount, workDays = policy.workdays.map { it.dayOfWeek }.toSet(), ) if (dailyRate == BigDecimal.ZERO) return dailyRate if (clockInTime != null && clockOutTime != null) { - val policyMinutes = SalaryCalculator.calculateWorkMinutes( + val policyMinutes = salaryCalculator.calculateWorkMinutes( policy.clockInTime, policy.clockOutTime, ) - val actualMinutes = SalaryCalculator.calculateWorkMinutes(clockInTime, clockOutTime) - return SalaryCalculator.calculateEarnings(dailyRate, policyMinutes, actualMinutes) + val actualMinutes = salaryCalculator.calculateWorkMinutes(clockInTime, clockOutTime) + return salaryCalculator.calculateEarnings(dailyRate, policyMinutes, actualMinutes) } return dailyRate diff --git a/src/main/kotlin/com/moa/entity/SalaryType.kt b/src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt similarity index 73% rename from src/main/kotlin/com/moa/entity/SalaryType.kt rename to src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt index 8383572..988dff6 100644 --- a/src/main/kotlin/com/moa/entity/SalaryType.kt +++ b/src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt @@ -1,45 +1,24 @@ -package com.moa.entity +package com.moa.service.calculator +import com.moa.entity.SalaryInputType +import org.springframework.stereotype.Service import java.math.BigDecimal import java.math.RoundingMode import java.time.* /** - * 급여 산정 방식을 정의하는 열거형입니다. + * 급여, 일급, 근로 시간 등을 계산하는 서비스입니다. + * * 외부 상태를 가지지 않지만, 계산 책임을 애플리케이션 서비스로 통일하기 위해 Spring Bean으로 관리합니다. */ -enum class SalaryType { - /** 연봉 기반 산정 방식 */ - YEARLY, - - /** 월급 기반 산정 방식 */ - MONTHLY; - - companion object { - /** - * 입력된 급여 유형([SalaryInputType])을 내부 처리용 급여 산정 방식([SalaryType])으로 변환합니다. - * - * @param inputType 외부에서 입력된 급여 유형 (예: ANNUAL, MONTHLY) - * @return 매핑된 [SalaryType] 인스턴스 - */ - fun from(inputType: SalaryInputType): SalaryType = when (inputType) { - SalaryInputType.ANNUAL -> YEARLY - SalaryInputType.MONTHLY -> MONTHLY - } - } -} - -/** - * 급여, 일급, 근로 시간 등을 계산하는 순수 유틸리티 객체입니다. - * * 외부 상태를 가지지 않으며, 제공된 파라미터만을 기반으로 계산을 수행합니다. - */ -object SalaryCalculator { +@Service +class SalaryCalculator { /** * 특정 일자가 속한 달의 일일 급여(일급)를 계산합니다. * * 이 메서드는 직원의 월 기본급을 해당 월의 '총 소정 근로일수'로 나누어 일급을 산출합니다. - * 연봉([SalaryType.YEARLY])인 경우 금액을 12로 나누어 월 기본급을 먼저 구합니다. - * 최종 산출된 일급은 소수점 첫째 자리에서 반올림([RoundingMode.HALF_UP]) 처리됩니다. + * 연봉([com.moa.entity.SalaryInputType.ANNUAL])인 경우 금액을 12로 나누어 월 기본급을 먼저 구합니다. + * 최종 산출된 일급은 소수점 첫째 자리에서 반올림([java.math.RoundingMode.HALF_UP]) 처리됩니다. * * @param targetDate 기준 일자 (이 일자가 속한 월을 기준으로 총 근로일수를 계산합니다) * @param salaryType 급여 산정 방식 (연봉 또는 월급) @@ -49,18 +28,18 @@ object SalaryCalculator { */ fun calculateDailyRate( targetDate: LocalDate, - salaryType: SalaryType, + salaryType: SalaryInputType, salaryAmount: Long, workDays: Set ): BigDecimal { val monthlySalary = when (salaryType) { - SalaryType.YEARLY -> salaryAmount.toBigDecimal().divide(BigDecimal(12), 0, RoundingMode.HALF_UP) - SalaryType.MONTHLY -> salaryAmount.toBigDecimal() + SalaryInputType.ANNUAL -> salaryAmount.toBigDecimal().divide(BigDecimal(12), 0, RoundingMode.HALF_UP) + SalaryInputType.MONTHLY -> salaryAmount.toBigDecimal() } val yearMonth = YearMonth.from(targetDate) val periodStart = yearMonth.atDay(1) - val periodEnd = yearMonth.atEndOfMonth().plusDays(1) // exclusive + val periodEnd = yearMonth.atEndOfMonth().plusDays(1) val workDaysCount = getWorkDaysInPeriod(periodStart, periodEnd, workDays) diff --git a/src/main/kotlin/com/moa/service/dto/NotificationSettingResponse.kt b/src/main/kotlin/com/moa/service/dto/NotificationSettingResponse.kt index 18856f2..341ad83 100644 --- a/src/main/kotlin/com/moa/service/dto/NotificationSettingResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/NotificationSettingResponse.kt @@ -1,7 +1,7 @@ package com.moa.service.dto -import com.moa.entity.NotificationSetting -import com.moa.entity.NotificationSettingType +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationSettingType data class NotificationSettingResponse( val type: NotificationSettingType, diff --git a/src/main/kotlin/com/moa/service/dto/NotificationSettingUpdateRequest.kt b/src/main/kotlin/com/moa/service/dto/NotificationSettingUpdateRequest.kt index 8335511..91b2052 100644 --- a/src/main/kotlin/com/moa/service/dto/NotificationSettingUpdateRequest.kt +++ b/src/main/kotlin/com/moa/service/dto/NotificationSettingUpdateRequest.kt @@ -1,6 +1,6 @@ package com.moa.service.dto -import com.moa.entity.NotificationSettingType +import com.moa.entity.notification.NotificationSettingType data class NotificationSettingUpdateRequest( val type: NotificationSettingType, diff --git a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt index 2c71c0f..289a63d 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt @@ -1,6 +1,9 @@ package com.moa.service.notification import com.moa.entity.* +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationType import com.moa.repository.* import org.slf4j.LoggerFactory import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index e863b23..7762d19 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -1,7 +1,7 @@ package com.moa.service.notification -import com.moa.entity.NotificationLog -import com.moa.entity.NotificationStatus +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus import com.moa.repository.FcmTokenRepository import com.moa.repository.NotificationLogRepository import com.moa.service.FcmService diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index b03261f..65ea138 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -1,11 +1,11 @@ package com.moa.service.notification import com.moa.entity.DailyWorkScheduleType -import com.moa.entity.NotificationLog -import com.moa.entity.NotificationType +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationType import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.EarningsCalculator +import com.moa.service.calculator.EarningsCalculator import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt index 1f496e9..f27862f 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt @@ -1,9 +1,9 @@ package com.moa.service.notification -import com.moa.entity.NotificationSetting -import com.moa.entity.NotificationSettingType import com.moa.entity.Term import com.moa.entity.TermAgreement +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationSettingType import com.moa.repository.NotificationSettingRepository import com.moa.repository.TermAgreementRepository import com.moa.service.dto.NotificationSettingResponse diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 108331c..50694b2 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -1,9 +1,9 @@ package com.moa.service.notification import com.moa.entity.DailyWorkScheduleType -import com.moa.entity.NotificationLog -import com.moa.entity.NotificationStatus -import com.moa.entity.NotificationType +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType import com.moa.repository.NotificationLogRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt index 93a4072..c86b374 100644 --- a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt @@ -1,8 +1,12 @@ package com.moa.service.notification -import com.moa.entity.* +import com.moa.entity.FcmToken +import com.moa.entity.PaydayDay +import com.moa.entity.Profile +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationType import com.moa.repository.* -import com.moa.service.resolveEffectivePayday import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -49,14 +53,14 @@ class PaydayNotificationBatchService( } private fun findPaydayProfiles(date: LocalDate): List { - val candidatePaydayDays = (1..31) - .filter { resolveEffectivePayday(date.year, date.monthValue, it) == date } + val candidatePaydayDays = PaydayDay.resolvingTo(date) + .map { it.value } if (candidatePaydayDays.isEmpty()) { return emptyList() } - return profileRepository.findAllByPaydayDayIn(candidatePaydayDays) + return profileRepository.findAllByPaydayDay_ValueIn(candidatePaydayDays) } private fun findRequiredTermCodes(): Set = diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7022613..1dbfa01 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: - ddl-auto: update + ddl-auto: validate jwt: secret-key: ${secret.jwt.prod.access.secret-key} diff --git a/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt b/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt new file mode 100644 index 0000000..4d8a208 --- /dev/null +++ b/src/test/kotlin/com/moa/entity/DailyEventTypeTest.kt @@ -0,0 +1,58 @@ +package com.moa.entity + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class DailyEventTypeTest { + + @Test + fun `급여일과 같은 날짜면 PAYDAY 이벤트를 반환한다`() { + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 6, 25), + paydayDay = PaydayDay(25), + ) + + assertThat(result).containsExactly(DailyEventType.PAYDAY) + } + + @Test + fun `급여일과 다른 날짜면 빈 이벤트 목록을 반환한다`() { + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 6, 24), + paydayDay = PaydayDay(25), + ) + + assertThat(result).isEmpty() + } + + @Test + fun `설정한 급여일이 말일보다 크면 말일을 급여일로 간주한다`() { + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 2, 28), + paydayDay = PaydayDay(31), + ) + + assertThat(result).containsExactly(DailyEventType.PAYDAY) + } + + @Test + fun `말일 보정이 적용된 달에서도 말일 이전 날짜는 급여일이 아니다`() { + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 2, 27), + paydayDay = PaydayDay(31), + ) + + assertThat(result).isEmpty() + } + + @Test + fun `설정한 급여일이 주말이면 직전 평일을 급여일로 간주한다`() { + val result = DailyEventType.resolve( + date = LocalDate.of(2025, 5, 30), + paydayDay = PaydayDay(31), + ) + + assertThat(result).containsExactly(DailyEventType.PAYDAY) + } +} diff --git a/src/test/kotlin/com/moa/entity/DailyWorkStatusTypeTest.kt b/src/test/kotlin/com/moa/entity/DailyWorkStatusTypeTest.kt new file mode 100644 index 0000000..18537b2 --- /dev/null +++ b/src/test/kotlin/com/moa/entity/DailyWorkStatusTypeTest.kt @@ -0,0 +1,64 @@ +package com.moa.entity + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class DailyWorkStatusTypeTest { + + private val date = LocalDate.of(2025, 6, 9) + + @Test + fun `근무 유형이 NONE이면 NONE을 반환한다`() { + val result = DailyWorkStatusType.resolve( + date = date, + scheduleType = DailyWorkScheduleType.NONE, + clockIn = LocalTime.of(9, 0), + clockOut = LocalTime.of(18, 0), + now = date.atTime(12, 0), + ) + + assertThat(result).isEqualTo(DailyWorkStatusType.NONE) + } + + @Test + fun `종료 시각 전이면 SCHEDULED를 반환한다`() { + val result = DailyWorkStatusType.resolve( + date = date, + scheduleType = DailyWorkScheduleType.WORK, + clockIn = LocalTime.of(9, 0), + clockOut = LocalTime.of(18, 0), + now = date.atTime(17, 59), + ) + + assertThat(result).isEqualTo(DailyWorkStatusType.SCHEDULED) + } + + @Test + fun `종료 시각 이후면 COMPLETED를 반환한다`() { + val result = DailyWorkStatusType.resolve( + date = date, + scheduleType = DailyWorkScheduleType.WORK, + clockIn = LocalTime.of(9, 0), + clockOut = LocalTime.of(18, 0), + now = date.atTime(18, 0), + ) + + assertThat(result).isEqualTo(DailyWorkStatusType.COMPLETED) + } + + @Test + fun `자정 넘김 근무는 익일 종료 시각 기준으로 판정한다`() { + val result = DailyWorkStatusType.resolve( + date = date, + scheduleType = DailyWorkScheduleType.WORK, + clockIn = LocalTime.of(22, 0), + clockOut = LocalTime.of(2, 0), + now = LocalDateTime.of(2025, 6, 10, 1, 0), + ) + + assertThat(result).isEqualTo(DailyWorkStatusType.SCHEDULED) + } +} diff --git a/src/test/kotlin/com/moa/entity/PaydayDayTest.kt b/src/test/kotlin/com/moa/entity/PaydayDayTest.kt new file mode 100644 index 0000000..067d311 --- /dev/null +++ b/src/test/kotlin/com/moa/entity/PaydayDayTest.kt @@ -0,0 +1,40 @@ +package com.moa.entity + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class PaydayDayTest { + + @Test + fun `급여일은 1일부터 31일까지의 값만 허용한다`() { + assertThatThrownBy { PaydayDay(0) } + .isInstanceOf(IllegalArgumentException::class.java) + + assertThatThrownBy { PaydayDay(32) } + .isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `설정한 급여일이 해당 월에 없으면 말일로 보정한다`() { + val result = PaydayDay(31).resolveEffectiveDate(2025, 2) + + assertThat(result).isEqualTo(LocalDate.of(2025, 2, 28)) + } + + @Test + fun `실제 급여일이 주말이면 직전 금요일로 당긴다`() { + val result = PaydayDay(31).resolveEffectiveDate(2025, 5) + + assertThat(result).isEqualTo(LocalDate.of(2025, 5, 30)) + } + + @Test + fun `특정 날짜가 실제 급여일인지 판정할 수 있다`() { + val paydayDay = PaydayDay(25) + + assertThat(paydayDay.isPayday(LocalDate.of(2025, 6, 25))).isTrue() + assertThat(paydayDay.isPayday(LocalDate.of(2025, 6, 24))).isFalse() + } +} diff --git a/src/test/kotlin/com/moa/service/fcm/FcmSendLearningTest.kt b/src/test/kotlin/com/moa/learning/fcm/FcmSendLearningTest.kt similarity index 98% rename from src/test/kotlin/com/moa/service/fcm/FcmSendLearningTest.kt rename to src/test/kotlin/com/moa/learning/fcm/FcmSendLearningTest.kt index 99f5e79..97d79d8 100644 --- a/src/test/kotlin/com/moa/service/fcm/FcmSendLearningTest.kt +++ b/src/test/kotlin/com/moa/learning/fcm/FcmSendLearningTest.kt @@ -1,4 +1,4 @@ -package com.moa.service.fcm +package com.moa.learning.fcm import com.google.auth.oauth2.GoogleCredentials import com.google.firebase.FirebaseApp diff --git a/src/test/kotlin/com/moa/repository/ProfileRepositoryTest.kt b/src/test/kotlin/com/moa/repository/ProfileRepositoryTest.kt new file mode 100644 index 0000000..0368dba --- /dev/null +++ b/src/test/kotlin/com/moa/repository/ProfileRepositoryTest.kt @@ -0,0 +1,65 @@ +package com.moa.repository + +import com.moa.entity.PaydayDay +import com.moa.entity.Profile +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest + +@DataJpaTest +class ProfileRepositoryTest @Autowired constructor( + private val profileRepository: ProfileRepository, +) { + + @Test + fun `paydayDay value 목록에 포함되는 프로필만 조회한다`() { + val included = profileRepository.save( + Profile( + memberId = 1L, + nickname = "included", + paydayDay = PaydayDay(25), + ), + ) + profileRepository.save( + Profile( + memberId = 2L, + nickname = "excluded", + paydayDay = PaydayDay(10), + ), + ) + + val result = profileRepository.findAllByPaydayDay_ValueIn(listOf(25, 31)) + + assertThat(result.map { it.id }).containsExactly(included.id) + } + + @Test + fun `여러 paydayDay value를 전달하면 해당 값들을 가진 프로필을 모두 조회한다`() { + val first = profileRepository.save( + Profile( + memberId = 3L, + nickname = "first", + paydayDay = PaydayDay(25), + ), + ) + val second = profileRepository.save( + Profile( + memberId = 4L, + nickname = "second", + paydayDay = PaydayDay(31), + ), + ) + profileRepository.save( + Profile( + memberId = 5L, + nickname = "third", + paydayDay = PaydayDay(12), + ), + ) + + val result = profileRepository.findAllByPaydayDay_ValueIn(listOf(25, 31)) + + assertThat(result.map { it.id }).containsExactlyInAnyOrder(first.id, second.id) + } +} diff --git a/src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt similarity index 98% rename from src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt rename to src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt index beb021a..1fa68d6 100644 --- a/src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt +++ b/src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.calculator import com.moa.entity.* import com.moa.repository.PayrollVersionRepository @@ -16,7 +16,8 @@ import java.time.LocalTime class EarningsCalculatorTest { private val payrollVersionRepository: PayrollVersionRepository = mockk() - private val sut = EarningsCalculator(payrollVersionRepository) + private val salaryCalculator = SalaryCalculator() + private val sut = EarningsCalculator(payrollVersionRepository, salaryCalculator) companion object { private const val MEMBER_ID = 1L diff --git a/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt similarity index 68% rename from src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt rename to src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt index 6dbc380..bd2c687 100644 --- a/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt +++ b/src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt @@ -1,6 +1,7 @@ -package com.moa.entity +package com.moa.service.calculator -import org.assertj.core.api.Assertions.assertThat +import com.moa.entity.SalaryInputType +import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import java.math.BigDecimal import java.math.RoundingMode @@ -9,6 +10,7 @@ import java.time.LocalDate import java.time.LocalTime class SalaryCalculatorTest { + private val salaryCalculator = SalaryCalculator() companion object { private val WEEKDAYS = setOf( @@ -25,15 +27,15 @@ class SalaryCalculatorTest { val targetDate = LocalDate.of(2025, 2, 3) val monthlySalary = 3_000_000L - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = monthlySalary, workDays = WEEKDAYS, ) val expected = BigDecimal(3_000_000).divide(BigDecimal(20), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } // --- 연봉 기반 일급 계산 --- @@ -44,16 +46,16 @@ class SalaryCalculatorTest { val targetDate = LocalDate.of(2025, 2, 3) val yearlySalary = 36_000_000L - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.YEARLY, + salaryType = SalaryInputType.ANNUAL, salaryAmount = yearlySalary, workDays = WEEKDAYS, ) val expected = BigDecimal(36_000_000).divide(BigDecimal(12), 0, RoundingMode.HALF_UP) .divide(BigDecimal(20), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } // --- 월별 근무일수 차이 --- @@ -63,15 +65,15 @@ class SalaryCalculatorTest { // 2025년 3월: 평일 21일 val targetDate = LocalDate.of(2025, 3, 10) - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 2_100_000L, workDays = WEEKDAYS, ) val expected = BigDecimal(2_100_000).divide(BigDecimal(21), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } @Test @@ -79,15 +81,15 @@ class SalaryCalculatorTest { // 2025년 1월: 평일 23일 val targetDate = LocalDate.of(2025, 1, 15) - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 2_300_000L, workDays = WEEKDAYS, ) val expected = BigDecimal(2_300_000).divide(BigDecimal(23), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } // --- 2월 처리 --- @@ -97,15 +99,15 @@ class SalaryCalculatorTest { // 2025년 2월 (비윤년): 평일 20일 val targetDate = LocalDate.of(2025, 2, 10) - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 2_000_000L, workDays = WEEKDAYS, ) val expected = BigDecimal(2_000_000).divide(BigDecimal(20), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } @Test @@ -113,15 +115,15 @@ class SalaryCalculatorTest { // 2024년 2월 (윤년): 평일 21일 val targetDate = LocalDate.of(2024, 2, 10) - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 2_100_000L, workDays = WEEKDAYS, ) val expected = BigDecimal(2_100_000).divide(BigDecimal(21), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } // --- 근무요일 설정 --- @@ -132,58 +134,58 @@ class SalaryCalculatorTest { // 2025년 2월: 월~토 = 24일 val targetDate = LocalDate.of(2025, 2, 3) - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = targetDate, - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 2_400_000L, workDays = sixDayWork, ) val expected = BigDecimal(2_400_000).divide(BigDecimal(24), 0, RoundingMode.HALF_UP) - assertThat(result).isEqualByComparingTo(expected) + Assertions.assertThat(result).isEqualByComparingTo(expected) } // --- 엣지 케이스 --- @Test fun `근무요일이 없으면 일급은 0을 반환한다`() { - val result = SalaryCalculator.calculateDailyRate( + val result = salaryCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 2, 3), - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = 3_000_000L, workDays = emptySet(), ) - assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } // --- 근무 시간(분) 계산 --- @Test fun `calculateWorkMinutes - 9시에서 18시는 540분을 반환한다`() { - val result = SalaryCalculator.calculateWorkMinutes( + val result = salaryCalculator.calculateWorkMinutes( LocalTime.of(9, 0), LocalTime.of(18, 0), ) - assertThat(result).isEqualTo(540L) + Assertions.assertThat(result).isEqualTo(540L) } @Test fun `calculateWorkMinutes - 자정넘김 22시에서 2시는 240분을 반환한다`() { - val result = SalaryCalculator.calculateWorkMinutes( + val result = salaryCalculator.calculateWorkMinutes( LocalTime.of(22, 0), LocalTime.of(2, 0), ) - assertThat(result).isEqualTo(240L) + Assertions.assertThat(result).isEqualTo(240L) } @Test fun `calculateWorkMinutes - 시작시간과 종료시간이 같으면 0분을 반환한다`() { - val result = SalaryCalculator.calculateWorkMinutes( + val result = salaryCalculator.calculateWorkMinutes( LocalTime.of(9, 0), LocalTime.of(9, 0), ) - assertThat(result).isEqualTo(0L) + Assertions.assertThat(result).isEqualTo(0L) } // --- 실제 수입 계산 --- @@ -191,45 +193,45 @@ class SalaryCalculatorTest { @Test fun `calculateEarnings - 실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { val dailyRate = BigDecimal(150_000) - val result = SalaryCalculator.calculateEarnings(dailyRate, 540, 540) - assertThat(result).isEqualByComparingTo(dailyRate) + val result = salaryCalculator.calculateEarnings(dailyRate, 540, 540) + Assertions.assertThat(result).isEqualByComparingTo(dailyRate) } @Test fun `calculateEarnings - 초과 근무시 분급 기준으로 증가된 금액을 반환한다`() { val dailyRate = BigDecimal(150_000) // 540분 정책, 600분 실제 (1시간 초과) - val result = SalaryCalculator.calculateEarnings(dailyRate, 540, 600) - assertThat(result).isGreaterThan(dailyRate) + val result = salaryCalculator.calculateEarnings(dailyRate, 540, 600) + Assertions.assertThat(result).isGreaterThan(dailyRate) } @Test fun `calculateEarnings - 조기 퇴근시 분급 기준으로 감소된 금액을 반환한다`() { val dailyRate = BigDecimal(150_000) // 540분 정책, 480분 실제 (1시간 조기 퇴근) - val result = SalaryCalculator.calculateEarnings(dailyRate, 540, 480) - assertThat(result).isLessThan(dailyRate) + val result = salaryCalculator.calculateEarnings(dailyRate, 540, 480) + Assertions.assertThat(result).isLessThan(dailyRate) } @Test fun `같은 월급이라도 월마다 근무일수에 따라 일급이 달라진다`() { val salary = 3_000_000L - val febResult = SalaryCalculator.calculateDailyRate( + val febResult = salaryCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 2, 1), - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = salary, workDays = WEEKDAYS, ) - val marResult = SalaryCalculator.calculateDailyRate( + val marResult = salaryCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 3, 1), - salaryType = SalaryType.MONTHLY, + salaryType = SalaryInputType.MONTHLY, salaryAmount = salary, workDays = WEEKDAYS, ) // 2월(20일) > 3월(21일) → 2월 일급이 더 높아야 함 - assertThat(febResult).isGreaterThan(marResult) + Assertions.assertThat(febResult).isGreaterThan(marResult) } }