Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/main/kotlin/com/moa/controller/PublicHolidayController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.moa.controller

import com.moa.common.auth.AdminAuth
import com.moa.common.response.ApiResponse
import com.moa.service.PublicHolidayService
import com.moa.service.dto.PublicHolidayCreateRequest
import com.moa.service.dto.PublicHolidayResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.*

@Tag(name = "Public Holiday", description = "공휴일 관리 API")
@RestController
@RequestMapping("/api/v1/admin/public-holidays")
class PublicHolidayController(
private val publicHolidayService: PublicHolidayService,
) {

@AdminAuth
@SecurityRequirement(name = "AdminKey")
@GetMapping
fun getByYear(@RequestParam year: Int) =
ApiResponse.success(
publicHolidayService.getByYear(year).map { PublicHolidayResponse.from(it) }
)

@AdminAuth
@SecurityRequirement(name = "AdminKey")
@PostMapping
fun create(@RequestBody request: PublicHolidayCreateRequest) =
ApiResponse.success(
PublicHolidayResponse.from(publicHolidayService.create(request.date, request.name))
)

@AdminAuth
@SecurityRequirement(name = "AdminKey")
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long) =
ApiResponse.success(publicHolidayService.delete(id))
Comment on lines +38 to +39
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete currently wraps a Unit return value in ApiResponse.success(publicHolidayService.delete(id)), which will set content to a Unit instance rather than returning an empty success response. Prefer calling publicHolidayService.delete(id) and then returning ApiResponse.success() so the response body is consistent with other void endpoints.

