diff --git a/backend/UniVerse.Api/Controllers/SyncController.cs b/backend/UniVerse.Api/Controllers/SyncController.cs index 141cb2c..5dc9607 100644 --- a/backend/UniVerse.Api/Controllers/SyncController.cs +++ b/backend/UniVerse.Api/Controllers/SyncController.cs @@ -19,10 +19,11 @@ public class SyncController : ControllerBase /// Запустить синхронизацию расписания лекций из Modeus. /// /// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных - /// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности, - /// периоду и типу занятий. + /// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду, + /// размеру выборки, аудиториям, участникам, реализациям курсов/циклов, + /// специальностям, годам набора, профилям, учебным планам и типам занятий. /// - /// Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId. + /// Параметры поиска событий во внешнем сервисе расписания. /// Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей. /// Требуется аутентификация. /// Требуется роль Admin. diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index 5de3dc1..f4a4213 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -2994,9 +2994,9 @@ "Sync" ], "summary": "Запустить синхронизацию расписания лекций из Modeus.", - "description": "Только Admin. Выполняет upsert лекций и связанных курсов на основе данных\nиз внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,\nпериоду и типу занятий.\n\n**Required roles:** Admin", + "description": "Только Admin. Выполняет upsert лекций и связанных курсов на основе данных\nиз внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,\nразмеру выборки, аудиториям, участникам, реализациям курсов/циклов,\nспециальностям, годам набора, профилям, учебным планам и типам занятий.\n\n**Required roles:** Admin", "requestBody": { - "description": "Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.", + "description": "Параметры поиска событий во внешнем сервисе расписания.", "content": { "application/json": { "schema": { @@ -5725,7 +5725,10 @@ "type": "object", "properties": { "specialtyCode": { - "type": "string", + "type": "array", + "items": { + "type": "string" + }, "nullable": true }, "timeMin": { @@ -5744,6 +5747,61 @@ "type": "string" }, "nullable": true + }, + "size": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "roomId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "attendeePersonId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "courseUnitRealizationId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "cycleRealizationId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "learningStartYear": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "profileName": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "curriculumId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true } }, "additionalProperties": false diff --git a/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs b/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs index b8dd58a..d0720d9 100644 --- a/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs +++ b/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs @@ -1,10 +1,18 @@ namespace UniVerse.Application.DTOs.Sync; public record SyncScheduleRequest( - string? SpecialtyCode, + IReadOnlyList? SpecialtyCode, DateTime? TimeMin, DateTime? TimeMax, - IReadOnlyList? TypeId + IReadOnlyList? TypeId, + int? Size = null, + IReadOnlyList? RoomId = null, + IReadOnlyList? AttendeePersonId = null, + IReadOnlyList? CourseUnitRealizationId = null, + IReadOnlyList? CycleRealizationId = null, + IReadOnlyList? LearningStartYear = null, + IReadOnlyList? ProfileName = null, + IReadOnlyList? CurriculumId = null ); public record SyncResultDto( diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs index 230d046..c55b711 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -24,29 +24,30 @@ public class ModeusApiClient : IModeusApiClient public async Task SearchEventsAsync(SyncScheduleRequest request) { - const int pageSize = 900; - var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) - ? [] - : request.SpecialtyCode - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var typeIds = request.TypeId ?? []; + var pageSize = request.Size is > 0 ? request.Size.Value : 900; var body = new Dictionary { ["size"] = pageSize, ["timeMin"] = request.TimeMin, - ["timeMax"] = request.TimeMax, - ["specialtyCode"] = specialtyCodes + ["timeMax"] = request.TimeMax }; - if (typeIds.Count > 0) - body["typeId"] = typeIds; + AddNonEmpty(body, "roomId", request.RoomId); + AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId); + AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId); + AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId); + AddNonEmpty(body, "specialtyCode", request.SpecialtyCode); + AddNonEmpty(body, "learningStartYear", request.LearningStartYear); + AddNonEmpty(body, "profileName", request.ProfileName); + AddNonEmpty(body, "curriculumId", request.CurriculumId); + AddNonEmpty(body, "typeId", request.TypeId); var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); var requestJson = JsonSerializer.Serialize(body); await EnsureSuccessAsync(response, "Modeus events search", - BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson)); + BuildEventsRequestSummary(requestJson)); return await ReadJsonAsync(response, "Modeus events search", - BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson)) + BuildEventsRequestSummary(requestJson)) ?? new ModeusEventsResponse(); } @@ -97,16 +98,15 @@ public class ModeusApiClient : IModeusApiClient response.StatusCode); } - private static string BuildEventsRequestSummary( - int size, - IReadOnlyList specialtyCodes, - DateTime? timeMin, - DateTime? timeMax, - IReadOnlyList typeIds, - string requestJson) + private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}"; + + private static void AddNonEmpty( + IDictionary body, + string key, + IReadOnlyList? values) { - var typeFilter = typeIds.Count > 0 ? $"typeId=[{string.Join(", ", typeIds)}]" : "typeId="; - return $"size={size}, specialtyCode=[{string.Join(", ", specialtyCodes)}], timeMin={timeMin:O}, timeMax={timeMax:O}, {typeFilter}. Request JSON: {requestJson}"; + if (values is { Count: > 0 }) + body[key] = values; } private static async Task ReadJsonAsync(HttpResponseMessage response, string operation, string requestSummary) diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index f568c15..f5fb967 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -104,9 +104,6 @@ public class ScheduleSyncService : IScheduleSyncService catch (Exception ex) { _logger.LogError(ex, "Schedule sync failed"); - var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) - ? [] - : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, @@ -114,14 +111,9 @@ public class ScheduleSyncService : IScheduleSyncService updated, skipped, [ - "size=900", $"requestJson={BuildScheduleRequestJson(request)}", - $"specialtyCode=[{string.Join(", ", specialtyCodes)}]", $"timeMin={request.TimeMin:O}", - $"timeMax={request.TimeMax:O}", - request.TypeId is { Count: > 0 } - ? $"typeId=[{string.Join(", ", request.TypeId)}]" - : "typeId=" + $"timeMax={request.TimeMax:O}" ])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; @@ -498,24 +490,35 @@ public class ScheduleSyncService : IScheduleSyncService private static string BuildScheduleRequestJson(SyncScheduleRequest request) { - var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) - ? [] - : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var body = new Dictionary { - ["size"] = 900, + ["size"] = request.Size is > 0 ? request.Size.Value : 900, ["timeMin"] = request.TimeMin, - ["timeMax"] = request.TimeMax, - ["specialtyCode"] = specialtyCodes + ["timeMax"] = request.TimeMax }; - if (request.TypeId is { Count: > 0 }) - body["typeId"] = request.TypeId; + AddNonEmpty(body, "roomId", request.RoomId); + AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId); + AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId); + AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId); + AddNonEmpty(body, "specialtyCode", request.SpecialtyCode); + AddNonEmpty(body, "learningStartYear", request.LearningStartYear); + AddNonEmpty(body, "profileName", request.ProfileName); + AddNonEmpty(body, "curriculumId", request.CurriculumId); + AddNonEmpty(body, "typeId", request.TypeId); return JsonSerializer.Serialize(body); } + private static void AddNonEmpty( + IDictionary body, + string key, + IReadOnlyList? values) + { + if (values is { Count: > 0 }) + body[key] = values; + } + private static string? GetHrefId(string? href) { if (string.IsNullOrWhiteSpace(href)) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0487f23..cf6474d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -204,9 +204,17 @@ export type ApiScheduleTypeId = | 'CUR_CHECK' export interface SyncScheduleRequest { - specialtyCode?: string | null + size: number timeMin?: string | null timeMax?: string | null + roomId?: string[] | null + attendeePersonId?: string[] | null + courseUnitRealizationId?: string[] | null + cycleRealizationId?: string[] | null + specialtyCode?: string[] | null + learningStartYear?: number[] | null + profileName?: string[] | null + curriculumId?: string[] | null typeId?: ApiScheduleTypeId[] | null } diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 8ee7f85..f8503e6 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -36,19 +36,23 @@ const syncErrorDetails = ref([]) const syncStatus = ref(null) const syncResult = ref(null) const isCreateLectureModalOpen = ref(false) +const showAdvancedSyncFilters = ref(false) const addToast = inject('addToast') as | ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined +const lockedScheduleTypeIds: ApiScheduleTypeId[] = ['LECT', 'EVENT_OTHER', 'CONS'] +const defaultScheduleTypeIds: ApiScheduleTypeId[] = ['LECT'] const scheduleTypeOptions: Array<{ id: ApiScheduleTypeId; label: string }> = [ - { id: 'MID_CHECK', label: 'Аттестация' }, - { id: 'CONS', label: 'Консультация' }, - { id: 'LAB', label: 'Лабораторное занятие' }, { id: 'LECT', label: 'Лекционное занятие' }, - { id: 'SEMI', label: 'Практическое занятие' }, { id: 'EVENT_OTHER', label: 'Прочее' }, + { id: 'CONS', label: 'Консультация' }, + { id: 'MID_CHECK', label: 'Аттестация' }, + { id: 'LAB', label: 'Лабораторное занятие' }, + { id: 'SEMI', label: 'Практическое занятие' }, { id: 'SELF', label: 'Самостоятельная работа' }, { id: 'CUR_CHECK', label: 'Текущий контроль' }, ] +const syncSchedulePageSize = 900 function toInputDateTime(date: Date) { const offsetMs = date.getTimezoneOffset() * 60000 @@ -61,9 +65,33 @@ const inTwoWeeks = new Date(todayStart) inTwoWeeks.setDate(inTwoWeeks.getDate() + 14) inTwoWeeks.setHours(23, 59, 0, 0) +function parseStringList(value: string) { + return value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean) +} + +function parseNumberList(value: string) { + return parseStringList(value) + .map((item) => Number(item)) + .filter((item) => Number.isInteger(item)) +} + +function toNullableList(values: T[]) { + return values.length ? values : null +} + const syncForm = ref({ + roomId: '', + attendeePersonId: '', + courseUnitRealizationId: '', + cycleRealizationId: '', specialtyCode: '', - typeIds: [] as ApiScheduleTypeId[], + learningStartYear: '', + profileName: '', + curriculumId: '', + typeIds: [...defaultScheduleTypeIds], timeMin: toInputDateTime(todayStart), timeMax: toInputDateTime(inTwoWeeks), }) @@ -78,6 +106,19 @@ const visibleSyncDetails = computed(() => { if (syncErrorDetails.value.length) return syncErrorDetails.value return visibleSyncResult.value?.details ?? [] }) +const activeAdvancedSyncFilters = computed(() => { + const fields = [ + syncForm.value.roomId, + syncForm.value.attendeePersonId, + syncForm.value.courseUnitRealizationId, + syncForm.value.cycleRealizationId, + syncForm.value.specialtyCode, + syncForm.value.profileName, + syncForm.value.curriculumId, + ] + const filledTextFields = fields.filter((value) => parseStringList(value).length > 0).length + return filledTextFields + (parseNumberList(syncForm.value.learningStartYear).length ? 1 : 0) +}) const tabConfig: Record = { lectures: { @@ -211,8 +252,18 @@ async function runScheduleSync() { syncResult.value = null try { syncResult.value = await syncApi.schedule({ - specialtyCode: syncForm.value.specialtyCode.trim() || null, - typeId: syncForm.value.typeIds.length ? syncForm.value.typeIds : null, + size: syncSchedulePageSize, + roomId: toNullableList(parseStringList(syncForm.value.roomId)), + attendeePersonId: toNullableList(parseStringList(syncForm.value.attendeePersonId)), + courseUnitRealizationId: toNullableList( + parseStringList(syncForm.value.courseUnitRealizationId), + ), + cycleRealizationId: toNullableList(parseStringList(syncForm.value.cycleRealizationId)), + specialtyCode: toNullableList(parseStringList(syncForm.value.specialtyCode)), + learningStartYear: toNullableList(parseNumberList(syncForm.value.learningStartYear)), + profileName: toNullableList(parseStringList(syncForm.value.profileName)), + curriculumId: toNullableList(parseStringList(syncForm.value.curriculumId)), + typeId: toNullableList(syncForm.value.typeIds), timeMin: syncForm.value.timeMin ? new Date(syncForm.value.timeMin).toISOString() : null, timeMax: syncForm.value.timeMax ? new Date(syncForm.value.timeMax).toISOString() : null, }) @@ -298,6 +349,178 @@ onMounted(() => { + + + + Синхронизация расписания + {{ syncMeta }} + + {{ + syncStatus?.status ?? 'idle' + }} + + + + + + Период с + + + + Период по + + + + + + Типы пар + + + + {{ type.label }} + + + + + + + + Дополнительные фильтры + · {{ activeAdvancedSyncFilters }} + + ⌄ + + + + + ID аудиторий + + + + ID участников + + + + ID реализаций курсов + + + + ID реализаций циклов + + + + Коды специальностей + + + + Годы начала обучения + + + + Названия профилей + + + + ID учебных планов + + + + + + + + {{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }} + + + {{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }} + + + + + + {{ syncError || visibleSyncResult.error }} + + + Создано: {{ visibleSyncResult.created }} / обновлено: + {{ visibleSyncResult.updated }} / пропущено: {{ visibleSyncResult.skipped }} + + + + {{ syncError }} + + + Подробности ошибки + + + {{ detail }} + + + + + + Лекции @@ -321,69 +544,6 @@ onMounted(() => { /> - - - - - Синхронизация расписания - {{ syncMeta }} - - {{ - syncStatus?.status ?? 'idle' - }} - - - - Период с - - Период по - - Код специальности - - Типы пар - - - - {{ type.label }} - - - - - Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }}, - пропущено: - {{ visibleSyncResult.skipped }} - - - {{ syncError || visibleSyncResult?.error }} - - - Подробности ошибки - - - {{ detail }} - - - - - - - {{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }} - - - {{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }} - - - - { } .grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: 1fr; gap: 16px; } .section-heading { @@ -458,46 +618,168 @@ onMounted(() => { font-weight: 600; color: var(--color-text-secondary); } +.sync-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} +.sync-primary-fields { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + align-items: end; +} +.sync-fields label { + display: flex; + flex-direction: column; + gap: 6px; +} +.sync-fields textarea { + min-height: 68px; + resize: vertical; +} +.type-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.field-title { + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} +.advanced-sync { + display: flex; + flex-direction: column; + gap: 10px; +} +.advanced-toggle { + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--color-slate-500-a20); + border-radius: var(--radius-sm); + padding: 8px 12px; + background: var(--color-white-a90); + color: var(--color-text); + font-size: 13px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 14px var(--color-black-a04); +} +.advanced-toggle:hover { + border-color: var(--color-primary-a30); + background: var(--color-white-a96); +} +.advanced-toggle:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.advanced-toggle[aria-expanded='true'] { + border-color: var(--color-primary-a30); + background: var(--color-primary-a08); + color: var(--color-primary-border); +} +.advanced-toggle-icon { + line-height: 1; + transition: transform 0.16s ease; +} +.advanced-toggle-icon.open { + transform: rotate(180deg); +} +.sync-advanced-fields { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + border: 1px solid var(--color-slate-500-a20); + border-radius: var(--radius-sm); + padding: 12px; + background: var(--color-white-a82); + box-shadow: 0 8px 22px var(--color-black-a04), inset 0 1px 0 var(--color-white-a90); +} +.sync-advanced-fields .glass-input { + background: var(--color-white-a96); + border-color: var(--color-slate-500-a20); +} .form-actions { display: flex; gap: 10px; flex-wrap: wrap; } .type-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 8px; } .type-option { - min-height: 38px; + position: relative; + min-height: 32px; display: flex; align-items: center; - gap: 8px; - padding: 8px 10px; - border: 1px solid var(--color-border-glass); - border-radius: var(--radius-sm); - background: var(--color-white-a72); + padding: 8px 14px; + border: 1px solid var(--color-slate-500-a20); + border-radius: 10px; + background: var(--color-white-a86); color: var(--color-text); - cursor: pointer; + cursor: default; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease; } .type-option input { - flex: 0 0 auto; + position: absolute; + inset: 0; + opacity: 0; + cursor: default; +} +.type-option.locked, +.type-option.locked input { + cursor: pointer; } .type-option span { font-size: 13px; line-height: 1.2; } +.type-option:not(.locked) { + border-color: var(--color-slate-500-a10); + background: var(--color-white-a50); + color: var(--color-text-secondary); + opacity: 0.62; +} +.type-option:has(input:checked) { + border-color: var(--color-primary-a30); + background: var(--color-primary-a18); + color: var(--color-primary-dark); + font-weight: 600; +} +.type-option.locked:hover { + border-color: var(--color-primary-a30); + background: var(--color-primary-a18); +} +.type-option:not(.locked):hover { + border-color: var(--color-slate-500-a10); + background: var(--color-white-a50); +} +.type-option:has(input:checked):hover, +.type-option:has(input:checked):active { + background: var(--color-primary-a25); +} .sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; } .sync-result { + border: 1px solid var(--color-primary-light); + border-radius: var(--radius-sm); + padding: 9px 12px; + background: var(--color-success-bg-a90); font-size: 13px; - color: var(--color-text-secondary); + color: var(--color-success-text); } -.sync-error { - font-size: 13px; +.sync-result.failed { + border-color: var(--color-danger-light); + background: var(--color-danger-bg-a90); color: var(--color-error); } .sync-details {