feat(identity): повышение курса и жизненный цикл студентов
This commit is contained in:
@@ -12,7 +12,7 @@ spring:
|
|||||||
- id: identity
|
- id: identity
|
||||||
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||||
predicates:
|
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
|
- id: progress-profile
|
||||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||||
predicates:
|
predicates:
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package ru.ysu.lms.identity
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||||
|
|
||||||
// The identity service manages its own JWT keys at runtime, so the generic
|
// The identity service manages its own JWT keys at runtime, so the generic
|
||||||
// auto-configuration from the shared library is excluded.
|
// auto-configuration from the shared library is excluded.
|
||||||
|
@EnableScheduling
|
||||||
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
|
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
|
||||||
class IdentityServiceApplication
|
class IdentityServiceApplication
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,22 @@ import ru.ysu.lms.common.NotFoundException
|
|||||||
import ru.ysu.lms.common.UnauthorizedException
|
import ru.ysu.lms.common.UnauthorizedException
|
||||||
import ru.ysu.lms.identity.domain.Account
|
import ru.ysu.lms.identity.domain.Account
|
||||||
import ru.ysu.lms.identity.domain.AccountStatus
|
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.domain.StudentStatus
|
||||||
import ru.ysu.lms.identity.repo.AccountRepository
|
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.identity.repo.StudentRepository
|
||||||
import ru.ysu.lms.security.JwtProperties
|
import ru.ysu.lms.security.JwtProperties
|
||||||
import ru.ysu.lms.security.LmsRole
|
import ru.ysu.lms.security.LmsRole
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@Service
|
@Service
|
||||||
class AuthService(
|
class AuthService(
|
||||||
private val accounts: AccountRepository,
|
private val accounts: AccountRepository,
|
||||||
private val students: StudentRepository,
|
private val students: StudentRepository,
|
||||||
|
private val enrollments: EnrollmentRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val jwtEncoder: JwtEncoder,
|
private val jwtEncoder: JwtEncoder,
|
||||||
private val jwtProperties: JwtProperties,
|
private val jwtProperties: JwtProperties,
|
||||||
@@ -77,6 +81,14 @@ class AuthService(
|
|||||||
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
|
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
|
||||||
throw UnauthorizedException("Доступ к системе закрыт")
|
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)
|
@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
|
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.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
@@ -14,6 +15,10 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
|||||||
@Param("groupId") groupId: UUID,
|
@Param("groupId") groupId: UUID,
|
||||||
): List<Enrollment>
|
): 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 findByStudentAccountId(accountId: UUID): Enrollment?
|
||||||
|
|
||||||
fun findFirstByStudentAccountId(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,
|
@PathVariable id: UUID,
|
||||||
): GroupDetail = groupService.detail(id)
|
): GroupDetail = groupService.detail(id)
|
||||||
|
|
||||||
|
/** Архив группы без массового изменения статусов студентов. */
|
||||||
|
@PostMapping("/{id}/archive")
|
||||||
|
fun archive(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): GroupDetail = groupService.archiveGroup(id)
|
||||||
|
|
||||||
@GetMapping("/{id}/credentials")
|
@GetMapping("/{id}/credentials")
|
||||||
fun credentials(
|
fun credentials(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
|
|||||||
@@ -270,11 +270,27 @@ class GroupService(
|
|||||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
}.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
|
@Transactional
|
||||||
fun deleteUser(
|
fun deleteUser(
|
||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
currentAccountId: UUID,
|
currentAccountId: UUID,
|
||||||
|
confirm: String,
|
||||||
): DeleteUserResult {
|
): DeleteUserResult {
|
||||||
if (accountId == currentAccountId) {
|
if (accountId == currentAccountId) {
|
||||||
throw BadRequestException("Нельзя удалить собственный аккаунт")
|
throw BadRequestException("Нельзя удалить собственный аккаунт")
|
||||||
@@ -285,12 +301,26 @@ class GroupService(
|
|||||||
}
|
}
|
||||||
val student = students.findById(accountId).orElse(null)
|
val student = students.findById(accountId).orElse(null)
|
||||||
val fullName = student?.fullName.orEmpty()
|
val fullName = student?.fullName.orEmpty()
|
||||||
|
validateDeleteConfirmation(account, fullName, confirm)
|
||||||
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
|
enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) }
|
||||||
if (student != null) students.delete(student)
|
if (student != null) students.delete(student)
|
||||||
accounts.delete(account)
|
accounts.delete(account)
|
||||||
return DeleteUserResult(accountId.toString(), account.login, fullName)
|
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
|
@Transactional
|
||||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||||
val student =
|
val student =
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ class StudentController(
|
|||||||
@RequestParam(required = false, name = "q") query: String?,
|
@RequestParam(required = false, name = "q") query: String?,
|
||||||
): List<AdminUser> = groupService.users(query)
|
): List<AdminUser> = groupService.users(query)
|
||||||
|
|
||||||
/** Полное удаление пользователя из identity. */
|
/** Полное удаление пользователя из identity; [confirm] — логин или ФИО студента. */
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
|
@RequestParam("confirm") confirm: String,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@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.Account
|
||||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||||
import ru.ysu.lms.identity.repo.AccountRepository
|
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.LoginAttemptRepository
|
||||||
import ru.ysu.lms.identity.repo.StudentRepository
|
import ru.ysu.lms.identity.repo.StudentRepository
|
||||||
import ru.ysu.lms.security.JwtProperties
|
import ru.ysu.lms.security.JwtProperties
|
||||||
@@ -47,6 +48,8 @@ class AuthServiceLoginBackoffTest {
|
|||||||
): AuthService {
|
): AuthService {
|
||||||
val students = mockk<StudentRepository>()
|
val students = mockk<StudentRepository>()
|
||||||
every { students.findById(account.id) } returns Optional.empty()
|
every { students.findById(account.id) } returns Optional.empty()
|
||||||
|
val enrollments = mockk<EnrollmentRepository>()
|
||||||
|
every { enrollments.findByStudentAccountId(account.id) } returns null
|
||||||
val passwordEncoder = mockk<PasswordEncoder>()
|
val passwordEncoder = mockk<PasswordEncoder>()
|
||||||
every { passwordEncoder.matches(any(), any()) } returns matches
|
every { passwordEncoder.matches(any(), any()) } returns matches
|
||||||
val jwtEncoder = mockk<JwtEncoder>()
|
val jwtEncoder = mockk<JwtEncoder>()
|
||||||
@@ -56,6 +59,7 @@ class AuthServiceLoginBackoffTest {
|
|||||||
return AuthService(
|
return AuthService(
|
||||||
accounts,
|
accounts,
|
||||||
students,
|
students,
|
||||||
|
enrollments,
|
||||||
passwordEncoder,
|
passwordEncoder,
|
||||||
jwtEncoder,
|
jwtEncoder,
|
||||||
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
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