feat(identity): повышение курса и жизненный цикл студентов
This commit is contained in:
@@ -12,7 +12,7 @@ spring:
|
||||
- id: identity
|
||||
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||
predicates:
|
||||
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**
|
||||
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**,/api/v1/promotion/**
|
||||
- id: progress-profile
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
predicates:
|
||||
|
||||
@@ -2,10 +2,12 @@ package ru.ysu.lms.identity
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
|
||||
// The identity service manages its own JWT keys at runtime, so the generic
|
||||
// auto-configuration from the shared library is excluded.
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
|
||||
class IdentityServiceApplication
|
||||
|
||||
|
||||
@@ -14,18 +14,22 @@ import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.AccountStatus
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class AuthService(
|
||||
private val accounts: AccountRepository,
|
||||
private val students: StudentRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
@@ -77,6 +81,14 @@ class AuthService(
|
||||
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
|
||||
throw UnauthorizedException("Доступ к системе закрыт")
|
||||
}
|
||||
ensureGroupOpen(account)
|
||||
}
|
||||
|
||||
private fun ensureGroupOpen(account: Account) {
|
||||
val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status
|
||||
if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) {
|
||||
throw UnauthorizedException("Группа закрыта")
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class PromotionAction {
|
||||
PROMOTE,
|
||||
GRADUATE,
|
||||
ACADEMIC,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class PromotionEntityType {
|
||||
GROUP,
|
||||
STUDENT,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class PromotionItemStatus {
|
||||
PLANNED,
|
||||
APPLIED,
|
||||
SKIPPED,
|
||||
ERROR,
|
||||
ROLLED_BACK,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class PromotionMode {
|
||||
DRY_RUN,
|
||||
APPLY,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Шапка запуска повышения. JSONB-поле `result` намеренно не отображается
|
||||
* Hibernate: оно читается/пишется через [ru.ysu.lms.identity.promotion.PromotionJsonStore],
|
||||
* потому что Hibernate 7 в этом проекте использует Jackson 2, а приложение — Jackson 3.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "promotion_runs")
|
||||
class PromotionRun(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false)
|
||||
var year: Int = 0,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var mode: PromotionMode = PromotionMode.DRY_RUN,
|
||||
@Column(name = "actor_account_id")
|
||||
var actorAccountId: UUID? = null,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: PromotionRunStatus = PromotionRunStatus.PLANNED,
|
||||
@Column(name = "started_at", nullable = false)
|
||||
var startedAt: Instant = Instant.now(),
|
||||
@Column(name = "finished_at")
|
||||
var finishedAt: Instant? = null,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/** По-элементный план/результат повышения. before/after хранятся в JSONB через JDBC. */
|
||||
@Entity
|
||||
@Table(name = "promotion_run_items")
|
||||
class PromotionRunItem(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
@Column(name = "run_id", nullable = false)
|
||||
var runId: UUID = UUID(0, 0),
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "entity_type", nullable = false, length = 16)
|
||||
var entityType: PromotionEntityType = PromotionEntityType.GROUP,
|
||||
@Column(name = "entity_id", nullable = false)
|
||||
var entityId: UUID = UUID(0, 0),
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
var action: PromotionAction = PromotionAction.PROMOTE,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: PromotionItemStatus = PromotionItemStatus.PLANNED,
|
||||
@Column(nullable = false)
|
||||
var error: String = "",
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class PromotionRunStatus {
|
||||
PLANNED,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
ROLLED_BACK,
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.identity.domain.Enrollment
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.MembershipHistory
|
||||
import ru.ysu.lms.identity.domain.PromotionAction
|
||||
import ru.ysu.lms.identity.domain.PromotionEntityType
|
||||
import ru.ysu.lms.identity.domain.PromotionItemStatus
|
||||
import ru.ysu.lms.identity.domain.PromotionMode
|
||||
import ru.ysu.lms.identity.domain.PromotionRun
|
||||
import ru.ysu.lms.identity.domain.PromotionRunItem
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import ru.ysu.lms.identity.domain.Student
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
private data class OrderedItem(
|
||||
val item: PromotionRunItem,
|
||||
val state: PromotionItemState,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Component
|
||||
class PromotionApplier(
|
||||
private val runs: PromotionRunRepository,
|
||||
private val items: PromotionRunItemRepository,
|
||||
private val json: PromotionJsonStore,
|
||||
private val groups: GroupRepository,
|
||||
private val students: StudentRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val history: MembershipHistoryRepository,
|
||||
private val verifier: PromotionItemVerifier,
|
||||
private val runLock: PromotionRunLock,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@Transactional
|
||||
fun apply(
|
||||
runId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
runLock.acquire(runId)
|
||||
val run = loadRun(runId)
|
||||
if (run.status == PromotionRunStatus.COMPLETED) return
|
||||
checkCanApply(run)
|
||||
val states = json.readItemStates(runId)
|
||||
val runItems = items.findByRunIdOrderByIdAsc(runId)
|
||||
val conflicts = runItems.mapNotNull { verifier.verifyBefore(it, states[it.id]) }
|
||||
if (conflicts.isNotEmpty()) {
|
||||
promotionConflict("Данные изменились после dry-run: ${conflicts.joinToString("; ")}")
|
||||
}
|
||||
markRunning(run, actorAccountId)
|
||||
val now = Instant.now(clock)
|
||||
val ordered = orderForApply(runItems, states)
|
||||
ordered.forEach { orderedItem -> applyItem(orderedItem, run, actorAccountId) }
|
||||
run.status = PromotionRunStatus.COMPLETED
|
||||
run.finishedAt = now
|
||||
runs.saveAndFlush(run)
|
||||
updateResult(run, mapOf("status" to "COMPLETED", "appliedAt" to now.toString()))
|
||||
}
|
||||
|
||||
private fun checkCanApply(run: PromotionRun) {
|
||||
if (run.status != PromotionRunStatus.PLANNED || run.mode != PromotionMode.DRY_RUN) {
|
||||
promotionConflict("Применить можно только запланированный dry-run (статус ${run.status})")
|
||||
}
|
||||
if (runs.existsByYearAndModeAndStatusIn(run.year, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
|
||||
promotionConflict("Повышение за ${run.year} уже применено или выполняется")
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRunning(
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
run.mode = PromotionMode.APPLY
|
||||
run.status = PromotionRunStatus.RUNNING
|
||||
run.actorAccountId = actorAccountId ?: run.actorAccountId
|
||||
run.startedAt = Instant.now(clock)
|
||||
run.finishedAt = null
|
||||
try {
|
||||
runs.saveAndFlush(run)
|
||||
} catch (error: DataIntegrityViolationException) {
|
||||
promotionConflict("Повышение за ${run.year} уже применено или выполняется: ${error.mostSpecificCause.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyItem(
|
||||
ordered: OrderedItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
when (ordered.item.entityType) {
|
||||
PromotionEntityType.GROUP -> applyGroup(ordered, run, actorAccountId)
|
||||
PromotionEntityType.STUDENT -> applyStudent(ordered, run, actorAccountId)
|
||||
}
|
||||
ordered.item.status = PromotionItemStatus.APPLIED
|
||||
ordered.item.error = ""
|
||||
items.save(ordered.item)
|
||||
}
|
||||
|
||||
private fun applyGroup(
|
||||
ordered: OrderedItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val group = loadGroup(ordered.item.entityId)
|
||||
when (ordered.item.action) {
|
||||
PromotionAction.PROMOTE -> promoteGroup(group, ordered.state.after)
|
||||
PromotionAction.GRADUATE -> {
|
||||
group.status = GroupStatus.GRADUATED
|
||||
groups.save(group)
|
||||
}
|
||||
PromotionAction.ACADEMIC -> throw ConflictException("Некорректный элемент плана для группы")
|
||||
}
|
||||
if (ordered.item.action == PromotionAction.PROMOTE) {
|
||||
activeEnrollments(group.id).forEach { enrollment ->
|
||||
recordHistory(enrollment, group, group, MembershipAction.PROMOTE, run, actorAccountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun promoteGroup(
|
||||
group: GroupEntity,
|
||||
after: Map<String, Any?>,
|
||||
) {
|
||||
val course = after.courseValue() ?: throw ConflictException("В плане группы ${group.name} нет курса")
|
||||
val name = after.nameValue() ?: throw ConflictException("В плане группы ${group.name} нет имени")
|
||||
group.course = course.toShort()
|
||||
group.name = name
|
||||
groups.save(group)
|
||||
}
|
||||
|
||||
private fun applyStudent(
|
||||
ordered: OrderedItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
when (ordered.item.action) {
|
||||
PromotionAction.GRADUATE -> graduateStudent(ordered, run, actorAccountId)
|
||||
PromotionAction.ACADEMIC -> moveAcademicStudent(ordered, run, actorAccountId)
|
||||
PromotionAction.PROMOTE -> throw ConflictException("Некорректный элемент плана для студента")
|
||||
}
|
||||
}
|
||||
|
||||
private fun graduateStudent(
|
||||
ordered: OrderedItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val student = loadStudent(ordered.item.entityId)
|
||||
val groupId = ordered.state.before.uuidValue("groupId")
|
||||
student.status = StudentStatus.GRADUATED
|
||||
students.save(student)
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = student.accountId!!,
|
||||
fromGroupId = groupId,
|
||||
toGroupId = null,
|
||||
action = MembershipAction.GRADUATE,
|
||||
reason = "Повышение ${run.year}",
|
||||
actorAccountId = actorAccountId,
|
||||
promotionRunId = run.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun moveAcademicStudent(
|
||||
ordered: OrderedItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val student = loadStudent(ordered.item.entityId)
|
||||
val enrollment =
|
||||
enrollments.findByStudentAccountId(student.accountId!!)
|
||||
?: throw ConflictException("У студента ${student.fullName} нет зачисления")
|
||||
val target = ensureTargetGroup(ordered.state.after)
|
||||
val fromGroupId = ordered.state.before.uuidValue("groupId")
|
||||
enrollment.group = target
|
||||
enrollments.save(enrollment)
|
||||
student.enrollmentYear =
|
||||
ordered.state.after
|
||||
.intValue("enrollmentYear")
|
||||
?.toShort()
|
||||
student.status = StudentStatus.ACADEMIC_LEAVE
|
||||
students.save(student)
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = student.accountId!!,
|
||||
fromGroupId = fromGroupId,
|
||||
toGroupId = target.id,
|
||||
action = MembershipAction.ACADEMIC,
|
||||
reason = "Повышение ${run.year}",
|
||||
actorAccountId = actorAccountId,
|
||||
promotionRunId = run.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureTargetGroup(after: Map<String, Any?>): GroupEntity {
|
||||
val target =
|
||||
after.mapValue("targetGroup")
|
||||
?: promotionConflict("В плане академ-перевода нет целевой группы")
|
||||
val id =
|
||||
target.uuidValue("id")
|
||||
?: promotionConflict("В плане академ-перевода нет id целевой группы")
|
||||
groups.findById(id).orElse(null)?.let { return it }
|
||||
if (target["created"] != true) {
|
||||
promotionConflict("Целевая группа ${target.nameValue()} не найдена")
|
||||
}
|
||||
return groups.save(
|
||||
GroupEntity(
|
||||
id = id,
|
||||
name = target.nameValue() ?: promotionConflict("Нет имени целевой группы"),
|
||||
stream = target.textValue("stream"),
|
||||
course = target.intValue("course")?.toShort(),
|
||||
number = target.intValue("number")?.toShort(),
|
||||
degree = target.textValue("degree"),
|
||||
enrollmentYear = target.intValue("enrollmentYear")?.toShort(),
|
||||
status = GroupStatus.ACTIVE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun orderForApply(
|
||||
runItems: List<PromotionRunItem>,
|
||||
states: Map<UUID, PromotionItemState>,
|
||||
): List<OrderedItem> =
|
||||
runItems
|
||||
.mapNotNull { item -> states[item.id]?.let { OrderedItem(item, it) } }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ if (it.item.entityType == PromotionEntityType.GROUP) 0 else 1 },
|
||||
{ if (it.item.entityType == PromotionEntityType.GROUP) -(it.state.before.courseValue() ?: 0) else 0 },
|
||||
{ it.item.action.ordinal },
|
||||
{ it.item.entityId.toString() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun activeEnrollments(groupId: UUID): List<Enrollment> =
|
||||
enrollments.findAllByGroupId(groupId).filter { it.student?.status == StudentStatus.ACTIVE }
|
||||
|
||||
private fun recordHistory(
|
||||
enrollment: Enrollment,
|
||||
from: GroupEntity,
|
||||
to: GroupEntity,
|
||||
action: MembershipAction,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = enrollment.student!!.accountId!!,
|
||||
fromGroupId = from.id,
|
||||
toGroupId = to.id,
|
||||
action = action,
|
||||
reason = "Повышение ${run.year}",
|
||||
actorAccountId = actorAccountId,
|
||||
promotionRunId = run.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadRun(runId: UUID): PromotionRun = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
|
||||
|
||||
private fun loadGroup(groupId: UUID): GroupEntity =
|
||||
groups.findById(groupId).orElseThrow { NotFoundException("Группа $groupId не найдена") }
|
||||
|
||||
private fun loadStudent(studentId: UUID): Student =
|
||||
students.findById(studentId).orElseThrow { NotFoundException("Студент $studentId не найден") }
|
||||
|
||||
private fun updateResult(
|
||||
run: PromotionRun,
|
||||
extra: Map<String, Any?>,
|
||||
) {
|
||||
val current = json.readRunResult(run.id) ?: emptyMap()
|
||||
json.writeRunResult(run.id, current + extra)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_APPLY_STATUSES = listOf(PromotionRunStatus.RUNNING, PromotionRunStatus.COMPLETED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
|
||||
@Configuration
|
||||
class PromotionClockConfig {
|
||||
@Bean
|
||||
fun promotionClock(): Clock = Clock.system(ZoneId.of("Europe/Moscow"))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/promotion")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class PromotionController(
|
||||
private val promotionService: PromotionService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<PromotionRunSummary> = promotionService.list()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): PromotionRunDetail = promotionService.report(id)
|
||||
|
||||
@PostMapping("/dry-run")
|
||||
fun dryRun(
|
||||
@RequestBody(required = false) body: DryRunRequest?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): PromotionRunDetail = promotionService.dryRun(body?.year, UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/{id}/apply")
|
||||
fun apply(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): PromotionRunDetail = promotionService.apply(id, UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
fun cancel(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): PromotionRunDetail = promotionService.cancel(id, UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/{id}/rollback")
|
||||
fun rollback(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): PromotionRunDetail = promotionService.rollback(id, UUID.fromString(jwt.subject))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class PromotionRunSummary(
|
||||
val id: String,
|
||||
val year: Int,
|
||||
val mode: String,
|
||||
val status: String,
|
||||
val actorAccountId: String?,
|
||||
val startedAt: Instant,
|
||||
val finishedAt: Instant?,
|
||||
val result: Map<String, Any?>?,
|
||||
)
|
||||
|
||||
data class PromotionItemView(
|
||||
val id: String,
|
||||
val entityType: String,
|
||||
val entityId: String,
|
||||
val action: String,
|
||||
val status: String,
|
||||
val error: String,
|
||||
val createdAt: Instant,
|
||||
val before: Map<String, Any?>,
|
||||
val after: Map<String, Any?>,
|
||||
)
|
||||
|
||||
data class PromotionRunDetail(
|
||||
val id: String,
|
||||
val year: Int,
|
||||
val mode: String,
|
||||
val status: String,
|
||||
val actorAccountId: String?,
|
||||
val startedAt: Instant,
|
||||
val finishedAt: Instant?,
|
||||
val result: Map<String, Any?>?,
|
||||
val items: List<PromotionItemView>,
|
||||
)
|
||||
|
||||
data class DryRunRequest(
|
||||
val year: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import ru.ysu.lms.identity.domain.PromotionAction
|
||||
import ru.ysu.lms.identity.domain.PromotionEntityType
|
||||
import ru.ysu.lms.identity.domain.PromotionRunItem
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
|
||||
/** Сверка текущего состояния с before/after журнала повышения. */
|
||||
@Component
|
||||
class PromotionItemVerifier(
|
||||
private val groups: GroupRepository,
|
||||
private val students: StudentRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
) {
|
||||
fun verifyBefore(
|
||||
item: PromotionRunItem,
|
||||
state: PromotionItemState?,
|
||||
): String? {
|
||||
if (state == null) return "нет before/after для элемента ${item.id}"
|
||||
return when (item.entityType) {
|
||||
PromotionEntityType.GROUP -> verifyGroupBefore(item, state.before)
|
||||
PromotionEntityType.STUDENT -> verifyStudentBefore(item, state.before, state.after)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyAfter(
|
||||
item: PromotionRunItem,
|
||||
state: PromotionItemState?,
|
||||
): String? {
|
||||
if (state == null) return "нет before/after для элемента ${item.id}"
|
||||
return when (item.entityType) {
|
||||
PromotionEntityType.GROUP -> verifyGroupAfter(item, state.after)
|
||||
PromotionEntityType.STUDENT -> verifyStudentAfter(item, state.after)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyGroupBefore(
|
||||
item: PromotionRunItem,
|
||||
before: Map<String, Any?>,
|
||||
): String? {
|
||||
val group =
|
||||
groups.findById(item.entityId).orElse(null)
|
||||
?: return "группа ${item.entityId} не найдена"
|
||||
if (!groupMatches(group, before)) {
|
||||
return "группа ${group.name}: состояние отличается от плана"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun verifyStudentBefore(
|
||||
item: PromotionRunItem,
|
||||
before: Map<String, Any?>,
|
||||
after: Map<String, Any?>,
|
||||
): String? {
|
||||
val student =
|
||||
students.findById(item.entityId).orElse(null)
|
||||
?: return "студент ${item.entityId} не найден"
|
||||
val currentGroupId =
|
||||
enrollments
|
||||
.findByStudentAccountId(item.entityId)
|
||||
?.group
|
||||
?.id
|
||||
?.toString()
|
||||
if (student.status.name != before.statusValue() ||
|
||||
student.enrollmentYear?.toInt() != before.intValue("enrollmentYear") ||
|
||||
currentGroupId != before.textValue("groupId")
|
||||
) {
|
||||
return "студент ${student.fullName}: состояние отличается от плана"
|
||||
}
|
||||
if (item.action == PromotionAction.ACADEMIC && !targetExists(after)) {
|
||||
return "студент ${student.fullName}: целевая группа недоступна"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun verifyGroupAfter(
|
||||
item: PromotionRunItem,
|
||||
after: Map<String, Any?>,
|
||||
): String? {
|
||||
val group =
|
||||
groups.findById(item.entityId).orElse(null)
|
||||
?: return "группа ${item.entityId} не найдена"
|
||||
if (!groupMatches(group, after)) {
|
||||
return "группа ${group.name}: ожидалось состояние после повышения"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun groupMatches(
|
||||
group: GroupEntity,
|
||||
state: Map<String, Any?>,
|
||||
): Boolean =
|
||||
listOf(
|
||||
group.status.name == state.statusValue(),
|
||||
group.course?.toInt() == state.courseValue(),
|
||||
group.name == state.nameValue(),
|
||||
group.stream == state.textValue("stream"),
|
||||
group.degree == state.textValue("degree"),
|
||||
group.number?.toInt() == state.intValue("number"),
|
||||
group.enrollmentYear?.toInt() == state.intValue("enrollmentYear"),
|
||||
).all { it }
|
||||
|
||||
private fun verifyStudentAfter(
|
||||
item: PromotionRunItem,
|
||||
after: Map<String, Any?>,
|
||||
): String? {
|
||||
val student =
|
||||
students.findById(item.entityId).orElse(null)
|
||||
?: return "студент ${item.entityId} не найден"
|
||||
val currentGroupId =
|
||||
enrollments
|
||||
.findByStudentAccountId(item.entityId)
|
||||
?.group
|
||||
?.id
|
||||
?.toString()
|
||||
if (student.status.name != after.statusValue() ||
|
||||
student.enrollmentYear?.toInt() != after.intValue("enrollmentYear") ||
|
||||
currentGroupId != after.textValue("groupId")
|
||||
) {
|
||||
return "студент ${student.fullName}: ожидалось состояние после повышения"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun targetExists(after: Map<String, Any?>): Boolean {
|
||||
val target = after.mapValue("targetGroup") ?: return false
|
||||
if (target["created"] == true) return true
|
||||
val id = target.uuidValue("id") ?: return false
|
||||
return groups.findById(id).isPresent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Component
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.util.UUID
|
||||
|
||||
data class PromotionItemState(
|
||||
val before: Map<String, Any?>,
|
||||
val after: Map<String, Any?>,
|
||||
)
|
||||
|
||||
/**
|
||||
* JSONB-колонки promotion_runs/promotion_run_items пишутся через JdbcTemplate.
|
||||
*
|
||||
* Hibernate 7.2 в этом проекте подключает Jackson 2 (`JacksonJsonFormatMapper`),
|
||||
* а приложение использует Jackson 3, поэтому `@JdbcTypeCode(SqlTypes.JSON)` на
|
||||
* Jackson 3-типах ненадёжен. JDBC-запись сохраняет совместимость с Jackson 3 и
|
||||
* работает в одной транзакции с JPA (DataSourceUtils).
|
||||
*/
|
||||
@Component
|
||||
class PromotionJsonStore(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val mapper: ObjectMapper,
|
||||
) {
|
||||
fun writeRunResult(
|
||||
runId: UUID,
|
||||
result: Map<String, Any?>,
|
||||
) {
|
||||
jdbc.update(
|
||||
"UPDATE identity.promotion_runs SET result = CAST(? AS jsonb) WHERE id = ?",
|
||||
mapper.writeValueAsString(result),
|
||||
runId,
|
||||
)
|
||||
}
|
||||
|
||||
fun readRunResult(runId: UUID): Map<String, Any?>? =
|
||||
jdbc
|
||||
.queryForList(
|
||||
"SELECT result::text FROM identity.promotion_runs WHERE id = ?",
|
||||
String::class.java,
|
||||
runId,
|
||||
).firstOrNull()
|
||||
?.let(::readMap)
|
||||
|
||||
fun writeItemStates(
|
||||
itemId: UUID,
|
||||
before: Map<String, Any?>,
|
||||
after: Map<String, Any?>,
|
||||
) {
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE identity.promotion_run_items
|
||||
SET before_state = CAST(? AS jsonb),
|
||||
after_state = CAST(? AS jsonb)
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
mapper.writeValueAsString(before),
|
||||
mapper.writeValueAsString(after),
|
||||
itemId,
|
||||
)
|
||||
}
|
||||
|
||||
fun readItemStates(runId: UUID): Map<UUID, PromotionItemState> =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT id, before_state::text AS before_state, after_state::text AS after_state
|
||||
FROM identity.promotion_run_items
|
||||
WHERE run_id = ?
|
||||
""".trimIndent(),
|
||||
{ rs, _ ->
|
||||
UUID.fromString(rs.getString("id")) to
|
||||
PromotionItemState(
|
||||
before = readMap(rs.getString("before_state")) ?: emptyMap(),
|
||||
after = readMap(rs.getString("after_state")) ?: emptyMap(),
|
||||
)
|
||||
},
|
||||
runId,
|
||||
).toMap()
|
||||
|
||||
private fun readMap(json: String?): Map<String, Any?>? {
|
||||
if (json == null) return null
|
||||
val raw = mapper.readValue(json, Map::class.java) as Map<*, *>
|
||||
return raw.entries.associate { it.key.toString() to it.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.Ids
|
||||
import ru.ysu.lms.identity.domain.Enrollment
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.PromotionAction
|
||||
import ru.ysu.lms.identity.domain.PromotionEntityType
|
||||
import ru.ysu.lms.identity.domain.Student
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class PlannedPromotion(
|
||||
val entityType: PromotionEntityType,
|
||||
val entityId: UUID,
|
||||
val action: PromotionAction,
|
||||
val before: Map<String, Any?>,
|
||||
val after: Map<String, Any?>,
|
||||
)
|
||||
|
||||
data class PromotionPlan(
|
||||
val items: List<PlannedPromotion>,
|
||||
val result: Map<String, Any?>,
|
||||
)
|
||||
|
||||
private data class TargetKey(
|
||||
val stream: String,
|
||||
val number: Short,
|
||||
val degree: String,
|
||||
val course: Short,
|
||||
val enrollmentYear: Short,
|
||||
)
|
||||
|
||||
private data class TargetGroup(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val stream: String,
|
||||
val number: Short,
|
||||
val degree: String,
|
||||
val course: Short,
|
||||
val enrollmentYear: Short,
|
||||
val created: Boolean,
|
||||
) {
|
||||
fun asState(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"id" to id.toString(),
|
||||
"name" to name,
|
||||
"stream" to stream,
|
||||
"number" to number.toInt(),
|
||||
"degree" to degree,
|
||||
"course" to course.toInt(),
|
||||
"enrollmentYear" to enrollmentYear.toInt(),
|
||||
"created" to created,
|
||||
)
|
||||
}
|
||||
|
||||
private data class AcademicSource(
|
||||
val stream: String,
|
||||
val course: Short,
|
||||
val number: Short,
|
||||
val degree: String,
|
||||
)
|
||||
|
||||
private data class GroupBasic(
|
||||
val course: Short,
|
||||
val degree: String,
|
||||
val limit: Int,
|
||||
val stream: String?,
|
||||
val number: Short?,
|
||||
)
|
||||
|
||||
@Component
|
||||
class PromotionPlanner(
|
||||
private val groups: GroupRepository,
|
||||
private val students: StudentRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun plan(year: Int): PromotionPlan {
|
||||
val warnings = mutableListOf<String>()
|
||||
val allGroups = groups.findAll()
|
||||
val enrollmentByStudent = enrollmentIndex()
|
||||
val groupChanges = planGroups(allGroups, warnings)
|
||||
val graduatedGroupIds = groupChanges.filter { it.action == PromotionAction.GRADUATE }.map { it.entityId }.toSet()
|
||||
val targetCache = mutableMapOf<TargetKey, TargetGroup>()
|
||||
val studentChanges = mutableListOf<PlannedPromotion>()
|
||||
students.findAll().forEach { student ->
|
||||
planStudent(student, enrollmentByStudent[student.accountId], year, graduatedGroupIds, targetCache, warnings)
|
||||
?.let(studentChanges::add)
|
||||
}
|
||||
val items = groupChanges + studentChanges
|
||||
return PromotionPlan(items = items, result = summary(year, items, warnings))
|
||||
}
|
||||
|
||||
private fun enrollmentIndex(): Map<UUID, Enrollment> =
|
||||
enrollments
|
||||
.findAllWithStudentAndGroup()
|
||||
.mapNotNull { enrollment ->
|
||||
val studentId = enrollment.student?.accountId ?: return@mapNotNull null
|
||||
studentId to enrollment
|
||||
}.toMap()
|
||||
|
||||
private fun planGroups(
|
||||
allGroups: List<GroupEntity>,
|
||||
warnings: MutableList<String>,
|
||||
): List<PlannedPromotion> =
|
||||
allGroups
|
||||
.filter { it.status == GroupStatus.ACTIVE }
|
||||
.sortedByDescending { it.course ?: 0 }
|
||||
.mapNotNull { group -> planGroup(group, warnings) }
|
||||
|
||||
private fun planGroup(
|
||||
group: GroupEntity,
|
||||
warnings: MutableList<String>,
|
||||
): PlannedPromotion? {
|
||||
val basic = groupBasic(group)
|
||||
if (basic == null) {
|
||||
warnings.add("Группа ${group.name}: не заполнены или некорректны курс/степень — пропущена")
|
||||
return null
|
||||
}
|
||||
val before = groupState(group)
|
||||
if (basic.course < basic.limit) {
|
||||
if (basic.stream == null || basic.number == null) {
|
||||
warnings.add("Группа ${group.name}: не заполнены поток/номер — пропущена")
|
||||
return null
|
||||
}
|
||||
val nextCourse = (basic.course + 1).toShort()
|
||||
return PlannedPromotion(
|
||||
entityType = PromotionEntityType.GROUP,
|
||||
entityId = group.id,
|
||||
action = PromotionAction.PROMOTE,
|
||||
before = before,
|
||||
after =
|
||||
before +
|
||||
mapOf(
|
||||
"course" to nextCourse.toInt(),
|
||||
"name" to groupName(basic.stream, nextCourse, basic.number, basic.degree),
|
||||
),
|
||||
)
|
||||
}
|
||||
return PlannedPromotion(
|
||||
entityType = PromotionEntityType.GROUP,
|
||||
entityId = group.id,
|
||||
action = PromotionAction.GRADUATE,
|
||||
before = before,
|
||||
after = before + mapOf("status" to GroupStatus.GRADUATED.name),
|
||||
)
|
||||
}
|
||||
|
||||
private fun groupBasic(group: GroupEntity): GroupBasic? {
|
||||
val course = group.course ?: return null
|
||||
val degree = group.degree ?: return null
|
||||
val limit = courseLimit(degree) ?: return null
|
||||
return GroupBasic(course, degree, limit, group.stream, group.number)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun planStudent(
|
||||
student: Student,
|
||||
enrollment: Enrollment?,
|
||||
year: Int,
|
||||
graduatedGroupIds: Set<UUID>,
|
||||
targetCache: MutableMap<TargetKey, TargetGroup>,
|
||||
warnings: MutableList<String>,
|
||||
): PlannedPromotion? =
|
||||
when (student.status) {
|
||||
StudentStatus.ACTIVE -> planActiveStudent(student, enrollment, graduatedGroupIds)
|
||||
StudentStatus.ACADEMIC_LEAVE ->
|
||||
planAcademicStudent(student, enrollment, year, targetCache, warnings)
|
||||
StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED -> null
|
||||
}
|
||||
|
||||
private fun planActiveStudent(
|
||||
student: Student,
|
||||
enrollment: Enrollment?,
|
||||
graduatedGroupIds: Set<UUID>,
|
||||
): PlannedPromotion? {
|
||||
val group = enrollment?.group ?: return null
|
||||
if (group.id !in graduatedGroupIds) return null
|
||||
val before = studentState(student, group)
|
||||
return PlannedPromotion(
|
||||
entityType = PromotionEntityType.STUDENT,
|
||||
entityId = student.accountId!!,
|
||||
action = PromotionAction.GRADUATE,
|
||||
before = before,
|
||||
after = before + mapOf("status" to StudentStatus.GRADUATED.name),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun planAcademicStudent(
|
||||
student: Student,
|
||||
enrollment: Enrollment?,
|
||||
year: Int,
|
||||
targetCache: MutableMap<TargetKey, TargetGroup>,
|
||||
warnings: MutableList<String>,
|
||||
): PlannedPromotion? {
|
||||
val group = enrollment?.group
|
||||
if (group == null) {
|
||||
warnings.add("Студент ${student.fullName}: нет зачисления — академ пропущен")
|
||||
return null
|
||||
}
|
||||
val source = academicSource(group)
|
||||
if (source == null) {
|
||||
warnings.add("Студент ${student.fullName}: у группы ${group.name} не заполнены поля — академ пропущен")
|
||||
return null
|
||||
}
|
||||
val targetYear = year - (source.course - 1)
|
||||
if (targetYear < 2000 || targetYear > 2100) {
|
||||
warnings.add("Студент ${student.fullName}: некорректный год целевой группы $targetYear")
|
||||
return null
|
||||
}
|
||||
val key = TargetKey(source.stream, source.number, source.degree, source.course, targetYear.toShort())
|
||||
val target = targetCache.getOrPut(key) { resolveTarget(key) }
|
||||
val before = studentState(student, group)
|
||||
val after =
|
||||
before +
|
||||
mapOf(
|
||||
"status" to StudentStatus.ACADEMIC_LEAVE.name,
|
||||
"enrollmentYear" to target.enrollmentYear.toInt(),
|
||||
"groupId" to target.id.toString(),
|
||||
"groupName" to target.name,
|
||||
"targetGroup" to target.asState(),
|
||||
)
|
||||
return PlannedPromotion(
|
||||
entityType = PromotionEntityType.STUDENT,
|
||||
entityId = student.accountId!!,
|
||||
action = PromotionAction.ACADEMIC,
|
||||
before = before,
|
||||
after = after,
|
||||
)
|
||||
}
|
||||
|
||||
private fun academicSource(group: GroupEntity): AcademicSource? {
|
||||
val stream = group.stream
|
||||
val course = group.course
|
||||
if (stream == null || course == null) return null
|
||||
val number = group.number
|
||||
val degree = group.degree
|
||||
if (number == null || degree == null) return null
|
||||
return AcademicSource(stream, course, number, degree)
|
||||
}
|
||||
|
||||
private fun resolveTarget(key: TargetKey): TargetGroup {
|
||||
val existing =
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
key.stream,
|
||||
key.degree,
|
||||
key.number,
|
||||
key.course,
|
||||
key.enrollmentYear,
|
||||
)
|
||||
if (existing != null && existing.status == GroupStatus.ACTIVE) {
|
||||
return TargetGroup(
|
||||
id = existing.id,
|
||||
name = existing.name,
|
||||
stream = key.stream,
|
||||
number = key.number,
|
||||
degree = key.degree,
|
||||
course = key.course,
|
||||
enrollmentYear = key.enrollmentYear,
|
||||
created = false,
|
||||
)
|
||||
}
|
||||
return TargetGroup(
|
||||
id = Ids.v7(),
|
||||
name = groupName(key.stream, key.course, key.number, key.degree),
|
||||
stream = key.stream,
|
||||
number = key.number,
|
||||
degree = key.degree,
|
||||
course = key.course,
|
||||
enrollmentYear = key.enrollmentYear,
|
||||
created = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun summary(
|
||||
year: Int,
|
||||
items: List<PlannedPromotion>,
|
||||
warnings: List<String>,
|
||||
): Map<String, Any?> =
|
||||
mapOf(
|
||||
"year" to year,
|
||||
"generatedAt" to Instant.now(clock).toString(),
|
||||
"counts" to
|
||||
mapOf(
|
||||
"groupsPromote" to items.count { it.entityType == PromotionEntityType.GROUP && it.action == PromotionAction.PROMOTE },
|
||||
"groupsGraduate" to items.count { it.entityType == PromotionEntityType.GROUP && it.action == PromotionAction.GRADUATE },
|
||||
"studentsGraduate" to
|
||||
items.count { it.entityType == PromotionEntityType.STUDENT && it.action == PromotionAction.GRADUATE },
|
||||
"studentsAcademic" to
|
||||
items.count { it.entityType == PromotionEntityType.STUDENT && it.action == PromotionAction.ACADEMIC },
|
||||
),
|
||||
"warnings" to warnings,
|
||||
)
|
||||
|
||||
private fun groupState(group: GroupEntity): Map<String, Any?> =
|
||||
mapOf(
|
||||
"id" to group.id.toString(),
|
||||
"name" to group.name,
|
||||
"stream" to group.stream,
|
||||
"course" to group.course?.toInt(),
|
||||
"number" to group.number?.toInt(),
|
||||
"degree" to group.degree,
|
||||
"enrollmentYear" to group.enrollmentYear?.toInt(),
|
||||
"status" to group.status.name,
|
||||
)
|
||||
|
||||
private fun studentState(
|
||||
student: Student,
|
||||
group: GroupEntity,
|
||||
): Map<String, Any?> =
|
||||
mapOf(
|
||||
"id" to student.accountId.toString(),
|
||||
"studentNo" to student.studentNo,
|
||||
"fullName" to student.fullName,
|
||||
"status" to student.status.name,
|
||||
"enrollmentYear" to student.enrollmentYear?.toInt(),
|
||||
"groupId" to group.id.toString(),
|
||||
"groupName" to group.name,
|
||||
)
|
||||
|
||||
private fun groupName(
|
||||
stream: String,
|
||||
course: Short,
|
||||
number: Short,
|
||||
degree: String,
|
||||
): String = "$stream-$course$number$degree"
|
||||
|
||||
private fun courseLimit(degree: String): Int? =
|
||||
when (degree) {
|
||||
"БО" -> 4
|
||||
"МО" -> 2
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.MembershipHistory
|
||||
import ru.ysu.lms.identity.domain.PromotionAction
|
||||
import ru.ysu.lms.identity.domain.PromotionEntityType
|
||||
import ru.ysu.lms.identity.domain.PromotionItemStatus
|
||||
import ru.ysu.lms.identity.domain.PromotionRun
|
||||
import ru.ysu.lms.identity.domain.PromotionRunItem
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Component
|
||||
class PromotionRollback(
|
||||
private val runs: PromotionRunRepository,
|
||||
private val items: PromotionRunItemRepository,
|
||||
private val json: PromotionJsonStore,
|
||||
private val groups: GroupRepository,
|
||||
private val students: StudentRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val history: MembershipHistoryRepository,
|
||||
private val verifier: PromotionItemVerifier,
|
||||
private val runLock: PromotionRunLock,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@Transactional
|
||||
fun rollback(
|
||||
runId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
runLock.acquire(runId)
|
||||
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
|
||||
if (run.status != PromotionRunStatus.COMPLETED) {
|
||||
promotionConflict("Откатить можно только завершённое повышение (статус ${run.status})")
|
||||
}
|
||||
val states = json.readItemStates(runId)
|
||||
val applied =
|
||||
items
|
||||
.findByRunIdOrderByIdAsc(runId)
|
||||
.filter { it.status == PromotionItemStatus.APPLIED }
|
||||
.map { OrderedRollbackItem(it, states[it.id]) }
|
||||
.sortedWith(rollbackOrder())
|
||||
.reversed()
|
||||
val conflicts = applied.mapNotNull { verifier.verifyAfter(it.item, it.state) }
|
||||
if (conflicts.isNotEmpty()) {
|
||||
promotionConflict("Ручные изменения мешают откату: ${conflicts.joinToString("; ")}")
|
||||
}
|
||||
applied.forEach { restoreItem(it, run, actorAccountId) }
|
||||
deleteCreatedGroups(applied)
|
||||
run.status = PromotionRunStatus.ROLLED_BACK
|
||||
run.finishedAt = Instant.now(clock)
|
||||
runs.saveAndFlush(run)
|
||||
updateResult(run.id, mapOf("status" to "ROLLED_BACK", "rolledBackAt" to Instant.now(clock).toString()))
|
||||
}
|
||||
|
||||
private fun restoreItem(
|
||||
ordered: OrderedRollbackItem,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val state = ordered.state ?: throw ConflictException("Нет before/after для элемента ${ordered.item.id}")
|
||||
when (ordered.item.entityType) {
|
||||
PromotionEntityType.GROUP -> restoreGroup(ordered.item, state, run, actorAccountId)
|
||||
PromotionEntityType.STUDENT -> restoreStudent(ordered.item, state, run, actorAccountId)
|
||||
}
|
||||
ordered.item.status = PromotionItemStatus.ROLLED_BACK
|
||||
ordered.item.error = ""
|
||||
items.save(ordered.item)
|
||||
}
|
||||
|
||||
private fun restoreGroup(
|
||||
item: PromotionRunItem,
|
||||
state: PromotionItemState,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val group = groups.findById(item.entityId).orElseThrow { NotFoundException("Группа ${item.entityId} не найдена") }
|
||||
group.name = state.before.nameValue() ?: group.name
|
||||
group.course = state.before.courseValue()?.toShort() ?: group.course
|
||||
group.status = GroupStatus.valueOf(state.before.statusValue() ?: group.status.name)
|
||||
groups.save(group)
|
||||
if (item.action == PromotionAction.PROMOTE) {
|
||||
activeEnrollments(group.id).forEach { enrollment ->
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = enrollment.student!!.accountId!!,
|
||||
fromGroupId = group.id,
|
||||
toGroupId = group.id,
|
||||
action = MembershipAction.RETURN,
|
||||
reason = "Откат повышения ${run.year}",
|
||||
actorAccountId = actorAccountId,
|
||||
promotionRunId = run.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreStudent(
|
||||
item: PromotionRunItem,
|
||||
state: PromotionItemState,
|
||||
run: PromotionRun,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val student = students.findById(item.entityId).orElseThrow { NotFoundException("Студент ${item.entityId} не найден") }
|
||||
val beforeGroupId = state.before.uuidValue("groupId")
|
||||
val afterGroupId = state.after.uuidValue("groupId")
|
||||
val enrollment =
|
||||
enrollments.findByStudentAccountId(student.accountId!!)
|
||||
?: throw ConflictException("У студента ${student.fullName} нет зачисления")
|
||||
if (beforeGroupId != null && enrollment.group?.id != beforeGroupId) {
|
||||
enrollment.group =
|
||||
groups.findById(beforeGroupId).orElseThrow {
|
||||
ConflictException("Группа $beforeGroupId для отката не найдена")
|
||||
}
|
||||
enrollments.save(enrollment)
|
||||
}
|
||||
student.status = StudentStatus.valueOf(state.before.statusValue() ?: student.status.name)
|
||||
student.enrollmentYear = state.before.intValue("enrollmentYear")?.toShort()
|
||||
students.save(student)
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = student.accountId!!,
|
||||
fromGroupId = if (item.action == PromotionAction.ACADEMIC) afterGroupId else null,
|
||||
toGroupId = beforeGroupId,
|
||||
action = MembershipAction.RETURN,
|
||||
reason = "Откат повышения ${run.year}",
|
||||
actorAccountId = actorAccountId,
|
||||
promotionRunId = run.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteCreatedGroups(applied: List<OrderedRollbackItem>) {
|
||||
val createdIds =
|
||||
applied
|
||||
.filter { it.item.entityType == PromotionEntityType.STUDENT && it.item.action == PromotionAction.ACADEMIC }
|
||||
.mapNotNull { it.state?.after?.mapValue("targetGroup") }
|
||||
.filter { it["created"] == true }
|
||||
.mapNotNull { it.uuidValue("id") }
|
||||
.distinct()
|
||||
createdIds.forEach { groupId ->
|
||||
if (enrollments.countByGroupId(groupId) == 0L) {
|
||||
groups.findById(groupId).orElse(null)?.let(groups::delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollbackOrder(): Comparator<OrderedRollbackItem> =
|
||||
compareBy(
|
||||
{ if (it.item.entityType == PromotionEntityType.GROUP) 0 else 1 },
|
||||
{ if (it.item.entityType == PromotionEntityType.GROUP) -(it.state?.before?.courseValue() ?: 0) else 0 },
|
||||
{ it.item.action.ordinal },
|
||||
{ it.item.entityId.toString() },
|
||||
)
|
||||
|
||||
private fun activeEnrollments(groupId: UUID) =
|
||||
enrollments.findAllByGroupId(groupId).filter { it.student?.status == StudentStatus.ACTIVE }
|
||||
|
||||
private fun updateResult(
|
||||
runId: UUID,
|
||||
extra: Map<String, Any?>,
|
||||
) {
|
||||
val current = json.readRunResult(runId) ?: emptyMap()
|
||||
json.writeRunResult(runId, current + extra)
|
||||
}
|
||||
}
|
||||
|
||||
private data class OrderedRollbackItem(
|
||||
val item: PromotionRunItem,
|
||||
val state: PromotionItemState?,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.jdbc.core.ResultSetExtractor
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Транзакционная блокировка на конкретный запуск повышения.
|
||||
*
|
||||
* Блокировка удерживается до конца текущей транзакции, поэтому повторный
|
||||
* apply/rollback того же запуска не может параллельно продублировать историю.
|
||||
*/
|
||||
@Component
|
||||
class PromotionRunLock(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) {
|
||||
fun acquire(runId: UUID) {
|
||||
jdbc.query(
|
||||
"SELECT pg_advisory_xact_lock(hashtext(?))",
|
||||
ResultSetExtractor { rs ->
|
||||
rs.next()
|
||||
Unit
|
||||
},
|
||||
"promotion-run-$runId",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/** Автоматический dry-run 31 августа 00:00 МСК + догон при старте сервиса. */
|
||||
@Component
|
||||
@ConditionalOnProperty(
|
||||
prefix = "lms.promotion",
|
||||
name = ["scheduler-enabled"],
|
||||
havingValue = "true",
|
||||
matchIfMissing = true,
|
||||
)
|
||||
class PromotionScheduler(
|
||||
private val promotionService: PromotionService,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Scheduled(cron = "0 0 0 31 8 *", zone = "Europe/Moscow")
|
||||
fun scheduledDryRun() {
|
||||
runCatching { promotionService.ensureScheduledDryRun() }
|
||||
.onFailure { log.error("Не удалось построить плановый dry-run повышения", it) }
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun catchUpDryRun() {
|
||||
runCatching { promotionService.ensureScheduledDryRun() }
|
||||
.onFailure { log.error("Догон dry-run повышения при старте не удался", it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.identity.domain.PromotionItemStatus
|
||||
import ru.ysu.lms.identity.domain.PromotionMode
|
||||
import ru.ysu.lms.identity.domain.PromotionRun
|
||||
import ru.ysu.lms.identity.domain.PromotionRunItem
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import ru.ysu.lms.identity.repo.PromotionRunItemRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunRepository
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class PromotionService(
|
||||
private val runs: PromotionRunRepository,
|
||||
private val items: PromotionRunItemRepository,
|
||||
private val json: PromotionJsonStore,
|
||||
private val planner: PromotionPlanner,
|
||||
private val applier: PromotionApplier,
|
||||
private val rollback: PromotionRollback,
|
||||
private val runLock: PromotionRunLock,
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<PromotionRunSummary> = runs.findAllByOrderByYearDescStartedAtDesc().map(::summary)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun report(runId: UUID): PromotionRunDetail = detail(runId)
|
||||
|
||||
@Transactional
|
||||
fun dryRun(
|
||||
year: Int? = null,
|
||||
actorAccountId: UUID? = null,
|
||||
): PromotionRunDetail {
|
||||
val targetYear = year ?: LocalDate.now(clock).year
|
||||
validateYear(targetYear)
|
||||
val existing = runs.findFirstByYearAndModeOrderByStartedAtDesc(targetYear, PromotionMode.DRY_RUN)
|
||||
if (existing != null && existing.status == PromotionRunStatus.PLANNED) {
|
||||
return detail(existing.id)
|
||||
}
|
||||
if (runs.existsByYearAndModeAndStatusIn(targetYear, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
|
||||
throw ConflictException("Повышение за $targetYear уже применено; сначала выполните откат")
|
||||
}
|
||||
return createPlan(targetYear, actorAccountId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun apply(
|
||||
runId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
): PromotionRunDetail {
|
||||
applier.apply(runId, actorAccountId)
|
||||
return detail(runId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancel(
|
||||
runId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
): PromotionRunDetail {
|
||||
runLock.acquire(runId)
|
||||
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
|
||||
if (run.status != PromotionRunStatus.PLANNED || run.mode != PromotionMode.DRY_RUN) {
|
||||
throw ConflictException("Отменить можно только запланированный dry-run (статус ${run.status})")
|
||||
}
|
||||
val now = Instant.now(clock)
|
||||
run.status = PromotionRunStatus.FAILED
|
||||
run.finishedAt = now
|
||||
runs.saveAndFlush(run)
|
||||
updateResult(
|
||||
run.id,
|
||||
mapOf(
|
||||
"status" to "CANCELLED",
|
||||
"cancelledAt" to now.toString(),
|
||||
"cancelledBy" to actorAccountId?.toString(),
|
||||
),
|
||||
)
|
||||
return detail(run.id)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun rollback(
|
||||
runId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
): PromotionRunDetail {
|
||||
rollback.rollback(runId, actorAccountId)
|
||||
return detail(runId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun ensureScheduledDryRun(): PromotionRunDetail? = ensureScheduledDryRun(LocalDate.now(clock))
|
||||
|
||||
/**
|
||||
* Идемпотентная страховка для планировщика и догона при старте.
|
||||
* Блокировка advisory lock не даёт двум инстансам создать два плана.
|
||||
*/
|
||||
@Transactional
|
||||
fun ensureScheduledDryRun(today: LocalDate): PromotionRunDetail? {
|
||||
val year = today.year
|
||||
if (today.isBefore(LocalDate.of(year, AUGUST, LAST_DAY_OF_AUGUST))) return null
|
||||
val acquired =
|
||||
jdbc.queryForObject(
|
||||
"SELECT pg_try_advisory_xact_lock(hashtext(?))",
|
||||
Boolean::class.java,
|
||||
"promotion-ensure-$year",
|
||||
) ?: false
|
||||
if (!acquired || runs.existsByYear(year) || runs.existsByYearAndModeAndStatusIn(year, PromotionMode.APPLY, ACTIVE_APPLY_STATUSES)) {
|
||||
return null
|
||||
}
|
||||
return createPlan(year, null)
|
||||
}
|
||||
|
||||
private fun createPlan(
|
||||
year: Int,
|
||||
actorAccountId: UUID?,
|
||||
): PromotionRunDetail {
|
||||
val plan = planner.plan(year)
|
||||
val now = Instant.now(clock)
|
||||
val run =
|
||||
runs.saveAndFlush(
|
||||
PromotionRun(
|
||||
year = year,
|
||||
mode = PromotionMode.DRY_RUN,
|
||||
actorAccountId = actorAccountId,
|
||||
status = PromotionRunStatus.PLANNED,
|
||||
startedAt = now,
|
||||
),
|
||||
)
|
||||
val entities =
|
||||
plan.items.map { planned ->
|
||||
PromotionRunItem(
|
||||
runId = run.id,
|
||||
entityType = planned.entityType,
|
||||
entityId = planned.entityId,
|
||||
action = planned.action,
|
||||
status = PromotionItemStatus.PLANNED,
|
||||
createdAt = now,
|
||||
)
|
||||
}
|
||||
val saved = items.saveAllAndFlush(entities)
|
||||
plan.items.zip(saved).forEach { (planned, item) ->
|
||||
json.writeItemStates(item.id, planned.before, planned.after)
|
||||
}
|
||||
json.writeRunResult(run.id, plan.result)
|
||||
return detail(run.id)
|
||||
}
|
||||
|
||||
private fun detail(runId: UUID): PromotionRunDetail {
|
||||
val run = runs.findById(runId).orElseThrow { NotFoundException("Запуск повышения не найден") }
|
||||
val states = json.readItemStates(runId)
|
||||
val itemViews =
|
||||
items.findByRunIdOrderByIdAsc(runId).map { item ->
|
||||
val state = states[item.id]
|
||||
PromotionItemView(
|
||||
id = item.id.toString(),
|
||||
entityType = item.entityType.name,
|
||||
entityId = item.entityId.toString(),
|
||||
action = item.action.name,
|
||||
status = item.status.name,
|
||||
error = item.error,
|
||||
createdAt = item.createdAt,
|
||||
before = state?.before ?: emptyMap(),
|
||||
after = state?.after ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
return PromotionRunDetail(
|
||||
id = run.id.toString(),
|
||||
year = run.year,
|
||||
mode = run.mode.name,
|
||||
status = run.status.name,
|
||||
actorAccountId = run.actorAccountId?.toString(),
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = run.finishedAt,
|
||||
result = json.readRunResult(runId),
|
||||
items = itemViews,
|
||||
)
|
||||
}
|
||||
|
||||
private fun summary(run: PromotionRun): PromotionRunSummary =
|
||||
PromotionRunSummary(
|
||||
id = run.id.toString(),
|
||||
year = run.year,
|
||||
mode = run.mode.name,
|
||||
status = run.status.name,
|
||||
actorAccountId = run.actorAccountId?.toString(),
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = run.finishedAt,
|
||||
result = json.readRunResult(run.id),
|
||||
)
|
||||
|
||||
private fun validateYear(year: Int) {
|
||||
if (year !in YEAR_RANGE) {
|
||||
throw BadRequestException("Год повышения вне диапазона ${YEAR_RANGE.first}–${YEAR_RANGE.last}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateResult(
|
||||
runId: UUID,
|
||||
extra: Map<String, Any?>,
|
||||
) {
|
||||
val current = json.readRunResult(runId) ?: emptyMap()
|
||||
json.writeRunResult(runId, current + extra)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUGUST = 8
|
||||
const val LAST_DAY_OF_AUGUST = 31
|
||||
val YEAR_RANGE = 2000..2100
|
||||
val ACTIVE_APPLY_STATUSES = listOf(PromotionRunStatus.RUNNING, PromotionRunStatus.COMPLETED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import java.util.UUID
|
||||
|
||||
internal fun promotionConflict(message: String): Nothing = throw ConflictException(message)
|
||||
|
||||
internal fun Map<String, Any?>.textValue(key: String): String? = this[key] as? String
|
||||
|
||||
internal fun Map<String, Any?>.intValue(key: String): Int? = (this[key] as? Number)?.toInt()
|
||||
|
||||
internal fun Map<String, Any?>.uuidValue(key: String): UUID? = textValue(key)?.let(UUID::fromString)
|
||||
|
||||
internal fun Map<String, Any?>.mapValue(key: String): Map<String, Any?>? =
|
||||
(this[key] as? Map<*, *>)?.entries?.associate { it.key.toString() to it.value }
|
||||
|
||||
internal fun Map<String, Any?>.courseValue(): Int? = intValue("course")
|
||||
|
||||
internal fun Map<String, Any?>.statusValue(): String? = textValue("status")
|
||||
|
||||
internal fun Map<String, Any?>.nameValue(): String? = textValue("name")
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
@@ -14,6 +15,10 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||
@Param("groupId") groupId: UUID,
|
||||
): List<Enrollment>
|
||||
|
||||
@Query("select e from Enrollment e join fetch e.student join fetch e.group")
|
||||
fun findAllWithStudentAndGroup(): List<Enrollment>
|
||||
|
||||
@EntityGraph(attributePaths = ["group"])
|
||||
fun findByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.PromotionRunItem
|
||||
import java.util.UUID
|
||||
|
||||
interface PromotionRunItemRepository : JpaRepository<PromotionRunItem, UUID> {
|
||||
fun findByRunIdOrderByIdAsc(runId: UUID): List<PromotionRunItem>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.PromotionMode
|
||||
import ru.ysu.lms.identity.domain.PromotionRun
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import java.util.UUID
|
||||
|
||||
interface PromotionRunRepository : JpaRepository<PromotionRun, UUID> {
|
||||
fun findAllByOrderByYearDescStartedAtDesc(): List<PromotionRun>
|
||||
|
||||
fun findFirstByYearAndModeOrderByStartedAtDesc(
|
||||
year: Int,
|
||||
mode: PromotionMode,
|
||||
): PromotionRun?
|
||||
|
||||
fun findFirstByYearAndModeAndStatusInOrderByStartedAtDesc(
|
||||
year: Int,
|
||||
mode: PromotionMode,
|
||||
statuses: Collection<PromotionRunStatus>,
|
||||
): PromotionRun?
|
||||
|
||||
fun existsByYear(year: Int): Boolean
|
||||
|
||||
fun existsByYearAndModeAndStatusIn(
|
||||
year: Int,
|
||||
mode: PromotionMode,
|
||||
statuses: Collection<PromotionRunStatus>,
|
||||
): Boolean
|
||||
}
|
||||
@@ -87,6 +87,12 @@ class GroupController(
|
||||
@PathVariable id: UUID,
|
||||
): GroupDetail = groupService.detail(id)
|
||||
|
||||
/** Архив группы без массового изменения статусов студентов. */
|
||||
@PostMapping("/{id}/archive")
|
||||
fun archive(
|
||||
@PathVariable id: UUID,
|
||||
): GroupDetail = groupService.archiveGroup(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@PathVariable id: UUID,
|
||||
|
||||
@@ -270,11 +270,27 @@ class GroupService(
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Удаляет студента из identity: зачисление, профиль, аккаунт. */
|
||||
/** Архивирует группу, не меняя статусы студентов. */
|
||||
@Transactional
|
||||
fun archiveGroup(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
if (group.status == GroupStatus.ARCHIVED) {
|
||||
throw BadRequestException("Группа ${group.name} уже в архиве")
|
||||
}
|
||||
group.status = GroupStatus.ARCHIVED
|
||||
groups.save(group)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет студента из identity: зачисление, профиль, аккаунт.
|
||||
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteUser(
|
||||
accountId: UUID,
|
||||
currentAccountId: UUID,
|
||||
confirm: String,
|
||||
): DeleteUserResult {
|
||||
if (accountId == currentAccountId) {
|
||||
throw BadRequestException("Нельзя удалить собственный аккаунт")
|
||||
@@ -285,12 +301,26 @@ class GroupService(
|
||||
}
|
||||
val student = students.findById(accountId).orElse(null)
|
||||
val fullName = student?.fullName.orEmpty()
|
||||
validateDeleteConfirmation(account, fullName, confirm)
|
||||
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
|
||||
if (student != null) students.delete(student)
|
||||
accounts.delete(account)
|
||||
return DeleteUserResult(accountId.toString(), account.login, fullName)
|
||||
}
|
||||
|
||||
private fun validateDeleteConfirmation(
|
||||
account: Account,
|
||||
fullName: String,
|
||||
confirm: String,
|
||||
) {
|
||||
val expected = setOf(account.login, fullName).filter { it.isNotBlank() }
|
||||
if (confirm.trim() !in expected) {
|
||||
throw BadRequestException(
|
||||
"Неверное подтверждение удаления: укажите логин ${account.login} или ФИО ${fullName.ifBlank { "студента" }}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||
val student =
|
||||
|
||||
@@ -29,10 +29,11 @@ class StudentController(
|
||||
@RequestParam(required = false, name = "q") query: String?,
|
||||
): List<AdminUser> = groupService.users(query)
|
||||
|
||||
/** Полное удаление пользователя из identity. */
|
||||
/** Полное удаление пользователя из identity; [confirm] — логин или ФИО студента. */
|
||||
@DeleteMapping("/{id}")
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
@RequestParam("confirm") confirm: String,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject))
|
||||
): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject), confirm)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/students")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class StudentLifecycleController(
|
||||
private val lifecycle: StudentLifecycleService,
|
||||
) {
|
||||
@PostMapping("/{id}/academic-leave")
|
||||
fun academicLeave(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody(required = false) body: StudentReasonRequest?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentLifecycleResult = lifecycle.academicLeave(id, body, actor(jwt))
|
||||
|
||||
@PostMapping("/{id}/return")
|
||||
fun returnFromLeave(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: StudentGroupRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentLifecycleResult = lifecycle.returnFromLeave(id, body, actor(jwt))
|
||||
|
||||
@PostMapping("/{id}/expel")
|
||||
fun expel(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody(required = false) body: StudentReasonRequest?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentLifecycleResult = lifecycle.expel(id, body, actor(jwt))
|
||||
|
||||
@PostMapping("/{id}/archive")
|
||||
fun archive(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentLifecycleResult = lifecycle.archive(id, actor(jwt))
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
fun restore(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: StudentGroupRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): StudentLifecycleResult = lifecycle.restore(id, body, actor(jwt))
|
||||
|
||||
private fun actor(jwt: Jwt): UUID = UUID.fromString(jwt.subject)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import jakarta.validation.constraints.NotNull
|
||||
import java.util.UUID
|
||||
|
||||
/** Тело операций жизненного цикла, где нужна только причина. */
|
||||
data class StudentReasonRequest(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/** Возврат из академотпуска и восстановление из архива требуют целевую группу. */
|
||||
data class StudentGroupRequest(
|
||||
@field:NotNull(message = "Укажите группу")
|
||||
val groupId: UUID? = null,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/** Компактный результат операции жизненного цикла студента. */
|
||||
data class StudentLifecycleResult(
|
||||
val studentId: String,
|
||||
val fullName: String,
|
||||
val status: String,
|
||||
val action: String,
|
||||
val fromGroupId: String? = null,
|
||||
val fromGroupName: String? = null,
|
||||
val toGroupId: String? = null,
|
||||
val toGroupName: String? = null,
|
||||
val enrollmentYear: Short? = null,
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.AccountStatus
|
||||
import ru.ysu.lms.identity.domain.Enrollment
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.MembershipHistory
|
||||
import ru.ysu.lms.identity.domain.Student
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Жизненный цикл студента: академотпуск, возврат, отчисление, архив и восстановление.
|
||||
*
|
||||
* Зачисление всегда остаётся одной записью на студента. Отчисление и архив сохраняют
|
||||
* текущую группу в зачислении, чтобы история и отчёты не теряли место учёбы; вход
|
||||
* блокируется статусом студента (и статусом аккаунта для архива).
|
||||
*/
|
||||
@Service
|
||||
class StudentLifecycleService(
|
||||
private val students: StudentRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val groups: GroupRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val history: MembershipHistoryRepository,
|
||||
private val cohortResolver: GroupCohortResolver,
|
||||
) {
|
||||
/**
|
||||
* Академотпуск: студент переезжает в группу того же курса следующего набора.
|
||||
* Год набора считается как `текущий учебный год - (курс - 1)`, поэтому после
|
||||
* возврата студент догоняет свою же параллель на год младше.
|
||||
*/
|
||||
@Transactional
|
||||
fun academicLeave(
|
||||
studentId: UUID,
|
||||
request: StudentReasonRequest?,
|
||||
actorAccountId: UUID?,
|
||||
): StudentLifecycleResult {
|
||||
val student = loadStudent(studentId)
|
||||
requireStatus(student, StudentStatus.ACTIVE, "Академический отпуск доступен только активному студенту")
|
||||
val enrollment = currentEnrollment(studentId)
|
||||
val source = enrollment.group ?: throw BadRequestException("У студента нет текущей группы")
|
||||
val target = resolveTargetGroup(academicCohort(source))
|
||||
moveEnrollment(enrollment, target)
|
||||
student.status = StudentStatus.ACADEMIC_LEAVE
|
||||
student.enrollmentYear = target.enrollmentYear
|
||||
students.save(student)
|
||||
record(studentId, source.id, target.id, MembershipAction.ACADEMIC, request?.reason, actorAccountId)
|
||||
return result(student, MembershipAction.ACADEMIC, source, target)
|
||||
}
|
||||
|
||||
/** Возврат из академотпуска в указанную активную группу. */
|
||||
@Transactional
|
||||
fun returnFromLeave(
|
||||
studentId: UUID,
|
||||
request: StudentGroupRequest,
|
||||
actorAccountId: UUID?,
|
||||
): StudentLifecycleResult {
|
||||
val student = loadStudent(studentId)
|
||||
requireStatus(student, StudentStatus.ACADEMIC_LEAVE, "Вернуть можно только студента в академическом отпуске")
|
||||
val target = activeGroup(request.groupId ?: throw BadRequestException("Укажите группу возврата"))
|
||||
val enrollment = currentEnrollment(studentId)
|
||||
val source = enrollment.group ?: throw BadRequestException("У студента нет текущей группы")
|
||||
moveEnrollment(enrollment, target)
|
||||
student.status = StudentStatus.ACTIVE
|
||||
student.enrollmentYear = target.enrollmentYear
|
||||
students.save(student)
|
||||
record(studentId, source.id, target.id, MembershipAction.RETURN, request.reason, actorAccountId)
|
||||
return result(student, MembershipAction.RETURN, source, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отчисление. Зачисление в текущую группу сохраняется для истории, но статус
|
||||
* EXPELLED закрывает вход ([ru.ysu.lms.identity.auth.AuthService]).
|
||||
*/
|
||||
@Transactional
|
||||
fun expel(
|
||||
studentId: UUID,
|
||||
request: StudentReasonRequest?,
|
||||
actorAccountId: UUID?,
|
||||
): StudentLifecycleResult {
|
||||
val student = loadStudent(studentId)
|
||||
val blocked = setOf(StudentStatus.ARCHIVED, StudentStatus.GRADUATED, StudentStatus.EXPELLED)
|
||||
if (student.status in blocked) {
|
||||
throw BadRequestException("Нельзя отчислить студента в статусе ${student.status.name}")
|
||||
}
|
||||
val source = enrollments.findByStudentAccountId(studentId)?.group
|
||||
student.status = StudentStatus.EXPELLED
|
||||
students.save(student)
|
||||
record(studentId, source?.id, null, MembershipAction.EXPELL, request?.reason, actorAccountId)
|
||||
return result(student, MembershipAction.EXPELL, source, null)
|
||||
}
|
||||
|
||||
/** Архив закрывает и профиль студента, и аккаунт; зачисление сохраняется. */
|
||||
@Transactional
|
||||
fun archive(
|
||||
studentId: UUID,
|
||||
actorAccountId: UUID?,
|
||||
): StudentLifecycleResult {
|
||||
val student = loadStudent(studentId)
|
||||
if (student.status == StudentStatus.ARCHIVED) {
|
||||
throw BadRequestException("Студент уже в архиве")
|
||||
}
|
||||
val now = Instant.now()
|
||||
val account = loadAccount(studentId)
|
||||
account.status = AccountStatus.ARCHIVED
|
||||
account.archivedAt = now
|
||||
account.updatedAt = now
|
||||
accounts.save(account)
|
||||
val source = enrollments.findByStudentAccountId(studentId)?.group
|
||||
student.status = StudentStatus.ARCHIVED
|
||||
students.save(student)
|
||||
record(studentId, source?.id, null, MembershipAction.ARCHIVE, null, actorAccountId)
|
||||
return result(student, MembershipAction.ARCHIVE, source, null)
|
||||
}
|
||||
|
||||
/** Восстановление из архива или после отчисления в выбранную активную группу. */
|
||||
@Transactional
|
||||
fun restore(
|
||||
studentId: UUID,
|
||||
request: StudentGroupRequest,
|
||||
actorAccountId: UUID?,
|
||||
): StudentLifecycleResult {
|
||||
val student = loadStudent(studentId)
|
||||
if (student.status !in setOf(StudentStatus.ARCHIVED, StudentStatus.EXPELLED)) {
|
||||
throw BadRequestException("Восстановить можно только студента из архива или после отчисления")
|
||||
}
|
||||
val target = activeGroup(request.groupId ?: throw BadRequestException("Укажите группу восстановления"))
|
||||
val account = loadAccount(studentId)
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.archivedAt = null
|
||||
account.updatedAt = Instant.now()
|
||||
accounts.save(account)
|
||||
val enrollment = enrollments.findByStudentAccountId(studentId)
|
||||
val source = enrollment?.group
|
||||
if (enrollment == null) {
|
||||
enrollments.save(Enrollment(group = target, student = student))
|
||||
} else {
|
||||
moveEnrollment(enrollment, target)
|
||||
}
|
||||
student.status = StudentStatus.ACTIVE
|
||||
student.enrollmentYear = target.enrollmentYear
|
||||
students.save(student)
|
||||
record(studentId, source?.id, target.id, MembershipAction.RESTORE, request.reason, actorAccountId)
|
||||
return result(student, MembershipAction.RESTORE, source, target)
|
||||
}
|
||||
|
||||
private fun academicCohort(source: GroupEntity): GroupCohort {
|
||||
val stream = source.stream ?: invalidSourceGroup(source)
|
||||
val course = source.course ?: invalidSourceGroup(source)
|
||||
val number = source.number ?: invalidSourceGroup(source)
|
||||
val degree = source.degree ?: invalidSourceGroup(source)
|
||||
val year = cohortResolver.defaultEnrollmentYear(course)
|
||||
return cohortResolver.validate(GroupCohort(stream, course, number, degree, year))
|
||||
}
|
||||
|
||||
private fun resolveTargetGroup(cohort: GroupCohort): GroupEntity {
|
||||
val existing =
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
cohort.stream,
|
||||
cohort.degree,
|
||||
cohort.number,
|
||||
cohort.course,
|
||||
cohort.enrollmentYear,
|
||||
)
|
||||
if (existing != null) {
|
||||
if (existing.status != GroupStatus.ACTIVE) {
|
||||
throw ConflictException("Группа ${existing.name} существует, но не активна")
|
||||
}
|
||||
return existing
|
||||
}
|
||||
return groups.save(
|
||||
GroupEntity(
|
||||
name = cohort.name,
|
||||
stream = cohort.stream,
|
||||
course = cohort.course,
|
||||
number = cohort.number,
|
||||
degree = cohort.degree,
|
||||
enrollmentYear = cohort.enrollmentYear,
|
||||
status = GroupStatus.ACTIVE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun invalidSourceGroup(group: GroupEntity): Nothing =
|
||||
throw BadRequestException("У группы ${group.name} не заполнены поток, курс, номер или степень")
|
||||
|
||||
private fun loadStudent(studentId: UUID): Student =
|
||||
students.findById(studentId).orElse(null)
|
||||
?: throw NotFoundException("Студент не найден")
|
||||
|
||||
private fun loadAccount(studentId: UUID): Account =
|
||||
accounts.findById(studentId).orElse(null)
|
||||
?: throw NotFoundException("Аккаунт студента не найден")
|
||||
|
||||
private fun currentEnrollment(studentId: UUID): Enrollment =
|
||||
enrollments.findByStudentAccountId(studentId)
|
||||
?: throw BadRequestException("У студента нет текущей группы")
|
||||
|
||||
private fun activeGroup(groupId: UUID): GroupEntity {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
if (group.status != GroupStatus.ACTIVE) {
|
||||
throw BadRequestException("Группа ${group.name} не активна")
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
private fun moveEnrollment(
|
||||
enrollment: Enrollment,
|
||||
target: GroupEntity,
|
||||
) {
|
||||
enrollment.group = target
|
||||
enrollments.save(enrollment)
|
||||
}
|
||||
|
||||
private fun requireStatus(
|
||||
student: Student,
|
||||
expected: StudentStatus,
|
||||
message: String,
|
||||
) {
|
||||
if (student.status != expected) throw BadRequestException(message)
|
||||
}
|
||||
|
||||
private fun record(
|
||||
studentId: UUID,
|
||||
fromGroupId: UUID?,
|
||||
toGroupId: UUID?,
|
||||
action: MembershipAction,
|
||||
reason: String?,
|
||||
actorAccountId: UUID? = null,
|
||||
) {
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = studentId,
|
||||
fromGroupId = fromGroupId,
|
||||
toGroupId = toGroupId,
|
||||
action = action,
|
||||
reason = reason?.trim().orEmpty(),
|
||||
actorAccountId = actorAccountId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun result(
|
||||
student: Student,
|
||||
action: MembershipAction,
|
||||
from: GroupEntity?,
|
||||
to: GroupEntity?,
|
||||
): StudentLifecycleResult =
|
||||
StudentLifecycleResult(
|
||||
studentId = student.accountId.toString(),
|
||||
fullName = student.fullName,
|
||||
status = student.status.name,
|
||||
action = action.name,
|
||||
fromGroupId = from?.id?.toString(),
|
||||
fromGroupName = from?.name,
|
||||
toGroupId = to?.id?.toString(),
|
||||
toGroupName = to?.name,
|
||||
enrollmentYear = student.enrollmentYear,
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import ru.ysu.lms.identity.auth.LoginRequest
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.LoginAttemptRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
@@ -47,6 +48,8 @@ class AuthServiceLoginBackoffTest {
|
||||
): AuthService {
|
||||
val students = mockk<StudentRepository>()
|
||||
every { students.findById(account.id) } returns Optional.empty()
|
||||
val enrollments = mockk<EnrollmentRepository>()
|
||||
every { enrollments.findByStudentAccountId(account.id) } returns null
|
||||
val passwordEncoder = mockk<PasswordEncoder>()
|
||||
every { passwordEncoder.matches(any(), any()) } returns matches
|
||||
val jwtEncoder = mockk<JwtEncoder>()
|
||||
@@ -56,6 +59,7 @@ class AuthServiceLoginBackoffTest {
|
||||
return AuthService(
|
||||
accounts,
|
||||
students,
|
||||
enrollments,
|
||||
passwordEncoder,
|
||||
jwtEncoder,
|
||||
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package ru.ysu.lms.identity.promotion
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.PromotionMode
|
||||
import ru.ysu.lms.identity.domain.PromotionRunStatus
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.PromotionRunRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.identity.roster.CreateGroupRequest
|
||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
||||
@Testcontainers
|
||||
@Transactional
|
||||
class PromotionServiceIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun datasource(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { "lms" }
|
||||
registry.add("spring.datasource.password") { "lms" }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired lateinit var promotionService: PromotionService
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
|
||||
@Autowired lateinit var students: StudentRepository
|
||||
|
||||
@Autowired lateinit var enrollments: EnrollmentRepository
|
||||
|
||||
@Autowired lateinit var history: MembershipHistoryRepository
|
||||
|
||||
@Autowired lateinit var runs: PromotionRunRepository
|
||||
|
||||
@Test
|
||||
fun `dry run only plans and does not change data`() {
|
||||
val fixture = fixture()
|
||||
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
|
||||
assertEquals("DRY_RUN", plan.mode)
|
||||
assertEquals("PLANNED", plan.status)
|
||||
assertEquals(5, plan.items.size)
|
||||
assertTrue(plan.items.any { it.action == "PROMOTE" && it.entityId == fixture.promoteGroupId.toString() })
|
||||
assertTrue(plan.items.any { it.action == "PROMOTE" && it.entityId == fixture.academicGroupId.toString() })
|
||||
assertTrue(plan.items.any { it.action == "GRADUATE" && it.entityId == fixture.graduateGroupId.toString() })
|
||||
assertTrue(plan.items.any { it.action == "GRADUATE" && it.entityId == fixture.graduateStudentId.toString() })
|
||||
assertTrue(plan.items.any { it.action == "ACADEMIC" && it.entityId == fixture.academicStudentId.toString() })
|
||||
|
||||
val promoteGroup = groups.findById(fixture.promoteGroupId).orElseThrow()
|
||||
assertEquals(1.toShort(), promoteGroup.course)
|
||||
assertEquals("ПИ-13БО", promoteGroup.name)
|
||||
val graduateGroup = groups.findById(fixture.graduateGroupId).orElseThrow()
|
||||
assertEquals(GroupStatus.ACTIVE, graduateGroup.status)
|
||||
assertEquals(StudentStatus.ACTIVE, students.findById(fixture.graduateStudentId).orElseThrow().status)
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(fixture.academicStudentId).orElseThrow().status)
|
||||
assertNull(
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
"ПИ",
|
||||
"БО",
|
||||
1,
|
||||
2,
|
||||
2025,
|
||||
),
|
||||
)
|
||||
|
||||
val repeated = promotionService.dryRun(2026, null)
|
||||
assertEquals(plan.id, repeated.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply promotes groups graduates and moves academic students`() {
|
||||
val fixture = fixture()
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
|
||||
val applied = promotionService.apply(UUID.fromString(plan.id), null)
|
||||
|
||||
assertEquals("COMPLETED", applied.status)
|
||||
assertEquals("APPLY", applied.mode)
|
||||
assertTrue(applied.items.all { it.status == "APPLIED" })
|
||||
|
||||
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
|
||||
assertEquals(2.toShort(), promoted.course)
|
||||
assertEquals("ПИ-23БО", promoted.name)
|
||||
assertEquals(GroupStatus.ACTIVE, promoted.status)
|
||||
|
||||
val graduatedGroup = groups.findById(fixture.graduateGroupId).orElseThrow()
|
||||
assertEquals(GroupStatus.GRADUATED, graduatedGroup.status)
|
||||
assertEquals(StudentStatus.GRADUATED, students.findById(fixture.graduateStudentId).orElseThrow().status)
|
||||
|
||||
val academic = students.findById(fixture.academicStudentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, academic.status)
|
||||
assertEquals(2025.toShort(), academic.enrollmentYear)
|
||||
val academicGroup = enrollments.findByStudentAccountId(fixture.academicStudentId)?.group
|
||||
assertNotNull(academicGroup)
|
||||
assertEquals("ПИ-21БО", academicGroup!!.name)
|
||||
assertEquals(2.toShort(), academicGroup.course)
|
||||
assertEquals(2025.toShort(), academicGroup.enrollmentYear)
|
||||
assertEquals(GroupStatus.ACTIVE, academicGroup.status)
|
||||
|
||||
val promoteEvents = history.findByStudentIdOrderByAtDesc(fixture.promoteStudentId)
|
||||
assertTrue(promoteEvents.any { it.action == MembershipAction.PROMOTE })
|
||||
val graduateEvents = history.findByStudentIdOrderByAtDesc(fixture.graduateStudentId)
|
||||
assertTrue(graduateEvents.any { it.action == MembershipAction.GRADUATE })
|
||||
val academicEvents = history.findByStudentIdOrderByAtDesc(fixture.academicStudentId)
|
||||
assertTrue(academicEvents.any { it.action == MembershipAction.ACADEMIC })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated apply is idempotent`() {
|
||||
fixture()
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
val first = promotionService.apply(UUID.fromString(plan.id), null)
|
||||
val historyBefore = history.count()
|
||||
|
||||
val second = promotionService.apply(UUID.fromString(plan.id), null)
|
||||
|
||||
assertEquals(first.id, second.id)
|
||||
assertEquals("COMPLETED", second.status)
|
||||
assertEquals(historyBefore, history.count())
|
||||
assertEquals(1, runs.findAll().count { it.year == 2026 && it.mode == PromotionMode.APPLY })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel marks planned dry run and allows rebuild`() {
|
||||
fixture()
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
|
||||
val cancelled = promotionService.cancel(UUID.fromString(plan.id), null)
|
||||
|
||||
assertEquals("FAILED", cancelled.status)
|
||||
assertEquals("CANCELLED", cancelled.result?.get("status"))
|
||||
assertNotNull(cancelled.finishedAt)
|
||||
assertThrows(ConflictException::class.java) {
|
||||
promotionService.apply(UUID.fromString(plan.id), null)
|
||||
}
|
||||
|
||||
val rebuilt = promotionService.dryRun(2026, null)
|
||||
assertNotEquals(plan.id, rebuilt.id)
|
||||
assertEquals("PLANNED", rebuilt.status)
|
||||
assertEquals(2, runs.findAll().count { it.year == 2026 && it.mode == PromotionMode.DRY_RUN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rollback restores data and removes created target group`() {
|
||||
val fixture = fixture()
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
promotionService.apply(UUID.fromString(plan.id), null)
|
||||
val academicGroupId = enrollments.findByStudentAccountId(fixture.academicStudentId)!!.group!!.id
|
||||
|
||||
val rolledBack = promotionService.rollback(UUID.fromString(plan.id), null)
|
||||
|
||||
assertEquals("ROLLED_BACK", rolledBack.status)
|
||||
assertTrue(rolledBack.items.all { it.status == "ROLLED_BACK" })
|
||||
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
|
||||
assertEquals(1.toShort(), promoted.course)
|
||||
assertEquals("ПИ-13БО", promoted.name)
|
||||
assertEquals(GroupStatus.ACTIVE, groups.findById(fixture.graduateGroupId).orElseThrow().status)
|
||||
assertEquals(StudentStatus.ACTIVE, students.findById(fixture.graduateStudentId).orElseThrow().status)
|
||||
val academic = students.findById(fixture.academicStudentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, academic.status)
|
||||
assertEquals(2024.toShort(), academic.enrollmentYear)
|
||||
assertEquals(fixture.academicGroupId, enrollments.findByStudentAccountId(fixture.academicStudentId)!!.group!!.id)
|
||||
assertTrue(groups.findById(academicGroupId).isEmpty)
|
||||
assertTrue(history.findByStudentIdOrderByAtDesc(fixture.graduateStudentId).any { it.action == MembershipAction.RETURN })
|
||||
|
||||
val historyAfterRollback = history.count()
|
||||
assertThrows(ConflictException::class.java) {
|
||||
promotionService.rollback(UUID.fromString(plan.id), null)
|
||||
}
|
||||
assertEquals(historyAfterRollback, history.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rollback reports manual conflict`() {
|
||||
val fixture = fixture()
|
||||
val plan = promotionService.dryRun(2026, null)
|
||||
promotionService.apply(UUID.fromString(plan.id), null)
|
||||
val promoted = groups.findById(fixture.promoteGroupId).orElseThrow()
|
||||
promoted.course = 3
|
||||
groups.saveAndFlush(promoted)
|
||||
|
||||
val error =
|
||||
assertThrows(ConflictException::class.java) {
|
||||
promotionService.rollback(UUID.fromString(plan.id), null)
|
||||
}
|
||||
|
||||
assertTrue(error.message.contains("ПИ-23БО"))
|
||||
assertEquals(PromotionRunStatus.COMPLETED, runs.findById(UUID.fromString(plan.id)).orElseThrow().status)
|
||||
assertEquals(3.toShort(), groups.findById(fixture.promoteGroupId).orElseThrow().course)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduled dry run guard respects date and deduplicates by year`() {
|
||||
val before = runs.count()
|
||||
assertNull(promotionService.ensureScheduledDryRun(LocalDate.of(2026, 8, 30)))
|
||||
assertEquals(before, runs.count())
|
||||
|
||||
val created = promotionService.ensureScheduledDryRun(LocalDate.of(2026, 8, 31))
|
||||
assertNotNull(created)
|
||||
assertEquals("DRY_RUN", created!!.mode)
|
||||
assertEquals("PLANNED", created.status)
|
||||
|
||||
assertNull(promotionService.ensureScheduledDryRun(LocalDate.of(2026, 9, 15)))
|
||||
assertEquals(1, runs.findAll().count { it.year == 2026 })
|
||||
}
|
||||
|
||||
private fun fixture(): Fixture {
|
||||
val promote = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 3, degree = "БО", enrollmentYear = 2025))
|
||||
val graduate = groupService.create(CreateGroupRequest(stream = "ПИ", course = 4, number = 4, degree = "БО", enrollmentYear = 2022))
|
||||
val academic = groupService.create(CreateGroupRequest(stream = "ПИ", course = 2, number = 1, degree = "БО", enrollmentYear = 2024))
|
||||
val promoteStudent = groupService.createStudent(UUID.fromString(promote.id), student("1001", "Иванов"), null)
|
||||
val graduateStudent = groupService.createStudent(UUID.fromString(graduate.id), student("1002", "Петров"), null)
|
||||
val academicStudent = groupService.createStudent(UUID.fromString(academic.id), student("1003", "Сидоров"), null)
|
||||
val academicId = UUID.fromString(academicStudent.id)
|
||||
val entity = students.findById(academicId).orElseThrow()
|
||||
entity.status = StudentStatus.ACADEMIC_LEAVE
|
||||
students.saveAndFlush(entity)
|
||||
return Fixture(
|
||||
promoteGroupId = UUID.fromString(promote.id),
|
||||
graduateGroupId = UUID.fromString(graduate.id),
|
||||
academicGroupId = UUID.fromString(academic.id),
|
||||
promoteStudentId = UUID.fromString(promoteStudent.id),
|
||||
graduateStudentId = UUID.fromString(graduateStudent.id),
|
||||
academicStudentId = academicId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun student(
|
||||
number: String,
|
||||
lastName: String,
|
||||
) = CreateStudentRequest(studentNo = number, lastName = lastName, firstName = "Тест")
|
||||
|
||||
private data class Fixture(
|
||||
val promoteGroupId: UUID,
|
||||
val graduateGroupId: UUID,
|
||||
val academicGroupId: UUID,
|
||||
val promoteStudentId: UUID,
|
||||
val graduateStudentId: UUID,
|
||||
val academicStudentId: UUID,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.identity.auth.LoginRequest
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.AccountStatus
|
||||
import ru.ysu.lms.identity.domain.GroupStatus
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
import ru.ysu.lms.identity.domain.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
|
||||
@Testcontainers
|
||||
@Transactional
|
||||
class StudentLifecycleIntegrationTest {
|
||||
companion object {
|
||||
@Container
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun datasource(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { "lms" }
|
||||
registry.add("spring.datasource.password") { "lms" }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired lateinit var lifecycle: StudentLifecycleService
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var authService: AuthService
|
||||
|
||||
@Autowired lateinit var cohortResolver: GroupCohortResolver
|
||||
|
||||
@Autowired lateinit var accounts: AccountRepository
|
||||
|
||||
@Autowired lateinit var students: StudentRepository
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
|
||||
@Autowired lateinit var enrollments: EnrollmentRepository
|
||||
|
||||
@Autowired lateinit var history: MembershipHistoryRepository
|
||||
|
||||
@Test
|
||||
fun `academic leave creates target cohort group and records history`() {
|
||||
val source = createGroup(course = 2, number = 1, enrollmentYear = 2024)
|
||||
val credential = createStudent(source, "7101", "Иванов", "Иван")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
val expectedYear = cohortResolver.defaultEnrollmentYear(2)
|
||||
val actor = adminActor()
|
||||
|
||||
val result = lifecycle.academicLeave(studentId, StudentReasonRequest("Болезнь"), actor)
|
||||
|
||||
val target =
|
||||
requireNotNull(
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear("ПИ", "БО", 1, 2, expectedYear),
|
||||
)
|
||||
assertEquals("ПИ-21БО", target.name)
|
||||
assertEquals(expectedYear, target.enrollmentYear)
|
||||
assertEquals(GroupStatus.ACTIVE, target.status)
|
||||
assertEquals("ACADEMIC", result.action)
|
||||
assertEquals(target.id.toString(), result.toGroupId)
|
||||
|
||||
val student = students.findById(studentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
|
||||
assertEquals(expectedYear, student.enrollmentYear)
|
||||
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
|
||||
val event =
|
||||
history
|
||||
.findByStudentIdOrderByAtDesc(studentId)
|
||||
.first { it.action == MembershipAction.ACADEMIC }
|
||||
assertEquals(source, event.fromGroupId)
|
||||
assertEquals(target.id, event.toGroupId)
|
||||
assertEquals("Болезнь", event.reason)
|
||||
assertEquals(actor, event.actorAccountId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `academic leave reuses an existing target cohort group`() {
|
||||
val source = createGroup(course = 2, number = 2, enrollmentYear = 2023)
|
||||
val expectedYear = cohortResolver.defaultEnrollmentYear(2)
|
||||
val existing =
|
||||
groupService.create(
|
||||
CreateGroupRequest(
|
||||
stream = "ПИ",
|
||||
course = 2,
|
||||
number = 2,
|
||||
degree = "БО",
|
||||
enrollmentYear = expectedYear,
|
||||
),
|
||||
)
|
||||
val credential = createStudent(source, "7102", "Петров", "Пётр")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
|
||||
val result = lifecycle.academicLeave(studentId, null, null)
|
||||
|
||||
assertEquals(existing.id, result.toGroupId)
|
||||
assertEquals(
|
||||
1,
|
||||
groups.findAll().count {
|
||||
it.stream == "ПИ" &&
|
||||
it.degree == "БО" &&
|
||||
it.number == 2.toShort() &&
|
||||
it.course == 2.toShort() &&
|
||||
it.enrollmentYear == expectedYear
|
||||
},
|
||||
)
|
||||
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `return from academic leave moves to requested active group`() {
|
||||
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)
|
||||
val credential = createStudent(source, "7103", "Сидоров", "Сидор")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
lifecycle.academicLeave(studentId, StudentReasonRequest("Семейные обстоятельства"), null)
|
||||
val target = createGroup(course = 2, number = 4, enrollmentYear = 2025)
|
||||
|
||||
val result = lifecycle.returnFromLeave(studentId, StudentGroupRequest(target, "Возврат"), null)
|
||||
|
||||
val student = students.findById(studentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACTIVE, student.status)
|
||||
assertEquals(2025.toShort(), student.enrollmentYear)
|
||||
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
assertEquals("RETURN", result.action)
|
||||
assertEquals(target.toString(), result.toGroupId)
|
||||
|
||||
val event =
|
||||
history
|
||||
.findByStudentIdOrderByAtDesc(studentId)
|
||||
.first { it.action == MembershipAction.RETURN }
|
||||
assertEquals(target, event.toGroupId)
|
||||
assertEquals("Возврат", event.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expel blocks login and keeps current enrollment for history`() {
|
||||
val group = createGroup(course = 1, number = 5, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7104", "Кузнецов", "Кузьма")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
|
||||
lifecycle.expel(studentId, StudentReasonRequest("Нарушение правил"), null)
|
||||
|
||||
val error =
|
||||
runCatching {
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is UnauthorizedException)
|
||||
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
|
||||
assertEquals(group, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
val event =
|
||||
history
|
||||
.findByStudentIdOrderByAtDesc(studentId)
|
||||
.first { it.action == MembershipAction.EXPELL }
|
||||
assertEquals(group, event.fromGroupId)
|
||||
assertEquals("Нарушение правил", event.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student in archived group cannot login`() {
|
||||
val group = createGroup(course = 1, number = 12, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7110", "Архипов", "Артём")
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
|
||||
groupService.archiveGroup(group)
|
||||
|
||||
val error =
|
||||
runCatching {
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is UnauthorizedException)
|
||||
assertEquals("Группа закрыта", error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student in active group can login`() {
|
||||
val group = createGroup(course = 1, number = 13, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7111", "Активов", "Антон")
|
||||
|
||||
val response = authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
|
||||
assertEquals("STUDENT", response.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `student without group can login`() {
|
||||
val group = createGroup(course = 1, number = 14, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7112", "Свободов", "Семён")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
enrollments.findByStudentAccountId(studentId)?.let { enrollments.delete(it) }
|
||||
enrollments.flush()
|
||||
|
||||
val response = authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
|
||||
assertEquals("STUDENT", response.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore returns an expelled student to active group`() {
|
||||
val group = createGroup(course = 1, number = 10, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7109", "Соколов", "Семён")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
lifecycle.expel(studentId, StudentReasonRequest("Отчисление"), null)
|
||||
val target = createGroup(course = 1, number = 11, enrollmentYear = 2025)
|
||||
|
||||
val result =
|
||||
lifecycle.restore(
|
||||
studentId,
|
||||
StudentGroupRequest(target, "Восстановление после отчисления"),
|
||||
null,
|
||||
)
|
||||
|
||||
val student = students.findById(studentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACTIVE, student.status)
|
||||
assertEquals(2025.toShort(), student.enrollmentYear)
|
||||
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
val account = accounts.findById(studentId).orElseThrow()
|
||||
assertEquals(AccountStatus.ACTIVE, account.status)
|
||||
assertNull(account.archivedAt)
|
||||
assertEquals("RESTORE", result.action)
|
||||
val event =
|
||||
history
|
||||
.findByStudentIdOrderByAtDesc(studentId)
|
||||
.first { it.action == MembershipAction.RESTORE }
|
||||
assertEquals(group, event.fromGroupId)
|
||||
assertEquals(target, event.toGroupId)
|
||||
assertEquals("Восстановление после отчисления", event.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archive blocks login and restore returns student to active group`() {
|
||||
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7105", "Морозов", "Марк")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
|
||||
lifecycle.archive(studentId, null)
|
||||
|
||||
assertEquals(StudentStatus.ARCHIVED, students.findById(studentId).orElseThrow().status)
|
||||
val archived = accounts.findById(studentId).orElseThrow()
|
||||
assertEquals(AccountStatus.ARCHIVED, archived.status)
|
||||
assertNotNull(archived.archivedAt)
|
||||
val error =
|
||||
runCatching {
|
||||
authService.login(LoginRequest(credential.studentNo, credential.password))
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is UnauthorizedException)
|
||||
|
||||
val target = createGroup(course = 1, number = 7, enrollmentYear = 2025)
|
||||
val result = lifecycle.restore(studentId, StudentGroupRequest(target, "Восстановление"), null)
|
||||
|
||||
val restored = accounts.findById(studentId).orElseThrow()
|
||||
assertEquals(AccountStatus.ACTIVE, restored.status)
|
||||
assertNull(restored.archivedAt)
|
||||
val student = students.findById(studentId).orElseThrow()
|
||||
assertEquals(StudentStatus.ACTIVE, student.status)
|
||||
assertEquals(2025.toShort(), student.enrollmentYear)
|
||||
assertEquals(target, enrollments.findByStudentAccountId(studentId)?.group?.id)
|
||||
assertEquals("RESTORE", result.action)
|
||||
val event =
|
||||
history
|
||||
.findByStudentIdOrderByAtDesc(studentId)
|
||||
.first { it.action == MembershipAction.RESTORE }
|
||||
assertEquals(target, event.toGroupId)
|
||||
assertEquals("Восстановление", event.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group archive changes only group status`() {
|
||||
val group = createGroup(course = 1, number = 8, enrollmentYear = 2025)
|
||||
val credential = createStudent(group, "7106", "Волков", "Владимир")
|
||||
val studentId = UUID.fromString(credential.id)
|
||||
|
||||
val detail = groupService.archiveGroup(group)
|
||||
|
||||
assertEquals("ARCHIVED", detail.status)
|
||||
assertEquals(1, detail.students.size)
|
||||
assertEquals(GroupStatus.ARCHIVED, groups.findById(group).orElseThrow().status)
|
||||
assertEquals(StudentStatus.ACTIVE, students.findById(studentId).orElseThrow().status)
|
||||
assertThrows(BadRequestException::class.java) { groupService.archiveGroup(group) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete requires confirm equal to login or full name`() {
|
||||
val group = createGroup(course = 1, number = 9, enrollmentYear = 2025)
|
||||
val first = createStudent(group, "7107", "Иванов", "Иван")
|
||||
val firstId = UUID.fromString(first.id)
|
||||
|
||||
assertThrows(BadRequestException::class.java) {
|
||||
groupService.deleteUser(firstId, UUID.randomUUID(), "неверно")
|
||||
}
|
||||
assertTrue(accounts.findById(firstId).isPresent)
|
||||
assertTrue(students.findById(firstId).isPresent)
|
||||
|
||||
val deleted = groupService.deleteUser(firstId, UUID.randomUUID(), "7107")
|
||||
|
||||
assertEquals("7107", deleted.login)
|
||||
assertTrue(accounts.findById(firstId).isEmpty)
|
||||
assertTrue(students.findById(firstId).isEmpty)
|
||||
assertNull(enrollments.findByStudentAccountId(firstId))
|
||||
|
||||
val second = createStudent(group, "7108", "Петров", "Пётр")
|
||||
val secondId = UUID.fromString(second.id)
|
||||
|
||||
groupService.deleteUser(secondId, UUID.randomUUID(), "Петров Пётр")
|
||||
|
||||
assertTrue(accounts.findById(secondId).isEmpty)
|
||||
assertTrue(students.findById(secondId).isEmpty)
|
||||
assertFalse(students.existsById(secondId))
|
||||
}
|
||||
|
||||
private fun createGroup(
|
||||
course: Short,
|
||||
number: Short,
|
||||
enrollmentYear: Short,
|
||||
): UUID {
|
||||
val created =
|
||||
groupService.create(
|
||||
CreateGroupRequest(
|
||||
stream = "ПИ",
|
||||
course = course,
|
||||
number = number,
|
||||
degree = "БО",
|
||||
enrollmentYear = enrollmentYear,
|
||||
),
|
||||
)
|
||||
return UUID.fromString(created.id)
|
||||
}
|
||||
|
||||
private fun createStudent(
|
||||
groupId: UUID,
|
||||
studentNo: String,
|
||||
lastName: String,
|
||||
firstName: String,
|
||||
) = groupService.createStudent(
|
||||
groupId,
|
||||
CreateStudentRequest(studentNo = studentNo, lastName = lastName, firstName = firstName),
|
||||
null,
|
||||
)
|
||||
|
||||
private fun adminActor(): UUID {
|
||||
val account =
|
||||
Account(
|
||||
login = "admin-${UUID.randomUUID()}",
|
||||
passwordHash = "test",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
return accounts.saveAndFlush(account).id
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user