Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Attendance 도메인 재설계, 출석 기능 구현 #15

Merged
merged 2 commits into from
May 15, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.depromeet.makers.domain.exception

open class AttendanceException(
errorCode: ErrorCode,
) : DomainException(errorCode)

class AttendanceBeforeTimeException : AttendanceException(ErrorCode.BEFORE_AVAILABLE_ATTENDANCE_TIME)
class AttendanceAfterTimeException : AttendanceException(ErrorCode.AFTER_AVAILABLE_ATTENDANCE_TIME)
class AttendanceAlreadyExistsException : AttendanceException(ErrorCode.ALREADY_ATTENDANCE)
class InvalidCheckInTimeException : AttendanceException(ErrorCode.INVALID_CHECKIN_TIME)
class MissingPlaceParamException : AttendanceException(ErrorCode.MISSING_PLACE_PARAM)
class InvalidCheckInDistanceException : AttendanceException(ErrorCode.INVALID_CHECKIN_DISTANCE)
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,15 @@ enum class ErrorCode(
*/
INVALID_SESSION_PLACE("SE0001", "오프라인 세션의 장소가 기입이 필요합니다"),
SESSION_ALREADY_EXISTS("SE0002", "이미 해당 세션이 존재합니다"),

/**
* 출석 관련 오류
* @see com.depromeet.makers.domain.exception.AttendanceException
*/
BEFORE_AVAILABLE_ATTENDANCE_TIME("AT0001", "출석 시간 이전입니다"),
AFTER_AVAILABLE_ATTENDANCE_TIME("AT0002", "출석, 지각 가능 시간이 지났습니다"),
ALREADY_ATTENDANCE("AT0003", "이미 출석 처리 되었습니다."),
INVALID_CHECKIN_TIME("AT0004", "현재 주차에 해당하는 세션을 찾을 수 없습니다."),
MISSING_PLACE_PARAM("AT0005", "오프라인 세션 출석체크의 현재 위치 정보가 누락되었습니다."),
INVALID_CHECKIN_DISTANCE("AT0006", "현재 위치와 세션 장소의 거리가 너무 멉니다."),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.makers.domain.gateway

import com.depromeet.makers.domain.model.Attendance

interface AttendanceGateway {
fun save(attendance: Attendance): Attendance

fun findByMemberIdAndGenerationAndWeek(memberId: String, generation: Int, week: Int): Attendance
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.makers.domain.gateway

import com.depromeet.makers.domain.model.Session
import java.time.LocalDateTime

interface SessionGateway {
fun save(session: Session): Session
Expand All @@ -12,4 +13,8 @@ interface SessionGateway {
fun getById(sessionId: String): Session

fun findAllByGeneration(generation: Int): List<Session>

fun findByGenerationAndWeek(generation: Int, week: Int): Session

fun findByStartTimeBetween(startTime: LocalDateTime, endTime: LocalDateTime): Session?
}
75 changes: 75 additions & 0 deletions src/main/kotlin/com/depromeet/makers/domain/model/Attendance.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.depromeet.makers.domain.model

import com.depromeet.makers.domain.exception.AttendanceAfterTimeException
import com.depromeet.makers.domain.exception.AttendanceAlreadyExistsException
import com.depromeet.makers.domain.exception.AttendanceBeforeTimeException
import com.depromeet.makers.util.generateULID
import java.time.LocalDateTime

data class Attendance(
val attendanceId: String,
val generation: Int,
val week: Int,
val member: Member,
val attendanceStatus: AttendanceStatus,
val attendanceTime: LocalDateTime?
) {
fun checkIn(attendanceTime: LocalDateTime, sessionStartTime: LocalDateTime): Attendance {
if (!isAttendanceOnHold()) {
throw AttendanceAlreadyExistsException()
}
sessionStartTime.isAvailableCheckInTime(attendanceTime)

return this.copy(
attendanceStatus = attendanceTime.checkIn(sessionStartTime),
attendanceTime = LocalDateTime.now()
)
}

fun isAttendanceOnHold() = attendanceStatus.isAttendanceOnHold()

fun isAttendance() = attendanceStatus.isAttendance()

fun isAbsence() = attendanceStatus.isAbsence()

fun isTardy() = attendanceStatus.isTardy()

private fun LocalDateTime.isAvailableCheckInTime(sessionStartTime: LocalDateTime): Boolean {
// 출석 가능 시간은 세션 시작 시간의 30분 전부터 120분 후까지 입니다. (정책에 따라 수정 필요)
if (this.isBefore(sessionStartTime.minusMinutes(30))) {
throw AttendanceBeforeTimeException()
}
if (this.isAfter(sessionStartTime.plusMinutes(120))) {
throw AttendanceAfterTimeException()
}
return true
}

private fun LocalDateTime.checkIn(sessionStartTime: LocalDateTime): AttendanceStatus {
// 출석 인정 시간은 세션 시작 시간의 30분 전부터 30분 후까지 입니다. (정책에 따라 수정 필요)
if (this.isAfter(sessionStartTime.minusMinutes(30)) && this.isBefore(sessionStartTime.plusMinutes(30))) {
return AttendanceStatus.ATTENDANCE
}
// 지각 인정 시간은 세션 시작 시간의 30분 후부터 120분 후까지 입니다. (정책에 따라 수정 필요)
else if (this.isAfter(sessionStartTime.plusMinutes(30)) && this.isBefore(sessionStartTime.plusMinutes(120))) {
return AttendanceStatus.TARDY
}
throw AttendanceAfterTimeException()
}

companion object {
fun newAttendance(
generation: Int,
week: Int,
member: Member,
attendance: AttendanceStatus = AttendanceStatus.ATTENDANCE_ON_HOLD
) = Attendance(
attendanceId = generateULID(),
generation = generation,
week = week,
member = member,
attendanceStatus = attendance,
attendanceTime = null
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ package com.depromeet.makers.domain.model
enum class AttendanceStatus(
val description: String
) {
ATTENDANCE_ON_HOLD("출석 대기"),
ATTENDANCE("출석"),
ABSENCE("결석"),
ATTENDANCE_ON_HOLD("출석 대기"),
TARDY("지각");

fun isAttendanceOnHold() = this == ATTENDANCE_ON_HOLD

fun isAttendance() = this == ATTENDANCE

fun isAbsence() = this == ABSENCE

fun isTardy() = this == TARDY
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ data class Session(
val startTime: LocalDateTime,
val sessionType: SessionType,
val place: Place,
val attendanceMemberIds: Set<SessionAttendance>,
) {
fun isOnline() = sessionType.isOnline()

fun isOffline() = sessionType.isOffline()

fun update(
generation: Int = this.generation,
week: Int = this.week,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ enum class SessionType {
ONLINE, OFFLINE;

fun isOnline() = this == ONLINE

fun isOffline() = this == OFFLINE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.depromeet.makers.domain.usecase

import com.depromeet.makers.domain.exception.InvalidCheckInDistanceException
import com.depromeet.makers.domain.exception.InvalidCheckInTimeException
import com.depromeet.makers.domain.exception.MissingPlaceParamException
import com.depromeet.makers.domain.gateway.AttendanceGateway
import com.depromeet.makers.domain.gateway.SessionGateway
import com.depromeet.makers.domain.model.Attendance
import com.depromeet.makers.domain.model.Member
import java.time.DayOfWeek
import java.time.LocalDateTime
import kotlin.math.*

class CheckInSession(
private val attendanceGateway: AttendanceGateway,
private val sessionGateway: SessionGateway,
) : UseCase<CheckInSession.CheckInSessionInput, Unit> {
data class CheckInSessionInput(
val now: LocalDateTime,
val member: Member,
val longitude: Double?,
val latitude: Double?,
)

override fun execute(input: CheckInSessionInput) {
val monday = input.now.getMonday()

val thisWeekSession = sessionGateway.findByStartTimeBetween(
monday,
monday.plusDays(7)
) ?: throw InvalidCheckInTimeException()

val attendance = runCatching {
attendanceGateway.findByMemberIdAndGenerationAndWeek(
input.member.memberId,
thisWeekSession.generation,
thisWeekSession.week
)
}.getOrDefault(
Attendance.newAttendance(
generation = thisWeekSession.generation,
week = thisWeekSession.week,
member = input.member,
)
)

// 오프라인 세션의 경우 거리 확인 로직
if (thisWeekSession.isOffline()) {
if (input.latitude == null || input.longitude == null) {
throw MissingPlaceParamException()
}

val distance = calculateDistance(
thisWeekSession.place.latitude,
thisWeekSession.place.longitude,
input.latitude,
input.longitude,
)

if (!inRange(distance, 100.0)) { // 100m 기준 (임시 설정)
throw InvalidCheckInDistanceException()
}
}

attendanceGateway.save(attendance.checkIn(input.now, thisWeekSession.startTime))
}

private fun LocalDateTime.getMonday(): LocalDateTime {
val dayOfWeek = this.dayOfWeek
val daysToAdd = DayOfWeek.MONDAY.value - dayOfWeek.value
return this.plusDays(daysToAdd.toLong())
}

private fun inRange(distance: Double, range: Double) = distance <= range

private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val earthRadius = 6371000

val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadius * c
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class CreateNewSession(
startTime = input.startTime,
sessionType = input.sessionType,
place = getNewPlace(input),
attendanceMemberIds = emptySet(),
)
return sessionGateWay.save(newSession)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.depromeet.makers.infrastructure.db.entity

import com.depromeet.makers.domain.model.Attendance
import com.depromeet.makers.domain.model.AttendanceStatus
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import java.time.LocalDateTime

@Entity(name = "attendances")
class AttendanceEntity private constructor(
@Id
@Column(name = "id", length = 26, columnDefinition = "CHAR(26)", nullable = false)
val id: String,

@Column(name = "generation", nullable = false)
val generation: Int,

@Column(name = "week", nullable = false)
val week: Int,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
val member: MemberEntity,

@Enumerated(EnumType.STRING)
@Column(name = "attendance_status", nullable = false)
val attendanceStatus: AttendanceStatus,

@Column(name = "attendance_time", nullable = true)
val attendanceTime: LocalDateTime?,
) {
fun toDomain() = Attendance(
attendanceId = id,
generation = generation,
week = week,
member = member.toDomain(),
attendanceStatus = attendanceStatus,
attendanceTime = attendanceTime,
)

companion object {
fun fromDomain(attendance: Attendance) = with(attendance) {
AttendanceEntity(
id = attendanceId,
generation = generation,
week = week,
member = MemberEntity.fromDomain(member),
attendanceStatus = attendanceStatus,
attendanceTime = attendanceTime
)
}
}
}

This file was deleted.

Loading