fix(import): построчные ошибки до записи

- preflight занятого логина и неактивной целевой группы
- applied=false с номером строки, без частичных студентов
- LDD-IMPORT-003; baseline 58 claims / 368 тестов
This commit is contained in:
2026-10-29 19:20:16 +03:00
parent 238c689175
commit 1570196cc7
11 changed files with 142 additions and 30 deletions

View File

@@ -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(

View File

@@ -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)