feat(identity): группы-когорты, история и импорт v2
This commit is contained in:
@@ -13,6 +13,8 @@ import ru.ysu.lms.common.ConflictException
|
||||
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.StudentStatus
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
@@ -61,10 +63,22 @@ class AuthService(
|
||||
backoff.recordFailure(login)
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
ensureLoginAllowed(account)
|
||||
backoff.reset(login)
|
||||
return toResponse(account, displayName(account))
|
||||
}
|
||||
|
||||
private fun ensureLoginAllowed(account: Account) {
|
||||
if (account.status != AccountStatus.ACTIVE) {
|
||||
throw UnauthorizedException("Аккаунт архивирован")
|
||||
}
|
||||
if (account.role != LmsRole.STUDENT) return
|
||||
val status = students.findById(account.id).map { it.status }.orElse(null)
|
||||
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
|
||||
throw UnauthorizedException("Доступ к системе закрыт")
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun me(accountId: UUID): MeResponse {
|
||||
val account =
|
||||
|
||||
@@ -27,6 +27,11 @@ class Account(
|
||||
var mustChangePassword: Boolean = true,
|
||||
@Column(name = "temp_password", length = 64)
|
||||
var tempPassword: String? = null,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: AccountStatus = AccountStatus.ACTIVE,
|
||||
@Column(name = "archived_at")
|
||||
var archivedAt: Instant? = null,
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class AccountStatus {
|
||||
ACTIVE,
|
||||
ARCHIVED,
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -13,8 +15,19 @@ import java.util.UUID
|
||||
class GroupEntity(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
@Column(nullable = false, length = 100)
|
||||
var name: String = "",
|
||||
@Column(length = 50)
|
||||
var stream: String? = null,
|
||||
var course: Short? = null,
|
||||
var number: Short? = null,
|
||||
@Column(length = 2)
|
||||
var degree: String? = null,
|
||||
@Column(name = "enrollment_year")
|
||||
var enrollmentYear: Short? = null,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: GroupStatus = GroupStatus.ACTIVE,
|
||||
@Column(name = "task_duration_min", nullable = false)
|
||||
var taskDurationMin: Int = 30,
|
||||
@Column(name = "created_at", nullable = false)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class GroupStatus {
|
||||
ACTIVE,
|
||||
GRADUATED,
|
||||
ARCHIVED,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class MembershipAction {
|
||||
ENROLL,
|
||||
TRANSFER,
|
||||
ACADEMIC,
|
||||
RETURN,
|
||||
GRADUATE,
|
||||
EXPELL,
|
||||
PROMOTE,
|
||||
IMPORT,
|
||||
ARCHIVE,
|
||||
RESTORE,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
|
||||
@Entity
|
||||
@Table(name = "membership_history")
|
||||
class MembershipHistory(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
@Column(name = "student_id", nullable = false)
|
||||
var studentId: UUID = UUID(0, 0),
|
||||
@Column(name = "from_group_id")
|
||||
var fromGroupId: UUID? = null,
|
||||
@Column(name = "to_group_id")
|
||||
var toGroupId: UUID? = null,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
var action: MembershipAction = MembershipAction.ENROLL,
|
||||
@Column(nullable = false)
|
||||
var reason: String = "",
|
||||
@Column(name = "actor_account_id")
|
||||
var actorAccountId: UUID? = null,
|
||||
@Column(name = "promotion_run_id")
|
||||
var promotionRunId: UUID? = null,
|
||||
@Column(nullable = false)
|
||||
var at: Instant = Instant.now(),
|
||||
)
|
||||
@@ -2,6 +2,8 @@ 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.FetchType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.JoinColumn
|
||||
@@ -28,6 +30,11 @@ class Student(
|
||||
var basis: String = "",
|
||||
@Column(name = "note", nullable = false)
|
||||
var note: String = "",
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: StudentStatus = StudentStatus.ACTIVE,
|
||||
@Column(name = "enrollment_year")
|
||||
var enrollmentYear: Short? = null,
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "account_id")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
enum class StudentStatus {
|
||||
ACTIVE,
|
||||
ACADEMIC_LEAVE,
|
||||
EXPELLED,
|
||||
GRADUATED,
|
||||
ARCHIVED,
|
||||
}
|
||||
@@ -9,5 +9,15 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
|
||||
fun existsByName(name: String): Boolean
|
||||
|
||||
fun findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
stream: String,
|
||||
degree: String,
|
||||
number: Short,
|
||||
course: Short,
|
||||
enrollmentYear: Short,
|
||||
): GroupEntity?
|
||||
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
|
||||
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.MembershipHistory
|
||||
import java.util.UUID
|
||||
|
||||
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
|
||||
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
/** Разобранная группа-когорта. Имя всегда производное от полей. */
|
||||
data class GroupCohort(
|
||||
val stream: String,
|
||||
val course: Short,
|
||||
val number: Short,
|
||||
val degree: String,
|
||||
val enrollmentYear: Short,
|
||||
) {
|
||||
val name: String
|
||||
get() = "$stream-$course$number$degree"
|
||||
}
|
||||
|
||||
object GroupNameParser {
|
||||
private val pattern = Regex("^(.+)-(\\d)(\\d+)(БО|МО)$")
|
||||
|
||||
fun parse(name: String): GroupCohort? {
|
||||
val match = pattern.matchEntire(name.trim()) ?: return null
|
||||
val course = match.groupValues[2].toShortOrNull() ?: return null
|
||||
val number = match.groupValues[3].toShortOrNull() ?: return null
|
||||
return GroupCohort(
|
||||
stream = match.groupValues[1],
|
||||
course = course,
|
||||
number = number,
|
||||
degree = match.groupValues[4],
|
||||
enrollmentYear = 0, // год набора из имени не выводится
|
||||
)
|
||||
}
|
||||
|
||||
fun validCourse(
|
||||
degree: String,
|
||||
course: Short,
|
||||
): Boolean =
|
||||
when (degree) {
|
||||
"БО" -> course in 1..4
|
||||
"МО" -> course in 1..2
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.time.LocalDate
|
||||
|
||||
@Component
|
||||
class GroupCohortResolver {
|
||||
fun resolve(request: CreateGroupRequest): GroupCohort {
|
||||
val fromName =
|
||||
request.name
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { GroupNameParser.parse(it) }
|
||||
val stream =
|
||||
request.stream
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: fromName?.stream
|
||||
val course = request.course ?: fromName?.course
|
||||
val number = request.number ?: fromName?.number
|
||||
val degree =
|
||||
request.degree
|
||||
?.trim()
|
||||
?.uppercase()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: fromName?.degree
|
||||
val missing = listOf(stream, course, number, degree).any { it == null }
|
||||
if (missing) {
|
||||
throw BadRequestException("Укажите поток, курс, номер, степень и год набора")
|
||||
}
|
||||
val resolvedStream = stream!!
|
||||
val resolvedCourse = course!!
|
||||
val resolvedNumber = number!!
|
||||
val resolvedDegree = degree!!
|
||||
val year = request.enrollmentYear ?: defaultEnrollmentYear(resolvedCourse)
|
||||
return validate(GroupCohort(resolvedStream, resolvedCourse, resolvedNumber, resolvedDegree, year))
|
||||
}
|
||||
|
||||
fun validate(cohort: GroupCohort): GroupCohort {
|
||||
validateStream(cohort.stream)
|
||||
validateDegreeAndCourse(cohort)
|
||||
validateNumberAndYear(cohort)
|
||||
return cohort
|
||||
}
|
||||
|
||||
private fun validateStream(stream: String) {
|
||||
if (stream.isBlank() || stream.length > 50) {
|
||||
throw BadRequestException("Поток: от 1 до 50 символов")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDegreeAndCourse(cohort: GroupCohort) {
|
||||
if (cohort.degree !in setOf("БО", "МО")) {
|
||||
throw BadRequestException("Степень должна быть БО или МО")
|
||||
}
|
||||
if (!GroupNameParser.validCourse(cohort.degree, cohort.course)) {
|
||||
throw BadRequestException("Курс ${cohort.course} недопустим для степени ${cohort.degree}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateNumberAndYear(cohort: GroupCohort) {
|
||||
if (cohort.number <= 0) {
|
||||
throw BadRequestException("Номер группы должен быть положительным")
|
||||
}
|
||||
if (cohort.enrollmentYear !in 2000..2100) {
|
||||
throw BadRequestException("Год набора вне диапазона 2000–2100")
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultEnrollmentYear(course: Short): Short {
|
||||
val now = LocalDate.now()
|
||||
val academicYearStart = if (now.monthValue >= 9) now.year else now.year - 1
|
||||
return (academicYearStart - (course - 1)).toShort()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.util.UUID
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class GroupController(
|
||||
private val groupService: GroupService,
|
||||
private val importService: GroupImportService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
@@ -39,17 +40,18 @@ class GroupController(
|
||||
fun createStudent(
|
||||
@PathVariable id: UUID,
|
||||
@Valid @RequestBody body: CreateStudentRequest,
|
||||
): Credential = groupService.createStudent(id, body)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(name = "name", required = false) name: String?,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): ImportResult {
|
||||
if (file.isEmpty) {
|
||||
throw BadRequestException("Выберите файл со списком группы")
|
||||
}
|
||||
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
|
||||
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
|
||||
}
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@@ -63,14 +65,16 @@ class GroupController(
|
||||
fun enroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.enroll(id, studentId)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.unenroll(id, studentId)
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.identity.auth.Passwords
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
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 ru.ysu.lms.security.LmsRole
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class GroupImportService(
|
||||
private val groups: GroupRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val students: StudentRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val history: MembershipHistoryRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
) {
|
||||
@Transactional
|
||||
fun import(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
actorAccountId: UUID?,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
if (parsed.errors.isNotEmpty()) {
|
||||
return ImportResult(
|
||||
applied = false,
|
||||
groupName = parsed.cohort?.name,
|
||||
errors = parsed.errors.map { ImportError(it.row, it.message) },
|
||||
)
|
||||
}
|
||||
val cohort = parsed.cohort
|
||||
if (cohort == null) {
|
||||
return ImportResult(
|
||||
applied = false,
|
||||
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
|
||||
)
|
||||
}
|
||||
val group = findOrCreateGroup(cohort)
|
||||
val added = mutableListOf<Credential>()
|
||||
val updated = mutableListOf<String>()
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val student = upsertStudent(parsedStudent, cohort, added, updated)
|
||||
moveToGroup(student, group, actorAccountId)
|
||||
}
|
||||
return ImportResult(
|
||||
applied = true,
|
||||
groupId = group.id.toString(),
|
||||
groupName = group.name,
|
||||
added = added,
|
||||
updated = updated,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findOrCreateGroup(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 upsertStudent(
|
||||
parsed: ParsedStudent,
|
||||
cohort: GroupCohort,
|
||||
added: MutableList<Credential>,
|
||||
updated: MutableList<String>,
|
||||
): Student {
|
||||
val existing = students.findByStudentNo(parsed.studentNo)
|
||||
if (existing != null) {
|
||||
existing.lastName = parsed.lastName
|
||||
existing.firstName = parsed.firstName
|
||||
existing.middleName = parsed.middleName
|
||||
existing.basis = parsed.basis
|
||||
existing.note = parsed.note
|
||||
existing.status = StudentStatus.ACTIVE
|
||||
existing.enrollmentYear = cohort.enrollmentYear
|
||||
students.save(existing)
|
||||
updated += parsed.studentNo
|
||||
return existing
|
||||
}
|
||||
if (accounts.existsByLogin(parsed.studentNo)) {
|
||||
throw ConflictException("Логин ${parsed.studentNo} уже занят другим аккаунтом")
|
||||
}
|
||||
val password = Passwords.generate()
|
||||
val account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = parsed.studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val student =
|
||||
Student(
|
||||
studentNo = parsed.studentNo,
|
||||
lastName = parsed.lastName,
|
||||
firstName = parsed.firstName,
|
||||
middleName = parsed.middleName,
|
||||
basis = parsed.basis,
|
||||
note = parsed.note,
|
||||
status = StudentStatus.ACTIVE,
|
||||
enrollmentYear = cohort.enrollmentYear,
|
||||
account = account,
|
||||
)
|
||||
students.save(student)
|
||||
added +=
|
||||
Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = parsed.studentNo,
|
||||
fullName = student.fullName,
|
||||
password = password,
|
||||
)
|
||||
return student
|
||||
}
|
||||
|
||||
private fun moveToGroup(
|
||||
student: Student,
|
||||
group: GroupEntity,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
val accountId = student.accountId ?: return
|
||||
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||
val fromGroupId = enrollment?.group?.id
|
||||
if (fromGroupId == group.id) return
|
||||
if (enrollment == null) {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
} else {
|
||||
enrollment.group = group
|
||||
enrollments.save(enrollment)
|
||||
}
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = accountId,
|
||||
fromGroupId = fromGroupId,
|
||||
toGroupId = group.id,
|
||||
action = MembershipAction.IMPORT,
|
||||
reason = "Импорт Excel",
|
||||
actorAccountId = actorAccountId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,37 +10,43 @@ import ru.ysu.lms.identity.auth.Passwords
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
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 ru.ysu.lms.security.LmsRole
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Service
|
||||
class GroupService(
|
||||
private val groups: GroupRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val students: StudentRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val history: MembershipHistoryRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
private val cohortResolver: GroupCohortResolver,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun createStudent(
|
||||
groupId: UUID,
|
||||
request: CreateStudentRequest,
|
||||
actorAccountId: UUID? = null,
|
||||
): Credential {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val group = activeGroup(groupId)
|
||||
val studentNo = request.studentNo.trim()
|
||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||
if (students.existsByStudentNo(studentNo)) {
|
||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||
}
|
||||
ensureStudentNumberAvailable(studentNo)
|
||||
val password = Passwords.generate()
|
||||
val account =
|
||||
accounts.save(
|
||||
@@ -52,7 +58,6 @@ class GroupService(
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||
val student =
|
||||
students.save(
|
||||
Student(
|
||||
@@ -62,10 +67,19 @@ class GroupService(
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
status = StudentStatus.ACTIVE,
|
||||
enrollmentYear = group.enrollmentYear,
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
recordHistory(
|
||||
studentNo = student.accountId!!,
|
||||
fromGroupId = null,
|
||||
toGroupId = group.id,
|
||||
action = MembershipAction.ENROLL,
|
||||
actorAccountId = actorAccountId,
|
||||
)
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = studentNo,
|
||||
@@ -76,11 +90,31 @@ class GroupService(
|
||||
|
||||
@Transactional
|
||||
fun create(request: CreateGroupRequest): GroupSummary {
|
||||
val name = request.name.trim()
|
||||
if (groups.existsByName(name)) {
|
||||
throw ConflictException("Группа «$name» уже существует")
|
||||
val cohort = cohortResolver.resolve(request)
|
||||
val existing =
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
cohort.stream,
|
||||
cohort.degree,
|
||||
cohort.number,
|
||||
cohort.course,
|
||||
cohort.enrollmentYear,
|
||||
)
|
||||
if (existing != null) {
|
||||
throw ConflictException("Группа «${cohort.name}» с таким годом набора уже существует")
|
||||
}
|
||||
return summary(groups.save(GroupEntity(name = name)))
|
||||
val group =
|
||||
groups.save(
|
||||
GroupEntity(
|
||||
name = cohort.name,
|
||||
stream = cohort.stream,
|
||||
course = cohort.course,
|
||||
number = cohort.number,
|
||||
degree = cohort.degree,
|
||||
enrollmentYear = cohort.enrollmentYear,
|
||||
status = GroupStatus.ACTIVE,
|
||||
),
|
||||
)
|
||||
return summary(group)
|
||||
}
|
||||
|
||||
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
|
||||
@@ -116,129 +150,52 @@ class GroupService(
|
||||
fun enroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
actorAccountId: UUID? = null,
|
||||
): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val group = activeGroup(groupId)
|
||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||
val existing = enrollments.findByStudentAccountId(studentId)
|
||||
if (existing != null) {
|
||||
if (existing.group?.id == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
}
|
||||
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
|
||||
val fromGroupId = existing?.group?.id
|
||||
if (fromGroupId == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
}
|
||||
if (existing == null) {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
} else {
|
||||
existing.group = group
|
||||
enrollments.save(existing)
|
||||
} else {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
}
|
||||
student.status = StudentStatus.ACTIVE
|
||||
student.enrollmentYear = group.enrollmentYear
|
||||
students.save(student)
|
||||
recordHistory(studentId, fromGroupId, group.id, MembershipAction.TRANSFER, actorAccountId)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||
/** Убрать студента из группы; аккаунт и данные сохраняются. */
|
||||
@Transactional
|
||||
fun unenroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
actorAccountId: UUID? = null,
|
||||
): GroupDetail {
|
||||
val enrollment =
|
||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
val student = students.findById(studentId).orElse(null)
|
||||
if (student != null) {
|
||||
student.status = StudentStatus.EXPELLED
|
||||
students.save(student)
|
||||
}
|
||||
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun import(
|
||||
requestedName: String?,
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
||||
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
||||
val added = mutableListOf<Credential>()
|
||||
val updated = mutableListOf<String>()
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val student = upsertStudent(parsedStudent, added, updated)
|
||||
enrollIntoGroup(group, student)
|
||||
}
|
||||
return ImportResult(group.id.toString(), group.name, added, updated)
|
||||
}
|
||||
|
||||
/** Обновляет существующего студента или создаёт нового с временным паролем. */
|
||||
private fun upsertStudent(
|
||||
parsed: ParsedStudent,
|
||||
added: MutableList<Credential>,
|
||||
updated: MutableList<String>,
|
||||
): Student {
|
||||
val existing = students.findByStudentNo(parsed.studentNo)
|
||||
if (existing != null) {
|
||||
existing.lastName = parsed.lastName
|
||||
existing.firstName = parsed.firstName
|
||||
existing.middleName = parsed.middleName
|
||||
existing.basis = parsed.basis
|
||||
existing.note = parsed.note
|
||||
students.save(existing)
|
||||
updated.add(parsed.studentNo)
|
||||
return existing
|
||||
}
|
||||
val password = Passwords.generate()
|
||||
val account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = parsed.studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val created =
|
||||
Student(
|
||||
studentNo = parsed.studentNo,
|
||||
lastName = parsed.lastName,
|
||||
firstName = parsed.firstName,
|
||||
middleName = parsed.middleName,
|
||||
basis = parsed.basis,
|
||||
note = parsed.note,
|
||||
)
|
||||
created.account = account
|
||||
students.save(created)
|
||||
added.add(
|
||||
Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = parsed.studentNo,
|
||||
fullName = parsed.fullName,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Зачисляет студента в группу (или переводит из другой). */
|
||||
private fun enrollIntoGroup(
|
||||
group: GroupEntity,
|
||||
student: Student,
|
||||
) {
|
||||
val accountId = student.accountId!!
|
||||
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||
if (enrollment == null) {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
} else {
|
||||
enrollment.group = group
|
||||
enrollments.save(enrollment)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||
return GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
return summary(group)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -255,11 +212,24 @@ class GroupService(
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
status = student.status.name,
|
||||
enrollmentYear = student.enrollmentYear,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
return GroupDetail(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
stream = group.stream,
|
||||
course = group.course,
|
||||
number = group.number,
|
||||
degree = group.degree,
|
||||
enrollmentYear = group.enrollmentYear,
|
||||
status = group.status.name,
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
students = rows,
|
||||
)
|
||||
}
|
||||
|
||||
/** Список всех пользователей (студентов) с фильтром по подстроке. */
|
||||
@@ -286,6 +256,8 @@ class GroupService(
|
||||
studentNo = s.studentNo,
|
||||
fullName = s.fullName,
|
||||
basis = s.basis,
|
||||
status = s.status.name,
|
||||
enrollmentYear = s.enrollmentYear,
|
||||
groupId = groupInfo?.first?.toString(),
|
||||
groupName = groupInfo?.second,
|
||||
)
|
||||
@@ -341,27 +313,69 @@ class GroupService(
|
||||
): ExportFile {
|
||||
val detail = detail(groupId)
|
||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||
val year = detail.enrollmentYear?.let { " ($it)" }.orEmpty()
|
||||
return when (format.lowercase()) {
|
||||
"csv" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
filename = "credentials_$safeName$year.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
filename = "credentials_$safeName$year.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
else -> throw BadRequestException("Формат должен быть xlsx или csv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureStudentNumberAvailable(studentNo: String) {
|
||||
if (students.existsByStudentNo(studentNo)) {
|
||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||
}
|
||||
if (accounts.existsByLogin(studentNo)) {
|
||||
throw ConflictException("Логин $studentNo уже занят другим аккаунтом")
|
||||
}
|
||||
}
|
||||
|
||||
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 recordHistory(
|
||||
studentNo: UUID,
|
||||
fromGroupId: UUID?,
|
||||
toGroupId: UUID?,
|
||||
action: MembershipAction,
|
||||
actorAccountId: UUID?,
|
||||
) {
|
||||
history.save(
|
||||
MembershipHistory(
|
||||
studentId = studentNo,
|
||||
fromGroupId = fromGroupId,
|
||||
toGroupId = toGroupId,
|
||||
action = action,
|
||||
actorAccountId = actorAccountId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun summary(group: GroupEntity): GroupSummary =
|
||||
GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
stream = group.stream,
|
||||
course = group.course,
|
||||
number = group.number,
|
||||
degree = group.degree,
|
||||
enrollmentYear = group.enrollmentYear,
|
||||
status = group.status.name,
|
||||
studentCount = enrollments.countByGroupId(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
|
||||
@@ -5,13 +5,23 @@ import jakarta.validation.constraints.NotBlank
|
||||
data class GroupSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val stream: String? = null,
|
||||
val course: Short? = null,
|
||||
val number: Short? = null,
|
||||
val degree: String? = null,
|
||||
val enrollmentYear: Short? = null,
|
||||
val status: String = "ACTIVE",
|
||||
val studentCount: Long,
|
||||
val taskDurationMin: Int,
|
||||
)
|
||||
|
||||
data class CreateGroupRequest(
|
||||
@field:NotBlank(message = "Укажите название группы")
|
||||
val name: String,
|
||||
val name: String? = null,
|
||||
val stream: String? = null,
|
||||
val course: Short? = null,
|
||||
val number: Short? = null,
|
||||
val degree: String? = null,
|
||||
val enrollmentYear: Short? = null,
|
||||
)
|
||||
|
||||
data class CreateStudentRequest(
|
||||
@@ -30,6 +40,8 @@ data class StudentRow(
|
||||
val studentNo: String,
|
||||
val fullName: String,
|
||||
val basis: String,
|
||||
val status: String = "ACTIVE",
|
||||
val enrollmentYear: Short? = null,
|
||||
val mustChangePassword: Boolean,
|
||||
val tempPassword: String?,
|
||||
)
|
||||
@@ -37,6 +49,12 @@ data class StudentRow(
|
||||
data class GroupDetail(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val stream: String? = null,
|
||||
val course: Short? = null,
|
||||
val number: Short? = null,
|
||||
val degree: String? = null,
|
||||
val enrollmentYear: Short? = null,
|
||||
val status: String = "ACTIVE",
|
||||
val taskDurationMin: Int,
|
||||
val students: List<StudentRow>,
|
||||
)
|
||||
@@ -49,10 +67,17 @@ data class Credential(
|
||||
)
|
||||
|
||||
data class ImportResult(
|
||||
val groupId: String,
|
||||
val groupName: String,
|
||||
val added: List<Credential>,
|
||||
val updated: List<String>,
|
||||
val applied: Boolean = true,
|
||||
val groupId: String? = null,
|
||||
val groupName: String? = null,
|
||||
val added: List<Credential> = emptyList(),
|
||||
val updated: List<String> = emptyList(),
|
||||
val errors: List<ImportError> = emptyList(),
|
||||
)
|
||||
|
||||
data class ImportError(
|
||||
val row: Int,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ResetPasswordResponse(
|
||||
@@ -75,6 +100,8 @@ data class AdminUser(
|
||||
val studentNo: String,
|
||||
val fullName: String,
|
||||
val basis: String,
|
||||
val status: String = "ACTIVE",
|
||||
val enrollmentYear: Short? = null,
|
||||
val groupId: String?,
|
||||
val groupName: String?,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,45 @@ data class ParsedStudent(
|
||||
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
||||
}
|
||||
|
||||
data class ParsedGroup(
|
||||
val groupName: String,
|
||||
val students: List<ParsedStudent>,
|
||||
data class ImportRowError(
|
||||
val row: Int,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ParsedGroup(
|
||||
val cohort: GroupCohort?,
|
||||
val students: List<ParsedStudent>,
|
||||
val errors: List<ImportRowError>,
|
||||
)
|
||||
|
||||
private data class HeaderRule(
|
||||
val key: String,
|
||||
val exact: Set<String> = emptySet(),
|
||||
val prefixes: List<String> = emptyList(),
|
||||
) {
|
||||
fun matches(value: String): Boolean = value in exact || prefixes.any { value.startsWith(it) }
|
||||
}
|
||||
|
||||
private val HEADER_RULES =
|
||||
listOf(
|
||||
HeaderRule("stream", exact = setOf("поток"), prefixes = listOf("поток ")),
|
||||
HeaderRule("course", exact = setOf("курс", "курс обучения")),
|
||||
HeaderRule("groupNumber", exact = setOf("номер", "номер группы")),
|
||||
HeaderRule("degree", prefixes = listOf("степень")),
|
||||
HeaderRule("year", exact = setOf("год"), prefixes = listOf("год набора")),
|
||||
HeaderRule(
|
||||
"studentNo",
|
||||
exact = setOf("№"),
|
||||
prefixes = listOf("№ ", "студенч", "зачёт", "зачет", "номер студента"),
|
||||
),
|
||||
HeaderRule("fullName", prefixes = listOf("фио", "фамилия имя")),
|
||||
HeaderRule("lastName", prefixes = listOf("фамили")),
|
||||
HeaderRule("firstName", exact = setOf("имя"), prefixes = listOf("имя ")),
|
||||
HeaderRule("middleName", prefixes = listOf("отчеств")),
|
||||
HeaderRule("basis", prefixes = listOf("база", "основ")),
|
||||
HeaderRule("note", prefixes = listOf("примеч")),
|
||||
)
|
||||
|
||||
@Component
|
||||
class StudentImportParser {
|
||||
fun parse(
|
||||
@@ -30,11 +64,18 @@ class StudentImportParser {
|
||||
bytes: ByteArray,
|
||||
): ParsedGroup {
|
||||
val lower = filename.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
|
||||
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
|
||||
val rows =
|
||||
when {
|
||||
lower.endsWith(".csv") -> parseCsv(bytes)
|
||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseExcel(bytes)
|
||||
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
|
||||
}
|
||||
val headerIndex = rows.indexOfFirst { row -> mapColumns(row).isNotEmpty() }
|
||||
if (headerIndex < 0) {
|
||||
throw BadRequestException("Не найдена шапка таблицы. Нужны столбцы: поток, курс, номер, степень, год набора, ФИО, №.")
|
||||
}
|
||||
val columns = requireColumns(rows[headerIndex])
|
||||
return parseRows(rows, headerIndex, columns)
|
||||
}
|
||||
|
||||
private fun parseExcel(bytes: ByteArray): List<List<String>> {
|
||||
@@ -55,9 +96,7 @@ class StudentImportParser {
|
||||
private fun parseCsv(bytes: ByteArray): List<List<String>> {
|
||||
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
|
||||
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
|
||||
if (lines.isEmpty()) {
|
||||
throw BadRequestException("Файл пуст")
|
||||
}
|
||||
if (lines.isEmpty()) throw BadRequestException("Файл пуст")
|
||||
val delimiter = detectDelimiter(lines.first())
|
||||
return lines.map { splitCsvLine(it, delimiter) }
|
||||
}
|
||||
@@ -89,101 +128,228 @@ class StudentImportParser {
|
||||
}
|
||||
|
||||
private fun parseRows(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
headerIndex: Int,
|
||||
columns: Map<String, Int>,
|
||||
): ParsedGroup {
|
||||
val headerIndex = requireHeader(rows)
|
||||
val columns = requireColumns(rows[headerIndex])
|
||||
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
|
||||
if (students.isEmpty()) {
|
||||
val errors = mutableListOf<ImportRowError>()
|
||||
val students = mutableListOf<ParsedStudent>()
|
||||
val seenStudentNos = mutableMapOf<String, Int>()
|
||||
var cohort: GroupCohort? = null
|
||||
|
||||
rows.drop(headerIndex + 1).forEachIndexed { index, row ->
|
||||
if (row.all { it.isBlank() }) return@forEachIndexed
|
||||
val rowNumber = headerIndex + index + 2
|
||||
val rowCohort = parseCohort(row, columns, rowNumber, errors)
|
||||
if (rowCohort != null) {
|
||||
if (cohort == null) {
|
||||
cohort = rowCohort
|
||||
} else if (cohort != rowCohort) {
|
||||
errors += ImportRowError(rowNumber, "Все строки файла должны относиться к одной группе")
|
||||
}
|
||||
}
|
||||
val student = parseStudent(row, columns, rowNumber, errors, seenStudentNos)
|
||||
if (student != null) students += student
|
||||
}
|
||||
|
||||
if (students.isEmpty() && errors.isEmpty()) {
|
||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||
}
|
||||
return ParsedGroup(groupNameFrom(filename, rows), students)
|
||||
return ParsedGroup(cohort = cohort, students = students, errors = errors)
|
||||
}
|
||||
|
||||
private fun requireHeader(rows: List<List<String>>): Int {
|
||||
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
||||
if (headerIndex < 0) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
||||
}
|
||||
return headerIndex
|
||||
private fun parseCohort(
|
||||
row: List<String>,
|
||||
columns: Map<String, Int>,
|
||||
rowNumber: Int,
|
||||
errors: MutableList<ImportRowError>,
|
||||
): GroupCohort? {
|
||||
val fields =
|
||||
CohortFields(
|
||||
stream = cell(row, columns, "stream"),
|
||||
course = cell(row, columns, "course"),
|
||||
number = cell(row, columns, "groupNumber"),
|
||||
degree = cell(row, columns, "degree").uppercase(),
|
||||
year = cell(row, columns, "year"),
|
||||
)
|
||||
val before = errors.size
|
||||
val numbers =
|
||||
CohortNumbers(
|
||||
course = parseInt(fields.course),
|
||||
number = parseInt(fields.number),
|
||||
year = parseInt(fields.year),
|
||||
)
|
||||
validateRequiredCohortFields(fields, rowNumber, errors)
|
||||
validateCohortNumbers(fields, numbers, rowNumber, errors)
|
||||
validateCohortDegree(fields.degree, numbers.course, rowNumber, errors)
|
||||
if (errors.size > before) return null
|
||||
return GroupCohort(
|
||||
stream = fields.stream,
|
||||
course = numbers.course!!.toShort(),
|
||||
number = numbers.number!!.toShort(),
|
||||
degree = fields.degree,
|
||||
enrollmentYear = numbers.year!!.toShort(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun requireColumns(header: List<String>): Map<String, Int> {
|
||||
val columns = mapColumns(header)
|
||||
if (columns["studentNo"] == null) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
||||
}
|
||||
return columns
|
||||
private fun validateRequiredCohortFields(
|
||||
fields: CohortFields,
|
||||
rowNumber: Int,
|
||||
errors: MutableList<ImportRowError>,
|
||||
) {
|
||||
if (fields.stream.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен поток")
|
||||
if (fields.course.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен курс")
|
||||
if (fields.number.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен номер группы")
|
||||
if (fields.degree.isBlank()) errors += ImportRowError(rowNumber, "Не заполнена степень")
|
||||
if (fields.year.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен год набора")
|
||||
}
|
||||
|
||||
private fun validateCohortNumbers(
|
||||
fields: CohortFields,
|
||||
numbers: CohortNumbers,
|
||||
rowNumber: Int,
|
||||
errors: MutableList<ImportRowError>,
|
||||
) {
|
||||
if (fields.course.isNotBlank() && numbers.course == null) {
|
||||
errors += ImportRowError(rowNumber, "Курс должен быть числом")
|
||||
}
|
||||
if (fields.number.isNotBlank() && numbers.number == null) {
|
||||
errors += ImportRowError(rowNumber, "Номер группы должен быть числом")
|
||||
}
|
||||
if (fields.year.isNotBlank() && numbers.year == null) {
|
||||
errors += ImportRowError(rowNumber, "Год набора должен быть числом")
|
||||
}
|
||||
if (numbers.number != null && numbers.number <= 0) {
|
||||
errors += ImportRowError(rowNumber, "Номер группы должен быть положительным")
|
||||
}
|
||||
if (numbers.number != null && numbers.number > Short.MAX_VALUE) {
|
||||
errors += ImportRowError(rowNumber, "Слишком большой номер группы")
|
||||
}
|
||||
if (numbers.year != null && numbers.year !in 2000..2100) {
|
||||
errors += ImportRowError(rowNumber, "Год набора вне диапазона 2000–2100")
|
||||
}
|
||||
}
|
||||
|
||||
private data class CohortNumbers(
|
||||
val course: Int?,
|
||||
val number: Int?,
|
||||
val year: Int?,
|
||||
)
|
||||
|
||||
private fun validateCohortDegree(
|
||||
degree: String,
|
||||
course: Int?,
|
||||
rowNumber: Int,
|
||||
errors: MutableList<ImportRowError>,
|
||||
) {
|
||||
if (degree.isNotBlank() && degree !in setOf("БО", "МО")) {
|
||||
errors += ImportRowError(rowNumber, "Степень должна быть БО или МО")
|
||||
}
|
||||
if (course != null && degree in setOf("БО", "МО") && !GroupNameParser.validCourse(degree, course.toShort())) {
|
||||
errors += ImportRowError(rowNumber, "Курс $course недопустим для степени $degree")
|
||||
}
|
||||
}
|
||||
|
||||
private data class CohortFields(
|
||||
val stream: String,
|
||||
val course: String,
|
||||
val number: String,
|
||||
val degree: String,
|
||||
val year: String,
|
||||
)
|
||||
|
||||
private fun parseStudent(
|
||||
row: List<String>,
|
||||
columns: Map<String, Int>,
|
||||
rowNumber: Int,
|
||||
errors: MutableList<ImportRowError>,
|
||||
seenStudentNos: MutableMap<String, Int>,
|
||||
): ParsedStudent? {
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) return null
|
||||
val before = errors.size
|
||||
val studentNo = normalizeNumber(cell(row, columns, "studentNo"))
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
errors += ImportRowError(rowNumber, "Не заполнен № студента")
|
||||
} else {
|
||||
val previous = seenStudentNos.put(studentNo, rowNumber)
|
||||
if (previous != null) {
|
||||
errors += ImportRowError(rowNumber, "№ $studentNo уже встречается в строке $previous")
|
||||
}
|
||||
}
|
||||
|
||||
val fullName = cell(row, columns, "fullName")
|
||||
val lastName: String
|
||||
val firstName: String
|
||||
val middleName: String
|
||||
if (fullName.isNotBlank()) {
|
||||
val parts = fullName.split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
lastName = parts.getOrElse(0) { "" }
|
||||
firstName = parts.getOrElse(1) { "" }
|
||||
middleName = parts.drop(2).joinToString(" ")
|
||||
} else {
|
||||
lastName = cell(row, columns, "lastName")
|
||||
firstName = cell(row, columns, "firstName")
|
||||
middleName = cell(row, columns, "middleName")
|
||||
}
|
||||
if (lastName.isBlank()) errors += ImportRowError(rowNumber, "Не заполнено ФИО")
|
||||
|
||||
if (errors.size > before) return null
|
||||
return ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
firstName = firstName,
|
||||
middleName = middleName,
|
||||
basis = cell(row, columns, "basis"),
|
||||
note = cell(row, columns, "note"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isStudentHeader(value: String): Boolean {
|
||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||
return normalized.startsWith("студен") ||
|
||||
normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") ||
|
||||
normalized.startsWith("зачет")
|
||||
private fun requireColumns(header: List<String>): Map<String, Int> {
|
||||
val columns = mapColumns(header)
|
||||
val missing = mutableListOf<String>()
|
||||
val names =
|
||||
mapOf(
|
||||
"stream" to "поток",
|
||||
"course" to "курс",
|
||||
"groupNumber" to "номер",
|
||||
"degree" to "степень",
|
||||
"year" to "год набора",
|
||||
"studentNo" to "№",
|
||||
)
|
||||
names.forEach { (key, title) ->
|
||||
if (columns[key] == null) missing += title
|
||||
}
|
||||
if (columns["fullName"] == null && columns["lastName"] == null) missing += "ФИО"
|
||||
if (missing.isNotEmpty()) {
|
||||
throw BadRequestException("Не найдены столбцы: ${missing.joinToString(", ")}. Проверьте шапку таблицы.")
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
header.forEachIndexed { index, raw ->
|
||||
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
||||
|
||||
fun putIfAbsent(
|
||||
key: String,
|
||||
vararg prefixes: String,
|
||||
) {
|
||||
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
||||
result[key] = index
|
||||
}
|
||||
}
|
||||
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
|
||||
putIfAbsent("lastName", "фамили")
|
||||
putIfAbsent("firstName", "имя")
|
||||
putIfAbsent("middleName", "отчеств")
|
||||
putIfAbsent("basis", "основ")
|
||||
putIfAbsent("note", "примеч")
|
||||
val value = normalizeHeader(raw)
|
||||
HEADER_RULES
|
||||
.firstOrNull { it.matches(value) }
|
||||
?.let { result.putIfAbsent(it.key, index) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
private fun normalizeHeader(value: String): String =
|
||||
value
|
||||
.lowercase()
|
||||
.replace('\u00a0', ' ')
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
|
||||
private fun groupNameFrom(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): String {
|
||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
||||
for (row in rows.take(12)) {
|
||||
for (cell in row) {
|
||||
val value = cell.trim()
|
||||
if (value.length in 2..30 && groupRegex.matches(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
|
||||
}
|
||||
private fun cell(
|
||||
row: List<String>,
|
||||
columns: Map<String, Int>,
|
||||
key: String,
|
||||
): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
|
||||
private fun parseInt(value: String): Int? = normalizeNumber(value).takeIf { it.isNotBlank() }?.toIntOrNull()
|
||||
|
||||
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GroupNameParserTest {
|
||||
@Test
|
||||
fun `parses stream course number and degree`() {
|
||||
val group = GroupNameParser.parse("ПИ-11БО")
|
||||
assertEquals("ПИ", group?.stream)
|
||||
assertEquals(1, group?.course)
|
||||
assertEquals(1, group?.number)
|
||||
assertEquals("БО", group?.degree)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses stream with hyphen`() {
|
||||
val group = GroupNameParser.parse("ИВТ-ТОП-12БО")
|
||||
assertEquals("ИВТ-ТОП", group?.stream)
|
||||
assertEquals(1, group?.course)
|
||||
assertEquals(2, group?.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps leading zero in number`() {
|
||||
val group = GroupNameParser.parse("ПИ-101БО")
|
||||
assertEquals(1, group?.course)
|
||||
assertEquals(1, group?.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects invalid names`() {
|
||||
assertNull(GroupNameParser.parse("ПИ-11"))
|
||||
assertNull(GroupNameParser.parse("ПИ-11XX"))
|
||||
assertNull(GroupNameParser.parse(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validates course ranges`() {
|
||||
assertTrue(GroupNameParser.validCourse("БО", 4))
|
||||
assertFalse(GroupNameParser.validCourse("БО", 5))
|
||||
assertTrue(GroupNameParser.validCourse("МО", 2))
|
||||
assertFalse(GroupNameParser.validCourse("МО", 3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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.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.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.identity.auth.LoginRequest
|
||||
import ru.ysu.lms.identity.domain.MembershipAction
|
||||
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.StudentRepository
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@Transactional
|
||||
class GroupServiceIntegrationTest {
|
||||
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 groupService: GroupService
|
||||
|
||||
@Autowired lateinit var importService: GroupImportService
|
||||
|
||||
@Autowired lateinit var authService: AuthService
|
||||
|
||||
@Autowired lateinit var groups: GroupRepository
|
||||
|
||||
@Autowired lateinit var students: StudentRepository
|
||||
|
||||
@Autowired lateinit var enrollments: EnrollmentRepository
|
||||
|
||||
@Autowired lateinit var history: MembershipHistoryRepository
|
||||
|
||||
@Test
|
||||
fun `creates cohort group and rejects duplicate`() {
|
||||
val request =
|
||||
CreateGroupRequest(
|
||||
stream = "ПИ",
|
||||
course = 1,
|
||||
number = 1,
|
||||
degree = "БО",
|
||||
enrollmentYear = 2025,
|
||||
)
|
||||
val created = groupService.create(request)
|
||||
|
||||
assertEquals("ПИ-11БО", created.name)
|
||||
assertEquals(2025, created.enrollmentYear)
|
||||
assertEquals("ACTIVE", created.status)
|
||||
|
||||
val error =
|
||||
runCatching { groupService.create(request) }.exceptionOrNull()
|
||||
assertTrue(error is ConflictException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `imports students and records membership history`() {
|
||||
val result = importService.import("group.csv", csv(validRows()), null)
|
||||
|
||||
assertTrue(result.applied)
|
||||
assertEquals("ПИ-21БО", result.groupName)
|
||||
assertEquals(2, result.added.size)
|
||||
val group = groups.findByName("ПИ-21БО")!!
|
||||
val groupId = group.id
|
||||
assertEquals(2, enrollments.countByGroupId(groupId))
|
||||
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
assertEquals(StudentStatus.ACTIVE, student.status)
|
||||
assertEquals(2025.toShort(), student.enrollmentYear)
|
||||
val events = history.findByStudentIdOrderByAtDesc(studentId)
|
||||
assertEquals(MembershipAction.IMPORT, events.first().action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not apply import when there are row errors`() {
|
||||
val groupsBefore = groups.count()
|
||||
val studentsBefore = students.count()
|
||||
val bad =
|
||||
"""
|
||||
ПИ;1;1;БО;2025;Иванов Иван;3001;бюджет
|
||||
ПИ;1;1;БО;2025;Петров Пётр;3001;бюджет
|
||||
""".trimIndent()
|
||||
|
||||
val result = importService.import("bad.csv", csv(bad), null)
|
||||
|
||||
assertFalse(result.applied)
|
||||
assertTrue(result.errors.isNotEmpty())
|
||||
assertEquals(groupsBefore, groups.count())
|
||||
assertEquals(studentsBefore, students.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transfer and expulsion keep history`() {
|
||||
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
|
||||
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
|
||||
val imported = importService.import("group.csv", csv(validRows()), null)
|
||||
val student = students.findByStudentNo("2001")!!
|
||||
val studentId = student.accountId!!
|
||||
|
||||
groupService.enroll(UUID.fromString(second.id), studentId, null)
|
||||
assertEquals(
|
||||
second.id,
|
||||
enrollments
|
||||
.findByStudentAccountId(studentId)
|
||||
?.group
|
||||
?.id
|
||||
.toString(),
|
||||
)
|
||||
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||
|
||||
groupService.unenroll(UUID.fromString(second.id), studentId, null)
|
||||
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
|
||||
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
|
||||
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
|
||||
assertTrue(imported.applied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expelled student cannot login`() {
|
||||
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 9, degree = "БО", enrollmentYear = 2025))
|
||||
val credential =
|
||||
groupService.createStudent(
|
||||
UUID.fromString(group.id),
|
||||
CreateStudentRequest(studentNo = "9001", lastName = "Иванов", firstName = "Иван"),
|
||||
null,
|
||||
)
|
||||
authService.login(LoginRequest("9001", credential.password))
|
||||
|
||||
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
|
||||
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
|
||||
|
||||
assertTrue(error is UnauthorizedException)
|
||||
}
|
||||
|
||||
private fun validRows(): String =
|
||||
"""
|
||||
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет
|
||||
ПИ;2;1;БО;2025;Петров Пётр;2002;бюджет
|
||||
""".trimIndent()
|
||||
|
||||
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
|
||||
class StudentImportParserTest {
|
||||
private val parser = StudentImportParser()
|
||||
|
||||
@Test
|
||||
fun `parses new template and splits full name`() {
|
||||
val result = parser.parse("group.csv", csv(validRows()))
|
||||
|
||||
assertTrue(result.errors.isEmpty())
|
||||
assertEquals("ПИ-11БО", result.cohort?.name)
|
||||
assertEquals(2, result.students.size)
|
||||
assertEquals("Иванов", result.students[0].lastName)
|
||||
assertEquals("Иван", result.students[0].firstName)
|
||||
assertEquals("Иванович", result.students[0].middleName)
|
||||
assertEquals("1001", result.students[0].studentNo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports duplicate student number`() {
|
||||
val rows =
|
||||
"""
|
||||
ПИ;1;1;БО;2025;Иванов Иван;1001;бюджет
|
||||
ПИ;1;1;БО;2025;Петров Пётр;1001;бюджет
|
||||
""".trimIndent()
|
||||
val result = parser.parse("group.csv", csv(rows))
|
||||
|
||||
assertFalse(result.errors.isEmpty())
|
||||
assertTrue(result.errors.any { it.message.contains("уже встречается") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports different groups in one file`() {
|
||||
val rows =
|
||||
"""
|
||||
ПИ;1;1;БО;2025;Иванов Иван;1001;бюджет
|
||||
ПИ;2;1;БО;2025;Петров Пётр;1002;бюджет
|
||||
""".trimIndent()
|
||||
val result = parser.parse("group.csv", csv(rows))
|
||||
|
||||
assertFalse(result.errors.isEmpty())
|
||||
assertTrue(result.errors.any { it.message.contains("одной группе") }, result.errors.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports invalid course for degree`() {
|
||||
val rows = "ПИ;5;1;БО;2025;Иванов Иван;1001;бюджет"
|
||||
val result = parser.parse("group.csv", csv(rows))
|
||||
|
||||
assertFalse(result.errors.isEmpty())
|
||||
assertTrue(result.errors.any { it.message.contains("недопустим") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects unknown header`() {
|
||||
val bytes = "ФИО;№\nИванов Иван;1001".toByteArray()
|
||||
val error =
|
||||
runCatching { parser.parse("group.csv", bytes) }
|
||||
.exceptionOrNull()
|
||||
|
||||
assertTrue(error is BadRequestException)
|
||||
}
|
||||
|
||||
private fun validRows(): String =
|
||||
"""
|
||||
ПИ;1;1;БО;2025;Иванов Иван Иванович;1001;бюджет
|
||||
ПИ;1;1;БО;2025;Петров Пётр;1002;бюджет
|
||||
""".trimIndent()
|
||||
|
||||
private val header = "поток;курс;номер;степень;год набора;ФИО;№;база"
|
||||
|
||||
private fun csv(rows: String): ByteArray = (header + "\n" + rows).toByteArray()
|
||||
}
|
||||
Reference in New Issue
Block a user