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

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **57 claims, 366 тестов, 18/18 browser E2E**.
Baseline: **58 claims, 368 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера)

View File

@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**366**, 0 failures), upgrade миграций с master-схемы
тестов/линтеров (**368**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims
(27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
gateway, видимость активов и deploy-runbook. LDD baseline: **58 claims
(27 P0 / 31 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6

View File

@@ -65,15 +65,15 @@
| Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:|
| identity-service | 103 | 0 | 0 | 0 |
| identity-service | 105 | 0 | 0 | 0 |
| academic-service | 106 | 0 | 0 | 0 |
| progress-service | 38 | 0 | 0 | 0 |
| web | 112 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **366** | **0** | **0** | **0** |
| **Итого на момент этапа 6** | **368** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **366 тестов, 0 failures**
После закрытия рисков актуальный LDD-baseline — **368 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures)
Проверено автоматическими тестами (актуальный прогон — 368 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**57 claims, 27 P0 / 30 P1**),
LDD-контур: реестр `docs/evidence/claims.json` (**58 claims, 27 P0 / 31 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат:
**57/57 claims proven, P0 gaps = 0, 366 тестов, 0 failures**; mutation strength
**58/58 claims proven, P0 gaps = 0, 368 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.

View File

@@ -116,8 +116,8 @@
### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`.
- **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**.
- **366 автотестов**, 0 failures/errors/skipped.
- **58 claims**: 27 P0 + 31 P1; **58/58 proven, P0 gaps = 0**.
- **368 автотестов**, 0 failures/errors/skipped.
- Mutation testing:
- `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%.
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
```
Последний подтверждённый результат:
- `366` тестов, 0 failures;
- `57/57` claims, P0 gaps = 0;
- `368` тестов, 0 failures;
- `58/58` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS;
- smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
`feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов
и 57 claims, 57/57 proven.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 368 тестов
и 58 claims, 58/58 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и

View File

@@ -26,7 +26,7 @@
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе |
| LDD | implemented | 57 claims (27 P0 / 30 P1), 57/57 proven после release; `check` доказывает P0/unit, runtime P1 — release |
| LDD | implemented | 58 claims (27 P0 / 31 P1), 58/58 proven после release; `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 |
@@ -83,8 +83,9 @@
### P1
- **Импорт: ошибки БД.** Ошибки lookup/конфликта всё ещё могут выйти одним
flash-сообщением без номера строки; нужен row-level отчёт и для них.
- **Импорт: ошибки БД.** Занятый логин и неактивная группа теперь дают
row-level `applied=false`; неожиданные DB-ошибки вне preflight всё ещё
откатывают транзакцию без построчного отчёта.
- **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null`
и не создаёт план за предыдущий год; после долгого простоя в январе
пропущенный август не догоняется.
@@ -126,9 +127,9 @@
## 5. LDD
- Реестр: `docs/evidence/claims.json`, **57 claims** (27 P0 / 30 P1).
- Реестр: `docs/evidence/claims.json`, **58 claims** (27 P0 / 31 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **57/57 claims**, 366 тестов, mutation
- `./scripts/ldd-release.sh` — PASS: **58/58 claims**, 368 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с.

View File

@@ -27,7 +27,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 57 claims, 366 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 58 claims, 368 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе

View File

@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков:
- **366 автоматических тестов**, 0 failures/errors;
- **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы,
- **368 автоматических тестов**, 0 failures/errors;
- **58 claims**: 27 P0 + 31 P1, **58/58 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,

View File

@@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew test --rerun-tasks
```
Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**.
Результат: **368 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate
@@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew evidenceCheck
```
Результат: **57/57 claims proven, P0 gaps = 0**.
Результат: **58/58 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`.
Gate устроен так:
@@ -306,8 +306,8 @@ optional json fields`.
## 5. Вывод
Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**,
**366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
Текущая функциональность прошла LDD-контур: **58/58 claims proven, P0 gaps = 0**,
**368 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
масштабный load зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы».

View File

@@ -840,6 +840,20 @@
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
],
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
},
{
"id": "LDD-IMPORT-003",
"title": "Ошибки импорта報告 по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Занятый логин и неактивная целевая группа возвращают applied=false с номером строки и не создают частичных студентов/зачислений.",
"oracle": "ImportResult.errors[].row и отсутствие записей после ошибки",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports occupied login with row and writes nothing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()"
],
"limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта."
}
]
}

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)