Suggested change
fun delete(@PathVariable id: Long) =
ApiResponse.success(publicHolidayService.delete(id))
fun delete(@PathVariable id: Long) {
publicHolidayService.delete(id)
return ApiResponse.success()
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅜ 어드민이라 ㄱㅊ을 거 같은데 파일럿 너가 싫다면 고민좀 해볼게

}
19 changes: 14 additions & 5 deletions src/main/kotlin/com/moa/entity/DailyEventType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,33 @@ import java.time.LocalDate
*/
enum class DailyEventType {
/** 급여일에 해당하는 경우 */
PAYDAY;
PAYDAY,

/** 공휴일에 해당하는 경우 */
PUBLIC_HOLIDAY;

companion object {
/**
* 특정 일자와 급여일 설정을 바탕으로 해당 일자에 표시할 이벤트를 판정합니다.
*
* 현재는 급여일([PAYDAY])만 지원하며 추후 공휴일 등 다른 이벤트가 추가될 수 있습니다.
* 특정 일자와 급여일 설정, 공휴일 목록을 바탕으로 해당 일자에 표시할 이벤트를 판정합니다.
*
* @param date 이벤트를 판정할 기준 일자
* @param paydayDay 사용자 설정 급여일
* @param publicHolidays 해당 월의 공휴일 날짜 집합
* @return 해당 일자에 적용되는 [DailyEventType] 목록
*/
fun resolve(date: LocalDate, paydayDay: PaydayDay): List<DailyEventType> {
fun resolve(
date: LocalDate,
paydayDay: PaydayDay,
publicHolidays: Set<LocalDate>,
): List<DailyEventType> {
val events = mutableListOf<DailyEventType>()

if (paydayDay.isPayday(date)) {
events += PAYDAY
}
if (date in publicHolidays) {
events += PUBLIC_HOLIDAY
}

return events
}
Expand Down
19 changes: 19 additions & 0 deletions src/main/kotlin/com/moa/entity/PublicHoliday.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.moa.entity

import jakarta.persistence.*
import java.time.LocalDate

@Entity
@Table(name = "public_holiday")
class PublicHoliday(
@Column(nullable = false)
val date: LocalDate,

@Column(nullable = false, length = 50)
val name: String,
) : BaseEntity() {
Comment on lines +6 to +14
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PublicHoliday appears to represent a single holiday per calendar date, but the table definition doesn’t enforce uniqueness on date. Without a unique constraint (and/or a pre-save check), duplicate rows for the same date can be created, making management and deletions ambiguous (one delete won’t necessarily remove the holiday). Consider adding a unique constraint on date (and handling duplicate creates gracefully).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도적으로 제거한거임

개천절 + 추석 등 하루에 겹칠 수 있어서
그래서 무급 계산할 때 중복 계산 방지하기 위해서 Set<LocalDate> 중복 제거 필수


@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ enum class NotificationType(
) {
CLOCK_IN("출근 했어요!", "지금부터 급여가 쌓일 예정이에요."),
CLOCK_OUT("퇴근 했어요!", "오늘 ₩%s 벌었어요."),
PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요.");
PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요."),
PUBLIC_HOLIDAY("오늘은 공휴일이에요!", "오늘 하루 푹 쉬세요.");

fun getBody(vararg args: Any): String {
return String.format(body, *args)
Expand Down
12 changes: 12 additions & 0 deletions src/main/kotlin/com/moa/repository/PublicHolidayRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.moa.repository

import com.moa.entity.PublicHoliday
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDate

interface PublicHolidayRepository : JpaRepository<PublicHoliday, Long> {

fun findAllByDateBetween(start: LocalDate, end: LocalDate): List<PublicHoliday>

fun existsByDate(date: LocalDate): Boolean
}
4 changes: 4 additions & 0 deletions src/main/kotlin/com/moa/service/MemberEarningsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class MemberEarningsService(
type: DailyWorkScheduleType,
clockInTime: LocalTime?,
clockOutTime: LocalTime?,
publicHolidays: Set<LocalDate> = emptySet(),
): BigDecimal {
if (type == DailyWorkScheduleType.NONE) return BigDecimal.ZERO

Expand All @@ -80,6 +81,7 @@ class MemberEarningsService(
salaryType = payroll.salaryInputType,
salaryAmount = payroll.salaryAmount,
workDays = policy.workdays.map { it.dayOfWeek }.toSet(),
publicHolidays = publicHolidays,
)
if (dailyRate == BigDecimal.ZERO) return dailyRate

Expand All @@ -104,6 +106,7 @@ class MemberEarningsService(
policy: WorkPolicyVersion,
start: LocalDate,
endInclusive: LocalDate,
publicHolidays: Set<LocalDate> = emptySet(),
): Long {
val standardDailyMinutes = compensationCalculator.calculateWorkMinutes(
policy.clockInTime, policy.clockOutTime,
Expand All @@ -112,6 +115,7 @@ class MemberEarningsService(
start = start,
end = endInclusive.plusDays(1),
workDays = policy.workdays.map { it.dayOfWeek }.toSet(),
publicHolidays = publicHolidays,
)
return standardDailyMinutes * standardWorkDaysCount
}
Expand Down
46 changes: 46 additions & 0 deletions src/main/kotlin/com/moa/service/PublicHolidayService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.moa.service

import com.moa.common.exception.NotFoundException
import com.moa.entity.PublicHoliday
import com.moa.repository.PublicHolidayRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate

@Service
class PublicHolidayService(
private val publicHolidayRepository: PublicHolidayRepository,
) {

@Transactional(readOnly = true)
fun getHolidayDatesForMonth(year: Int, month: Int): Set<LocalDate> {
val start = LocalDate.of(year, month, 1)
val end = start.withDayOfMonth(start.lengthOfMonth())
return publicHolidayRepository.findAllByDateBetween(start, end)
.map { it.date }
.toSet()
}

@Transactional(readOnly = true)
fun isHoliday(date: LocalDate): Boolean =
publicHolidayRepository.existsByDate(date)

@Transactional(readOnly = true)
fun getByYear(year: Int): List<PublicHoliday> {
val start = LocalDate.of(year, 1, 1)
val end = LocalDate.of(year, 12, 31)
return publicHolidayRepository.findAllByDateBetween(start, end)
}

@Transactional
fun create(date: LocalDate, name: String): PublicHoliday =
publicHolidayRepository.save(PublicHoliday(date = date, name = name))

@Transactional
fun delete(id: Long) {
if (!publicHolidayRepository.existsById(id)) {
throw NotFoundException()
}
publicHolidayRepository.deleteById(id)
}
}
43 changes: 32 additions & 11 deletions src/main/kotlin/com/moa/service/WorkdayService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class WorkdayService(
private val profileRepository: ProfileRepository,
private val notificationSyncService: NotificationSyncService,
private val compensationCalculator: CompensationCalculator,
private val publicHolidayService: PublicHolidayService,
) {

@Transactional(readOnly = true)
Expand All @@ -40,12 +41,13 @@ class WorkdayService(

val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month)
val paydayDay = resolvePaydayDay(memberId)
val publicHolidays = publicHolidayService.getHolidayDatesForMonth(year, month)

return generateSequence(start) { it.plusDays(1) }
.takeWhile { !it.isAfter(end) }
.map { date ->
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date)
createWorkdayResponse(memberId, date, schedule, monthlyPolicy, paydayDay)
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date, publicHolidays)
createWorkdayResponse(memberId, date, schedule, monthlyPolicy, paydayDay, publicHolidays)
}
.toList()
}
Expand All @@ -55,6 +57,7 @@ class WorkdayService(
val (start, end) = resolveMonthRange(year, month)
val today = LocalDate.now()
val standardSalary = calculateStandardSalary(memberId, start).toLong()
val publicHolidays = publicHolidayService.getHolidayDatesForMonth(year, month)

val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month)
if (monthlyPolicy == null) {
Expand All @@ -66,7 +69,9 @@ class WorkdayService(
)
}

val standardMinutes = compensationCalculator.calculateStandardMinutes(monthlyPolicy, start, end)
val standardMinutes = compensationCalculator.calculateStandardMinutes(
monthlyPolicy, start, end, publicHolidays,
)

if (start.isAfter(today)) {
return MonthlyEarningsResponse(0, standardSalary, 0, standardMinutes)
Expand All @@ -82,7 +87,7 @@ class WorkdayService(
var workedMinutes = 0L
var date = start
while (!date.isAfter(lastCalculableDate)) {
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date)
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date, publicHolidays)
val status = DailyWorkStatusType.resolve(
date = date,
scheduleType = schedule.type,
Expand All @@ -103,6 +108,7 @@ class WorkdayService(
completedWork.type,
completedWork.clockIn,
completedWork.clockOut,
publicHolidays,
))
}

Expand Down Expand Up @@ -132,11 +138,12 @@ class WorkdayService(
.associateBy { it.date }

val monthlyPolicy = resolveMonthlyRepresentativePolicyOrNull(memberId, year, month)
val publicHolidays = publicHolidayService.getHolidayDatesForMonth(year, month)

return generateSequence(start) { it.plusDays(1) }
.takeWhile { !it.isAfter(end) }
.map { date ->
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date)
val schedule = resolveSchedule(savedSchedulesByDate[date], monthlyPolicy, date, publicHolidays)
MonthlyWorkdayResponse(date = date, type = schedule.type)
}
.toList()
Expand All @@ -149,8 +156,9 @@ class WorkdayService(
): WorkdayResponse {
val saved = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date)
val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue)
val schedule = resolveSchedule(saved, policy, date)
return createWorkdayResponse(memberId, date, schedule, policy, resolvePaydayDay(memberId))
val publicHolidays = if (publicHolidayService.isHoliday(date)) setOf(date) else emptySet()
val schedule = resolveSchedule(saved, policy, date, publicHolidays)
return createWorkdayResponse(memberId, date, schedule, policy, resolvePaydayDay(memberId), publicHolidays)
}

@Transactional
Expand Down Expand Up @@ -193,12 +201,14 @@ class WorkdayService(
)

val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime)
val publicHolidays = if (publicHolidayService.isHoliday(date)) setOf(date) else emptySet()
return createWorkdayResponse(
memberId,
date,
schedule,
resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue),
resolvePaydayDay(memberId),
publicHolidays,
)
}

Expand Down Expand Up @@ -232,12 +242,14 @@ class WorkdayService(
)

val schedule = ResolvedSchedule(savedSchedule.type, savedSchedule.clockInTime, savedSchedule.clockOutTime)
val publicHolidays = if (publicHolidayService.isHoliday(date)) setOf(date) else emptySet()
return createWorkdayResponse(
memberId,
date,
schedule,
resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue),
resolvePaydayDay(memberId),
publicHolidays,
)
}

