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
21 changes: 21 additions & 0 deletions src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.moa.entity.notification

import java.time.LocalDate
import java.time.LocalTime

class WorkScheduleTime private constructor(
val clockInTime: LocalTime,
val clockOutTime: LocalTime,
) {
val isMidnightCrossing: Boolean = clockOutTime < clockInTime

fun clockOutDate(baseDate: LocalDate): LocalDate =
if (isMidnightCrossing) baseDate.plusDays(1) else baseDate

companion object {
fun of(clockIn: LocalTime, clockOut: LocalTime) = WorkScheduleTime(
clockInTime = LocalTime.of(clockIn.hour, clockIn.minute),
clockOutTime = LocalTime.of(clockOut.hour, clockOut.minute),
)
}
}
7 changes: 4 additions & 3 deletions src/main/kotlin/com/moa/service/FcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.moa.service

import com.google.firebase.messaging.*
import com.moa.repository.FcmTokenRepository
import com.moa.service.dto.FcmRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

Expand All @@ -11,16 +12,16 @@ class FcmService(
) {
private val log = LoggerFactory.getLogger(javaClass)

fun sendEach(requests: List<Pair<String, Map<String, String>>>): List<Boolean> {
fun sendEach(requests: List<FcmRequest>): List<Boolean> {
if (requests.isEmpty()) return emptyList()
val results = ArrayList<Boolean>(requests.size)
requests.chunked(MAX_BATCH_SIZE).forEach { batch ->
try {
val messages = batch.map { (token, data) -> buildMessage(token, data) }
val messages = batch.map { buildMessage(it.token, it.data) }
val response = FirebaseMessaging.getInstance().sendEach(messages)
response.responses.forEachIndexed { i, sendResponse ->
if (!sendResponse.isSuccessful) {
handleFcmException(sendResponse.exception, batch[i].first)
handleFcmException(sendResponse.exception, batch[i].token)
}
results.add(sendResponse.isSuccessful)
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/com/moa/service/dto/FcmRequest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.moa.service.dto

data class FcmRequest(val token: String, val data: Map<String, String>)
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
package com.moa.service.notification

import com.moa.entity.*
import com.moa.entity.Workday
import com.moa.entity.WorkPolicyVersion
import com.moa.entity.notification.NotificationLog
import com.moa.entity.notification.NotificationSetting
import com.moa.entity.notification.NotificationSettingType
import com.moa.entity.notification.NotificationType
import com.moa.repository.*
import com.moa.entity.notification.WorkScheduleTime
import com.moa.repository.NotificationLogRepository
import com.moa.repository.WorkPolicyVersionRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.LocalTime

@Service
class NotificationBatchService(
private val workPolicyVersionRepository: WorkPolicyVersionRepository,
private val dailyWorkScheduleRepository: DailyWorkScheduleRepository,
private val notificationLogRepository: NotificationLogRepository,
private val notificationSettingRepository: NotificationSettingRepository,
private val fcmTokenRepository: FcmTokenRepository,
private val termRepository: TermRepository,
private val termAgreementRepository: TermAgreementRepository,
private val notificationEligibilityService: NotificationEligibilityService,
) {
private val log = LoggerFactory.getLogger(javaClass)

Expand All @@ -31,8 +29,8 @@ class NotificationBatchService(
if (workdayPolicies.isEmpty()) return

val memberIds = workdayPolicies.map { it.memberId }
val requiredTermCodes = findRequiredTermCodes()
val context = loadContext(memberIds, date)
val requiredTermCodes = notificationEligibilityService.findRequiredTermCodes()
val context = notificationEligibilityService.loadContext(memberIds, date)

log.info("Generating notifications for {} members on {}", memberIds.size, date)

Expand All @@ -57,79 +55,28 @@ class NotificationBatchService(
.filter { todayWorkday in it.workdays }
}

private fun findRequiredTermCodes(): Set<String> =
termRepository.findAll()
.filter { it.required }
.map { it.code }
.toSet()

private fun loadContext(memberIds: List<Long>, date: LocalDate): NotificationContext {
val agreementsMap = termAgreementRepository.findAllByMemberIdIn(memberIds)
.filter { it.agreed }
.groupBy { it.memberId }
.mapValues { (_, v) -> v.map { it.termCode }.toSet() }

val settingsMap = notificationSettingRepository.findAllByMemberIdIn(memberIds)
.associateBy { it.memberId }

val overridesMap = dailyWorkScheduleRepository.findAllByMemberIdInAndDate(memberIds, date)
.associateBy { it.memberId }

val tokensMap = fcmTokenRepository.findAllByMemberIdIn(memberIds)
.groupBy { it.memberId }

return NotificationContext(agreementsMap, settingsMap, overridesMap, tokensMap)
}

private fun createNotificationsIfEligible(
policy: WorkPolicyVersion,
date: LocalDate,
requiredCodes: Set<String>,
context: NotificationContext,
context: NotificationEligibilityContext,
): List<NotificationLog>? {
val memberId = policy.memberId

if (!context.hasAgreedToAll(memberId, requiredCodes)) return null
if (!context.isNotificationEnabled(memberId)) return null
if (context.isWorkSuppressed(memberId)) return null
if (!context.isSettingEnabled(memberId, NotificationSettingType.WORK)) return null
if (context.shouldSkipNotification(memberId)) return null
if (!context.hasFcmToken(memberId)) return null

val override = context.getOverride(memberId)
val clockInTime = truncateToMinute(override?.clockInTime ?: policy.clockInTime)
val clockOutTime = truncateToMinute(override?.clockOutTime ?: policy.clockOutTime)
val clockOutDate = if (clockOutTime < clockInTime) date.plusDays(1) else date
val schedule = WorkScheduleTime.of(
clockIn = override?.clockInTime ?: policy.clockInTime,
clockOut = override?.clockOutTime ?: policy.clockOutTime,
)

return listOf(
NotificationLog(memberId, NotificationType.CLOCK_IN, date, clockInTime),
NotificationLog(memberId, NotificationType.CLOCK_OUT, clockOutDate, clockOutTime),
NotificationLog(memberId, NotificationType.CLOCK_IN, date, schedule.clockInTime),
NotificationLog(memberId, NotificationType.CLOCK_OUT, schedule.clockOutDate(date), schedule.clockOutTime),
)
}

private fun truncateToMinute(time: LocalTime): LocalTime =
LocalTime.of(time.hour, time.minute)

private class NotificationContext(
private val agreementsMap: Map<Long, Set<String>>,
private val settingsMap: Map<Long, NotificationSetting>,
private val overridesMap: Map<Long, DailyWorkSchedule>,
private val tokensMap: Map<Long, List<FcmToken>>,
) {
fun hasAgreedToAll(memberId: Long, requiredCodes: Set<String>): Boolean {
val agreedCodes = agreementsMap[memberId] ?: emptySet()
return agreedCodes.containsAll(requiredCodes)
}

fun isNotificationEnabled(memberId: Long): Boolean =
settingsMap[memberId]?.workNotificationEnabled != false

fun isWorkSuppressed(memberId: Long): Boolean =
overridesMap[memberId]?.type == DailyWorkScheduleType.VACATION ||
overridesMap[memberId]?.type == DailyWorkScheduleType.NONE

fun hasFcmToken(memberId: Long): Boolean =
!tokensMap[memberId].isNullOrEmpty()

fun getOverride(memberId: Long): DailyWorkSchedule? =
overridesMap[memberId]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.moa.entity.notification.NotificationStatus
import com.moa.repository.FcmTokenRepository
import com.moa.repository.NotificationLogRepository
import com.moa.service.FcmService
import com.moa.service.dto.FcmRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDate
Expand Down Expand Up @@ -34,12 +35,6 @@ class NotificationDispatchService(
val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds)
.groupBy { it.memberId }

data class DispatchItem(
val notification: NotificationLog,
val token: String,
val data: Map<String, String>,
)

val dispatchItems = mutableListOf<DispatchItem>()
for (notification in pendingLogs) {
val tokens = tokensByMemberId[notification.memberId].orEmpty()
Expand All @@ -48,12 +43,17 @@ class NotificationDispatchService(
log.warn("No FCM tokens for member {}, marking as FAILED", notification.memberId)
continue
}
val data = notificationMessageBuilder.buildMessage(notification).toData()
tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) }
try {
val data = notificationMessageBuilder.buildMessage(notification).toData()
tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) }
} catch (e: Exception) {
notification.status = NotificationStatus.FAILED
log.error("Failed to build message for notification {}, member {}", notification.id, notification.memberId, e)
}
}

if (dispatchItems.isNotEmpty()) {
val results = fcmService.sendEach(dispatchItems.map { it.token to it.data })
val results = fcmService.sendEach(dispatchItems.map { FcmRequest(it.token, it.data) })
results.forEachIndexed { i, success ->
if (success) dispatchItems[i].notification.status = NotificationStatus.SENT
}
Expand All @@ -64,3 +64,9 @@ class NotificationDispatchService(
notificationLogRepository.saveAll(pendingLogs)
}
}

private data class DispatchItem(
val notification: NotificationLog,
val token: String,
val data: Map<String, String>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.moa.service.notification

import com.moa.common.exception.NotFoundException
import com.moa.entity.DailyWorkScheduleType
import com.moa.repository.DailyWorkScheduleRepository
import com.moa.repository.PayrollVersionRepository
import com.moa.repository.WorkPolicyVersionRepository
import com.moa.service.calculator.CompensationCalculator
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.time.LocalDate
import java.time.YearMonth

@Service
class NotificationEarningsService(
private val workPolicyVersionRepository: WorkPolicyVersionRepository,
private val payrollVersionRepository: PayrollVersionRepository,
private val dailyWorkScheduleRepository: DailyWorkScheduleRepository,
private val compensationCalculator: CompensationCalculator,
) {
fun calculateTodayEarnings(memberId: Long, date: LocalDate): BigDecimal {
val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue)
?: throw NotFoundException()
val payroll = resolveMonthlyRepresentativePayrollOrNull(memberId, date.year, date.monthValue)
?: throw NotFoundException()

val override = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date)
return compensationCalculator.calculateDailyEarnings(
date = date,
salaryType = payroll.salaryInputType,
salaryAmount = payroll.salaryAmount,
policy = policy,
type = override?.type ?: DailyWorkScheduleType.WORK,
clockInTime = override?.clockInTime,
clockOutTime = override?.clockOutTime,
)
}

private fun resolveMonthlyRepresentativePolicyOrNull(
memberId: Long,
year: Int,
month: Int,
) = workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(
memberId,
YearMonth.of(year, month).atEndOfMonth(),
)

private fun resolveMonthlyRepresentativePayrollOrNull(
memberId: Long,
year: Int,
month: Int,
) = payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(
memberId,
YearMonth.of(year, month).atEndOfMonth(),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.moa.service.notification

import com.moa.entity.DailyWorkSchedule
import com.moa.entity.DailyWorkScheduleType
import com.moa.entity.FcmToken
import com.moa.entity.notification.NotificationSetting
import com.moa.entity.notification.NotificationSettingType

/**
* 알림 발송 적격성 판단을 위한 조회 결과를 보관하는 컨텍스트입니다.
*
* [NotificationEligibilityService.loadContext]에서 생성되며,
* 배치 서비스가 회원별 적격성을 판단할 때 사용합니다.
*
* @param overridesMap 출퇴근 알림 전용. 월급날 알림에서는 빈 맵으로 생성됩니다.
*/
class NotificationEligibilityContext(
private val agreementsMap: Map<Long, Set<String>>,
private val settingsMap: Map<Long, NotificationSetting>,
private val tokensMap: Map<Long, List<FcmToken>>,
private val overridesMap: Map<Long, DailyWorkSchedule> = emptyMap(),
) {
fun hasAgreedToAll(memberId: Long, requiredCodes: Set<String>): Boolean {
val agreedCodes = agreementsMap[memberId] ?: emptySet()
return agreedCodes.containsAll(requiredCodes)
}

fun isSettingEnabled(memberId: Long, type: NotificationSettingType): Boolean =
settingsMap[memberId]?.isEnabled(type) != false

fun hasFcmToken(memberId: Long): Boolean =
!tokensMap[memberId].isNullOrEmpty()

fun shouldSkipNotification(memberId: Long): Boolean {
val overrideType = overridesMap[memberId]?.type
return overrideType == DailyWorkScheduleType.VACATION || overrideType == DailyWorkScheduleType.NONE
}

fun getOverride(memberId: Long): DailyWorkSchedule? = overridesMap[memberId]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.moa.service.notification

import com.moa.repository.TermRepository
import com.moa.repository.TermAgreementRepository
import com.moa.repository.NotificationSettingRepository
import com.moa.repository.FcmTokenRepository
import com.moa.repository.DailyWorkScheduleRepository
import org.springframework.stereotype.Service
import java.time.LocalDate

/**
* 알림 발송 적격성 판단에 필요한 데이터를 로딩하는 서비스입니다.
*
* 적격성 조건:
* - 필수 약관 전체 동의 여부
* - 알림 유형별 수신 설정 활성화 여부 (출퇴근 / 월급날)
* - 유효한 FCM 토큰 보유 여부
* - 알림 발송 제외 스케줄 여부 (휴가, 미근무)
*
* [NotificationBatchService], [PaydayNotificationBatchService]에서 공통으로 사용합니다.
*/
@Service
class NotificationEligibilityService(
private val termRepository: TermRepository,
private val termAgreementRepository: TermAgreementRepository,
private val notificationSettingRepository: NotificationSettingRepository,
private val fcmTokenRepository: FcmTokenRepository,
private val dailyWorkScheduleRepository: DailyWorkScheduleRepository,
) {
fun findRequiredTermCodes(): Set<String> =
termRepository.findAll()
.filter { it.required }
.map { it.code }
.toSet()

fun loadContext(
memberIds: List<Long>,
date: LocalDate? = null,
): NotificationEligibilityContext {
val agreementsMap = termAgreementRepository.findAllByMemberIdIn(memberIds)
.filter { it.agreed }
.groupBy { it.memberId }
.mapValues { (_, v) -> v.map { it.termCode }.toSet() }

val settingsMap = notificationSettingRepository.findAllByMemberIdIn(memberIds)
.associateBy { it.memberId }

val tokensMap = fcmTokenRepository.findAllByMemberIdIn(memberIds)
.groupBy { it.memberId }

val overridesMap = date?.let {
dailyWorkScheduleRepository.findAllByMemberIdInAndDate(memberIds, it)
.associateBy { schedule -> schedule.memberId }
} ?: emptyMap()

return NotificationEligibilityContext(agreementsMap, settingsMap, tokensMap, overridesMap)
}
}
Loading
Loading