diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index 7558af7..febc25d 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -1,4 +1,5 @@ using UniVerse.Application.DTOs.Sync; +using System.Text.Json.Serialization; namespace UniVerse.Application.Interfaces; @@ -20,6 +21,18 @@ public interface IModeusApiClient // Modeus API response models public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId); public record ModeusEventsResponse(List Events); -public record ModeusRoom(string Id, string Name, string? Building); -public record ModeusRoomsResponse(List Rooms); +public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address); +public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity); +public record ModeusRoomsEmbedded(List? Rooms); +public record ModeusPage(int Size, int TotalElements, int TotalPages, int Number); +public class ModeusRoomsResponse +{ + [JsonPropertyName("_embedded")] + public ModeusRoomsEmbedded? Embedded { get; init; } + public ModeusPage? Page { get; init; } + public List? Rooms { get; init; } + + [JsonIgnore] + public IReadOnlyList RoomItems => Embedded?.Rooms ?? Rooms ?? []; +} public record ModeusEmployee(string? Id, string FullName, string? Department); diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs index b1e2e24..3a739f3 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -29,9 +29,34 @@ public class ModeusApiClient : IModeusApiClient public async Task SearchRoomsAsync() { - var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { }); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync() ?? new(new()); + const int pageSize = 100; + var allRooms = new List(); + var page = 0; + var totalPages = 1; + + do + { + var body = new + { + name = "", + sort = "+building.name,+name", + size = pageSize, + page, + deleted = false + }; + + var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync() ?? new ModeusRoomsResponse(); + allRooms.AddRange(payload.RoomItems); + + totalPages = payload.Page?.TotalPages ?? page + 1; + page++; + } + while (page < totalPages); + + return new ModeusRoomsResponse { Rooms = allRooms }; } public async Task> SearchEmployeeAsync(string fullname) diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 7b3556e..ee113e5 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -53,16 +53,53 @@ public class ScheduleSyncService : IScheduleSyncService public async Task SyncRoomsAsync() { - int created = 0, updated = 0; - var rooms = await _modeus.SearchRoomsAsync(); - foreach (var room in rooms.Rooms) + int created = 0, updated = 0, skipped = 0; + try { - var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); - if (existing != null) { existing.Name = room.Name; existing.Building = room.Building; updated++; } - else { _db.Locations.Add(new Location { Name = room.Name, Building = room.Building, ExternalId = room.Id }); created++; } + var rooms = await _modeus.SearchRoomsAsync(); + foreach (var room in rooms?.RoomItems ?? []) + { + if (room is null || string.IsNullOrWhiteSpace(room.Id) || string.IsNullOrWhiteSpace(room.Name)) + { + skipped++; + continue; + } + + var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); + if (existing != null) + { + existing.Name = room.Name; + existing.Room = room.NameShort; + existing.Building = room.Building?.Name ?? room.Building?.NameShort; + existing.Address = room.Building?.Address; + updated++; + } + else + { + _db.Locations.Add(new Location + { + Name = room.Name, + Room = room.NameShort, + Building = room.Building?.Name ?? room.Building?.NameShort, + Address = room.Building?.Address, + ExternalId = room.Id + }); + created++; + } + } + + await _db.SaveChangesAsync(); + var result = new SyncResultDto(created, updated, skipped, null); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Rooms sync failed"); + var result = new SyncResultDto(created, updated, skipped, ex.Message); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); + return result; } - await _db.SaveChangesAsync(); - return new SyncResultDto(created, updated, 0, null); } public async Task> SearchEmployeesAsync(string fullname) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 19fdc3c..cf69825 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -9,6 +9,8 @@ import type { LocationDto, PagedResult, ReviewDto, + SyncResultDto, + SyncScheduleRequest, SyncStatusDto, TagDto, UserAchievementDto, @@ -112,4 +114,10 @@ export const tagsApi = { export const syncApi = { status: () => apiRequest('/sync/status'), + schedule: (request: SyncScheduleRequest) => + apiRequest('/sync/schedule', { + method: 'POST', + body: JSON.stringify(request), + }), + rooms: () => apiRequest('/sync/rooms', { method: 'POST' }), } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 40083d8..e2c3bae 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -141,12 +141,21 @@ export interface TagDto { export interface SyncStatusDto { lastSyncAt?: string | null status?: string | null - lastResult?: { - created: number - updated: number - skipped: number - error?: string | null - } | null + lastResult?: SyncResultDto | null +} + +export interface SyncScheduleRequest { + specialtyCode?: string | null + timeMin?: string | null + timeMax?: string | null + typeId?: string | null +} + +export interface SyncResultDto { + created: number + updated: number + skipped: number + error?: string | null } export interface UserAchievementDto { diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 1e58620..40a87b4 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -1,7 +1,7 @@ @@ -127,7 +215,7 @@ onMounted(async () => {

Управление лекциями и справочниками

- +
@@ -145,21 +233,40 @@ onMounted(async () => { -
Создать / редактировать
-
- - - - - - +
+
+
Синхронизация расписания
+
{{ syncMeta }}
+
+ {{ syncStatus?.status ?? 'idle' }} +
+ + + + + + + + + + + +
+ Создано: {{ visibleSyncResult.created }}, + обновлено: {{ visibleSyncResult.updated }}, + пропущено: {{ visibleSyncResult.skipped }} +
+
+ {{ syncError || visibleSyncResult?.error }} +
+
- - + +
@@ -174,7 +281,22 @@ onMounted(async () => { .tabs button { background: rgba(255,255,255,0.7); border: none; padding: 8px 18px; font-size: 13px; cursor: pointer; color: var(--color-text-secondary); } .tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; } +.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; } .form { display: flex; flex-direction: column; gap: 10px; } -textarea { padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-glass); background: rgba(255,255,255,0.8); } -.form-actions { display: flex; gap: 10px; } +.form label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); } +.form-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; } +.sync-result { font-size: 13px; color: var(--color-text-secondary); } +.sync-error { font-size: 13px; color: var(--color-error); } +.sync-status { + flex: 0 0 auto; + border: 1px solid var(--color-border-glass); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: var(--color-text-secondary); + background: rgba(255,255,255,0.72); +} +.sync-status.completed { color: #166534; background: rgba(220,252,231,0.9); border-color: #86EFAC; } +.sync-status.failed { color: #991B1B; background: rgba(254,226,226,0.9); border-color: #FCA5A5; }