diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index 35ea2ae..0e8acab 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: - id: identity uri: ${LMS_IDENTITY_URI:http://localhost:8081} predicates: - - Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/** + - Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**,/api/v1/promotion/** - id: progress-profile uri: ${LMS_PROGRESS_URI:http://localhost:8083} predicates: diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/IdentityServiceApplication.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/IdentityServiceApplication.kt index c422621..a87f54f 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/IdentityServiceApplication.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/IdentityServiceApplication.kt @@ -2,10 +2,12 @@ package ru.ysu.lms.identity import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling import ru.ysu.lms.security.LmsSecurityAutoConfiguration // The identity service manages its own JWT keys at runtime, so the generic // auto-configuration from the shared library is excluded. +@EnableScheduling @SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class]) class IdentityServiceApplication diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt index 5ef8970..f9d30ad 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt @@ -14,18 +14,22 @@ import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.AccountStatus +import ru.ysu.lms.identity.domain.GroupStatus import ru.ysu.lms.identity.domain.StudentStatus import ru.ysu.lms.identity.repo.AccountRepository +import ru.ysu.lms.identity.repo.EnrollmentRepository import ru.ysu.lms.identity.repo.StudentRepository import ru.ysu.lms.security.JwtProperties import ru.ysu.lms.security.LmsRole import java.time.Instant import java.util.UUID +@Suppress("LongParameterList") @Service class AuthService( private val accounts: AccountRepository, private val students: StudentRepository, + private val enrollments: EnrollmentRepository, private val passwordEncoder: PasswordEncoder, private val jwtEncoder: JwtEncoder, private val jwtProperties: JwtProperties, @@ -77,6 +81,14 @@ class AuthService( if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) { throw UnauthorizedException("Доступ к системе закрыт") } + ensureGroupOpen(account) + } + + private fun ensureGroupOpen(account: Account) { + val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status + if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) { + throw UnauthorizedException("Группа закрыта") + } } @Transactional(readOnly = true) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionAction.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionAction.kt new file mode 100644 index 0000000..cf26d84 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionAction.kt @@ -0,0 +1,7 @@ +package ru.ysu.lms.identity.domain + +enum class PromotionAction { + PROMOTE, + GRADUATE, + ACADEMIC, +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionEntityType.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionEntityType.kt new file mode 100644 index 0000000..8a89e93 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionEntityType.kt @@ -0,0 +1,6 @@ +package ru.ysu.lms.identity.domain + +enum class PromotionEntityType { + GROUP, + STUDENT, +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionItemStatus.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionItemStatus.kt new file mode 100644 index 0000000..726fb2b --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionItemStatus.kt @@ -0,0 +1,9 @@ +package ru.ysu.lms.identity.domain + +enum class PromotionItemStatus { + PLANNED, + APPLIED, + SKIPPED, + ERROR, + ROLLED_BACK, +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionMode.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionMode.kt new file mode 100644 index 0000000..35457d7 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionMode.kt @@ -0,0 +1,6 @@ +package ru.ysu.lms.identity.domain + +enum class PromotionMode { + DRY_RUN, + APPLY, +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRun.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRun.kt new file mode 100644 index 0000000..8b20c22 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRun.kt @@ -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, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunItem.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunItem.kt new file mode 100644 index 0000000..d53c98c --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunItem.kt @@ -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(), +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunStatus.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunStatus.kt new file mode 100644 index 0000000..0e64588 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/domain/PromotionRunStatus.kt @@ -0,0 +1,9 @@ +package ru.ysu.lms.identity.domain + +enum class PromotionRunStatus { + PLANNED, + RUNNING, + COMPLETED, + FAILED, + ROLLED_BACK, +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionApplier.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionApplier.kt new file mode 100644 index 0000000..35c46e0 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionApplier.kt @@ -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, + ) { + 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): 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, + states: Map, + ): List = + 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 = + 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, + ) { + 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) + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionClockConfig.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionClockConfig.kt new file mode 100644 index 0000000..0f4e8f8 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionClockConfig.kt @@ -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")) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt new file mode 100644 index 0000000..459a222 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt @@ -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 = 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)) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionDtos.kt new file mode 100644 index 0000000..f3454e9 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionDtos.kt @@ -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?, +) + +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, + val after: Map, +) + +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?, + val items: List, +) + +data class DryRunRequest( + val year: Int? = null, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionItemVerifier.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionItemVerifier.kt new file mode 100644 index 0000000..fbc7f42 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionItemVerifier.kt @@ -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? { + 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, + after: Map, + ): 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? { + 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, + ): 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? { + 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): 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 + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionJsonStore.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionJsonStore.kt new file mode 100644 index 0000000..3bf68c3 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionJsonStore.kt @@ -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, + val after: Map, +) + +/** + * 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, + ) { + jdbc.update( + "UPDATE identity.promotion_runs SET result = CAST(? AS jsonb) WHERE id = ?", + mapper.writeValueAsString(result), + runId, + ) + } + + fun readRunResult(runId: UUID): Map? = + jdbc + .queryForList( + "SELECT result::text FROM identity.promotion_runs WHERE id = ?", + String::class.java, + runId, + ).firstOrNull() + ?.let(::readMap) + + fun writeItemStates( + itemId: UUID, + before: Map, + after: Map, + ) { + 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 = + 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? { + 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 } + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionPlanner.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionPlanner.kt new file mode 100644 index 0000000..76ead10 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionPlanner.kt @@ -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, + val after: Map, +) + +data class PromotionPlan( + val items: List, + val result: Map, +) + +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 = + 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() + 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() + val studentChanges = mutableListOf() + 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 = + enrollments + .findAllWithStudentAndGroup() + .mapNotNull { enrollment -> + val studentId = enrollment.student?.accountId ?: return@mapNotNull null + studentId to enrollment + }.toMap() + + private fun planGroups( + allGroups: List, + warnings: MutableList, + ): List = + allGroups + .filter { it.status == GroupStatus.ACTIVE } + .sortedByDescending { it.course ?: 0 } + .mapNotNull { group -> planGroup(group, warnings) } + + private fun planGroup( + group: GroupEntity, + warnings: MutableList, + ): 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, + targetCache: MutableMap, + warnings: MutableList, + ): 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, + ): 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, + warnings: MutableList, + ): 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, + warnings: List, + ): Map = + 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 = + 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 = + 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 + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRollback.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRollback.kt new file mode 100644 index 0000000..478bbf5 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRollback.kt @@ -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) { + 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 = + 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, + ) { + val current = json.readRunResult(runId) ?: emptyMap() + json.writeRunResult(runId, current + extra) + } +} + +private data class OrderedRollbackItem( + val item: PromotionRunItem, + val state: PromotionItemState?, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRunLock.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRunLock.kt new file mode 100644 index 0000000..ac8ed21 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionRunLock.kt @@ -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", + ) + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionScheduler.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionScheduler.kt new file mode 100644 index 0000000..48782f6 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionScheduler.kt @@ -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) } + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionService.kt new file mode 100644 index 0000000..5dfe4e7 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionService.kt @@ -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 = 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, + ) { + 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) + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionStates.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionStates.kt new file mode 100644 index 0000000..0f91559 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionStates.kt @@ -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.textValue(key: String): String? = this[key] as? String + +internal fun Map.intValue(key: String): Int? = (this[key] as? Number)?.toInt() + +internal fun Map.uuidValue(key: String): UUID? = textValue(key)?.let(UUID::fromString) + +internal fun Map.mapValue(key: String): Map? = + (this[key] as? Map<*, *>)?.entries?.associate { it.key.toString() to it.value } + +internal fun Map.courseValue(): Int? = intValue("course") + +internal fun Map.statusValue(): String? = textValue("status") + +internal fun Map.nameValue(): String? = textValue("name") diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt index fd50b49..3185e87 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/EnrollmentRepository.kt @@ -1,5 +1,6 @@ package ru.ysu.lms.identity.repo +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -14,6 +15,10 @@ interface EnrollmentRepository : JpaRepository { @Param("groupId") groupId: UUID, ): List + @Query("select e from Enrollment e join fetch e.student join fetch e.group") + fun findAllWithStudentAndGroup(): List + + @EntityGraph(attributePaths = ["group"]) fun findByStudentAccountId(accountId: UUID): Enrollment? fun findFirstByStudentAccountId(accountId: UUID): Enrollment? diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunItemRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunItemRepository.kt new file mode 100644 index 0000000..039db92 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunItemRepository.kt @@ -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 { + fun findByRunIdOrderByIdAsc(runId: UUID): List +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunRepository.kt new file mode 100644 index 0000000..dd41017 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/repo/PromotionRunRepository.kt @@ -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 { + fun findAllByOrderByYearDescStartedAtDesc(): List + + fun findFirstByYearAndModeOrderByStartedAtDesc( + year: Int, + mode: PromotionMode, + ): PromotionRun? + + fun findFirstByYearAndModeAndStatusInOrderByStartedAtDesc( + year: Int, + mode: PromotionMode, + statuses: Collection, + ): PromotionRun? + + fun existsByYear(year: Int): Boolean + + fun existsByYearAndModeAndStatusIn( + year: Int, + mode: PromotionMode, + statuses: Collection, + ): Boolean +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt index 9f537a0..40f7da4 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt @@ -87,6 +87,12 @@ class GroupController( @PathVariable id: UUID, ): GroupDetail = groupService.detail(id) + /** Архив группы без массового изменения статусов студентов. */ + @PostMapping("/{id}/archive") + fun archive( + @PathVariable id: UUID, + ): GroupDetail = groupService.archiveGroup(id) + @GetMapping("/{id}/credentials") fun credentials( @PathVariable id: UUID, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt index de40136..6aa5c3b 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupService.kt @@ -270,11 +270,27 @@ class GroupService( }.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo })) } - /** Удаляет студента из identity: зачисление, профиль, аккаунт. */ + /** Архивирует группу, не меняя статусы студентов. */ + @Transactional + fun archiveGroup(groupId: UUID): GroupDetail { + val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } + if (group.status == GroupStatus.ARCHIVED) { + throw BadRequestException("Группа ${group.name} уже в архиве") + } + group.status = GroupStatus.ARCHIVED + groups.save(group) + return detail(groupId) + } + + /** + * Удаляет студента из identity: зачисление, профиль, аккаунт. + * [confirm] должен совпадать с логином аккаунта или ФИО студента. + */ @Transactional fun deleteUser( accountId: UUID, currentAccountId: UUID, + confirm: String, ): DeleteUserResult { if (accountId == currentAccountId) { throw BadRequestException("Нельзя удалить собственный аккаунт") @@ -285,12 +301,26 @@ class GroupService( } val student = students.findById(accountId).orElse(null) val fullName = student?.fullName.orEmpty() + validateDeleteConfirmation(account, fullName, confirm) enrollments.findByStudentAccountId(accountId)?.let { enrollments.delete(it) } if (student != null) students.delete(student) accounts.delete(account) return DeleteUserResult(accountId.toString(), account.login, fullName) } + private fun validateDeleteConfirmation( + account: Account, + fullName: String, + confirm: String, + ) { + val expected = setOf(account.login, fullName).filter { it.isNotBlank() } + if (confirm.trim() !in expected) { + throw BadRequestException( + "Неверное подтверждение удаления: укажите логин ${account.login} или ФИО ${fullName.ifBlank { "студента" }}", + ) + } + } + @Transactional fun resetPassword(studentAccountId: UUID): ResetPasswordResponse { val student = diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt index 44aa75d..2eab022 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt @@ -29,10 +29,11 @@ class StudentController( @RequestParam(required = false, name = "q") query: String?, ): List = groupService.users(query) - /** Полное удаление пользователя из identity. */ + /** Полное удаление пользователя из identity; [confirm] — логин или ФИО студента. */ @DeleteMapping("/{id}") fun delete( @PathVariable id: UUID, + @RequestParam("confirm") confirm: String, @AuthenticationPrincipal jwt: Jwt, - ): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject)) + ): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject), confirm) } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt new file mode 100644 index 0000000..a0e27af --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt @@ -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) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleDtos.kt new file mode 100644 index 0000000..e3c544f --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleDtos.kt @@ -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, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleService.kt new file mode 100644 index 0000000..f9db697 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleService.kt @@ -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, + ) +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt index ef4ceeb..f904b44 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt @@ -20,6 +20,7 @@ import ru.ysu.lms.identity.auth.LoginRequest import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.LoginAttempt import ru.ysu.lms.identity.repo.AccountRepository +import ru.ysu.lms.identity.repo.EnrollmentRepository import ru.ysu.lms.identity.repo.LoginAttemptRepository import ru.ysu.lms.identity.repo.StudentRepository import ru.ysu.lms.security.JwtProperties @@ -47,6 +48,8 @@ class AuthServiceLoginBackoffTest { ): AuthService { val students = mockk() every { students.findById(account.id) } returns Optional.empty() + val enrollments = mockk() + every { enrollments.findByStudentAccountId(account.id) } returns null val passwordEncoder = mockk() every { passwordEncoder.matches(any(), any()) } returns matches val jwtEncoder = mockk() @@ -56,6 +59,7 @@ class AuthServiceLoginBackoffTest { return AuthService( accounts, students, + enrollments, passwordEncoder, jwtEncoder, JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionServiceIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionServiceIntegrationTest.kt new file mode 100644 index 0000000..b677a13 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionServiceIntegrationTest.kt @@ -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, + ) +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt new file mode 100644 index 0000000..8891eab --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleIntegrationTest.kt @@ -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 + } +}