diff --git a/build.gradle.kts b/build.gradle.kts index 8bd75f8..8126654 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation("tools.jackson.module:jackson-module-kotlin") // test + testImplementation("io.mockk:mockk:1.13.12") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") diff --git a/src/main/kotlin/com/moa/controller/NotificationSettingController.kt b/src/main/kotlin/com/moa/controller/NotificationSettingController.kt index d02204a..c558126 100644 --- a/src/main/kotlin/com/moa/controller/NotificationSettingController.kt +++ b/src/main/kotlin/com/moa/controller/NotificationSettingController.kt @@ -3,8 +3,8 @@ package com.moa.controller import com.moa.common.auth.Auth import com.moa.common.auth.AuthMemberInfo import com.moa.common.response.ApiResponse -import com.moa.service.NotificationSettingService import com.moa.service.dto.NotificationSettingUpdateRequest +import com.moa.service.notification.NotificationSettingService import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.* diff --git a/src/main/kotlin/com/moa/controller/WorkdayController.kt b/src/main/kotlin/com/moa/controller/WorkdayController.kt index f13cd53..5e95ee4 100644 --- a/src/main/kotlin/com/moa/controller/WorkdayController.kt +++ b/src/main/kotlin/com/moa/controller/WorkdayController.kt @@ -24,6 +24,13 @@ class WorkdayController( @PathVariable date: LocalDate, ) = ApiResponse.success(workdayService.getSchedule(member.id, date)) + @GetMapping("/earnings") + fun getMonthlyEarnings( + @Auth member: AuthMemberInfo, + @RequestParam year: Int, + @RequestParam month: Int, + ) = ApiResponse.success(workdayService.getMonthlyEarnings(member.id, year, month)) + @GetMapping fun getMonthlySchedules( @Auth member: AuthMemberInfo, diff --git a/src/main/kotlin/com/moa/entity/NotificationType.kt b/src/main/kotlin/com/moa/entity/NotificationType.kt index 8b5595f..5141a73 100644 --- a/src/main/kotlin/com/moa/entity/NotificationType.kt +++ b/src/main/kotlin/com/moa/entity/NotificationType.kt @@ -4,8 +4,8 @@ enum class NotificationType( val title: String, val body: String ) { - CLOCK_IN("출근 시간이에요!", "지금부터 급여가 쌓일 예정이에요."), - CLOCK_OUT("퇴근 시간이에요!", "오늘 ₩%s 벌었어요."); + CLOCK_IN("출근 했어요!", "지금부터 급여가 쌓일 예정이에요."), + CLOCK_OUT("퇴근 했어요!", "오늘 ₩%s 벌었어요."); fun getBody(vararg args: Any): String { return String.format(body, *args) diff --git a/src/main/kotlin/com/moa/entity/SalaryType.kt b/src/main/kotlin/com/moa/entity/SalaryType.kt index 8a44e8f..2b67b50 100644 --- a/src/main/kotlin/com/moa/entity/SalaryType.kt +++ b/src/main/kotlin/com/moa/entity/SalaryType.kt @@ -3,7 +3,9 @@ package com.moa.entity import java.math.BigDecimal import java.math.RoundingMode import java.time.DayOfWeek +import java.time.Duration import java.time.LocalDate +import java.time.LocalTime import java.time.YearMonth enum class SalaryType { @@ -42,6 +44,21 @@ object SalaryCalculator { return monthlySalary.divide(BigDecimal(workDaysCount), 0, RoundingMode.HALF_UP) } + fun calculateWorkMinutes(clockIn: LocalTime, clockOut: LocalTime): Long { + val minutes = Duration.between(clockIn, clockOut).toMinutes() + return if (minutes <= 0) minutes + 24 * 60 else minutes + } + + fun calculateEarnings( + dailyRate: BigDecimal, + policyWorkMinutes: Long, + actualWorkMinutes: Long, + ): BigDecimal { + if (policyWorkMinutes <= 0) return dailyRate + val minuteRate = dailyRate.divide(BigDecimal(policyWorkMinutes), 10, RoundingMode.HALF_UP) + return minuteRate.multiply(BigDecimal(actualWorkMinutes)).setScale(0, RoundingMode.HALF_UP) + } + private fun getWorkDaysInPeriod( start: LocalDate, end: LocalDate, diff --git a/src/main/kotlin/com/moa/repository/WorkPolicyVersionRepository.kt b/src/main/kotlin/com/moa/repository/WorkPolicyVersionRepository.kt index a3eddad..7fc54e4 100644 --- a/src/main/kotlin/com/moa/repository/WorkPolicyVersionRepository.kt +++ b/src/main/kotlin/com/moa/repository/WorkPolicyVersionRepository.kt @@ -15,12 +15,12 @@ interface WorkPolicyVersionRepository : JpaRepository { @Query( """ - SELECT DISTINCT w FROM WorkPolicyVersion w - JOIN FETCH w.workdays - WHERE w.effectiveFrom = ( - SELECT MAX(w2.effectiveFrom) - FROM WorkPolicyVersion w2 - WHERE w2.memberId = w.memberId AND w2.effectiveFrom <= :date + select distinct w from WorkPolicyVersion w + join fetch w.workdays + where w.effectiveFrom = ( + select max(w2.effectiveFrom) + from WorkPolicyVersion w2 + where w2.memberId = w.memberId and w2.effectiveFrom <= :date ) """ ) diff --git a/src/main/kotlin/com/moa/service/EarningsCalculator.kt b/src/main/kotlin/com/moa/service/EarningsCalculator.kt new file mode 100644 index 0000000..caf08d1 --- /dev/null +++ b/src/main/kotlin/com/moa/service/EarningsCalculator.kt @@ -0,0 +1,70 @@ +package com.moa.service + +import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.SalaryCalculator +import com.moa.entity.SalaryType +import com.moa.entity.WorkPolicyVersion +import com.moa.repository.PayrollVersionRepository +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.LocalTime +import java.time.YearMonth + +@Service +class EarningsCalculator( + private val payrollVersionRepository: PayrollVersionRepository, +) { + fun getDefaultMonthlySalary(memberId: Long, date: LocalDate): Int? { + val lastDayOfMonth = YearMonth.from(date).atEndOfMonth() + val payroll = payrollVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, lastDayOfMonth, + ) ?: return null + + return when (SalaryType.from(payroll.salaryInputType)) { + SalaryType.YEARLY -> payroll.salaryAmount.toBigDecimal() + .divide(BigDecimal(12), 0, RoundingMode.HALF_UP).toInt() + SalaryType.MONTHLY -> payroll.salaryAmount.toInt() + } + } + + fun calculateDailyEarnings( + memberId: Long, + date: LocalDate, + policy: WorkPolicyVersion, + type: DailyWorkScheduleType, + clockInTime: LocalTime?, + clockOutTime: LocalTime?, + ): BigDecimal? { + if (type == DailyWorkScheduleType.NONE) return BigDecimal.ZERO + + val lastDayOfMonth = YearMonth.from(date).atEndOfMonth() + val payroll = payrollVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, lastDayOfMonth, + ) ?: return null + + val dailyRate = SalaryCalculator.calculateDailyRate( + targetDate = date, + salaryType = SalaryType.from(payroll.salaryInputType), + salaryAmount = payroll.salaryAmount, + workDays = policy.workdays.map { it.dayOfWeek }.toSet(), + ) + if (dailyRate == BigDecimal.ZERO) return dailyRate + + // 유급 휴가는 기본 일급 반환 + if (type == DailyWorkScheduleType.VACATION) return dailyRate + + if (clockInTime != null && clockOutTime != null) { + val policyMinutes = SalaryCalculator.calculateWorkMinutes( + policy.clockInTime, policy.clockOutTime, + ) + val actualMinutes = SalaryCalculator.calculateWorkMinutes(clockInTime, clockOutTime) + return SalaryCalculator.calculateEarnings(dailyRate, policyMinutes, actualMinutes) + } + + return dailyRate + } +} diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 6f2f063..f6ad9ed 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -5,22 +5,24 @@ import com.moa.common.exception.ErrorCode import com.moa.common.exception.NotFoundException import com.moa.entity.DailyWorkSchedule import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.SalaryCalculator import com.moa.entity.WorkPolicyVersion import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.dto.MonthlyWorkdayResponse -import com.moa.service.dto.WorkdayEditRequest -import com.moa.service.dto.WorkdayResponse -import com.moa.service.dto.WorkdayUpsertRequest +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 @Service class WorkdayService( private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, private val workPolicyVersionRepository: WorkPolicyVersionRepository, private val notificationSyncService: NotificationSyncService, + private val earningsCalculator: EarningsCalculator, ) { @Transactional(readOnly = true) @@ -28,44 +30,10 @@ class WorkdayService( memberId: Long, date: LocalDate, ): WorkdayResponse { - // 1. 저장된 스케줄이 있으면 최우선 - val savedSchedule = - dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) - - if (savedSchedule != null) { - return WorkdayResponse( - date = savedSchedule.date, - type = savedSchedule.type, - clockInTime = savedSchedule.clockInTime, - clockOutTime = savedSchedule.clockOutTime, - ) - } - - // 2. 월 대표 정책 기준으로 판단 - val monthlyPolicy = - resolveMonthlyRepresentativePolicy(memberId, date.year, date.monthValue) - - // 4. 요일 기준 WORK / NONE - val isWorkday = - monthlyPolicy.workdays.any { - it.dayOfWeek == date.dayOfWeek - } - - return if (isWorkday) { - WorkdayResponse( - date = date, - type = DailyWorkScheduleType.WORK, - clockInTime = monthlyPolicy.clockInTime, - clockOutTime = monthlyPolicy.clockOutTime, - ) - } else { - WorkdayResponse( - date = date, - type = DailyWorkScheduleType.NONE, - clockInTime = null, - clockOutTime = null, - ) - } + val saved = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) + val policy = resolveMonthlyRepresentativePolicy(memberId, date.year, date.monthValue) + val schedule = resolveScheduleForDate(saved, policy, date) + return createWorkdayResponse(memberId, date, schedule) } @Transactional(readOnly = true) @@ -89,24 +57,8 @@ class WorkdayService( return generateSequence(start) { it.plusDays(1) } .takeWhile { !it.isAfter(end) } .map { date -> - savedSchedulesByDate[date]?.let { - MonthlyWorkdayResponse( - date = date, - type = it.type, - ) - } ?: run { - val type = - if (monthlyPolicy.workdays.any { it.dayOfWeek == date.dayOfWeek }) { - DailyWorkScheduleType.WORK - } else { - DailyWorkScheduleType.NONE - } - - MonthlyWorkdayResponse( - date = date, - type = type, - ) - } + val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) + MonthlyWorkdayResponse(date = date, type = schedule.type) } .toList() } @@ -145,12 +97,8 @@ class WorkdayService( memberId, date, savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime, ) - return WorkdayResponse( - date = savedSchedule.date, - type = savedSchedule.type, - clockInTime = savedSchedule.clockInTime, - clockOutTime = savedSchedule.clockOutTime, - ) + val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) + return createWorkdayResponse(memberId, date, schedule) } @Transactional @@ -188,11 +136,104 @@ class WorkdayService( memberId, date, DailyWorkScheduleType.WORK, savedSchedule.clockInTime, savedSchedule.clockOutTime, ) + val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) + return createWorkdayResponse(memberId, date, schedule) + } + + @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 today = LocalDate.now() + val defaultSalary = earningsCalculator.getDefaultMonthlySalary(memberId, start) ?: 0 + + val monthlyPolicy = resolveMonthlyRepresentativePolicy(memberId, year, month) + + val policyDailyMinutes = SalaryCalculator.calculateWorkMinutes( + monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, + ) + val workDaysInMonth = generateSequence(start) { it.plusDays(1) } + .takeWhile { !it.isAfter(end) } + .count { d -> monthlyPolicy.workdays.any { it.dayOfWeek == d.dayOfWeek } } + val standardMinutes = policyDailyMinutes * workDaysInMonth + + if (start.isAfter(today)) { + return MonthlyEarningsResponse(0, defaultSalary, 0, standardMinutes) + } + + val lastCalculableDate = minOf(end, today) + + val savedSchedulesByDate = dailyWorkScheduleRepository + .findAllByMemberIdAndDateBetween(memberId, start, lastCalculableDate) + .associateBy { it.date } + + var totalEarnings = BigDecimal.ZERO + var workedMinutes = 0L + + var date = start + while (!date.isAfter(lastCalculableDate)) { + val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) + + if (schedule.type == DailyWorkScheduleType.WORK && schedule.clockIn != null && schedule.clockOut != null) { + workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, schedule.clockOut) + } else if (schedule.type == DailyWorkScheduleType.VACATION) { + workedMinutes += policyDailyMinutes + } + + val dailyEarnings = earningsCalculator.calculateDailyEarnings( + memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, schedule.clockOut, + ) + totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) + + date = date.plusDays(1) + } + + return MonthlyEarningsResponse( + totalEarnings = totalEarnings.toInt(), + defaultSalary = defaultSalary, + workedMinutes = workedMinutes, + standardMinutes = standardMinutes, + ) + } + + private fun resolveScheduleForDate( + saved: DailyWorkSchedule?, + policy: WorkPolicyVersion, + date: LocalDate, + ): ResolvedSchedule { + if (saved != null) { + return ResolvedSchedule(saved.type, saved.clockInTime, saved.clockOutTime) + } + val isWorkday = policy.workdays.any { it.dayOfWeek == date.dayOfWeek } + return if (isWorkday) { + ResolvedSchedule(DailyWorkScheduleType.WORK, policy.clockInTime, policy.clockOutTime) + } else { + ResolvedSchedule(DailyWorkScheduleType.NONE, null, null) + } + } + + private fun createWorkdayResponse( + memberId: Long, + date: LocalDate, + schedule: ResolvedSchedule, + ): WorkdayResponse { + if (schedule.type == DailyWorkScheduleType.NONE) { + return WorkdayResponse( + date = date, + type = DailyWorkScheduleType.NONE, + dailyPay = 0, + ) + } + val policy = resolveMonthlyRepresentativePolicy(memberId, date.year, date.monthValue) + val earnings = earningsCalculator.calculateDailyEarnings( + memberId, date, policy, schedule.type, schedule.clockIn, schedule.clockOut, + ) return WorkdayResponse( - date = savedSchedule.date, - type = savedSchedule.type, - clockInTime = savedSchedule.clockInTime, - clockOutTime = savedSchedule.clockOutTime, + date = date, + type = schedule.type, + dailyPay = earnings?.toInt() ?: 0, + clockInTime = schedule.clockIn, + clockOutTime = schedule.clockOut, ) } @@ -214,3 +255,9 @@ class WorkdayService( ?: throw IllegalStateException("해당 월의 마지막 날에 적용 가능한 근무 정책이 존재하지 않습니다. memberId=$memberId, year=$year, month=$month") } } + +private data class ResolvedSchedule( + val type: DailyWorkScheduleType, + val clockIn: LocalTime?, + val clockOut: LocalTime?, +) diff --git a/src/main/kotlin/com/moa/service/dto/MonthlyEarningsResponse.kt b/src/main/kotlin/com/moa/service/dto/MonthlyEarningsResponse.kt new file mode 100644 index 0000000..7d96f6e --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/MonthlyEarningsResponse.kt @@ -0,0 +1,8 @@ +package com.moa.service.dto + +data class MonthlyEarningsResponse( + val totalEarnings: Int, + val defaultSalary: Int, + val workedMinutes: Long, + val standardMinutes: Long, +) diff --git a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt index 37f32bc..bc9e436 100644 --- a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt @@ -8,6 +8,7 @@ import java.time.LocalTime data class WorkdayResponse( val date: LocalDate, val type: DailyWorkScheduleType, + val dailyPay: Int, @JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime? = null, @JsonFormat(pattern = "HH:mm") diff --git a/src/main/kotlin/com/moa/service/NotificationBatchScheduler.kt b/src/main/kotlin/com/moa/service/notification/NotificationBatchScheduler.kt similarity index 93% rename from src/main/kotlin/com/moa/service/NotificationBatchScheduler.kt rename to src/main/kotlin/com/moa/service/notification/NotificationBatchScheduler.kt index 2d0909d..7efe628 100644 --- a/src/main/kotlin/com/moa/service/NotificationBatchScheduler.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationBatchScheduler.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.notification import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/moa/service/NotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt similarity index 99% rename from src/main/kotlin/com/moa/service/NotificationBatchService.kt rename to src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt index dc1540f..2c71c0f 100644 --- a/src/main/kotlin/com/moa/service/NotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.notification import com.moa.entity.* import com.moa.repository.* diff --git a/src/main/kotlin/com/moa/service/NotificationDispatchScheduler.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchScheduler.kt similarity index 94% rename from src/main/kotlin/com/moa/service/NotificationDispatchScheduler.kt rename to src/main/kotlin/com/moa/service/notification/NotificationDispatchScheduler.kt index 9841a0b..f8680cf 100644 --- a/src/main/kotlin/com/moa/service/NotificationDispatchScheduler.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchScheduler.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.notification import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/moa/service/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt similarity index 94% rename from src/main/kotlin/com/moa/service/NotificationDispatchService.kt rename to src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index 82d8ac3..ca57e1e 100644 --- a/src/main/kotlin/com/moa/service/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -1,7 +1,9 @@ -package com.moa.service +package com.moa.service.notification import com.moa.entity.NotificationStatus import com.moa.repository.NotificationLogRepository +import com.moa.service.FcmService +import com.moa.service.FcmTokenService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/moa/service/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt similarity index 57% rename from src/main/kotlin/com/moa/service/NotificationMessageBuilder.kt rename to src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index b869a3e..a5b89ce 100644 --- a/src/main/kotlin/com/moa/service/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -1,24 +1,26 @@ -package com.moa.service +package com.moa.service.notification +import com.moa.entity.DailyWorkScheduleType import com.moa.entity.NotificationLog import com.moa.entity.NotificationType -import com.moa.entity.SalaryCalculator -import com.moa.entity.SalaryType -import com.moa.repository.PayrollVersionRepository +import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository +import com.moa.service.EarningsCalculator import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat import java.time.LocalDate +import java.time.YearMonth import java.util.* @Service class NotificationMessageBuilder( private val profileRepository: ProfileRepository, - private val payrollVersionRepository: PayrollVersionRepository, private val workPolicyVersionRepository: WorkPolicyVersionRepository, + private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, + private val earningsCalculator: EarningsCalculator, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -32,27 +34,29 @@ class NotificationMessageBuilder( } private fun buildClockOutBody(notification: NotificationLog): String { - val dailyRate = calculateDailyRate(notification.memberId, notification.scheduledDate) - if (dailyRate == null || dailyRate == BigDecimal.ZERO) { + val earnings = calculateTodayEarnings(notification.memberId, notification.scheduledDate) + if (earnings == null || earnings == BigDecimal.ZERO) { return CLOCK_OUT_FALLBACK_BODY } - val formatted = NumberFormat.getNumberInstance(Locale.KOREA).format(dailyRate) + val formatted = NumberFormat.getNumberInstance(Locale.KOREA).format(earnings) return notification.notificationType.getBody(formatted) } - private fun calculateDailyRate(memberId: Long, date: LocalDate): BigDecimal? { - val payroll = payrollVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, date) - ?: return null + private fun calculateTodayEarnings(memberId: Long, date: LocalDate): BigDecimal? { + val lastDayOfMonth = YearMonth.from(date).atEndOfMonth() val policy = workPolicyVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, date) - ?: return null + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, lastDayOfMonth, + ) ?: return null - return SalaryCalculator.calculateDailyRate( - targetDate = date, - salaryType = SalaryType.from(payroll.salaryInputType), - salaryAmount = payroll.salaryAmount, - workDays = policy.workdays.map { it.dayOfWeek }.toSet(), + val override = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) + return earningsCalculator.calculateDailyEarnings( + memberId = memberId, + date = date, + policy = policy, + type = override?.type ?: DailyWorkScheduleType.WORK, + clockInTime = override?.clockInTime, + clockOutTime = override?.clockOutTime, ) } diff --git a/src/main/kotlin/com/moa/service/NotificationSettingService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt similarity index 98% rename from src/main/kotlin/com/moa/service/NotificationSettingService.kt rename to src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt index acce1b7..1f496e9 100644 --- a/src/main/kotlin/com/moa/service/NotificationSettingService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSettingService.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.notification import com.moa.entity.NotificationSetting import com.moa.entity.NotificationSettingType diff --git a/src/main/kotlin/com/moa/service/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt similarity index 99% rename from src/main/kotlin/com/moa/service/NotificationSyncService.kt rename to src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 8ad5f53..56a19aa 100644 --- a/src/main/kotlin/com/moa/service/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -1,4 +1,4 @@ -package com.moa.service +package com.moa.service.notification import com.moa.entity.DailyWorkScheduleType import com.moa.entity.NotificationLog @@ -49,6 +49,7 @@ class NotificationSyncService( NotificationType.CLOCK_IN -> { clockInTime?.let { pendingLog.scheduledTime = truncateToMinute(it) } } + NotificationType.CLOCK_OUT -> { clockOutTime?.let { val truncated = truncateToMinute(it) diff --git a/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt b/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt index 2a9089e..c3cbb6d 100644 --- a/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt +++ b/src/test/kotlin/com/moa/entity/SalaryCalculatorTest.kt @@ -6,6 +6,7 @@ import java.math.BigDecimal import java.math.RoundingMode import java.time.DayOfWeek import java.time.LocalDate +import java.time.LocalTime class SalaryCalculatorTest { @@ -156,6 +157,51 @@ class SalaryCalculatorTest { assertThat(result).isEqualByComparingTo(BigDecimal.ZERO) } + // --- 근무 시간(분) 계산 --- + + @Test + fun `calculateWorkMinutes - 9시에서 18시는 540분을 반환한다`() { + val result = SalaryCalculator.calculateWorkMinutes( + LocalTime.of(9, 0), + LocalTime.of(18, 0), + ) + assertThat(result).isEqualTo(540L) + } + + @Test + fun `calculateWorkMinutes - 자정넘김 22시에서 2시는 240분을 반환한다`() { + val result = SalaryCalculator.calculateWorkMinutes( + LocalTime.of(22, 0), + LocalTime.of(2, 0), + ) + assertThat(result).isEqualTo(240L) + } + + // --- 실제 수입 계산 --- + + @Test + fun `calculateEarnings - 실제 근무시간이 정책과 같으면 일급과 동일한 금액을 반환한다`() { + val dailyRate = BigDecimal(150_000) + val result = SalaryCalculator.calculateEarnings(dailyRate, 540, 540) + 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) + } + + @Test + fun `calculateEarnings - 조기 퇴근시 분급 기준으로 감소된 금액을 반환한다`() { + val dailyRate = BigDecimal(150_000) + // 540분 정책, 480분 실제 (1시간 조기 퇴근) + val result = SalaryCalculator.calculateEarnings(dailyRate, 540, 480) + assertThat(result).isLessThan(dailyRate) + } + @Test fun `같은 월급이라도 월마다 근무일수에 따라 일급이 달라진다`() { val salary = 3_000_000L diff --git a/src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt b/src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt new file mode 100644 index 0000000..e6eb4f4 --- /dev/null +++ b/src/test/kotlin/com/moa/service/EarningsCalculatorTest.kt @@ -0,0 +1,221 @@ +package com.moa.service + +import com.moa.entity.* +import com.moa.repository.PayrollVersionRepository +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalTime + +@ExtendWith(MockKExtension::class) +class EarningsCalculatorTest { + + private val payrollVersionRepository: PayrollVersionRepository = mockk() + private val sut = EarningsCalculator(payrollVersionRepository) + + companion object { + private const val MEMBER_ID = 1L + private val DATE = LocalDate.of(2025, 6, 9) // Monday + private val LAST_DAY_OF_MONTH = LocalDate.of(2025, 6, 30) + } + + private fun createPolicy( + clockIn: LocalTime = LocalTime.of(9, 0), + clockOut: LocalTime = LocalTime.of(18, 0), + workdays: MutableSet = mutableSetOf(Workday.MON, Workday.TUE, Workday.WED, Workday.THU, Workday.FRI), + ) = WorkPolicyVersion( + memberId = MEMBER_ID, + effectiveFrom = DATE.minusDays(30), + clockInTime = clockIn, + clockOutTime = clockOut, + workdays = workdays, + ) + + private fun stubPayroll(salaryAmount: Long = 3_000_000) { + every { + payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + MEMBER_ID, LAST_DAY_OF_MONTH, + ) + } returns PayrollVersion( + memberId = MEMBER_ID, + effectiveFrom = DATE.minusDays(30), + salaryInputType = SalaryInputType.MONTHLY, + salaryAmount = salaryAmount, + ) + } + + @Test + fun `VACATION이면 유급 휴가로 기본 일급을 반환한다`() { + stubPayroll() + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = createPolicy(), + type = DailyWorkScheduleType.VACATION, + clockInTime = null, + clockOutTime = null, + ) + + // 3,000,000 / 21 workdays in June 2025 = 142,857 + assertThat(result).isNotNull + assertThat(result!!.toLong()).isEqualTo(142857L) + } + + @Test + fun `NONE이면 ZERO를 반환한다`() { + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = createPolicy(), + type = DailyWorkScheduleType.NONE, + clockInTime = null, + clockOutTime = null, + ) + + assertThat(result).isEqualTo(BigDecimal.ZERO) + } + + @Test + fun `PayrollVersion이 없으면 null을 반환한다`() { + every { + payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + MEMBER_ID, LAST_DAY_OF_MONTH, + ) + } returns null + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = createPolicy(), + type = DailyWorkScheduleType.WORK, + clockInTime = null, + clockOutTime = null, + ) + + assertThat(result).isNull() + } + + @Test + fun `정상 근무시 일급을 반환한다`() { + stubPayroll() + val policy = createPolicy() + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = policy, + type = DailyWorkScheduleType.WORK, + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(18, 0), + ) + + // 3,000,000 / 21 workdays in June 2025 = 142,857 + assertThat(result).isNotNull + assertThat(result!!.toLong()).isEqualTo(142857L) + } + + @Test + fun `초과 근무시 일급보다 높은 금액을 반환한다`() { + stubPayroll() + val policy = createPolicy() + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = policy, + type = DailyWorkScheduleType.WORK, + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(19, 0), // 1시간 초과 + ) + + val dailyRate = 3_000_000L / 21 + assertThat(result).isNotNull + assertThat(result!!.toLong()).isGreaterThan(dailyRate) + } + + @Test + fun `조기 퇴근시 일급보다 낮은 금액을 반환한다`() { + stubPayroll() + val policy = createPolicy() + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = policy, + type = DailyWorkScheduleType.WORK, + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(17, 0), // 1시간 조기 퇴근 + ) + + val dailyRate = 3_000_000L / 21 + assertThat(result).isNotNull + assertThat(result!!.toLong()).isLessThan(dailyRate) + } + + // --- getDefaultMonthlySalary --- + + @Test + fun `ANNUAL 3,600,000이면 월급 300,000을 반환한다`() { + every { + payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + MEMBER_ID, LAST_DAY_OF_MONTH, + ) + } returns PayrollVersion( + memberId = MEMBER_ID, + effectiveFrom = DATE.minusDays(30), + salaryInputType = SalaryInputType.ANNUAL, + salaryAmount = 3_600_000, + ) + + val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + + assertThat(result).isEqualTo(300_000) + } + + @Test + fun `MONTHLY 3,000,000이면 그대로 3,000,000을 반환한다`() { + stubPayroll(3_000_000) + + val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + + assertThat(result).isEqualTo(3_000_000) + } + + @Test + fun `PayrollVersion이 없으면 getDefaultMonthlySalary는 null을 반환한다`() { + every { + payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + MEMBER_ID, LAST_DAY_OF_MONTH, + ) + } returns null + + val result = sut.getDefaultMonthlySalary(MEMBER_ID, DATE) + + assertThat(result).isNull() + } + + @Test + fun `출퇴근 시간이 null이면 기본 일급을 반환한다`() { + stubPayroll() + val policy = createPolicy() + + val result = sut.calculateDailyEarnings( + memberId = MEMBER_ID, + date = DATE, + policy = policy, + type = DailyWorkScheduleType.WORK, + clockInTime = null, + clockOutTime = null, + ) + + // 3,000,000 / 21 = 142,857 + assertThat(result).isNotNull + assertThat(result!!.toLong()).isEqualTo(142857L) + } +}