From 800c5c7debe4612bafe5f670fd16536b51518d4a Mon Sep 17 00:00:00 2001 From: jeyong Date: Wed, 18 Mar 2026 03:02:44 +0900 Subject: [PATCH 1/4] Refactor WorkdayService to simplify earnings calculation and remove unused clock out resolution logic --- .../kotlin/com/moa/service/WorkdayService.kt | 34 ++----------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index bd11f69..b0bf9be 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -96,8 +96,6 @@ class WorkdayService( var totalEarnings = BigDecimal.ZERO var workedMinutes = 0L - val now = LocalTime.now() - var date = start while (!date.isAfter(lastCalculableDate)) { val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) @@ -107,14 +105,13 @@ class WorkdayService( 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 && schedule.clockIn != null && schedule.clockOut != null) { + workedMinutes += salaryCalculator.calculateWorkMinutes(schedule.clockIn, schedule.clockOut) val dailyEarnings = earningsCalculator.calculateDailyEarnings( - memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, adjustedClockOut, + memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, schedule.clockOut, ) totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) } @@ -360,31 +357,6 @@ class WorkdayService( private fun resolvePaydayDay(memberId: Long): PaydayDay = profileRepository.findByMemberId(memberId)?.paydayDay ?: throw NotFoundException() - - private fun resolveClockOutForEarnings( - targetDate: LocalDate, - today: LocalDate, - now: LocalTime, - schedule: ResolvedSchedule, - ): LocalTime? { - if (targetDate != today || - (schedule.type != DailyWorkScheduleType.WORK && schedule.type != DailyWorkScheduleType.VACATION) - ) { - return schedule.clockOut - } - - val clockIn = schedule.clockIn ?: return schedule.clockOut - val clockOut = schedule.clockOut ?: return null - - if (now.isBefore(clockIn)) { - return null - } - - return when { - !clockOut.isBefore(clockIn) -> minOf(now, clockOut) - else -> now - } - } } private data class ResolvedSchedule( From 1c643e550cf9400d08509aa716d061c4845ccc98 Mon Sep 17 00:00:00 2001 From: jeyong Date: Wed, 18 Mar 2026 03:31:35 +0900 Subject: [PATCH 2/4] Refactor WorkdayService and NotificationMessageBuilder to use MemberEarningsService for earnings calculations and improve code clarity --- .../kotlin/com/moa/service/WorkdayService.kt | 259 +++++++++++------- ...alculator.kt => CompensationCalculator.kt} | 39 +-- ...Calculator.kt => MemberEarningsService.kt} | 54 ++-- .../NotificationMessageBuilder.kt | 8 +- .../calculator/CompensationCalculatorTest.kt | 211 ++++++++++++++ ...orTest.kt => MemberEarningsServiceTest.kt} | 48 ++-- .../calculator/SalaryCalculatorTest.kt | 237 ---------------- 7 files changed, 450 insertions(+), 406 deletions(-) rename src/main/kotlin/com/moa/service/calculator/{SalaryCalculator.kt => CompensationCalculator.kt} (60%) rename src/main/kotlin/com/moa/service/calculator/{EarningsCalculator.kt => MemberEarningsService.kt} (62%) create mode 100644 src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt rename src/test/kotlin/com/moa/service/calculator/{EarningsCalculatorTest.kt => MemberEarningsServiceTest.kt} (79%) delete mode 100644 src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index b0bf9be..7ea27f9 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -7,8 +7,8 @@ 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.calculator.CompensationCalculator +import com.moa.service.calculator.MemberEarningsService import com.moa.service.dto.* import com.moa.service.notification.NotificationSyncService import org.springframework.stereotype.Service @@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDate import java.time.LocalTime +import java.time.YearMonth @Service class WorkdayService( @@ -23,14 +24,13 @@ class WorkdayService( private val workPolicyVersionRepository: WorkPolicyVersionRepository, private val profileRepository: ProfileRepository, private val notificationSyncService: NotificationSyncService, - private val earningsCalculator: EarningsCalculator, - private val salaryCalculator: SalaryCalculator, + private val memberEarningsService: MemberEarningsService, + private val compensationCalculator: CompensationCalculator, ) { @Transactional(readOnly = true) fun getMonthlyWorkdays(memberId: Long, year: Int, month: Int): List { - val start = LocalDate.of(year, month, 1) - val end = start.withDayOfMonth(start.lengthOfMonth()) + val (start, end) = resolveMonthRange(year, month) val savedSchedulesByDate = dailyWorkScheduleRepository @@ -43,12 +43,7 @@ class WorkdayService( return generateSequence(start) { it.plusDays(1) } .takeWhile { !it.isAfter(end) } .map { date -> - val schedule = - if (monthlyPolicy == null) { - ResolvedSchedule(DailyWorkScheduleType.NONE, null, null) - } else { - resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) - } + val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date) createWorkdayResponse(memberId, date, schedule, monthlyPolicy, paydayDay) } .toList() @@ -56,36 +51,35 @@ class WorkdayService( @Transactional(readOnly = true) fun getMonthlyEarnings(memberId: Long, year: Int, month: Int): MonthlyEarningsResponse { - val start = LocalDate.of(year, month, 1) - val end = start.withDayOfMonth(start.lengthOfMonth()) + val (start, end) = resolveMonthRange(year, month) val today = LocalDate.now() - val defaultSalary = earningsCalculator.getDefaultMonthlySalary(memberId, start) ?: 0 + val standardSalary = memberEarningsService.calculateStandardSalary(memberId, start).toLong() val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) if (monthlyPolicy == null) { return MonthlyEarningsResponse( workedEarnings = 0, - standardSalary = defaultSalary, + standardSalary = standardSalary, workedMinutes = 0, standardMinutes = 0, ) } - val policyDailyMinutes = salaryCalculator.calculateWorkMinutes( + val standardDailyMinutes = compensationCalculator.calculateWorkMinutes( monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, ) - val policyWorkDayOfWeeks = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() - val workDaysInMonth = salaryCalculator.getWorkDaysInPeriod( + val standardWorkDays = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() + val standardWorkDaysCount = compensationCalculator.getWorkDaysInPeriod( start = start, end = end.plusDays(1), - workDays = policyWorkDayOfWeeks + workDays = standardWorkDays ) - val standardMinutes = policyDailyMinutes * workDaysInMonth + val standardMinutes = standardDailyMinutes * standardWorkDaysCount if (start.isAfter(today)) { - return MonthlyEarningsResponse(0, defaultSalary, 0, standardMinutes) + return MonthlyEarningsResponse(0, standardSalary, 0, standardMinutes) } val lastCalculableDate = minOf(end, today) @@ -94,34 +88,38 @@ class WorkdayService( .findAllByMemberIdAndDateBetween(memberId, start, lastCalculableDate) .associateBy { it.date } - var totalEarnings = BigDecimal.ZERO + var workedEarnings = BigDecimal.ZERO var workedMinutes = 0L var date = start while (!date.isAfter(lastCalculableDate)) { - val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) + val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date) val status = DailyWorkStatusType.resolve( date = date, scheduleType = schedule.type, clockIn = schedule.clockIn, clockOut = schedule.clockOut, ) - val isCompletedWork = status == DailyWorkStatusType.COMPLETED && - (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) - - if (isCompletedWork && schedule.clockIn != null && schedule.clockOut != null) { - workedMinutes += salaryCalculator.calculateWorkMinutes(schedule.clockIn, schedule.clockOut) - val dailyEarnings = earningsCalculator.calculateDailyEarnings( - memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, schedule.clockOut, + val completedWork = resolveCompletedWorkForSettlement(schedule, status) + + if (completedWork != null) { + workedMinutes += compensationCalculator.calculateWorkMinutes(completedWork.clockIn, completedWork.clockOut) + val dailyEarnings = memberEarningsService.calculateDailyEarnings( + memberId, + date, + monthlyPolicy, + completedWork.type, + completedWork.clockIn, + completedWork.clockOut, ) - totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) + workedEarnings = workedEarnings.add(dailyEarnings) } date = date.plusDays(1) } return MonthlyEarningsResponse( - workedEarnings = totalEarnings.toLong(), - standardSalary = defaultSalary, + workedEarnings = workedEarnings.toLong(), + standardSalary = standardSalary, workedMinutes = workedMinutes, standardMinutes = standardMinutes, ) @@ -134,8 +132,7 @@ class WorkdayService( month: Int, ): List { - val start = LocalDate.of(year, month, 1) - val end = start.withDayOfMonth(start.lengthOfMonth()) + val (start, end) = resolveMonthRange(year, month) val savedSchedulesByDate = dailyWorkScheduleRepository @@ -147,12 +144,7 @@ class WorkdayService( return generateSequence(start) { it.plusDays(1) } .takeWhile { !it.isAfter(end) } .map { date -> - val schedule = - if (monthlyPolicy == null) { - ResolvedSchedule(DailyWorkScheduleType.NONE, null, null) - } else { - resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) - } + val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date) MonthlyWorkdayResponse(date = date, type = schedule.type) } .toList() @@ -165,12 +157,7 @@ class WorkdayService( ): WorkdayResponse { val saved = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue) - val schedule = - if (policy == null) { - ResolvedSchedule(DailyWorkScheduleType.NONE, null, null) - } else { - resolveScheduleForDate(saved, policy, date) - } + val schedule = resolveSchedule(saved, policy, date) return createWorkdayResponse(memberId, date, schedule, policy, resolvePaydayDay(memberId)) } @@ -183,22 +170,7 @@ class WorkdayService( clockIn to clockOut } - DailyWorkScheduleType.VACATION -> { - // 1. 요청에 시간이 있으면 사용, 없으면 정책 조회 - if (req.clockInTime != null && req.clockOutTime != null) { - req.clockInTime to req.clockOutTime - } else { - val policy = workPolicyVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, date) - ?: workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, - LocalDate.now() - ) ?: throw NotFoundException() - - // 요청값이 하나라도 비어있으면 정책의 기본 시간을 할당 - policy.clockInTime to policy.clockOutTime - } - } + DailyWorkScheduleType.VACATION -> resolveVacationTimes(memberId, date, req) DailyWorkScheduleType.NONE -> throw BadRequestException(ErrorCode.INVALID_WORKDAY_INPUT) } @@ -272,6 +244,12 @@ class WorkdayService( ) } + /** + * 저장된 스케줄과 정책 기본값 중 어떤 값을 응답에 반영할지 한 곳에서 결정하기 위한 헬퍼입니다. + * + * 월간 조회, 단건 조회, 월간 집계가 모두 같은 해석 규칙을 쓰도록 하여 + * "저장된 값이 있으면 우선, 없으면 정책으로 보완" 규칙의 중복을 줄입니다. + */ private fun resolveScheduleForDate( saved: DailyWorkSchedule?, policy: WorkPolicyVersion, @@ -288,6 +266,12 @@ class WorkdayService( } } + /** + * 근무일 화면 응답 생성 규칙을 한 곳에 모아 두기 위한 헬퍼입니다. + * + * 상태 계산, 이벤트 계산, 표시용 일급 계산이 여러 API에서 동일하게 동작해야 하므로 + * 응답 조립 로직을 서비스 메서드마다 반복하지 않도록 분리했습니다. + */ private fun createWorkdayResponse( memberId: Long, date: LocalDate, @@ -296,57 +280,142 @@ class WorkdayService( paydayDay: PaydayDay, ): WorkdayResponse { val events = DailyEventType.resolve(date, paydayDay) + val status = DailyWorkStatusType.resolve( + date = date, + scheduleType = schedule.type, + clockIn = schedule.clockIn, + clockOut = schedule.clockOut, + ) if (schedule.type == DailyWorkScheduleType.NONE) { return WorkdayResponse( date = date, type = DailyWorkScheduleType.NONE, - status = DailyWorkStatusType.NONE, + status = status, events = events, dailyPay = 0, ) } - val resolvedPolicy = policy ?: return WorkdayResponse( - date = date, - type = schedule.type, - status = DailyWorkStatusType.resolve( - date = date, - scheduleType = schedule.type, - clockIn = schedule.clockIn, - clockOut = schedule.clockOut, - ), - events = events, - dailyPay = 0, - clockInTime = schedule.clockIn, - clockOutTime = schedule.clockOut, - ) - val earnings = earningsCalculator.calculateDailyEarnings( - memberId, date, resolvedPolicy, schedule.type, schedule.clockIn, schedule.clockOut, - ) + val dailyPay = resolveDisplayedDailyPay(memberId, date, schedule, policy) + return WorkdayResponse( date = date, type = schedule.type, - status = DailyWorkStatusType.resolve( - date = date, - scheduleType = schedule.type, - clockIn = schedule.clockIn, - clockOut = schedule.clockOut, - ), + status = status, events = events, - dailyPay = earnings?.toInt() ?: 0, + dailyPay = dailyPay, clockInTime = schedule.clockIn, clockOutTime = schedule.clockOut, ) } + /** + * 정책이 없는 경우를 포함해 스케줄 해석 진입점을 단순화하기 위한 헬퍼입니다. + * + * 호출부가 매번 null 정책 분기를 직접 처리하지 않게 해서 + * 월간 조회와 단건 조회의 흐름을 더 짧고 동일한 형태로 유지합니다. + */ + private fun resolveSchedule( + saved: DailyWorkSchedule?, + policy: WorkPolicyVersion?, + date: LocalDate, + ): ResolvedSchedule { + if (policy == null) { + return ResolvedSchedule(DailyWorkScheduleType.NONE, null, null) + } + return resolveScheduleForDate(saved, policy, date) + } + + /** + * 월 시작일과 마지막 날 계산을 한 곳으로 모아 날짜 범위 표현을 통일하기 위한 헬퍼입니다. + * + * 같은 계산이 여러 메서드에 흩어지면 사소한 수정에도 중복 변경이 필요하므로 + * 월 단위 조회에서 공통으로 사용하는 범위 생성을 분리했습니다. + */ + private fun resolveMonthRange(year: Int, month: Int): Pair { + val start = LocalDate.of(year, month, 1) + return start to start.withDayOfMonth(start.lengthOfMonth()) + } + + /** + * 화면에 보여줄 `dailyPay` 계산 책임을 월 정산 로직과 분리하기 위한 헬퍼입니다. + * + * 같은 계산기를 사용하더라도 화면 표시는 "그날 보여줄 금액"이고, + * 월 집계는 "완료된 근무만 합산한 확정 소득"이므로 의도를 코드 레벨에서 구분합니다. + */ + private fun resolveDisplayedDailyPay( + memberId: Long, + date: LocalDate, + schedule: ResolvedSchedule, + policy: WorkPolicyVersion?, + ): Int { + if (policy == null) return 0 + + return memberEarningsService.calculateDailyEarnings( + memberId, date, policy, schedule.type, schedule.clockIn, schedule.clockOut, + ).toInt() + } + + /** + * 월 소득 집계에 포함 가능한 근무만 선별하기 위한 헬퍼입니다. + * + * 완료 여부, 근무 유형, 출퇴근 시간 존재 여부를 한 번에 확인해 + * 정산 대상 판정 규칙이 루프 본문에 흩어지지 않도록 분리했습니다. + */ + private fun resolveCompletedWorkForSettlement( + schedule: ResolvedSchedule, + status: DailyWorkStatusType, + ): CompletedWork? { + if (status != DailyWorkStatusType.COMPLETED) return null + if (schedule.type != DailyWorkScheduleType.WORK && schedule.type != DailyWorkScheduleType.VACATION) { + return null + } + + val clockIn = schedule.clockIn ?: return null + val clockOut = schedule.clockOut ?: return null + return CompletedWork(schedule.type, clockIn, clockOut) + } + + /** + * 휴무 입력 시 사용할 시간을 결정하는 규칙을 `upsertSchedule`에서 분리하기 위한 헬퍼입니다. + * + * 요청 시간이 모두 있으면 그대로 사용하고, 없으면 정책 시간으로 보완하는 규칙을 묶어 + * 상위 메서드가 "어떤 타입의 스케줄을 저장하는가"에만 집중하게 합니다. + */ + private fun resolveVacationTimes( + memberId: Long, + date: LocalDate, + req: WorkdayUpsertRequest, + ): Pair { + if (req.clockInTime != null && req.clockOutTime != null) { + val clockIn = req.clockInTime + val clockOut = req.clockOutTime + return clockIn to clockOut + } + + val policy = workPolicyVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, date) + ?: workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, + LocalDate.now() + ) + ?: throw NotFoundException() + + return policy.clockInTime to policy.clockOutTime + } + + /** + * 특정 월을 대표하는 정책 버전을 조회하는 규칙을 명시적으로 드러내기 위한 헬퍼입니다. + * + * 이 서비스는 월말 기준으로 그 달에 적용되는 최신 정책을 사용하므로, + * 조회 기준일 계산과 리포지토리 호출을 한 곳에 묶어 의미를 고정합니다. + */ private fun resolveMonthlyRepresentativePolicyOrNull( memberId: Long, year: Int, month: Int, ): WorkPolicyVersion? { - val lastDayOfMonth = - LocalDate.of(year, month, 1) - .withDayOfMonth(LocalDate.of(year, month, 1).lengthOfMonth()) + val lastDayOfMonth = YearMonth.of(year, month).atEndOfMonth() return workPolicyVersionRepository .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( @@ -364,3 +433,9 @@ private data class ResolvedSchedule( val clockIn: LocalTime?, val clockOut: LocalTime?, ) + +private data class CompletedWork( + val type: DailyWorkScheduleType, + val clockIn: LocalTime, + val clockOut: LocalTime, +) diff --git a/src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt b/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt similarity index 60% rename from src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt rename to src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt index 9f5db91..34bc186 100644 --- a/src/main/kotlin/com/moa/service/calculator/SalaryCalculator.kt +++ b/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt @@ -7,25 +7,25 @@ import java.math.RoundingMode import java.time.* /** - * 급여, 일급, 근로 시간 등을 계산하는 서비스입니다. + * 보상 산정에 필요한 계산 공식을 제공하는 서비스입니다. * - * 외부 상태를 가지지 않지만 계산 책임을 애플리케이션 서비스로 통일하기 위해 Spring Bean으로 관리합니다. + * 이 클래스는 외부 데이터를 조회하지 않고, 일급 산정과 근무 시간 환산 같은 순수 계산만 담당합니다. + * 회원별 급여 버전 조회나 정책 선택은 [MemberEarningsService]가 맡고, 이 클래스는 계산 공식 자체를 캡슐화합니다. */ @Service -class SalaryCalculator { +class CompensationCalculator { /** - * 특정 일자가 속한 달의 일일 급여(일급)를 계산합니다. + * 특정 일자가 속한 달의 기준 일급을 계산합니다. * - * 이 메서드는 회원의 월 기본급을 해당 월의 총 소정 근로일수로 나누어 일급을 산출합니다. - * 연봉([com.moa.entity.SalaryInputType.ANNUAL])인 경우 금액을 12로 나누어 월 기본급을 먼저 구합니다. - * 최종 산출된 일급은 소수점 첫째 자리에서 반올림([java.math.RoundingMode.HALF_UP]) 처리됩니다. + * 월급 또는 연봉으로 입력된 금액을 월 기준 금액으로 환산한 뒤, + * 해당 월의 총 소정 근로일수로 나누어 일급을 산출합니다. * * @param targetDate 기준 일자 (이 일자가 속한 월을 기준으로 총 근로일수를 계산합니다) * @param salaryType 급여 산정 방식 (연봉 또는 월급) * @param salaryAmount 책정된 급여 금액 - * @param workDays 회원의 주간 근무 요일 집합 (예: 월, 수, 금) - * @return 계산된 일급 금액. 해당 월에 근무일이 전혀 없는 경우 `0`을 반환합니다. + * @param workDays 주간 근무 요일 집합 (예: 월, 수, 금) + * @return 계산된 기준 일급 금액. 해당 월에 근무일이 전혀 없는 경우 `0`을 반환합니다. */ fun calculateDailyRate( targetDate: LocalDate, @@ -52,9 +52,8 @@ class SalaryCalculator { /** * 출근 시간과 퇴근 시간 사이의 총 근무 시간을 분(minute) 단위로 계산합니다. * - * 퇴근 시간이 출근 시간보다 빠른 경우에만, 익일 퇴근(야간 교대근무 등)으로 간주하여 - * 자동으로 24시간(1440분)을 더하여 계산합니다. - * 출근 시간과 퇴근 시간이 같으면 0분으로 계산합니다. + * 퇴근 시간이 출근 시간보다 빠른 경우에만 익일 퇴근으로 간주하여 24시간을 더합니다. + * 시각이 같으면 근무 시간은 0분으로 계산합니다. * * @param clockIn 출근 시간 * @param clockOut 퇴근 시간 @@ -66,16 +65,15 @@ class SalaryCalculator { } /** - * 일급과 실제 근무 시간을 바탕으로 최종 발생 소득을 계산합니다. + * 기준 일급과 실제 근무 시간을 바탕으로 최종 발생 소득을 계산합니다. * - * 소정 근로 시간 대비 실제 근로 시간의 비율을 일급에 곱하여 산출합니다. - * 계산의 정확도를 위해 분당 급여율 산출 시 소수점 10자리까지 유지하며, - * 최종 결과값에서 소수점 첫째 자리에서 반올림([RoundingMode.HALF_UP])하여 정수 단위로 맞춥니다. + * 기준 일급을 정책상 소정 근로 시간으로 나눈 분당 단가를 구한 뒤, + * 실제 근무 시간에 비례하도록 곱하여 최종 금액을 산출합니다. * * @param dailyRate 산정된 기준 일급 * @param policyWorkMinutes 정책상 소정 근로 시간 (분 단위) * @param actualWorkMinutes 실제 근로 시간 (분 단위) - * @return 계산된 최종 소득 금액. 정책상 근로 시간이 0 이하로 잘못 설정된 경우, 보정 없이 기준 일급을 그대로 반환합니다. + * @return 계산된 최종 소득 금액. 정책상 근로 시간이 0 이하이면 기준 일급을 그대로 반환합니다. */ fun calculateEarnings( dailyRate: BigDecimal, @@ -88,10 +86,13 @@ class SalaryCalculator { } /** - * 특정 기간 내에 포함된 실제 근무일(조건에 맞는 요일)의 총 일수를 계산합니다. + * 특정 기간 안에 포함되는 실제 근무일 수를 계산합니다. + * + * 월별 기준 일급이나 기준 근무 시간 합계를 계산할 때 공통으로 사용되는 + * "해당 기간 안에 몇 개의 근무 요일이 포함되는가"를 구하기 위한 헬퍼입니다. * * @param start 계산 시작 일자 (포함) - * @param end 계산 종료 일자 (제외, Exclusive) + * @param end 계산 종료 일자 (제외) * @param workDays 포함시킬 근무 요일 집합 * @return 기간 내 포함된 근무일의 총 개수 */ diff --git a/src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt b/src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt similarity index 62% rename from src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt rename to src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt index 596a4ca..6a1dc38 100644 --- a/src/main/kotlin/com/moa/service/calculator/EarningsCalculator.kt +++ b/src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt @@ -12,17 +12,18 @@ import java.time.LocalTime import java.time.YearMonth /** - * 회원의 급여 및 일일 소득을 계산하는 서비스입니다. + * 회원 단위의 급여 계산 유스케이스를 조합하는 서비스입니다. * - * 급여 계약 이력과 근무 정책을 기반으로 월 기본급과 특정 일자의 발생 소득을 계산합니다. + * 급여 계약 이력 조회와 근무 정책 해석을 바탕으로 기준 월급과 특정 일자의 소득 계산을 조립합니다. + * 실제 계산 공식은 [CompensationCalculator]에 위임하고, 이 서비스는 어떤 데이터를 기준으로 계산할지를 결정합니다. */ @Service -class EarningsCalculator( +class MemberEarningsService( private val payrollVersionRepository: PayrollVersionRepository, - private val salaryCalculator: SalaryCalculator, + private val compensationCalculator: CompensationCalculator, ) { /** - * 지정된 날짜가 속한 월을 기준으로 회원의 기본 월급을 계산합니다. + * 지정된 날짜가 속한 월을 기준으로 회원의 기준 월급을 계산합니다. * * 주어진 날짜가 속한 달의 마지막 날을 기준으로, 해당 시점에 유효한 가장 최근의 급여 정보를 바탕으로 계산합니다. * 급여 유형이 연봉([com.moa.entity.SalaryInputType.ANNUAL])인 경우 12로 나눈 후 소수점 첫째 자리에서 반올림(HALF_UP)한 값을 반환하며, @@ -30,20 +31,16 @@ class EarningsCalculator( * * @param memberId 회원의 고유 식별자 * @param date 기준 날짜 (이 날짜가 속한 월의 마지막 날을 기준으로 유효한 급여 정책을 적용합니다) - * @return 계산된 기본 월급 금액. 적용할 수 있는 급여 정보가 존재하지 않으면 `null`을 반환합니다. + * @return 계산된 기준 월급 금액. 해당 월에 적용할 수 있는 급여 정보가 없으면 `0`을 반환합니다. */ - fun getDefaultMonthlySalary(memberId: Long, date: LocalDate): Long? { - val lastDayOfMonth = YearMonth.from(date).atEndOfMonth() - val payroll = payrollVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, lastDayOfMonth, - ) ?: return null + fun calculateStandardSalary(memberId: Long, date: LocalDate): BigDecimal { + val payroll = findPayrollForMonth(memberId, date) ?: return BigDecimal.ZERO return when (payroll.salaryInputType) { SalaryInputType.ANNUAL -> payroll.salaryAmount.toBigDecimal() - .divide(BigDecimal(12), 0, RoundingMode.HALF_UP).toLong() + .divide(BigDecimal(12), 0, RoundingMode.HALF_UP) - SalaryInputType.MONTHLY -> payroll.salaryAmount + SalaryInputType.MONTHLY -> payroll.salaryAmount.toBigDecimal() } } @@ -63,7 +60,7 @@ class EarningsCalculator( * @param type 대상 일자의 근무 일정 유형 (예: 근무, 휴무 등) * @param clockInTime 실제 출근 시간. (기록이 없을 경우 `null`) * @param clockOutTime 실제 퇴근 시간. (기록이 없을 경우 `null`) - * @return 계산된 일일 소득 금액. 적용할 수 있는 급여 정보가 존재하지 않으면 `null`을 반환합니다. + * @return 계산된 일일 소득 금액. 해당 월에 적용할 수 있는 급여 정보가 없으면 `0`을 반환합니다. */ fun calculateDailyEarnings( memberId: Long, @@ -72,16 +69,12 @@ class EarningsCalculator( type: DailyWorkScheduleType, clockInTime: LocalTime?, clockOutTime: LocalTime?, - ): BigDecimal? { + ): BigDecimal { if (type == DailyWorkScheduleType.NONE) return BigDecimal.ZERO - val lastDayOfMonth = YearMonth.from(date).atEndOfMonth() - val payroll = payrollVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, lastDayOfMonth, - ) ?: return null + val payroll = findPayrollForMonth(memberId, date) ?: return BigDecimal.ZERO - val dailyRate = salaryCalculator.calculateDailyRate( + val dailyRate = compensationCalculator.calculateDailyRate( targetDate = date, salaryType = payroll.salaryInputType, salaryAmount = payroll.salaryAmount, @@ -90,13 +83,24 @@ class EarningsCalculator( if (dailyRate == BigDecimal.ZERO) return dailyRate if (clockInTime != null && clockOutTime != null) { - val policyMinutes = salaryCalculator.calculateWorkMinutes( + val policyMinutes = compensationCalculator.calculateWorkMinutes( policy.clockInTime, policy.clockOutTime, ) - val actualMinutes = salaryCalculator.calculateWorkMinutes(clockInTime, clockOutTime) - return salaryCalculator.calculateEarnings(dailyRate, policyMinutes, actualMinutes) + val actualMinutes = compensationCalculator.calculateWorkMinutes(clockInTime, clockOutTime) + return compensationCalculator.calculateEarnings(dailyRate, policyMinutes, actualMinutes) } return dailyRate } + + /** + * 월 기준 급여 버전 조회 규칙을 한 곳으로 고정하기 위한 헬퍼입니다. + * + * 월급 계산과 일소득 계산이 모두 같은 기준일을 사용해야 하므로, + * "해당 월 말일 시점에 유효한 최신 급여"라는 규칙을 중복 없이 재사용합니다. + */ + private fun findPayrollForMonth(memberId: Long, date: LocalDate) = + payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, YearMonth.from(date).atEndOfMonth(), + ) } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index 65ea138..29be732 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -5,7 +5,7 @@ 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.calculator.EarningsCalculator +import com.moa.service.calculator.MemberEarningsService import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat @@ -17,7 +17,7 @@ import java.util.* class NotificationMessageBuilder( private val workPolicyVersionRepository: WorkPolicyVersionRepository, private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, - private val earningsCalculator: EarningsCalculator, + private val memberEarningsService: MemberEarningsService, ) { fun buildMessage(notification: NotificationLog): NotificationMessage { @@ -32,7 +32,7 @@ class NotificationMessageBuilder( private fun buildClockOutBody(notification: NotificationLog): String { val earnings = calculateTodayEarnings(notification.memberId, notification.scheduledDate) - if (earnings == null || earnings == BigDecimal.ZERO) { + if (earnings == BigDecimal.ZERO) { return CLOCK_OUT_FALLBACK_BODY } val formatted = NumberFormat.getNumberInstance(Locale.KOREA).format(earnings) @@ -47,7 +47,7 @@ class NotificationMessageBuilder( ) ?: return null val override = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) - return earningsCalculator.calculateDailyEarnings( + return memberEarningsService.calculateDailyEarnings( memberId = memberId, date = date, policy = policy, diff --git a/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt new file mode 100644 index 0000000..49c79b2 --- /dev/null +++ b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt @@ -0,0 +1,211 @@ +package com.moa.service.calculator + +import com.moa.entity.SalaryInputType +import com.moa.entity.Workday +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime + +class CompensationCalculatorTest { + private val compensationCalculator = CompensationCalculator() + + @Test + fun `calculateDailyRate - 월급 300만원과 주5일 근무면 6월 기준 일급을 계산한다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal("142857")) + } + + @Test + fun `calculateDailyRate - 연봉은 월급으로 환산 후 일급을 계산한다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.ANNUAL, + salaryAmount = 36_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal("142857")) + } + + @Test + fun `calculateDailyRate - 근무일이 없으면 0을 반환한다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = emptySet(), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + } + + @Test + fun `calculateDailyRate - 주3일 근무면 해당 근무일 수로 나눈다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY, + ), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal("230769")) + } + + @Test + fun `calculateDailyRate - 단일 근무일만 있어도 계산한다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 1_000_000, + workDays = setOf(DayOfWeek.MONDAY), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal("200000")) + } + + @Test + fun `calculateDailyRate - 소수점은 반올림한다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 1_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + ), + ) + + assertThat(result.scale()).isEqualTo(0) + } + + @Test + fun `calculateDailyRate - Workday enum 기준 주5일도 같은 결과를 낸다`() { + val result = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 6, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = setOf( + Workday.MON.dayOfWeek, + Workday.TUE.dayOfWeek, + Workday.WED.dayOfWeek, + Workday.THU.dayOfWeek, + Workday.FRI.dayOfWeek, + ), + ) + + assertThat(result).isEqualByComparingTo(BigDecimal("142857")) + } + + @Test + fun `calculateDailyRate - 월마다 근무일 수가 다르면 결과도 달라진다`() { + val febResult = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 2, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ), + ) + val marResult = compensationCalculator.calculateDailyRate( + targetDate = LocalDate.of(2025, 3, 1), + salaryType = SalaryInputType.MONTHLY, + salaryAmount = 3_000_000, + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ), + ) + + assertThat(febResult).isNotEqualByComparingTo(marResult) + } + + @Test + fun `calculateWorkMinutes - 9시에서 18시는 540분을 반환한다`() { + val result = compensationCalculator.calculateWorkMinutes( + clockIn = LocalTime.of(9, 0), + clockOut = LocalTime.of(18, 0), + ) + + assertThat(result).isEqualTo(540) + } + + @Test + fun `calculateWorkMinutes - 자정넘김 22시에서 2시는 240분을 반환한다`() { + val result = compensationCalculator.calculateWorkMinutes( + clockIn = LocalTime.of(22, 0), + clockOut = LocalTime.of(2, 0), + ) + + assertThat(result).isEqualTo(240) + } + + @Test + fun `calculateWorkMinutes - 시작시간과 종료시간이 같으면 0분을 반환한다`() { + val result = compensationCalculator.calculateWorkMinutes( + clockIn = LocalTime.of(9, 0), + clockOut = LocalTime.of(9, 0), + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `calculateEarnings - 실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { + val dailyRate = BigDecimal("100000") + + val result = compensationCalculator.calculateEarnings(dailyRate, 540, 540) + + assertThat(result).isEqualByComparingTo(BigDecimal("100000")) + } + + @Test + fun `calculateEarnings - 초과 근무시 분급 기준으로 증가된 금액을 반환한다`() { + val dailyRate = BigDecimal("100000") + + val result = compensationCalculator.calculateEarnings(dailyRate, 540, 600) + + assertThat(result).isEqualByComparingTo(BigDecimal("111111")) + } + + @Test + fun `calculateEarnings - 조기 퇴근시 분급 기준으로 감소된 금액을 반환한다`() { + val dailyRate = BigDecimal("100000") + + val result = compensationCalculator.calculateEarnings(dailyRate, 540, 480) + + assertThat(result).isEqualByComparingTo(BigDecimal("88889")) + } +} diff --git a/src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt similarity index 79% rename from src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt rename to src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt index 1fa68d6..b285810 100644 --- a/src/test/kotlin/com/moa/service/calculator/EarningsCalculatorTest.kt +++ b/src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt @@ -13,11 +13,11 @@ import java.time.LocalDate import java.time.LocalTime @ExtendWith(MockKExtension::class) -class EarningsCalculatorTest { +class MemberEarningsServiceTest { private val payrollVersionRepository: PayrollVersionRepository = mockk() - private val salaryCalculator = SalaryCalculator() - private val sut = EarningsCalculator(payrollVersionRepository, salaryCalculator) + private val compensationCalculator = CompensationCalculator() + private val sut = MemberEarningsService(payrollVersionRepository, compensationCalculator) companion object { private const val MEMBER_ID = 1L @@ -64,9 +64,7 @@ class EarningsCalculatorTest { clockOutTime = LocalTime.of(18, 0), ) - // 정책 시간과 동일 → 일급 전액: 3,000,000 / 21 = 142,857 - assertThat(result).isNotNull - assertThat(result!!.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } @Test @@ -84,7 +82,7 @@ class EarningsCalculatorTest { } @Test - fun `PayrollVersion이 없으면 null을 반환한다`() { + fun `PayrollVersion이 없으면 0원을 반환한다`() { every { payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( MEMBER_ID, LAST_DAY_OF_MONTH, @@ -100,7 +98,7 @@ class EarningsCalculatorTest { clockOutTime = null, ) - assertThat(result).isNull() + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test @@ -117,9 +115,7 @@ class EarningsCalculatorTest { clockOutTime = LocalTime.of(18, 0), ) - // 3,000,000 / 21 workdays in June 2025 = 142,857 - assertThat(result).isNotNull - assertThat(result!!.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } @Test @@ -133,12 +129,11 @@ class EarningsCalculatorTest { policy = policy, type = DailyWorkScheduleType.WORK, clockInTime = LocalTime.of(9, 0), - clockOutTime = LocalTime.of(19, 0), // 1시간 초과 + clockOutTime = LocalTime.of(19, 0), ) val dailyRate = 3_000_000L / 21 - assertThat(result).isNotNull - assertThat(result!!.toLong()).isGreaterThan(dailyRate) + assertThat(result.toLong()).isGreaterThan(dailyRate) } @Test @@ -152,12 +147,11 @@ class EarningsCalculatorTest { policy = policy, type = DailyWorkScheduleType.WORK, clockInTime = LocalTime.of(9, 0), - clockOutTime = LocalTime.of(17, 0), // 1시간 조기 퇴근 + clockOutTime = LocalTime.of(17, 0), ) val dailyRate = 3_000_000L / 21 - assertThat(result).isNotNull - assertThat(result!!.toLong()).isLessThan(dailyRate) + assertThat(result.toLong()).isLessThan(dailyRate) } @Test @@ -177,8 +171,6 @@ class EarningsCalculatorTest { assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } - // --- getDefaultMonthlySalary --- - @Test fun `ANNUAL 3,600,000이면 월급 300,000을 반환한다`() { every { @@ -192,31 +184,31 @@ class EarningsCalculatorTest { salaryAmount = 3_600_000, ) - val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isEqualTo(300_000) + assertThat(result).isEqualByComparingTo(BigDecimal("300000")) } @Test fun `MONTHLY 3,000,000이면 그대로 3,000,000을 반환한다`() { stubPayroll(3_000_000) - val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isEqualTo(3_000_000) + assertThat(result).isEqualByComparingTo(BigDecimal("3000000")) } @Test - fun `PayrollVersion이 없으면 getDefaultMonthlySalary는 null을 반환한다`() { + fun `PayrollVersion이 없으면 calculateStandardSalary는 0을 반환한다`() { every { payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( MEMBER_ID, LAST_DAY_OF_MONTH, ) } returns null - val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isNull() + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test @@ -233,8 +225,6 @@ class EarningsCalculatorTest { clockOutTime = null, ) - // 3,000,000 / 21 = 142,857 - assertThat(result).isNotNull - assertThat(result!!.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } } diff --git a/src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt deleted file mode 100644 index bd2c687..0000000 --- a/src/test/kotlin/com/moa/service/calculator/SalaryCalculatorTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.moa.service.calculator - -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 -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.LocalTime - -class SalaryCalculatorTest { - private val salaryCalculator = SalaryCalculator() - - companion object { - private val WEEKDAYS = setOf( - DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, - ) - } - - // --- 월급 기반 일급 계산 --- - - @Test - fun `MONTHLY - 월급을 해당 월의 근무일수로 나눈 일급을 반환한다`() { - // 2025년 2월: 평일 20일 - val targetDate = LocalDate.of(2025, 2, 3) - val monthlySalary = 3_000_000L - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = monthlySalary, - workDays = WEEKDAYS, - ) - - val expected = BigDecimal(3_000_000).divide(BigDecimal(20), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - // --- 연봉 기반 일급 계산 --- - - @Test - fun `YEARLY - 연봉을 12로 나눈 월급 기준으로 일급을 계산한다`() { - // 2025년 2월: 평일 20일 - val targetDate = LocalDate.of(2025, 2, 3) - val yearlySalary = 36_000_000L - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - 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) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - // --- 월별 근무일수 차이 --- - - @Test - fun `3월은 평일 21일 기준으로 일급을 계산한다`() { - // 2025년 3월: 평일 21일 - val targetDate = LocalDate.of(2025, 3, 10) - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 2_100_000L, - workDays = WEEKDAYS, - ) - - val expected = BigDecimal(2_100_000).divide(BigDecimal(21), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - @Test - fun `1월은 평일 23일 기준으로 일급을 계산한다`() { - // 2025년 1월: 평일 23일 - val targetDate = LocalDate.of(2025, 1, 15) - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 2_300_000L, - workDays = WEEKDAYS, - ) - - val expected = BigDecimal(2_300_000).divide(BigDecimal(23), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - // --- 2월 처리 --- - - @Test - fun `2월은 28일 기준으로 근무일수를 계산한다`() { - // 2025년 2월 (비윤년): 평일 20일 - val targetDate = LocalDate.of(2025, 2, 10) - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 2_000_000L, - workDays = WEEKDAYS, - ) - - val expected = BigDecimal(2_000_000).divide(BigDecimal(20), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - @Test - fun `윤년 2월은 29일 기준으로 근무일수를 계산한다`() { - // 2024년 2월 (윤년): 평일 21일 - val targetDate = LocalDate.of(2024, 2, 10) - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 2_100_000L, - workDays = WEEKDAYS, - ) - - val expected = BigDecimal(2_100_000).divide(BigDecimal(21), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - // --- 근무요일 설정 --- - - @Test - fun `주6일 근무자는 토요일도 근무일수에 포함된다`() { - val sixDayWork = WEEKDAYS + DayOfWeek.SATURDAY - // 2025년 2월: 월~토 = 24일 - val targetDate = LocalDate.of(2025, 2, 3) - - val result = salaryCalculator.calculateDailyRate( - targetDate = targetDate, - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 2_400_000L, - workDays = sixDayWork, - ) - - val expected = BigDecimal(2_400_000).divide(BigDecimal(24), 0, RoundingMode.HALF_UP) - Assertions.assertThat(result).isEqualByComparingTo(expected) - } - - // --- 엣지 케이스 --- - - @Test - fun `근무요일이 없으면 일급은 0을 반환한다`() { - val result = salaryCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 2, 3), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 3_000_000L, - workDays = emptySet(), - ) - - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) - } - - // --- 근무 시간(분) 계산 --- - - @Test - fun `calculateWorkMinutes - 9시에서 18시는 540분을 반환한다`() { - val result = salaryCalculator.calculateWorkMinutes( - LocalTime.of(9, 0), - LocalTime.of(18, 0), - ) - Assertions.assertThat(result).isEqualTo(540L) - } - - @Test - fun `calculateWorkMinutes - 자정넘김 22시에서 2시는 240분을 반환한다`() { - val result = salaryCalculator.calculateWorkMinutes( - LocalTime.of(22, 0), - LocalTime.of(2, 0), - ) - Assertions.assertThat(result).isEqualTo(240L) - } - - @Test - fun `calculateWorkMinutes - 시작시간과 종료시간이 같으면 0분을 반환한다`() { - val result = salaryCalculator.calculateWorkMinutes( - LocalTime.of(9, 0), - LocalTime.of(9, 0), - ) - Assertions.assertThat(result).isEqualTo(0L) - } - - // --- 실제 수입 계산 --- - - @Test - fun `calculateEarnings - 실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { - val dailyRate = BigDecimal(150_000) - 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) - Assertions.assertThat(result).isGreaterThan(dailyRate) - } - - @Test - fun `calculateEarnings - 조기 퇴근시 분급 기준으로 감소된 금액을 반환한다`() { - val dailyRate = BigDecimal(150_000) - // 540분 정책, 480분 실제 (1시간 조기 퇴근) - val result = salaryCalculator.calculateEarnings(dailyRate, 540, 480) - Assertions.assertThat(result).isLessThan(dailyRate) - } - - @Test - fun `같은 월급이라도 월마다 근무일수에 따라 일급이 달라진다`() { - val salary = 3_000_000L - - val febResult = salaryCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 2, 1), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = salary, - workDays = WEEKDAYS, - ) - - val marResult = salaryCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 3, 1), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = salary, - workDays = WEEKDAYS, - ) - - // 2월(20일) > 3월(21일) → 2월 일급이 더 높아야 함 - Assertions.assertThat(febResult).isGreaterThan(marResult) - } -} From 2446af401dc831046da08b7786fdd835a2615c99 Mon Sep 17 00:00:00 2001 From: jeyong Date: Wed, 18 Mar 2026 03:37:26 +0900 Subject: [PATCH 3/4] Add MemberEarningsService for salary calculations and refactor WorkdayService to utilize it --- .../{calculator => }/MemberEarningsService.kt | 36 +++++++++++++- .../kotlin/com/moa/service/WorkdayService.kt | 28 +++-------- .../calculator/CompensationCalculator.kt | 2 +- .../NotificationMessageBuilder.kt | 2 +- .../MemberEarningsServiceTest.kt | 49 ++++++++++++++----- .../calculator/CompensationCalculatorTest.kt | 28 +++++++++++ 6 files changed, 108 insertions(+), 37 deletions(-) rename src/main/kotlin/com/moa/service/{calculator => }/MemberEarningsService.kt (75%) rename src/test/kotlin/com/moa/service/{calculator => }/MemberEarningsServiceTest.kt (79%) diff --git a/src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt b/src/main/kotlin/com/moa/service/MemberEarningsService.kt similarity index 75% rename from src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt rename to src/main/kotlin/com/moa/service/MemberEarningsService.kt index 6a1dc38..10ecb3f 100644 --- a/src/main/kotlin/com/moa/service/calculator/MemberEarningsService.kt +++ b/src/main/kotlin/com/moa/service/MemberEarningsService.kt @@ -1,9 +1,10 @@ -package com.moa.service.calculator +package com.moa.service import com.moa.entity.DailyWorkScheduleType import com.moa.entity.SalaryInputType import com.moa.entity.WorkPolicyVersion import com.moa.repository.PayrollVersionRepository +import com.moa.service.calculator.CompensationCalculator import org.springframework.stereotype.Service import java.math.BigDecimal import java.math.RoundingMode @@ -15,7 +16,7 @@ import java.time.YearMonth * 회원 단위의 급여 계산 유스케이스를 조합하는 서비스입니다. * * 급여 계약 이력 조회와 근무 정책 해석을 바탕으로 기준 월급과 특정 일자의 소득 계산을 조립합니다. - * 실제 계산 공식은 [CompensationCalculator]에 위임하고, 이 서비스는 어떤 데이터를 기준으로 계산할지를 결정합니다. + * 실제 계산 공식은 [com.moa.service.calculator.CompensationCalculator]에 위임하고, 이 서비스는 어떤 데이터를 기준으로 계산할지를 결정합니다. */ @Service class MemberEarningsService( @@ -93,6 +94,37 @@ class MemberEarningsService( return dailyRate } + /** + * 월 집계 응답의 `standardMinutes`를 계산합니다. + * + * 기준 근무 시간은 정책의 일일 소정 근로 시간과 해당 기간의 근무일 수를 곱해 산출합니다. + * 계산 공식 자체는 [CompensationCalculator]에 위임하고, 이 메서드는 월 집계 용어에 맞는 진입점을 제공합니다. + */ + fun calculateStandardMinutes( + policy: WorkPolicyVersion, + start: LocalDate, + endInclusive: LocalDate, + ): Long { + val standardDailyMinutes = compensationCalculator.calculateWorkMinutes( + policy.clockInTime, policy.clockOutTime, + ) + val standardWorkDaysCount = compensationCalculator.getWorkDaysInPeriod( + start = start, + end = endInclusive.plusDays(1), + workDays = policy.workdays.map { it.dayOfWeek }.toSet(), + ) + return standardDailyMinutes * standardWorkDaysCount + } + + /** + * 월 집계 응답의 `workedMinutes`를 계산합니다. + * + * `WorkdayService`가 근무 시간 계산 공식을 직접 알지 않도록, + * 실제 출퇴근 시각을 분 단위 근무 시간으로 환산하는 책임을 이 서비스로 모읍니다. + */ + fun calculateWorkedMinutes(clockInTime: LocalTime, clockOutTime: LocalTime): Long = + compensationCalculator.calculateWorkMinutes(clockInTime, clockOutTime) + /** * 월 기준 급여 버전 조회 규칙을 한 곳으로 고정하기 위한 헬퍼입니다. * diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 7ea27f9..4ee01a0 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -7,13 +7,10 @@ import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.calculator.CompensationCalculator -import com.moa.service.calculator.MemberEarningsService 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.LocalTime import java.time.YearMonth @@ -25,7 +22,6 @@ class WorkdayService( private val profileRepository: ProfileRepository, private val notificationSyncService: NotificationSyncService, private val memberEarningsService: MemberEarningsService, - private val compensationCalculator: CompensationCalculator, ) { @Transactional(readOnly = true) @@ -65,18 +61,7 @@ class WorkdayService( ) } - val standardDailyMinutes = compensationCalculator.calculateWorkMinutes( - monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, - ) - - val standardWorkDays = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() - val standardWorkDaysCount = compensationCalculator.getWorkDaysInPeriod( - start = start, - end = end.plusDays(1), - workDays = standardWorkDays - ) - - val standardMinutes = standardDailyMinutes * standardWorkDaysCount + val standardMinutes = memberEarningsService.calculateStandardMinutes(monthlyPolicy, start, end) if (start.isAfter(today)) { return MonthlyEarningsResponse(0, standardSalary, 0, standardMinutes) @@ -88,7 +73,7 @@ class WorkdayService( .findAllByMemberIdAndDateBetween(memberId, start, lastCalculableDate) .associateBy { it.date } - var workedEarnings = BigDecimal.ZERO + var workedEarnings = 0L var workedMinutes = 0L var date = start while (!date.isAfter(lastCalculableDate)) { @@ -102,7 +87,10 @@ class WorkdayService( val completedWork = resolveCompletedWorkForSettlement(schedule, status) if (completedWork != null) { - workedMinutes += compensationCalculator.calculateWorkMinutes(completedWork.clockIn, completedWork.clockOut) + workedMinutes += memberEarningsService.calculateWorkedMinutes( + completedWork.clockIn, + completedWork.clockOut, + ) val dailyEarnings = memberEarningsService.calculateDailyEarnings( memberId, date, @@ -111,14 +99,14 @@ class WorkdayService( completedWork.clockIn, completedWork.clockOut, ) - workedEarnings = workedEarnings.add(dailyEarnings) + workedEarnings += dailyEarnings.toLong() } date = date.plusDays(1) } return MonthlyEarningsResponse( - workedEarnings = workedEarnings.toLong(), + workedEarnings = workedEarnings, standardSalary = standardSalary, workedMinutes = workedMinutes, standardMinutes = standardMinutes, diff --git a/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt b/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt index 34bc186..07f410e 100644 --- a/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt +++ b/src/main/kotlin/com/moa/service/calculator/CompensationCalculator.kt @@ -10,7 +10,7 @@ import java.time.* * 보상 산정에 필요한 계산 공식을 제공하는 서비스입니다. * * 이 클래스는 외부 데이터를 조회하지 않고, 일급 산정과 근무 시간 환산 같은 순수 계산만 담당합니다. - * 회원별 급여 버전 조회나 정책 선택은 [MemberEarningsService]가 맡고, 이 클래스는 계산 공식 자체를 캡슐화합니다. + * 회원별 급여 버전 조회나 정책 선택은 [com.moa.service.MemberEarningsService]가 맡고, 이 클래스는 계산 공식 자체를 캡슐화합니다. */ @Service class CompensationCalculator { diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index 29be732..90f36d2 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -5,7 +5,7 @@ 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.calculator.MemberEarningsService +import com.moa.service.MemberEarningsService import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat diff --git a/src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt b/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt similarity index 79% rename from src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt rename to src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt index b285810..feb45f4 100644 --- a/src/test/kotlin/com/moa/service/calculator/MemberEarningsServiceTest.kt +++ b/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt @@ -1,11 +1,12 @@ -package com.moa.service.calculator +package com.moa.service import com.moa.entity.* import com.moa.repository.PayrollVersionRepository +import com.moa.service.calculator.CompensationCalculator import io.mockk.every import io.mockk.junit5.MockKExtension import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.math.BigDecimal @@ -64,7 +65,7 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(18, 0), ) - assertThat(result.toLong()).isEqualTo(142857L) + Assertions.assertThat(result.toLong()).isEqualTo(142857L) } @Test @@ -78,7 +79,7 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - assertThat(result).isEqualTo(BigDecimal.ZERO) + Assertions.assertThat(result).isEqualTo(BigDecimal.ZERO) } @Test @@ -98,7 +99,7 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test @@ -115,7 +116,7 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(18, 0), ) - assertThat(result.toLong()).isEqualTo(142857L) + Assertions.assertThat(result.toLong()).isEqualTo(142857L) } @Test @@ -133,7 +134,7 @@ class MemberEarningsServiceTest { ) val dailyRate = 3_000_000L / 21 - assertThat(result.toLong()).isGreaterThan(dailyRate) + Assertions.assertThat(result.toLong()).isGreaterThan(dailyRate) } @Test @@ -151,7 +152,7 @@ class MemberEarningsServiceTest { ) val dailyRate = 3_000_000L / 21 - assertThat(result.toLong()).isLessThan(dailyRate) + Assertions.assertThat(result.toLong()).isLessThan(dailyRate) } @Test @@ -168,7 +169,7 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(9, 0), ) - assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test @@ -186,7 +187,7 @@ class MemberEarningsServiceTest { val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isEqualByComparingTo(BigDecimal("300000")) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal("300000")) } @Test @@ -195,7 +196,7 @@ class MemberEarningsServiceTest { val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isEqualByComparingTo(BigDecimal("3000000")) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal("3000000")) } @Test @@ -208,7 +209,29 @@ class MemberEarningsServiceTest { val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + } + + @Test + fun `calculateStandardMinutes는 정책의 일일 근무시간과 월 근무일 수를 곱한다`() { + val result = sut.calculateStandardMinutes( + policy = createPolicy(), + start = LocalDate.of(2025, 6, 1), + endInclusive = LocalDate.of(2025, 6, 30), + ) + + // 2025-06 주5일 근무일 21일 * 540분 + Assertions.assertThat(result).isEqualTo(11_340) + } + + @Test + fun `calculateWorkedMinutes는 실제 출퇴근 시각을 분 단위로 환산한다`() { + val result = sut.calculateWorkedMinutes( + clockInTime = LocalTime.of(22, 0), + clockOutTime = LocalTime.of(2, 0), + ) + + Assertions.assertThat(result).isEqualTo(240) } @Test @@ -225,6 +248,6 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - assertThat(result.toLong()).isEqualTo(142857L) + Assertions.assertThat(result.toLong()).isEqualTo(142857L) } } diff --git a/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt index 49c79b2..1372a10 100644 --- a/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt +++ b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt @@ -208,4 +208,32 @@ class CompensationCalculatorTest { assertThat(result).isEqualByComparingTo(BigDecimal("88889")) } + + @Test + fun `getWorkDaysInPeriod - 시작일 포함 종료일 제외 기준으로 근무일 수를 계산한다`() { + val result = compensationCalculator.getWorkDaysInPeriod( + start = LocalDate.of(2025, 6, 1), + end = LocalDate.of(2025, 7, 1), + workDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ), + ) + + assertThat(result).isEqualTo(21) + } + + @Test + fun `getWorkDaysInPeriod - 종료일이 근무일이어도 제외한다`() { + val result = compensationCalculator.getWorkDaysInPeriod( + start = LocalDate.of(2025, 6, 2), + end = LocalDate.of(2025, 6, 9), + workDays = setOf(DayOfWeek.MONDAY), + ) + + assertThat(result).isEqualTo(1) + } } From 5d61cde02434f3cecfe99f6dac80463b48d6ed43 Mon Sep 17 00:00:00 2001 From: jeyong Date: Wed, 18 Mar 2026 03:48:12 +0900 Subject: [PATCH 4/4] Refactor test cases in MemberEarningsServiceTest and CompensationCalculatorTest for improved clarity and consistency --- .../moa/service/MemberEarningsServiceTest.kt | 50 +++++------ .../calculator/CompensationCalculatorTest.kt | 84 ++++--------------- 2 files changed, 38 insertions(+), 96 deletions(-) diff --git a/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt b/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt index feb45f4..b8bc8ab 100644 --- a/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt +++ b/src/test/kotlin/com/moa/service/MemberEarningsServiceTest.kt @@ -6,7 +6,7 @@ import com.moa.service.calculator.CompensationCalculator import io.mockk.every import io.mockk.junit5.MockKExtension import io.mockk.mockk -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.math.BigDecimal @@ -52,7 +52,7 @@ class MemberEarningsServiceTest { } @Test - fun `VACATION이면 저장된 시간 기반으로 급여를 계산한다`() { + fun `휴무이면 저장된 시간 기반으로 급여를 계산한다`() { stubPayroll() val policy = createPolicy() @@ -65,11 +65,11 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(18, 0), ) - Assertions.assertThat(result.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } @Test - fun `NONE이면 ZERO를 반환한다`() { + fun `근무 일정이 없으면 ZERO를 반환한다`() { val result = sut.calculateDailyEarnings( memberId = MEMBER_ID, date = DATE, @@ -79,11 +79,11 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - Assertions.assertThat(result).isEqualTo(BigDecimal.ZERO) + assertThat(result).isEqualTo(BigDecimal.ZERO) } @Test - fun `PayrollVersion이 없으면 0원을 반환한다`() { + fun `급여 정보가 없으면 0원을 반환한다`() { every { payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( MEMBER_ID, LAST_DAY_OF_MONTH, @@ -99,7 +99,7 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test @@ -116,7 +116,7 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(18, 0), ) - Assertions.assertThat(result.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } @Test @@ -134,7 +134,7 @@ class MemberEarningsServiceTest { ) val dailyRate = 3_000_000L / 21 - Assertions.assertThat(result.toLong()).isGreaterThan(dailyRate) + assertThat(result.toLong()).isGreaterThan(dailyRate) } @Test @@ -152,7 +152,7 @@ class MemberEarningsServiceTest { ) val dailyRate = 3_000_000L / 21 - Assertions.assertThat(result.toLong()).isLessThan(dailyRate) + assertThat(result.toLong()).isLessThan(dailyRate) } @Test @@ -169,11 +169,11 @@ class MemberEarningsServiceTest { clockOutTime = LocalTime.of(9, 0), ) - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test - fun `ANNUAL 3,600,000이면 월급 300,000을 반환한다`() { + fun `연봉 3,600,000이면 기준 월급 300,000을 반환한다`() { every { payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( MEMBER_ID, LAST_DAY_OF_MONTH, @@ -187,20 +187,20 @@ class MemberEarningsServiceTest { val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal("300000")) + assertThat(result).isEqualByComparingTo(BigDecimal("300000")) } @Test - fun `MONTHLY 3,000,000이면 그대로 3,000,000을 반환한다`() { + fun `월급 3,000,000이면 기준 월급으로 그대로 3,000,000을 반환한다`() { stubPayroll(3_000_000) val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal("3000000")) + assertThat(result).isEqualByComparingTo(BigDecimal("3000000")) } @Test - fun `PayrollVersion이 없으면 calculateStandardSalary는 0을 반환한다`() { + fun `급여 정보가 없으면 기준 월급으로 0을 반환한다`() { every { payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( MEMBER_ID, LAST_DAY_OF_MONTH, @@ -209,11 +209,11 @@ class MemberEarningsServiceTest { val result = sut.calculateStandardSalary(MEMBER_ID, DATE) - Assertions.assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) + assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } @Test - fun `calculateStandardMinutes는 정책의 일일 근무시간과 월 근무일 수를 곱한다`() { + fun `기준 근무 시간은 정책의 일일 근무시간과 월 근무일 수를 곱해 계산한다`() { val result = sut.calculateStandardMinutes( policy = createPolicy(), start = LocalDate.of(2025, 6, 1), @@ -221,17 +221,7 @@ class MemberEarningsServiceTest { ) // 2025-06 주5일 근무일 21일 * 540분 - Assertions.assertThat(result).isEqualTo(11_340) - } - - @Test - fun `calculateWorkedMinutes는 실제 출퇴근 시각을 분 단위로 환산한다`() { - val result = sut.calculateWorkedMinutes( - clockInTime = LocalTime.of(22, 0), - clockOutTime = LocalTime.of(2, 0), - ) - - Assertions.assertThat(result).isEqualTo(240) + assertThat(result).isEqualTo(11_340) } @Test @@ -248,6 +238,6 @@ class MemberEarningsServiceTest { clockOutTime = null, ) - Assertions.assertThat(result.toLong()).isEqualTo(142857L) + assertThat(result.toLong()).isEqualTo(142857L) } } diff --git a/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt index 1372a10..e022c46 100644 --- a/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt +++ b/src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt @@ -13,25 +13,7 @@ class CompensationCalculatorTest { private val compensationCalculator = CompensationCalculator() @Test - fun `calculateDailyRate - 월급 300만원과 주5일 근무면 6월 기준 일급을 계산한다`() { - val result = compensationCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 6, 1), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 3_000_000, - workDays = setOf( - DayOfWeek.MONDAY, - DayOfWeek.TUESDAY, - DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, - DayOfWeek.FRIDAY, - ), - ) - - assertThat(result).isEqualByComparingTo(BigDecimal("142857")) - } - - @Test - fun `calculateDailyRate - 연봉은 월급으로 환산 후 일급을 계산한다`() { + fun `연봉은 월급으로 환산한 뒤 일급을 계산한다`() { val result = compensationCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 6, 1), salaryType = SalaryInputType.ANNUAL, @@ -49,7 +31,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateDailyRate - 근무일이 없으면 0을 반환한다`() { + fun `근무일이 없으면 일급으로 0을 반환한다`() { val result = compensationCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 6, 1), salaryType = SalaryInputType.MONTHLY, @@ -61,7 +43,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateDailyRate - 주3일 근무면 해당 근무일 수로 나눈다`() { + fun `주3일 근무면 해당 근무일 수로 나누어 일급을 계산한다`() { val result = compensationCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 6, 1), salaryType = SalaryInputType.MONTHLY, @@ -77,19 +59,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateDailyRate - 단일 근무일만 있어도 계산한다`() { - val result = compensationCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 6, 1), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 1_000_000, - workDays = setOf(DayOfWeek.MONDAY), - ) - - assertThat(result).isEqualByComparingTo(BigDecimal("200000")) - } - - @Test - fun `calculateDailyRate - 소수점은 반올림한다`() { + fun `일급 계산 결과의 소수점은 반올림한다`() { val result = compensationCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 6, 1), salaryType = SalaryInputType.MONTHLY, @@ -105,25 +75,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateDailyRate - Workday enum 기준 주5일도 같은 결과를 낸다`() { - val result = compensationCalculator.calculateDailyRate( - targetDate = LocalDate.of(2025, 6, 1), - salaryType = SalaryInputType.MONTHLY, - salaryAmount = 3_000_000, - workDays = setOf( - Workday.MON.dayOfWeek, - Workday.TUE.dayOfWeek, - Workday.WED.dayOfWeek, - Workday.THU.dayOfWeek, - Workday.FRI.dayOfWeek, - ), - ) - - assertThat(result).isEqualByComparingTo(BigDecimal("142857")) - } - - @Test - fun `calculateDailyRate - 월마다 근무일 수가 다르면 결과도 달라진다`() { + fun `월마다 근무일 수가 다르면 일급 계산 결과도 달라진다`() { val febResult = compensationCalculator.calculateDailyRate( targetDate = LocalDate.of(2025, 2, 1), salaryType = SalaryInputType.MONTHLY, @@ -153,7 +105,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateWorkMinutes - 9시에서 18시는 540분을 반환한다`() { + fun `9시에서 18시까지는 540분을 반환한다`() { val result = compensationCalculator.calculateWorkMinutes( clockIn = LocalTime.of(9, 0), clockOut = LocalTime.of(18, 0), @@ -163,7 +115,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateWorkMinutes - 자정넘김 22시에서 2시는 240분을 반환한다`() { + fun `자정을 넘겨 22시에서 2시까지 근무하면 240분을 반환한다`() { val result = compensationCalculator.calculateWorkMinutes( clockIn = LocalTime.of(22, 0), clockOut = LocalTime.of(2, 0), @@ -173,7 +125,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateWorkMinutes - 시작시간과 종료시간이 같으면 0분을 반환한다`() { + fun `시작시간과 종료시간이 같으면 0분을 반환한다`() { val result = compensationCalculator.calculateWorkMinutes( clockIn = LocalTime.of(9, 0), clockOut = LocalTime.of(9, 0), @@ -183,7 +135,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateEarnings - 실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { + fun `실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { val dailyRate = BigDecimal("100000") val result = compensationCalculator.calculateEarnings(dailyRate, 540, 540) @@ -192,7 +144,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateEarnings - 초과 근무시 분급 기준으로 증가된 금액을 반환한다`() { + fun `초과 근무시 분급 기준으로 증가한 금액을 반환한다`() { val dailyRate = BigDecimal("100000") val result = compensationCalculator.calculateEarnings(dailyRate, 540, 600) @@ -201,7 +153,7 @@ class CompensationCalculatorTest { } @Test - fun `calculateEarnings - 조기 퇴근시 분급 기준으로 감소된 금액을 반환한다`() { + fun `조기 퇴근시 분급 기준으로 감소한 금액을 반환한다`() { val dailyRate = BigDecimal("100000") val result = compensationCalculator.calculateEarnings(dailyRate, 540, 480) @@ -210,16 +162,16 @@ class CompensationCalculatorTest { } @Test - fun `getWorkDaysInPeriod - 시작일 포함 종료일 제외 기준으로 근무일 수를 계산한다`() { + fun `시작일 포함 종료일 제외 기준으로 근무일 수를 계산한다`() { val result = compensationCalculator.getWorkDaysInPeriod( start = LocalDate.of(2025, 6, 1), end = LocalDate.of(2025, 7, 1), workDays = setOf( - DayOfWeek.MONDAY, - DayOfWeek.TUESDAY, - DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, - DayOfWeek.FRIDAY, + Workday.MON.dayOfWeek, + Workday.TUE.dayOfWeek, + Workday.WED.dayOfWeek, + Workday.THU.dayOfWeek, + Workday.FRI.dayOfWeek, ), ) @@ -227,7 +179,7 @@ class CompensationCalculatorTest { } @Test - fun `getWorkDaysInPeriod - 종료일이 근무일이어도 제외한다`() { + fun `종료일이 근무일이어도 근무일 수 계산에서는 제외한다`() { val result = compensationCalculator.getWorkDaysInPeriod( start = LocalDate.of(2025, 6, 2), end = LocalDate.of(2025, 6, 9),