fix(import): построчные ошибки до записи
- preflight занятого логина и неактивной целевой группы - applied=false с номером строки, без частичных студентов - LDD-IMPORT-003; baseline 58 claims / 368 тестов
This commit is contained in:
@@ -41,6 +41,23 @@ class GroupImportService(
|
||||
restoreInactiveStatuses: Boolean = false,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
validationFailure(parsed)?.let { return it }
|
||||
val cohort = parsed.cohort ?: return ImportResult(applied = false)
|
||||
val group =
|
||||
try {
|
||||
findOrCreateGroup(cohort)
|
||||
} catch (ex: ConflictException) {
|
||||
return ImportResult(
|
||||
applied = false,
|
||||
groupName = cohort.name,
|
||||
errors = listOf(ImportError(parsed.students.firstOrNull()?.row ?: 0, ex.message.orEmpty())),
|
||||
)
|
||||
}
|
||||
return applyStudents(parsed, cohort, group, actorAccountId, restoreInactiveStatuses)
|
||||
}
|
||||
|
||||
/** Ошибки, которые должны быть показаны до любой записи в БД. */
|
||||
private fun validationFailure(parsed: ParsedGroup): ImportResult? {
|
||||
if (parsed.errors.isNotEmpty()) {
|
||||
return ImportResult(
|
||||
applied = false,
|
||||
@@ -55,11 +72,20 @@ class GroupImportService(
|
||||
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
|
||||
)
|
||||
}
|
||||
val group = findOrCreateGroup(cohort)
|
||||
val preflight = preflightErrors(parsed)
|
||||
return if (preflight.isEmpty()) null else ImportResult(applied = false, groupName = cohort.name, errors = preflight)
|
||||
}
|
||||
|
||||
private fun applyStudents(
|
||||
parsed: ParsedGroup,
|
||||
cohort: GroupCohort,
|
||||
group: GroupEntity,
|
||||
actorAccountId: UUID?,
|
||||
restoreInactiveStatuses: Boolean,
|
||||
): ImportResult {
|
||||
val added = mutableListOf<Credential>()
|
||||
val updated = mutableListOf<String>()
|
||||
val skipped = mutableListOf<ImportError>()
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
|
||||
if (student.status == StudentStatus.ACTIVE) {
|
||||
@@ -85,6 +111,26 @@ class GroupImportService(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверки, которые нельзя откладывать до записи: иначе конфликт уникальности
|
||||
* откатит весь импорт без номера строки. Здесь же ловим дубли внутри файла.
|
||||
*/
|
||||
private fun preflightErrors(parsed: ParsedGroup): List<ImportError> {
|
||||
val errors = mutableListOf<ImportError>()
|
||||
val seen = HashSet<String>()
|
||||
parsed.students.forEach { student ->
|
||||
if (!seen.add(student.studentNo)) {
|
||||
errors += ImportError(student.row, "Студент ${student.studentNo} повторяется в файле")
|
||||
return@forEach
|
||||
}
|
||||
val exists = students.findByStudentNo(student.studentNo) != null
|
||||
if (!exists && accounts.existsByLogin(student.studentNo)) {
|
||||
errors += ImportError(student.row, "Логин ${student.studentNo} уже занят другим аккаунтом")
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity {
|
||||
val existing =
|
||||
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -16,12 +17,15 @@ 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.Account
|
||||
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
|
||||
@@ -58,6 +62,8 @@ class GroupServiceIntegrationTest {
|
||||
|
||||
@Autowired lateinit var students: StudentRepository
|
||||
|
||||
@Autowired lateinit var accounts: AccountRepository
|
||||
|
||||
@Autowired lateinit var enrollments: EnrollmentRepository
|
||||
|
||||
@Autowired lateinit var history: MembershipHistoryRepository
|
||||
@@ -241,6 +247,51 @@ class GroupServiceIntegrationTest {
|
||||
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import reports occupied login with row and writes nothing`() {
|
||||
accounts.saveAndFlush(
|
||||
Account(
|
||||
login = "3001",
|
||||
passwordHash = "x",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
),
|
||||
)
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;1;9;БО;2026;Иванов Иван;3001;бюджет"),
|
||||
null,
|
||||
)
|
||||
|
||||
assertFalse(result.applied)
|
||||
assertEquals(1, result.errors.size)
|
||||
val error = result.errors.single()
|
||||
assertEquals(2, error.row)
|
||||
assertTrue(error.message.contains("Логин 3001"))
|
||||
assertNull(students.findByStudentNo("3001"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import reports inactive target group and writes nothing`() {
|
||||
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
|
||||
groupService.archiveGroup(UUID.fromString(group.id))
|
||||
|
||||
val result =
|
||||
importService.import(
|
||||
"group.csv",
|
||||
csv("ПИ;1;8;БО;2026;Иванов Иван;3002;бюджет"),
|
||||
null,
|
||||
)
|
||||
|
||||
assertFalse(result.applied)
|
||||
assertEquals(1, result.errors.size)
|
||||
val error = result.errors.single()
|
||||
assertEquals(2, error.row)
|
||||
assertTrue(error.message.contains("не активна"))
|
||||
assertNull(students.findByStudentNo("3002"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import still updates an active student`() {
|
||||
importService.import("group.csv", csv(validRows()), null)
|
||||
|
||||
Reference in New Issue
Block a user