Expand Down Expand Up @@ -289,22 +301,27 @@ class WorkdayService(
saved: DailyWorkSchedule?,
policy: WorkPolicyVersion?,
date: LocalDate,
publicHolidays: Set<LocalDate> = emptySet(),
): ResolvedSchedule {
if (policy == null) {
return ResolvedSchedule(DailyWorkScheduleType.NONE, null, null)
}
return resolveScheduleForDate(saved, policy, date)
return resolveScheduleForDate(saved, policy, date, publicHolidays)
}

private fun resolveScheduleForDate(
saved: DailyWorkSchedule?,
policy: WorkPolicyVersion,
date: LocalDate,
publicHolidays: Set<LocalDate> = emptySet(),
): ResolvedSchedule {
if (saved != null) {
return ResolvedSchedule(saved.type, saved.clockInTime, saved.clockOutTime)
}
val isWorkday = policy.workdays.any { it.dayOfWeek == date.dayOfWeek }
if (date in publicHolidays && isWorkday) {
return ResolvedSchedule(DailyWorkScheduleType.NONE, null, null)
}
return if (isWorkday) {
ResolvedSchedule(DailyWorkScheduleType.WORK, policy.clockInTime, policy.clockOutTime)
} else {
Expand Down Expand Up @@ -338,8 +355,9 @@ class WorkdayService(
schedule: ResolvedSchedule,
policy: WorkPolicyVersion?,
paydayDay: PaydayDay,
publicHolidays: Set<LocalDate> = emptySet(),
): WorkdayResponse {
val events = DailyEventType.resolve(date, paydayDay)
val events = DailyEventType.resolve(date, paydayDay, publicHolidays)
val status = DailyWorkStatusType.resolve(
date = date,
scheduleType = schedule.type,
Expand All @@ -356,7 +374,7 @@ class WorkdayService(
dailyPay = 0,
)
}
val dailyPay = resolveDisplayedDailyPay(memberId, date, schedule, policy)
val dailyPay = resolveDisplayedDailyPay(memberId, date, schedule, policy, publicHolidays)

return WorkdayResponse(
date = date,
Expand All @@ -374,11 +392,12 @@ class WorkdayService(
date: LocalDate,
schedule: ResolvedSchedule,
policy: WorkPolicyVersion?,
publicHolidays: Set<LocalDate> = emptySet(),
): Int {
if (policy == null) return 0

return calculateDailyEarnings(
memberId, date, policy, schedule.type, schedule.clockIn, schedule.clockOut,
memberId, date, policy, schedule.type, schedule.clockIn, schedule.clockOut, publicHolidays,
).setScale(0, RoundingMode.HALF_UP).toInt()
}

Expand All @@ -404,6 +423,7 @@ class WorkdayService(
type: DailyWorkScheduleType,
clockInTime: LocalTime?,
clockOutTime: LocalTime?,
publicHolidays: Set<LocalDate> = emptySet(),
) = resolveMonthlyRepresentativePayrollOrNull(memberId, date.year, date.monthValue)?.let {
compensationCalculator.calculateDailyEarnings(
date = date,
Expand All @@ -413,6 +433,7 @@ class WorkdayService(
type = type,
clockInTime = clockInTime,
clockOutTime = clockOutTime,
publicHolidays = publicHolidays,
)
} ?: BigDecimal.ZERO

Expand Down
Loading
Loading