From bb79e1246f7675d9a1b04803891d1d56d2a7938f Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 05:07:25 +0900 Subject: [PATCH 01/10] Add CalendarController and refactor WorkdayService for calendar retrieval --- .../com/moa/controller/WorkdayController.kt | 17 +- .../controller/screen/CalendarController.kt | 26 +++ .../controller/{ => screen}/HomeController.kt | 2 +- .../{ => screen}/OnboardingController.kt | 2 +- .../kotlin/com/moa/service/WorkdayService.kt | 191 ++++++++++-------- .../com/moa/service/dto/CalendarResponse.kt | 6 + 6 files changed, 154 insertions(+), 90 deletions(-) create mode 100644 src/main/kotlin/com/moa/controller/screen/CalendarController.kt rename src/main/kotlin/com/moa/controller/{ => screen}/HomeController.kt (97%) rename src/main/kotlin/com/moa/controller/{ => screen}/OnboardingController.kt (98%) create mode 100644 src/main/kotlin/com/moa/service/dto/CalendarResponse.kt diff --git a/src/main/kotlin/com/moa/controller/WorkdayController.kt b/src/main/kotlin/com/moa/controller/WorkdayController.kt index 5e95ee4..ab9d7d1 100644 --- a/src/main/kotlin/com/moa/controller/WorkdayController.kt +++ b/src/main/kotlin/com/moa/controller/WorkdayController.kt @@ -18,12 +18,7 @@ class WorkdayController( private val workdayService: WorkdayService, ) { - @GetMapping("/{date}") - fun getSchedule( - @Auth member: AuthMemberInfo, - @PathVariable date: LocalDate, - ) = ApiResponse.success(workdayService.getSchedule(member.id, date)) - + @Deprecated("Use GET /api/v1/calendar instead") @GetMapping("/earnings") fun getMonthlyEarnings( @Auth member: AuthMemberInfo, @@ -31,6 +26,7 @@ class WorkdayController( @RequestParam month: Int, ) = ApiResponse.success(workdayService.getMonthlyEarnings(member.id, year, month)) + @Deprecated("Use GET /api/v1/calendar instead") @GetMapping fun getMonthlySchedules( @Auth member: AuthMemberInfo, @@ -39,7 +35,14 @@ class WorkdayController( ) = ApiResponse.success( workdayService.getMonthlySchedules(member.id, year, month) ) - + + @Deprecated("Use GET /api/v1/calendar instead") + @GetMapping("/{date}") + fun getSchedule( + @Auth member: AuthMemberInfo, + @PathVariable date: LocalDate, + ) = ApiResponse.success(workdayService.getSchedule(member.id, date)) + @PutMapping("/{date}") fun upsertSchedule( @Auth member: AuthMemberInfo, diff --git a/src/main/kotlin/com/moa/controller/screen/CalendarController.kt b/src/main/kotlin/com/moa/controller/screen/CalendarController.kt new file mode 100644 index 0000000..f3dbf7a --- /dev/null +++ b/src/main/kotlin/com/moa/controller/screen/CalendarController.kt @@ -0,0 +1,26 @@ +package com.moa.controller.screen + +import com.moa.common.auth.Auth +import com.moa.common.auth.AuthMemberInfo +import com.moa.common.response.ApiResponse +import com.moa.service.WorkdayService +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Calendar", description = "캘린더 화면 API") +@RestController +@RequestMapping("/api/v1/calendar") +class CalendarController( + private val workdayService: WorkdayService, +) { + + @GetMapping + fun getCalendar( + @Auth member: AuthMemberInfo, + @RequestParam year: Int, + @RequestParam month: Int, + ) = ApiResponse.success(workdayService.getCalendar(member.id, year, month)) +} diff --git a/src/main/kotlin/com/moa/controller/HomeController.kt b/src/main/kotlin/com/moa/controller/screen/HomeController.kt similarity index 97% rename from src/main/kotlin/com/moa/controller/HomeController.kt rename to src/main/kotlin/com/moa/controller/screen/HomeController.kt index f8aca84..9c0efdc 100644 --- a/src/main/kotlin/com/moa/controller/HomeController.kt +++ b/src/main/kotlin/com/moa/controller/screen/HomeController.kt @@ -1,4 +1,4 @@ -package com.moa.controller +package com.moa.controller.screen import com.moa.common.auth.Auth import com.moa.common.auth.AuthMemberInfo diff --git a/src/main/kotlin/com/moa/controller/OnboardingController.kt b/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt similarity index 98% rename from src/main/kotlin/com/moa/controller/OnboardingController.kt rename to src/main/kotlin/com/moa/controller/screen/OnboardingController.kt index 301ce0f..bb4969a 100644 --- a/src/main/kotlin/com/moa/controller/OnboardingController.kt +++ b/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt @@ -1,4 +1,4 @@ -package com.moa.controller +package com.moa.controller.screen import com.moa.common.auth.AuthMemberInfo import com.moa.common.auth.OnboardingAuth diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 0508541..e99a00f 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -26,19 +26,101 @@ class WorkdayService( ) { @Transactional(readOnly = true) - fun getSchedule( - memberId: Long, - date: LocalDate, - ): 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) + fun getCalendar(memberId: Long, year: Int, month: Int): CalendarResponse { + val start = LocalDate.of(year, month, 1) + val end = start.withDayOfMonth(start.lengthOfMonth()) + + val savedSchedulesByDate = + dailyWorkScheduleRepository + .findAllByMemberIdAndDateBetween(memberId, start, end) + .associateBy { it.date } + + val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) + + return CalendarResponse( + earnings = getMonthlyEarnings(memberId, year, month), + schedules = 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) + } + createWorkdayResponse(memberId, date, schedule) + } + .toList(), + ) + } + + @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 = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) + if (monthlyPolicy == null) { + return MonthlyEarningsResponse( + workedEarnings = 0, + standardSalary = defaultSalary, + workedMinutes = 0, + standardMinutes = 0, + ) + } + + val policyDailyMinutes = SalaryCalculator.calculateWorkMinutes( + monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, + ) + + val policyWorkDayOfWeeks = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() + val workDaysInMonth = SalaryCalculator.getWorkDaysInPeriod( + start = start, + end = end.plusDays(1), + workDays = policyWorkDayOfWeeks + ) + + val standardMinutes = policyDailyMinutes * workDaysInMonth + + if (start.isAfter(today)) { + return MonthlyEarningsResponse(0, defaultSalary, 0, standardMinutes) + } + + val lastCalculableDate = minOf(end, today.minusDays(1)) + + 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.type == DailyWorkScheduleType.VACATION) + && schedule.clockIn != null && schedule.clockOut != null + ) { + workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, schedule.clockOut) } - return createWorkdayResponse(memberId, date, schedule) + + 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( + workedEarnings = totalEarnings.toLong(), + standardSalary = defaultSalary, + workedMinutes = workedMinutes, + standardMinutes = standardMinutes, + ) } @Transactional(readOnly = true) @@ -72,6 +154,22 @@ class WorkdayService( .toList() } + @Transactional(readOnly = true) + fun getSchedule( + memberId: Long, + date: LocalDate, + ): 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) + } + return createWorkdayResponse(memberId, date, schedule) + } + @Transactional fun upsertSchedule(memberId: Long, date: LocalDate, req: WorkdayUpsertRequest): WorkdayResponse { val (clockIn, clockOut) = when (req.type) { @@ -158,75 +256,6 @@ class WorkdayService( 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 = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) - if (monthlyPolicy == null) { - return MonthlyEarningsResponse( - workedEarnings = 0, - standardSalary = defaultSalary, - workedMinutes = 0, - standardMinutes = 0, - ) - } - - val policyDailyMinutes = SalaryCalculator.calculateWorkMinutes( - monthlyPolicy.clockInTime, monthlyPolicy.clockOutTime, - ) - - val policyWorkDayOfWeeks = monthlyPolicy.workdays.map { it.dayOfWeek }.toSet() - val workDaysInMonth = SalaryCalculator.getWorkDaysInPeriod( - start = start, - end = end.plusDays(1), - workDays = policyWorkDayOfWeeks - ) - - val standardMinutes = policyDailyMinutes * workDaysInMonth - - if (start.isAfter(today)) { - return MonthlyEarningsResponse(0, defaultSalary, 0, standardMinutes) - } - - val lastCalculableDate = minOf(end, today.minusDays(1)) - - 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.type == DailyWorkScheduleType.VACATION) - && 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, - ) - totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) - - date = date.plusDays(1) - } - - return MonthlyEarningsResponse( - workedEarnings = totalEarnings.toLong(), - standardSalary = defaultSalary, - workedMinutes = workedMinutes, - standardMinutes = standardMinutes, - ) - } - private fun resolveScheduleForDate( saved: DailyWorkSchedule?, policy: WorkPolicyVersion, diff --git a/src/main/kotlin/com/moa/service/dto/CalendarResponse.kt b/src/main/kotlin/com/moa/service/dto/CalendarResponse.kt new file mode 100644 index 0000000..6902a1b --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/CalendarResponse.kt @@ -0,0 +1,6 @@ +package com.moa.service.dto + +data class CalendarResponse( + val earnings: MonthlyEarningsResponse, + val schedules: List, +) From dfe39fc65652a3081cf3e79d46c152621623a212 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 05:16:47 +0900 Subject: [PATCH 02/10] Add status resolution in WorkdayService --- .../moa/controller/screen/HomeController.kt | 1 + .../com/moa/entity/DailWorkStatusType.kt | 8 +++++ .../kotlin/com/moa/service/WorkdayService.kt | 32 ++++++++++++++++--- .../com/moa/service/dto/HomeResponse.kt | 2 ++ .../com/moa/service/dto/WorkdayResponse.kt | 2 ++ 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/moa/entity/DailWorkStatusType.kt diff --git a/src/main/kotlin/com/moa/controller/screen/HomeController.kt b/src/main/kotlin/com/moa/controller/screen/HomeController.kt index 9c0efdc..fe61848 100644 --- a/src/main/kotlin/com/moa/controller/screen/HomeController.kt +++ b/src/main/kotlin/com/moa/controller/screen/HomeController.kt @@ -32,6 +32,7 @@ class HomeController( standardSalary = earnings.standardSalary, dailyPay = schedule.dailyPay, type = schedule.type, + status = schedule.status, clockInTime = schedule.clockInTime, clockOutTime = schedule.clockOutTime, ) diff --git a/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt b/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt new file mode 100644 index 0000000..cac7054 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt @@ -0,0 +1,8 @@ +package com.moa.entity + +enum class DailWorkStatusType { + NONE, + SCHEDULED, + IN_PROGRESS, + COMPLETED, +} diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index e99a00f..7c603d8 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -3,10 +3,7 @@ package com.moa.service import com.moa.common.exception.BadRequestException import com.moa.common.exception.ErrorCode import com.moa.common.exception.NotFoundException -import com.moa.entity.DailyWorkSchedule -import com.moa.entity.DailyWorkScheduleType -import com.moa.entity.SalaryCalculator -import com.moa.entity.WorkPolicyVersion +import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.WorkPolicyVersionRepository import com.moa.service.dto.* @@ -15,6 +12,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime @Service @@ -281,6 +279,7 @@ class WorkdayService( return WorkdayResponse( date = date, type = DailyWorkScheduleType.NONE, + status = DailWorkStatusType.NONE, dailyPay = 0, ) } @@ -288,6 +287,7 @@ class WorkdayService( ?: return WorkdayResponse( date = date, type = schedule.type, + status = resolveDailWorkStatus(date, schedule), dailyPay = 0, clockInTime = schedule.clockIn, clockOutTime = schedule.clockOut, @@ -298,12 +298,36 @@ class WorkdayService( return WorkdayResponse( date = date, type = schedule.type, + status = resolveDailWorkStatus(date, schedule), dailyPay = earnings?.toInt() ?: 0, clockInTime = schedule.clockIn, clockOutTime = schedule.clockOut, ) } + private fun resolveDailWorkStatus( + date: LocalDate, + schedule: ResolvedSchedule, + ): DailWorkStatusType { + if (schedule.type == DailyWorkScheduleType.NONE) return DailWorkStatusType.NONE + + val clockIn = schedule.clockIn ?: return DailWorkStatusType.NONE + val clockOut = schedule.clockOut ?: return DailWorkStatusType.NONE + val now = LocalDateTime.now() + val startAt = date.atTime(clockIn) + val endAt = if (clockOut.isAfter(clockIn)) { + date.atTime(clockOut) + } else { + date.plusDays(1).atTime(clockOut) + } + + return when { + now.isBefore(startAt) -> DailWorkStatusType.SCHEDULED + now.isBefore(endAt) -> DailWorkStatusType.IN_PROGRESS + else -> DailWorkStatusType.COMPLETED + } + } + private fun resolveMonthlyRepresentativePolicyOrNull( memberId: Long, year: Int, diff --git a/src/main/kotlin/com/moa/service/dto/HomeResponse.kt b/src/main/kotlin/com/moa/service/dto/HomeResponse.kt index 9a21f81..f6dfbc7 100644 --- a/src/main/kotlin/com/moa/service/dto/HomeResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/HomeResponse.kt @@ -1,6 +1,7 @@ package com.moa.service.dto import com.fasterxml.jackson.annotation.JsonFormat +import com.moa.entity.DailWorkStatusType import com.moa.entity.DailyWorkScheduleType import java.time.LocalTime @@ -10,6 +11,7 @@ data class HomeResponse( val standardSalary: Long, val dailyPay: Int, val type: DailyWorkScheduleType, + val status: DailWorkStatusType, @field:JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime?, @field:JsonFormat(pattern = "HH:mm") diff --git a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt index 0eba965..857aab2 100644 --- a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt @@ -1,6 +1,7 @@ package com.moa.service.dto import com.fasterxml.jackson.annotation.JsonFormat +import com.moa.entity.DailWorkStatusType import com.moa.entity.DailyWorkScheduleType import java.time.LocalDate import java.time.LocalTime @@ -8,6 +9,7 @@ import java.time.LocalTime data class WorkdayResponse( val date: LocalDate, val type: DailyWorkScheduleType, + val status: DailWorkStatusType, val dailyPay: Int, @field:JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime? = null, From a0ba02ff37500ff3fce7cba71adad7525bb715ae Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 05:25:32 +0900 Subject: [PATCH 03/10] Add calendar event resolution for payday in WorkdayService --- .../com/moa/entity/CalendarEventType.kt | 6 +++ .../kotlin/com/moa/service/WorkdayService.kt | 37 +++++++++++++++++++ .../com/moa/service/dto/WorkdayResponse.kt | 2 + .../PaydayNotificationBatchService.kt | 21 +++++++---- 4 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/moa/entity/CalendarEventType.kt diff --git a/src/main/kotlin/com/moa/entity/CalendarEventType.kt b/src/main/kotlin/com/moa/entity/CalendarEventType.kt new file mode 100644 index 0000000..c8c8a68 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/CalendarEventType.kt @@ -0,0 +1,6 @@ +package com.moa.entity + +enum class CalendarEventType { + PAYDAY, + HOLIDAY, +} diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 7c603d8..5d5b871 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -5,12 +5,14 @@ import com.moa.common.exception.ErrorCode import com.moa.common.exception.NotFoundException import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository +import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository 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.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -19,6 +21,7 @@ import java.time.LocalTime class WorkdayService( private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, private val workPolicyVersionRepository: WorkPolicyVersionRepository, + private val profileRepository: ProfileRepository, private val notificationSyncService: NotificationSyncService, private val earningsCalculator: EarningsCalculator, ) { @@ -275,11 +278,14 @@ class WorkdayService( date: LocalDate, schedule: ResolvedSchedule, ): WorkdayResponse { + val events = resolveCalendarEvents(memberId, date) + if (schedule.type == DailyWorkScheduleType.NONE) { return WorkdayResponse( date = date, type = DailyWorkScheduleType.NONE, status = DailWorkStatusType.NONE, + events = events, dailyPay = 0, ) } @@ -288,6 +294,7 @@ class WorkdayService( date = date, type = schedule.type, status = resolveDailWorkStatus(date, schedule), + events = events, dailyPay = 0, clockInTime = schedule.clockIn, clockOutTime = schedule.clockOut, @@ -299,12 +306,42 @@ class WorkdayService( date = date, type = schedule.type, status = resolveDailWorkStatus(date, schedule), + events = events, dailyPay = earnings?.toInt() ?: 0, clockInTime = schedule.clockIn, clockOutTime = schedule.clockOut, ) } + private fun resolveCalendarEvents(memberId: Long, date: LocalDate): List { + val events = mutableListOf() + val paydayDay = profileRepository.findByMemberId(memberId)?.paydayDay + + if (paydayDay != null && isPayday(date, paydayDay)) { + events += CalendarEventType.PAYDAY + } + + // TODO: Add holiday event resolution when holiday data is available. + + return events + } + + private fun isPayday(date: LocalDate, paydayDay: Int): Boolean { + return resolveEffectivePayday(date.year, date.monthValue, paydayDay) == date + } + + // 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. + private fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { + val baseDate = LocalDate.of(year, month, 1) + .withDayOfMonth(minOf(paydayDay, LocalDate.of(year, month, 1).lengthOfMonth())) + + return when (baseDate.dayOfWeek) { + DayOfWeek.SATURDAY -> baseDate.minusDays(1) + DayOfWeek.SUNDAY -> baseDate.minusDays(2) + else -> baseDate + } + } + private fun resolveDailWorkStatus( date: LocalDate, schedule: ResolvedSchedule, diff --git a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt index 857aab2..8071ba2 100644 --- a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt @@ -1,6 +1,7 @@ package com.moa.service.dto import com.fasterxml.jackson.annotation.JsonFormat +import com.moa.entity.CalendarEventType import com.moa.entity.DailWorkStatusType import com.moa.entity.DailyWorkScheduleType import java.time.LocalDate @@ -10,6 +11,7 @@ data class WorkdayResponse( val date: LocalDate, val type: DailyWorkScheduleType, val status: DailWorkStatusType, + val events: List = emptyList(), val dailyPay: Int, @field:JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime? = null, diff --git a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt index 34eee13..f953e92 100644 --- a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt @@ -5,6 +5,7 @@ import com.moa.repository.* import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalTime import java.time.YearMonth @@ -49,14 +50,20 @@ class PaydayNotificationBatchService( } private fun findPaydayProfiles(date: LocalDate): List { - val dayOfMonth = date.dayOfMonth - val lastDayOfMonth = YearMonth.from(date).atEndOfMonth().dayOfMonth - val paydayDays = if (dayOfMonth == lastDayOfMonth) { - (dayOfMonth..31).toList() - } else { - listOf(dayOfMonth) + return profileRepository.findAllByPaydayDayIn((1..31).toList()) + .filter { resolveEffectivePayday(date.year, date.monthValue, it.paydayDay) == date } + } + + // 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. + private fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { + val yearMonth = YearMonth.of(year, month) + val baseDate = yearMonth.atDay(minOf(paydayDay, yearMonth.lengthOfMonth())) + + return when (baseDate.dayOfWeek) { + DayOfWeek.SATURDAY -> baseDate.minusDays(1) + DayOfWeek.SUNDAY -> baseDate.minusDays(2) + else -> baseDate } - return profileRepository.findAllByPaydayDayIn(paydayDays) } private fun findRequiredTermCodes(): Set = From 86b937529abdbf3ea90588bac624d1ac56b28db4 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 05:34:29 +0900 Subject: [PATCH 04/10] Refactor earnings calculation to adjust clock-out time based on current time --- .../kotlin/com/moa/service/WorkdayService.kt | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 5d5b871..9c22a48 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -89,7 +89,7 @@ class WorkdayService( return MonthlyEarningsResponse(0, defaultSalary, 0, standardMinutes) } - val lastCalculableDate = minOf(end, today.minusDays(1)) + val lastCalculableDate = minOf(end, today) val savedSchedulesByDate = dailyWorkScheduleRepository .findAllByMemberIdAndDateBetween(memberId, start, lastCalculableDate) @@ -97,19 +97,21 @@ 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) + val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) if ((schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) - && schedule.clockIn != null && schedule.clockOut != null + && schedule.clockIn != null && adjustedClockOut != null ) { - workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, schedule.clockOut) + workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, adjustedClockOut) } val dailyEarnings = earningsCalculator.calculateDailyEarnings( - memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, schedule.clockOut, + memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, adjustedClockOut, ) totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) @@ -365,6 +367,31 @@ class WorkdayService( } } + 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.isAfter(clockIn) -> minOf(now, clockOut) + else -> now + } + } + private fun resolveMonthlyRepresentativePolicyOrNull( memberId: Long, year: Int, From 9fe5819f32be498a672b7af8921eb130ada38e08 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 05:51:47 +0900 Subject: [PATCH 05/10] Refactor controller tag descriptions for clarity --- src/main/kotlin/com/moa/controller/AuthController.kt | 8 ++------ src/main/kotlin/com/moa/controller/FcmTokenController.kt | 2 +- .../com/moa/controller/NotificationSettingController.kt | 2 +- src/main/kotlin/com/moa/controller/ProfileController.kt | 4 ++-- src/main/kotlin/com/moa/controller/WorkdayController.kt | 2 +- .../com/moa/controller/screen/OnboardingController.kt | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/moa/controller/AuthController.kt b/src/main/kotlin/com/moa/controller/AuthController.kt index ea5b630..5670914 100644 --- a/src/main/kotlin/com/moa/controller/AuthController.kt +++ b/src/main/kotlin/com/moa/controller/AuthController.kt @@ -4,11 +4,7 @@ import com.moa.common.auth.Auth import com.moa.common.auth.AuthMemberInfo import com.moa.common.response.ApiResponse import com.moa.service.AuthService -import com.moa.service.dto.AppleSignInUpRequest -import com.moa.service.dto.AppleSignInUpResponse -import com.moa.service.dto.KaKaoSignInUpRequest -import com.moa.service.dto.KakaoSignInUpResponse -import com.moa.service.dto.LogoutRequest +import com.moa.service.dto.* import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.ResponseEntity @@ -16,7 +12,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController -@Tag(name = "Auth", description = "인증 API (카카오/애플 소셜 로그인)") +@Tag(name = "Auth", description = "인증 API") @RestController class AuthController( private val authService: AuthService, diff --git a/src/main/kotlin/com/moa/controller/FcmTokenController.kt b/src/main/kotlin/com/moa/controller/FcmTokenController.kt index eca563c..ac0fb23 100644 --- a/src/main/kotlin/com/moa/controller/FcmTokenController.kt +++ b/src/main/kotlin/com/moa/controller/FcmTokenController.kt @@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.* -@Tag(name = "fcm", description = "FCM TOKEN 갱신 및 삭제") +@Tag(name = "fcm", description = "Fcm Token 갱신 및 삭제") @RestController @RequestMapping("/api/v1/fcm/token") class FcmTokenController( diff --git a/src/main/kotlin/com/moa/controller/NotificationSettingController.kt b/src/main/kotlin/com/moa/controller/NotificationSettingController.kt index c558126..35acc16 100644 --- a/src/main/kotlin/com/moa/controller/NotificationSettingController.kt +++ b/src/main/kotlin/com/moa/controller/NotificationSettingController.kt @@ -8,7 +8,7 @@ import com.moa.service.notification.NotificationSettingService import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.* -@Tag(name = "NotificationSetting", description = "알림 설정 API (근무/급여일/프로모션 알림)") +@Tag(name = "NotificationSetting", description = "알림 설정 API") @RestController @RequestMapping("/api/v1/settings/notification") class NotificationSettingController( diff --git a/src/main/kotlin/com/moa/controller/ProfileController.kt b/src/main/kotlin/com/moa/controller/ProfileController.kt index 1eb28bf..80dda13 100644 --- a/src/main/kotlin/com/moa/controller/ProfileController.kt +++ b/src/main/kotlin/com/moa/controller/ProfileController.kt @@ -5,13 +5,13 @@ import com.moa.common.auth.AuthMemberInfo import com.moa.common.response.ApiResponse import com.moa.service.ProfileService import com.moa.service.dto.NicknameUpdateRequest -import com.moa.service.dto.WorkplaceUpdateRequest import com.moa.service.dto.PaydayUpdateRequest +import com.moa.service.dto.WorkplaceUpdateRequest import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.* -@Tag(name = "Profile", description = "프로필 API (닉네임, 근무지, 급여일 등)") +@Tag(name = "Profile", description = "프로필 API") @RestController @RequestMapping("/api/v1/profile") class ProfileController( diff --git a/src/main/kotlin/com/moa/controller/WorkdayController.kt b/src/main/kotlin/com/moa/controller/WorkdayController.kt index ab9d7d1..65840b6 100644 --- a/src/main/kotlin/com/moa/controller/WorkdayController.kt +++ b/src/main/kotlin/com/moa/controller/WorkdayController.kt @@ -11,7 +11,7 @@ import jakarta.validation.Valid import org.springframework.web.bind.annotation.* import java.time.LocalDate -@Tag(name = "Workday", description = "근무일/스케줄 API (출퇴근 일정 조회·등록·수정)") +@Tag(name = "Workday", description = "근무일/스케줄 API") @RestController @RequestMapping("/api/v1/workdays") class WorkdayController( diff --git a/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt b/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt index bb4969a..5d73d03 100644 --- a/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt +++ b/src/main/kotlin/com/moa/controller/screen/OnboardingController.kt @@ -12,7 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.* -@Tag(name = "Onboarding", description = "온보딩 API (최초 가입 시 프로필/급여/근무정책/약관 설정)") +@Tag(name = "Onboarding", description = "온보딩 화면 API") @RestController @RequestMapping("/api/v1/onboarding") class OnboardingController( From 7f789204b2ccf23db0ad1c128722715073e54d25 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 14:52:05 +0900 Subject: [PATCH 06/10] Refactor CalendarController and WorkdayService for monthly calendar retrieval --- .../controller/screen/CalendarController.kt | 8 ++++- .../kotlin/com/moa/service/WorkdayService.kt | 35 ++++++++++--------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/com/moa/controller/screen/CalendarController.kt b/src/main/kotlin/com/moa/controller/screen/CalendarController.kt index f3dbf7a..fd696a1 100644 --- a/src/main/kotlin/com/moa/controller/screen/CalendarController.kt +++ b/src/main/kotlin/com/moa/controller/screen/CalendarController.kt @@ -4,6 +4,7 @@ import com.moa.common.auth.Auth import com.moa.common.auth.AuthMemberInfo import com.moa.common.response.ApiResponse import com.moa.service.WorkdayService +import com.moa.service.dto.CalendarResponse import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -22,5 +23,10 @@ class CalendarController( @Auth member: AuthMemberInfo, @RequestParam year: Int, @RequestParam month: Int, - ) = ApiResponse.success(workdayService.getCalendar(member.id, year, month)) + ) = ApiResponse.success( + CalendarResponse( + earnings = workdayService.getMonthlyEarnings(member.id, year, month), + schedules = workdayService.getMonthlyWorkdays(member.id, year, month), + ) + ) } diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 9c22a48..5d74ad8 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -7,7 +7,11 @@ import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.dto.* +import com.moa.service.dto.MonthlyEarningsResponse +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.notification.NotificationSyncService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -27,7 +31,7 @@ class WorkdayService( ) { @Transactional(readOnly = true) - fun getCalendar(memberId: Long, year: Int, month: Int): CalendarResponse { + fun getMonthlyWorkdays(memberId: Long, year: Int, month: Int): List { val start = LocalDate.of(year, month, 1) val end = start.withDayOfMonth(start.lengthOfMonth()) @@ -38,21 +42,18 @@ class WorkdayService( val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) - return CalendarResponse( - earnings = getMonthlyEarnings(memberId, year, month), - schedules = 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) - } - createWorkdayResponse(memberId, date, schedule) - } - .toList(), - ) + 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) + } + createWorkdayResponse(memberId, date, schedule) + } + .toList() } @Transactional(readOnly = true) From 372cde67d3a39426be37ebeed282a68767133c70 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 15:41:03 +0900 Subject: [PATCH 07/10] Refactor DailWorkStatusType and WorkdayService for status resolution simplification --- .../com/moa/entity/DailWorkStatusType.kt | 1 - .../kotlin/com/moa/service/WorkdayService.kt | 49 ++++++++----------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt b/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt index cac7054..5a6c6fa 100644 --- a/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt +++ b/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt @@ -3,6 +3,5 @@ package com.moa.entity enum class DailWorkStatusType { NONE, SCHEDULED, - IN_PROGRESS, COMPLETED, } diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 5d74ad8..1fca188 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -7,11 +7,7 @@ import com.moa.entity.* import com.moa.repository.DailyWorkScheduleRepository import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.dto.MonthlyEarningsResponse -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 @@ -103,9 +99,11 @@ class WorkdayService( var date = start while (!date.isAfter(lastCalculableDate)) { val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) + val status = resolveDailWorkStatus(date, schedule) val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) - if ((schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) + if (status == DailWorkStatusType.COMPLETED && + (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) && schedule.clockIn != null && adjustedClockOut != null ) { workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, adjustedClockOut) @@ -316,6 +314,22 @@ class WorkdayService( ) } + private fun resolveMonthlyRepresentativePolicyOrNull( + memberId: Long, + year: Int, + month: Int, + ): WorkPolicyVersion? { + val lastDayOfMonth = + LocalDate.of(year, month, 1) + .withDayOfMonth(LocalDate.of(year, month, 1).lengthOfMonth()) + + return workPolicyVersionRepository + .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, + lastDayOfMonth, + ) + } + private fun resolveCalendarEvents(memberId: Long, date: LocalDate): List { val events = mutableListOf() val paydayDay = profileRepository.findByMemberId(memberId)?.paydayDay @@ -354,18 +368,13 @@ class WorkdayService( val clockIn = schedule.clockIn ?: return DailWorkStatusType.NONE val clockOut = schedule.clockOut ?: return DailWorkStatusType.NONE val now = LocalDateTime.now() - val startAt = date.atTime(clockIn) val endAt = if (clockOut.isAfter(clockIn)) { date.atTime(clockOut) } else { date.plusDays(1).atTime(clockOut) } - return when { - now.isBefore(startAt) -> DailWorkStatusType.SCHEDULED - now.isBefore(endAt) -> DailWorkStatusType.IN_PROGRESS - else -> DailWorkStatusType.COMPLETED - } + return if (now.isBefore(endAt)) DailWorkStatusType.SCHEDULED else DailWorkStatusType.COMPLETED } private fun resolveClockOutForEarnings( @@ -392,22 +401,6 @@ class WorkdayService( else -> now } } - - private fun resolveMonthlyRepresentativePolicyOrNull( - memberId: Long, - year: Int, - month: Int, - ): WorkPolicyVersion? { - val lastDayOfMonth = - LocalDate.of(year, month, 1) - .withDayOfMonth(LocalDate.of(year, month, 1).lengthOfMonth()) - - return workPolicyVersionRepository - .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, - lastDayOfMonth, - ) - } } private data class ResolvedSchedule( From f1fedb67130ef399b8a6c3e3a04989763c3d982e Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 15:53:04 +0900 Subject: [PATCH 08/10] Refactor WorkdayService to improve earnings calculation logic for completed work --- .../kotlin/com/moa/service/WorkdayService.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 1fca188..f1b101f 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -101,18 +101,19 @@ class WorkdayService( val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) val status = resolveDailWorkStatus(date, schedule) val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) - - if (status == DailWorkStatusType.COMPLETED && + val isCompletedWork = status == DailWorkStatusType.COMPLETED && (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) - && schedule.clockIn != null && adjustedClockOut != null - ) { + + if (isCompletedWork && schedule.clockIn != null && adjustedClockOut != null) { workedMinutes += SalaryCalculator.calculateWorkMinutes(schedule.clockIn, adjustedClockOut) } - val dailyEarnings = earningsCalculator.calculateDailyEarnings( - memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, adjustedClockOut, - ) - totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) + if (isCompletedWork) { + val dailyEarnings = earningsCalculator.calculateDailyEarnings( + memberId, date, monthlyPolicy, schedule.type, schedule.clockIn, adjustedClockOut, + ) + totalEarnings = totalEarnings.add(dailyEarnings ?: BigDecimal.ZERO) + } date = date.plusDays(1) } From 4302786bfd5c624b2ed4fe91a07cb8abadcd6c6d Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 16:05:40 +0900 Subject: [PATCH 09/10] Refactor DailWorkStatusType and CalendarEventType to DailyWorkStatusType and DailyEventType for consistency --- ...CalendarEventType.kt => DailyEventType.kt} | 2 +- ...rkStatusType.kt => DailyWorkStatusType.kt} | 2 +- .../kotlin/com/moa/service/WorkdayService.kt | 24 +++++++++---------- .../com/moa/service/dto/HomeResponse.kt | 4 ++-- .../com/moa/service/dto/WorkdayResponse.kt | 8 +++---- 5 files changed, 20 insertions(+), 20 deletions(-) rename src/main/kotlin/com/moa/entity/{CalendarEventType.kt => DailyEventType.kt} (62%) rename src/main/kotlin/com/moa/entity/{DailWorkStatusType.kt => DailyWorkStatusType.kt} (66%) diff --git a/src/main/kotlin/com/moa/entity/CalendarEventType.kt b/src/main/kotlin/com/moa/entity/DailyEventType.kt similarity index 62% rename from src/main/kotlin/com/moa/entity/CalendarEventType.kt rename to src/main/kotlin/com/moa/entity/DailyEventType.kt index c8c8a68..897acdb 100644 --- a/src/main/kotlin/com/moa/entity/CalendarEventType.kt +++ b/src/main/kotlin/com/moa/entity/DailyEventType.kt @@ -1,6 +1,6 @@ package com.moa.entity -enum class CalendarEventType { +enum class DailyEventType { PAYDAY, HOLIDAY, } diff --git a/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt b/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt similarity index 66% rename from src/main/kotlin/com/moa/entity/DailWorkStatusType.kt rename to src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt index 5a6c6fa..ea20144 100644 --- a/src/main/kotlin/com/moa/entity/DailWorkStatusType.kt +++ b/src/main/kotlin/com/moa/entity/DailyWorkStatusType.kt @@ -1,6 +1,6 @@ package com.moa.entity -enum class DailWorkStatusType { +enum class DailyWorkStatusType { NONE, SCHEDULED, COMPLETED, diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index f1b101f..7e06091 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -101,8 +101,8 @@ class WorkdayService( val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) val status = resolveDailWorkStatus(date, schedule) val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) - val isCompletedWork = status == DailWorkStatusType.COMPLETED && - (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) + 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) @@ -280,13 +280,13 @@ class WorkdayService( date: LocalDate, schedule: ResolvedSchedule, ): WorkdayResponse { - val events = resolveCalendarEvents(memberId, date) + val events = resolveDailyEvents(memberId, date) if (schedule.type == DailyWorkScheduleType.NONE) { return WorkdayResponse( date = date, type = DailyWorkScheduleType.NONE, - status = DailWorkStatusType.NONE, + status = DailyWorkStatusType.NONE, events = events, dailyPay = 0, ) @@ -331,12 +331,12 @@ class WorkdayService( ) } - private fun resolveCalendarEvents(memberId: Long, date: LocalDate): List { - val events = mutableListOf() + private fun resolveDailyEvents(memberId: Long, date: LocalDate): List { + val events = mutableListOf() val paydayDay = profileRepository.findByMemberId(memberId)?.paydayDay if (paydayDay != null && isPayday(date, paydayDay)) { - events += CalendarEventType.PAYDAY + events += DailyEventType.PAYDAY } // TODO: Add holiday event resolution when holiday data is available. @@ -363,11 +363,11 @@ class WorkdayService( private fun resolveDailWorkStatus( date: LocalDate, schedule: ResolvedSchedule, - ): DailWorkStatusType { - if (schedule.type == DailyWorkScheduleType.NONE) return DailWorkStatusType.NONE + ): DailyWorkStatusType { + if (schedule.type == DailyWorkScheduleType.NONE) return DailyWorkStatusType.NONE - val clockIn = schedule.clockIn ?: return DailWorkStatusType.NONE - val clockOut = schedule.clockOut ?: return DailWorkStatusType.NONE + val clockIn = schedule.clockIn ?: return DailyWorkStatusType.NONE + val clockOut = schedule.clockOut ?: return DailyWorkStatusType.NONE val now = LocalDateTime.now() val endAt = if (clockOut.isAfter(clockIn)) { date.atTime(clockOut) @@ -375,7 +375,7 @@ class WorkdayService( date.plusDays(1).atTime(clockOut) } - return if (now.isBefore(endAt)) DailWorkStatusType.SCHEDULED else DailWorkStatusType.COMPLETED + return if (now.isBefore(endAt)) DailyWorkStatusType.SCHEDULED else DailyWorkStatusType.COMPLETED } private fun resolveClockOutForEarnings( diff --git a/src/main/kotlin/com/moa/service/dto/HomeResponse.kt b/src/main/kotlin/com/moa/service/dto/HomeResponse.kt index f6dfbc7..86a1ccc 100644 --- a/src/main/kotlin/com/moa/service/dto/HomeResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/HomeResponse.kt @@ -1,8 +1,8 @@ package com.moa.service.dto import com.fasterxml.jackson.annotation.JsonFormat -import com.moa.entity.DailWorkStatusType import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.DailyWorkStatusType import java.time.LocalTime data class HomeResponse( @@ -11,7 +11,7 @@ data class HomeResponse( val standardSalary: Long, val dailyPay: Int, val type: DailyWorkScheduleType, - val status: DailWorkStatusType, + val status: DailyWorkStatusType, @field:JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime?, @field:JsonFormat(pattern = "HH:mm") diff --git a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt index 8071ba2..1cda463 100644 --- a/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt +++ b/src/main/kotlin/com/moa/service/dto/WorkdayResponse.kt @@ -1,17 +1,17 @@ package com.moa.service.dto import com.fasterxml.jackson.annotation.JsonFormat -import com.moa.entity.CalendarEventType -import com.moa.entity.DailWorkStatusType +import com.moa.entity.DailyEventType import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.DailyWorkStatusType import java.time.LocalDate import java.time.LocalTime data class WorkdayResponse( val date: LocalDate, val type: DailyWorkScheduleType, - val status: DailWorkStatusType, - val events: List = emptyList(), + val status: DailyWorkStatusType, + val events: List = emptyList(), val dailyPay: Int, @field:JsonFormat(pattern = "HH:mm") val clockInTime: LocalTime? = null, From 80882fca27c308d303ee03a216b36f3b41902d63 Mon Sep 17 00:00:00 2001 From: jeyong Date: Sun, 15 Mar 2026 16:31:34 +0900 Subject: [PATCH 10/10] Refactor PaydayNotificationBatchService and WorkdayService for improved payday resolution and event handling --- .../kotlin/com/moa/service/PaydayResolver.kt | 17 +++++ .../kotlin/com/moa/service/WorkdayService.kt | 73 ++++++++++--------- .../PaydayNotificationBatchService.kt | 21 ++---- 3 files changed, 62 insertions(+), 49 deletions(-) create mode 100644 src/main/kotlin/com/moa/service/PaydayResolver.kt diff --git a/src/main/kotlin/com/moa/service/PaydayResolver.kt b/src/main/kotlin/com/moa/service/PaydayResolver.kt new file mode 100644 index 0000000..51bfb38 --- /dev/null +++ b/src/main/kotlin/com/moa/service/PaydayResolver.kt @@ -0,0 +1,17 @@ +package com.moa.service + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +// 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. +fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { + val yearMonth = YearMonth.of(year, month) + val baseDate = yearMonth.atDay(minOf(paydayDay, yearMonth.lengthOfMonth())) + + return when (baseDate.dayOfWeek) { + DayOfWeek.SATURDAY -> baseDate.minusDays(1) + DayOfWeek.SUNDAY -> baseDate.minusDays(2) + else -> baseDate + } +} diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 7e06091..395e7ff 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -12,7 +12,6 @@ import com.moa.service.notification.NotificationSyncService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal -import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -37,6 +36,7 @@ class WorkdayService( .associateBy { it.date } val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month) + val paydayDay = resolvePaydayDay(memberId) return generateSequence(start) { it.plusDays(1) } .takeWhile { !it.isAfter(end) } @@ -47,7 +47,7 @@ class WorkdayService( } else { resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) } - createWorkdayResponse(memberId, date, schedule) + createWorkdayResponse(memberId, date, schedule, monthlyPolicy, paydayDay) } .toList() } @@ -99,7 +99,7 @@ class WorkdayService( var date = start while (!date.isAfter(lastCalculableDate)) { val schedule = resolveScheduleForDate(savedSchedulesByDate[date], monthlyPolicy, date) - val status = resolveDailWorkStatus(date, schedule) + val status = resolveDailyWorkStatus(date, schedule) val adjustedClockOut = resolveClockOutForEarnings(date, today, now, schedule) val isCompletedWork = status == DailyWorkStatusType.COMPLETED && (schedule.type == DailyWorkScheduleType.WORK || schedule.type == DailyWorkScheduleType.VACATION) @@ -170,7 +170,7 @@ class WorkdayService( } else { resolveScheduleForDate(saved, policy, date) } - return createWorkdayResponse(memberId, date, schedule) + return createWorkdayResponse(memberId, date, schedule, policy, resolvePaydayDay(memberId)) } @Transactional @@ -223,7 +223,13 @@ class WorkdayService( ) val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) - return createWorkdayResponse(memberId, date, schedule) + return createWorkdayResponse( + memberId, + date, + schedule, + resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue), + resolvePaydayDay(memberId), + ) } @Transactional @@ -256,7 +262,13 @@ class WorkdayService( ) val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime) - return createWorkdayResponse(memberId, date, schedule) + return createWorkdayResponse( + memberId, + date, + schedule, + resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue), + resolvePaydayDay(memberId), + ) } private fun resolveScheduleForDate( @@ -279,8 +291,10 @@ class WorkdayService( memberId: Long, date: LocalDate, schedule: ResolvedSchedule, + policy: WorkPolicyVersion?, + paydayDay: Int, ): WorkdayResponse { - val events = resolveDailyEvents(memberId, date) + val events = resolveDailyEvents(date, paydayDay) if (schedule.type == DailyWorkScheduleType.NONE) { return WorkdayResponse( @@ -291,23 +305,22 @@ class WorkdayService( dailyPay = 0, ) } - val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue) - ?: return WorkdayResponse( - date = date, - type = schedule.type, - status = resolveDailWorkStatus(date, schedule), - events = events, - dailyPay = 0, - clockInTime = schedule.clockIn, - clockOutTime = schedule.clockOut, - ) + val resolvedPolicy = policy ?: return WorkdayResponse( + date = date, + type = schedule.type, + status = resolveDailyWorkStatus(date, schedule), + events = events, + dailyPay = 0, + clockInTime = schedule.clockIn, + clockOutTime = schedule.clockOut, + ) val earnings = earningsCalculator.calculateDailyEarnings( - memberId, date, policy, schedule.type, schedule.clockIn, schedule.clockOut, + memberId, date, resolvedPolicy, schedule.type, schedule.clockIn, schedule.clockOut, ) return WorkdayResponse( date = date, type = schedule.type, - status = resolveDailWorkStatus(date, schedule), + status = resolveDailyWorkStatus(date, schedule), events = events, dailyPay = earnings?.toInt() ?: 0, clockInTime = schedule.clockIn, @@ -331,11 +344,13 @@ class WorkdayService( ) } - private fun resolveDailyEvents(memberId: Long, date: LocalDate): List { + private fun resolvePaydayDay(memberId: Long): Int = + profileRepository.findByMemberId(memberId)?.paydayDay ?: throw NotFoundException() + + private fun resolveDailyEvents(date: LocalDate, paydayDay: Int): List { val events = mutableListOf() - val paydayDay = profileRepository.findByMemberId(memberId)?.paydayDay - if (paydayDay != null && isPayday(date, paydayDay)) { + if (isPayday(date, paydayDay)) { events += DailyEventType.PAYDAY } @@ -348,19 +363,7 @@ class WorkdayService( return resolveEffectivePayday(date.year, date.monthValue, paydayDay) == date } - // 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. - private fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { - val baseDate = LocalDate.of(year, month, 1) - .withDayOfMonth(minOf(paydayDay, LocalDate.of(year, month, 1).lengthOfMonth())) - - return when (baseDate.dayOfWeek) { - DayOfWeek.SATURDAY -> baseDate.minusDays(1) - DayOfWeek.SUNDAY -> baseDate.minusDays(2) - else -> baseDate - } - } - - private fun resolveDailWorkStatus( + private fun resolveDailyWorkStatus( date: LocalDate, schedule: ResolvedSchedule, ): DailyWorkStatusType { diff --git a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt index f953e92..93a4072 100644 --- a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt @@ -2,13 +2,12 @@ package com.moa.service.notification import com.moa.entity.* import com.moa.repository.* +import com.moa.service.resolveEffectivePayday import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalTime -import java.time.YearMonth @Service class PaydayNotificationBatchService( @@ -50,20 +49,14 @@ class PaydayNotificationBatchService( } private fun findPaydayProfiles(date: LocalDate): List { - return profileRepository.findAllByPaydayDayIn((1..31).toList()) - .filter { resolveEffectivePayday(date.year, date.monthValue, it.paydayDay) == date } - } - - // 월급일이 해당 월에 없으면 말일로 보정하고, 그 날짜가 주말이면 직전 금요일로 당긴다. - private fun resolveEffectivePayday(year: Int, month: Int, paydayDay: Int): LocalDate { - val yearMonth = YearMonth.of(year, month) - val baseDate = yearMonth.atDay(minOf(paydayDay, yearMonth.lengthOfMonth())) + val candidatePaydayDays = (1..31) + .filter { resolveEffectivePayday(date.year, date.monthValue, it) == date } - return when (baseDate.dayOfWeek) { - DayOfWeek.SATURDAY -> baseDate.minusDays(1) - DayOfWeek.SUNDAY -> baseDate.minusDays(2) - else -> baseDate + if (candidatePaydayDays.isEmpty()) { + return emptyList() } + + return profileRepository.findAllByPaydayDayIn(candidatePaydayDays) } private fun findRequiredTermCodes(): Set =