feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости

- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
2026-10-29 20:51:27 +03:00
parent 7ef5c866a3
commit 82394cc688
48 changed files with 1196 additions and 156 deletions

View File

@@ -17,13 +17,15 @@ class GroupEntity(
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var name: String = "",
@Column(length = 50)
@Column(length = 50, nullable = false)
var stream: String? = null,
@Column(nullable = false)
var course: Short? = null,
@Column(nullable = false)
var number: Short? = null,
@Column(length = 2)
@Column(length = 2, nullable = false)
var degree: String? = null,
@Column(name = "enrollment_year")
@Column(name = "enrollment_year", nullable = false)
var enrollmentYear: Short? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)

View File

@@ -21,6 +21,8 @@ data class AccountInfoDto(
val id: String,
val login: String,
val role: String,
/** ФИО студента; для не-студенческих аккаунтов пусто. */
val fullName: String = "",
)
/** Группа с текущим составом — без учётных данных. */

View File

@@ -76,15 +76,25 @@ class InternalService(
)
}
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
/** Подписи авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true)
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
val requested = ids.map(::parseId).distinct()
if (requested.isEmpty()) return emptyList()
val found = accounts.findAllById(requested).associateBy { it.id }
val names =
students
.findAllById(requested)
.mapNotNull { student -> student.accountId?.let { it to student.fullName } }
.toMap()
return requested.mapNotNull { id ->
found[id]?.let { account ->
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
AccountInfoDto(
id = account.id.toString(),
login = account.login,
role = account.role.name,
fullName = names[account.id].orEmpty(),
)
}
}
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.GroupEntity
import java.util.UUID
@@ -20,4 +21,6 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findAllByOrderByNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(pageable: Pageable): List<GroupEntity>
}

View File

@@ -6,4 +6,9 @@ import java.util.UUID
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
fun findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(
toGroupId: UUID,
fromGroupId: UUID,
): List<MembershipHistory>
}

View File

@@ -31,7 +31,10 @@ class GroupController(
private val auditService: AuditService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "0") limit: Int,
): List<GroupSummary> = groupService.list(page, limit)
@PostMapping
fun create(
@@ -148,6 +151,29 @@ class GroupController(
return detail
}
/** Возврат группы из архива. */
@PostMapping("/{id}/restore")
fun restore(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail {
val detail = groupService.restoreGroup(id)
auditService.record(
jwt.toAuditRequest(
action = "GROUP_RESTORE",
entityType = "GROUP",
entityId = id.toString(),
details = "name=${detail.name}",
),
)
return detail
}
@GetMapping("/{id}/history")
fun history(
@PathVariable id: UUID,
): List<MembershipHistoryRow> = groupService.history(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.springframework.data.domain.PageRequest
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -34,8 +35,22 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val cohortResolver: GroupCohortResolver,
) {
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
fun list(
page: Int = 0,
limit: Int = 0,
): List<GroupSummary> {
val rows =
if (limit <= 0) {
groups.findAllByOrderByEnrollmentYearDescNameAsc()
} else {
groups.findAllByOrderByEnrollmentYearDescNameAsc(
PageRequest.of(page.coerceAtLeast(0), limit.coerceIn(1, MAX_GROUP_PAGE)),
)
}
return rows.map { summary(it) }
}
@Transactional
fun createStudent(
@@ -262,6 +277,43 @@ class GroupService(
return detail(groupId)
}
/** Последние события членства, связанные с группой (вход или выход). */
@Transactional(readOnly = true)
fun history(groupId: UUID): List<MembershipHistoryRow> {
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val events = history.findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(groupId, groupId)
val studentIds = events.map { it.studentId }.distinct()
val studentsById = students.findAllById(studentIds).associateBy { it.accountId }
val groupIds = events.flatMap { listOfNotNull(it.fromGroupId, it.toGroupId) }.distinct()
val groupsById = groups.findAllById(groupIds).associateBy { it.id }
return events.map { event ->
MembershipHistoryRow(
id = event.id.toString(),
studentId = event.studentId.toString(),
studentName = studentsById[event.studentId]?.fullName.orEmpty(),
fromGroupId = event.fromGroupId?.toString(),
fromGroupName = event.fromGroupId?.let { groupsById[it]?.name },
toGroupId = event.toGroupId?.toString(),
toGroupName = event.toGroupId?.let { groupsById[it]?.name },
action = event.action.name,
reason = event.reason,
at = event.at.toString(),
)
}
}
/** Возвращает архивную группу в работу; состав и история не меняются. */
@Transactional
fun restoreGroup(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status == GroupStatus.ACTIVE) {
throw BadRequestException("Группа ${group.name} уже активна")
}
group.status = GroupStatus.ACTIVE
groups.save(group)
return detail(groupId)
}
/**
* Удаляет студента из identity: зачисление, профиль, аккаунт.
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
@@ -350,6 +402,10 @@ class GroupService(
}
}
private companion object {
const val MAX_GROUP_PAGE = 500
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {

View File

@@ -85,6 +85,20 @@ data class ResetPasswordResponse(
val password: String,
)
/** Событие истории членства для страницы группы. */
data class MembershipHistoryRow(
val id: String,
val studentId: String,
val studentName: String,
val fromGroupId: String?,
val fromGroupName: String?,
val toGroupId: String?,
val toGroupName: String?,
val action: String,
val reason: String,
val at: String,
)
data class StudentCandidate(
val id: String,
val fullName: String,

View File

@@ -0,0 +1,43 @@
-- Финальные ограничения когорты. Легаси-группы, которые не удалось разобрать
-- из имени в V5, должны быть исправлены вручную до применения этой миграции:
-- SELECT id, name, stream, degree, course, number, enrollment_year
-- FROM identity.groups
-- WHERE stream IS NULL OR degree IS NULL OR course IS NULL
-- OR number IS NULL OR enrollment_year IS NULL;
-- После UPDATE полей миграцию можно применить повторно.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM groups
WHERE stream IS NULL
OR degree IS NULL
OR course IS NULL
OR number IS NULL
OR enrollment_year IS NULL
) THEN
RAISE EXCEPTION
'Есть группы с незаполненной когортой; исправьте их перед V14 (см. комментарий в миграции)';
END IF;
END $$;
ALTER TABLE groups
ALTER COLUMN stream SET NOT NULL,
ALTER COLUMN degree SET NOT NULL,
ALTER COLUMN course SET NOT NULL,
ALTER COLUMN number SET NOT NULL,
ALTER COLUMN enrollment_year SET NOT NULL;
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_degree_check;
ALTER TABLE groups ADD CONSTRAINT groups_degree_check CHECK (degree IN ('БО', 'МО'));
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_course_range;
ALTER TABLE groups ADD CONSTRAINT groups_course_range CHECK (
(degree = 'БО' AND course BETWEEN 1 AND 4)
OR (degree = 'МО' AND course BETWEEN 1 AND 2)
);
-- Полная уникальность когорты: все поля уже NOT NULL.
DROP INDEX IF EXISTS groups_cohort_unique;
CREATE UNIQUE INDEX groups_cohort_unique
ON groups (stream, degree, number, course, enrollment_year);

View File

@@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
@@ -25,10 +29,13 @@ import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.GroupController
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.security.LmsRole
import java.nio.charset.StandardCharsets
import java.util.UUID
@@ -63,6 +70,8 @@ class AuditIntegrationTest {
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groupController: GroupController
@Autowired lateinit var context: WebApplicationContext
private val objectMapper = ObjectMapper()
@@ -378,6 +387,80 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
}
@Test
@Suppress("LongMethod")
fun `roster operations record group and student audit events`() {
val actor =
accounts
.save(
Account(
login = "audit.admin.${UUID.randomUUID().toString().take(8)}",
passwordHash = "x",
role = LmsRole.ADMIN,
mustChangePassword = false,
),
).id
val jwt =
Jwt
.withTokenValue("admin-token")
.header("alg", "none")
.subject(actor.toString())
.claim("login", "admin.audit")
.build()
val stream = "A" + UUID.randomUUID().toString().take(8)
SecurityContextHolder.getContext().authentication =
JwtAuthenticationToken(jwt, listOf(SimpleGrantedAuthority("ROLE_ADMIN")))
try {
val group =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 1, degree = "БО", enrollmentYear = 2025),
jwt,
)
val credential =
groupController.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(studentNo = "9901", lastName = "Аудитов", firstName = "Иван"),
jwt,
)
val target =
groupController.create(
CreateGroupRequest(stream = stream, course = 1, number = 2, degree = "БО", enrollmentYear = 2025),
jwt,
)
groupController.enroll(UUID.fromString(target.id), UUID.fromString(credential.id), jwt)
groupController.archive(UUID.fromString(target.id), jwt)
groupController.restore(UUID.fromString(target.id), jwt)
val csvBytes =
(
"поток;курс;номер;степень;год набора;ФИО;№;база\n" +
"$stream;1;3;БО;2025;Импортов Пётр;9902;бюджет"
).toByteArray(StandardCharsets.UTF_8)
groupController.import(MockMultipartFile("file", "group.csv", "text/csv", csvBytes), false, jwt)
val events = auditEvents.findTop200ByOrderByOccurredAtDesc()
assertTrue(
events
.map { it.action }
.toSet()
.containsAll(
setOf(
"GROUP_CREATE",
"STUDENT_CREATE",
"GROUP_ENROLL",
"GROUP_IMPORT",
"GROUP_ARCHIVE",
"GROUP_RESTORE",
),
),
)
val created = events.first { it.action == "GROUP_CREATE" && it.details.contains(group.name) }
assertEquals(actor, created.actorAccountId)
assertEquals("admin.audit", created.actorLogin)
} finally {
SecurityContextHolder.clearContext()
}
}
private data class AuditPage(
val details: List<String>,
val cursor: String?,

View File

@@ -130,6 +130,24 @@ class InternalControllerIntegrationTest {
}
}
@Test
fun `lookup includes student full name and leaves staff name empty`() {
val student = student(login = "a.ivanov", studentNo = "123456")
val staff = account("t.teacher")
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["${student.accountId}","${staff.id}"]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(2) }
jsonPath("$[0].fullName") { value("Иванов Иван Иванович") }
jsonPath("$[1].fullName") { value("") }
}
}
@Test
fun `lookup ignores missing accounts and duplicates`() {
val existing = account("a.ivanov")
@@ -275,8 +293,29 @@ class InternalControllerIntegrationTest {
@Test
fun `valid token returns groups sorted by name with status`() {
val graduated = groups.save(GroupEntity(name = "ПИ-22БО", enrollmentYear = 2025, status = GroupStatus.GRADUATED))
val active = groups.save(GroupEntity(name = "ПИ-21БО", enrollmentYear = 2025))
val graduated =
groups.save(
GroupEntity(
name = "ПИ-22БО",
stream = "ПИ",
course = 2,
number = 2,
degree = "БО",
enrollmentYear = 2025,
status = GroupStatus.GRADUATED,
),
)
val active =
groups.save(
GroupEntity(
name = "ПИ-21БО",
stream = "ПИ",
course = 2,
number = 1,
degree = "БО",
enrollmentYear = 2025,
),
)
mockMvc
.get("/api/v1/internal/groups") {
@@ -338,7 +377,19 @@ class InternalControllerIntegrationTest {
),
)
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
private fun group(name: String): GroupEntity {
val match = Regex("""-(\d)(\d+)(БО|МО)$""").find(name)
return groups.save(
GroupEntity(
name = name,
stream = "ПИ",
course = match?.groupValues?.get(1)?.toShortOrNull() ?: 1,
number = match?.groupValues?.get(2)?.toShortOrNull() ?: 1,
degree = match?.groupValues?.get(3) ?: "БО",
enrollmentYear = 2025,
),
)
}
private fun enroll(
group: GroupEntity,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
@@ -13,6 +14,7 @@ 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.ConflictException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@@ -26,6 +28,7 @@ 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.io.ByteArrayOutputStream
import java.util.UUID
@SpringBootTest
@@ -89,6 +92,40 @@ class GroupServiceIntegrationTest {
assertTrue(error is ConflictException)
}
@Test
fun `creates two distinct cohorts in the same stream`() {
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))
assertEquals("ТЕСТ-11БО", first.name)
assertEquals("ТЕСТ-12БО", second.name)
}
@Test
fun `lists groups page by page and returns all without limit`() {
val stream = "P" + UUID.randomUUID().toString().take(8)
repeat(3) { index ->
groupService.create(
CreateGroupRequest(
stream = stream,
course = 1,
number = (index + 1).toShort(),
degree = "БО",
enrollmentYear = 2025,
),
)
}
val first = groupService.list(page = 0, limit = 2).filter { it.name.startsWith(stream) }
val second = groupService.list(page = 1, limit = 2).filter { it.name.startsWith(stream) }
val all = groupService.list().filter { it.name.startsWith(stream) }
assertEquals(2, first.size)
assertEquals(1, second.size)
assertEquals(3, all.size)
assertTrue(first.intersect(second.toSet()).isEmpty())
}
@Test
fun `imports students and records membership history`() {
val result = importService.import("group.csv", csv(validRows()), null)
@@ -106,6 +143,11 @@ class GroupServiceIntegrationTest {
assertEquals(2025.toShort(), student.enrollmentYear)
val events = history.findByStudentIdOrderByAtDesc(studentId)
assertEquals(MembershipAction.IMPORT, events.first().action)
val groupHistory = groupService.history(groupId)
val imported = groupHistory.first { it.studentId == studentId.toString() }
assertEquals("IMPORT", imported.action)
assertEquals("Иванов Иван", imported.studentName)
assertEquals("ПИ-21БО", imported.toGroupName)
}
@Test
@@ -272,6 +314,64 @@ class GroupServiceIntegrationTest {
assertNull(students.findByStudentNo("3001"))
}
@Test
fun `restores archived group and rejects repeated restore`() {
val created = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 7, degree = "БО", enrollmentYear = 2026))
val id = UUID.fromString(created.id)
assertEquals("ARCHIVED", groupService.archiveGroup(id).status)
assertEquals("ACTIVE", groupService.restoreGroup(id).status)
val error = runCatching { groupService.restoreGroup(id) }.exceptionOrNull()
assertTrue(error is BadRequestException)
}
@Test
fun `xlsx import reports occupied login before writing`() {
val studentNo = "X" + UUID.randomUUID().toString().take(8)
accounts.save(
Account(
login = studentNo,
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result = importService.import("group.xlsx", xlsx("ПИ;1;4;БО;2026;Иванов Иван;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("Логин $studentNo"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `xlsx import reports inactive target group before writing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 5, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val studentNo = "Y" + UUID.randomUUID().toString().take(8)
val result = importService.import("group.xlsx", xlsx("ПИ;1;5;БО;2026;Петров Пётр;$studentNo;бюджет"), null)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
assertEquals(2, result.errors.single().row)
assertTrue(
result.errors
.single()
.message
.contains("не активна"),
)
assertNull(students.findByStudentNo(studentNo))
}
@Test
fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
@@ -324,4 +424,22 @@ class GroupServiceIntegrationTest {
""".trimIndent()
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
private fun xlsx(row: String): ByteArray {
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
val values = row.split(";")
return XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("group")
sheet.createRow(0).apply {
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
}
sheet.createRow(1).apply {
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
}
ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
}
}
}