fix: синхронизации аудиторий

This commit is contained in:
2026-05-11 23:59:13 +03:00
parent 6824d7ce7d
commit 34334e9a8d
6 changed files with 255 additions and 41 deletions
@@ -1,4 +1,5 @@
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
using System.Text.Json.Serialization;
namespace UniVerse.Application.Interfaces; namespace UniVerse.Application.Interfaces;
@@ -20,6 +21,18 @@ public interface IModeusApiClient
// Modeus API response models // Modeus API response models
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId); public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
public record ModeusEventsResponse(List<ModeusEvent> Events); public record ModeusEventsResponse(List<ModeusEvent> Events);
public record ModeusRoom(string Id, string Name, string? Building); public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address);
public record ModeusRoomsResponse(List<ModeusRoom> Rooms); public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
public record ModeusRoomsEmbedded(List<ModeusRoom>? 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<ModeusRoom>? Rooms { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusRoom> RoomItems => Embedded?.Rooms ?? Rooms ?? [];
}
public record ModeusEmployee(string? Id, string FullName, string? Department); public record ModeusEmployee(string? Id, string FullName, string? Department);
@@ -29,9 +29,34 @@ public class ModeusApiClient : IModeusApiClient
public async Task<ModeusRoomsResponse> SearchRoomsAsync() public async Task<ModeusRoomsResponse> SearchRoomsAsync()
{ {
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { }); const int pageSize = 100;
response.EnsureSuccessStatusCode(); var allRooms = new List<ModeusRoom>();
return await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new(new()); 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<ModeusRoomsResponse>() ?? new ModeusRoomsResponse();
allRooms.AddRange(payload.RoomItems);
totalPages = payload.Page?.TotalPages ?? page + 1;
page++;
}
while (page < totalPages);
return new ModeusRoomsResponse { Rooms = allRooms };
} }
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
@@ -53,16 +53,53 @@ public class ScheduleSyncService : IScheduleSyncService
public async Task<SyncResultDto> SyncRoomsAsync() public async Task<SyncResultDto> SyncRoomsAsync()
{ {
int created = 0, updated = 0; int created = 0, updated = 0, skipped = 0;
var rooms = await _modeus.SearchRoomsAsync(); try
foreach (var room in rooms.Rooms)
{ {
var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); var rooms = await _modeus.SearchRoomsAsync();
if (existing != null) { existing.Name = room.Name; existing.Building = room.Building; updated++; } foreach (var room in rooms?.RoomItems ?? [])
else { _db.Locations.Add(new Location { Name = room.Name, Building = room.Building, ExternalId = room.Id }); created++; } {
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<List<EmployeeDto>> SearchEmployeesAsync(string fullname) public async Task<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
+8
View File
@@ -9,6 +9,8 @@ import type {
LocationDto, LocationDto,
PagedResult, PagedResult,
ReviewDto, ReviewDto,
SyncResultDto,
SyncScheduleRequest,
SyncStatusDto, SyncStatusDto,
TagDto, TagDto,
UserAchievementDto, UserAchievementDto,
@@ -112,4 +114,10 @@ export const tagsApi = {
export const syncApi = { export const syncApi = {
status: () => apiRequest<SyncStatusDto>('/sync/status'), status: () => apiRequest<SyncStatusDto>('/sync/status'),
schedule: (request: SyncScheduleRequest) =>
apiRequest<SyncResultDto>('/sync/schedule', {
method: 'POST',
body: JSON.stringify(request),
}),
rooms: () => apiRequest<SyncResultDto>('/sync/rooms', { method: 'POST' }),
} }
+15 -6
View File
@@ -141,12 +141,21 @@ export interface TagDto {
export interface SyncStatusDto { export interface SyncStatusDto {
lastSyncAt?: string | null lastSyncAt?: string | null
status?: string | null status?: string | null
lastResult?: { lastResult?: SyncResultDto | null
created: number }
updated: number
skipped: number export interface SyncScheduleRequest {
error?: string | null specialtyCode?: string | null
} | 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 { export interface UserAchievementDto {
+144 -22
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { coursesApi, lecturesApi, locationsApi, tagsApi } from '@/api' import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
import type { CourseDto, LectureDto, LocationDto, TagDto } from '@/api/types' import type { CourseDto, LectureDto, LocationDto, SyncResultDto, SyncStatusDto, TagDto } from '@/api/types'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue' import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue' import EmptyState from '@/components/ui/EmptyState.vue'
@@ -19,6 +19,35 @@ const courses = ref<CourseDto[]>([])
const locations = ref<LocationDto[]>([]) const locations = ref<LocationDto[]>([])
const tags = ref<TagDto[]>([]) const tags = ref<TagDto[]>([])
const loading = ref(false) const loading = ref(false)
const syncingSchedule = ref(false)
const syncingRooms = ref(false)
const syncError = ref('')
const syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(null)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
function toInputDateTime(date: Date) {
const offsetMs = date.getTimezoneOffset() * 60000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
const now = new Date()
const inTwoWeeks = new Date(now)
inTwoWeeks.setDate(inTwoWeeks.getDate() + 14)
const syncForm = ref({
specialtyCode: '',
typeId: '',
timeMin: toInputDateTime(now),
timeMax: toInputDateTime(inTwoWeeks),
})
const syncMeta = computed(() => {
if (!syncStatus.value?.lastSyncAt) return 'Синхронизация ещё не выполнялась'
return `Последняя: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
})
const visibleSyncResult = computed(() => syncResult.value ?? syncStatus.value?.lastResult ?? null)
const tabConfig: Record<TabKey, TabConfig> = { const tabConfig: Record<TabKey, TabConfig> = {
lectures: { lectures: {
@@ -107,19 +136,78 @@ const current = computed(() => {
} }
}) })
onMounted(async () => { async function loadData() {
loading.value = true loading.value = true
const [lecturesResult, coursesResult, locationsResult, tagsResult] = await Promise.allSettled([ const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] = await Promise.allSettled([
lecturesApi.list({ PageSize: 100 }), lecturesApi.list({ PageSize: 100 }),
coursesApi.list(), coursesApi.list(),
locationsApi.list(), locationsApi.list(),
tagsApi.list(), tagsApi.list(),
syncApi.status(),
]) ])
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (coursesResult.status === 'fulfilled') courses.value = coursesResult.value if (coursesResult.status === 'fulfilled') courses.value = coursesResult.value
if (locationsResult.status === 'fulfilled') locations.value = locationsResult.value if (locationsResult.status === 'fulfilled') locations.value = locationsResult.value
if (tagsResult.status === 'fulfilled') tags.value = tagsResult.value if (tagsResult.status === 'fulfilled') tags.value = tagsResult.value
if (syncStatusResult.status === 'fulfilled') syncStatus.value = syncStatusResult.value
loading.value = false loading.value = false
}
async function refreshSyncStatus() {
try {
syncStatus.value = await syncApi.status()
} catch {
// The table refresh is more important than the status badge here.
}
}
async function runScheduleSync() {
syncingSchedule.value = true
syncError.value = ''
syncResult.value = null
try {
syncResult.value = await syncApi.schedule({
specialtyCode: syncForm.value.specialtyCode.trim() || null,
typeId: syncForm.value.typeId.trim() || null,
timeMin: syncForm.value.timeMin ? new Date(syncForm.value.timeMin).toISOString() : null,
timeMax: syncForm.value.timeMax ? new Date(syncForm.value.timeMax).toISOString() : null,
})
if (syncResult.value.error) {
syncError.value = syncResult.value.error
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
} else {
addToast?.('Расписание синхронизировано.', 'success')
}
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
addToast?.(syncError.value, 'error')
await refreshSyncStatus()
} finally {
syncingSchedule.value = false
}
}
async function runRoomsSync() {
syncingRooms.value = true
syncError.value = ''
syncResult.value = null
try {
syncResult.value = await syncApi.rooms()
addToast?.('Аудитории синхронизированы.', 'success')
await loadData()
} catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
addToast?.(syncError.value, 'error')
} finally {
syncingRooms.value = false
}
}
onMounted(() => {
void loadData()
}) })
</script> </script>
@@ -127,7 +215,7 @@ onMounted(async () => {
<div class="admin-lectures page-content"> <div class="admin-lectures page-content">
<div class="header"> <div class="header">
<h1 class="page-title">Управление лекциями и справочниками</h1> <h1 class="page-title">Управление лекциями и справочниками</h1>
<button class="btn-primary">Создать запись</button> <button class="btn-secondary" type="button" :disabled="loading" @click="loadData">Обновить</button>
</div> </div>
<div class="tabs"> <div class="tabs">
@@ -145,21 +233,40 @@ onMounted(async () => {
</GlassCard> </GlassCard>
<GlassCard> <GlassCard>
<div class="section-title">Создать / редактировать</div> <div class="section-heading">
<form class="form"> <div>
<label>Название</label> <div class="section-title">Синхронизация расписания</div>
<input class="glass-input" placeholder="Введите название" /> <div class="sync-meta">{{ syncMeta }}</div>
<label>Описание</label> </div>
<textarea rows="4" placeholder="Описание записи"></textarea> <span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{ syncStatus?.status ?? 'idle' }}</span>
<label>Статус синхронизации</label> </div>
<select class="glass-input">
<option>Синхронизировано</option> <form class="form" @submit.prevent="runScheduleSync">
<option>Ожидает</option> <label>Период с</label>
<option>Ошибка</option> <input v-model="syncForm.timeMin" class="glass-input" type="datetime-local" />
</select> <label>Период по</label>
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" />
<label>Код специальности</label>
<input v-model="syncForm.specialtyCode" class="glass-input" placeholder="Например, 09.03.04" />
<label>Тип занятия</label>
<input v-model="syncForm.typeId" class="glass-input" placeholder="Оставьте пустым для всех типов" />
<div v-if="visibleSyncResult" class="sync-result">
Создано: {{ visibleSyncResult.created }},
обновлено: {{ visibleSyncResult.updated }},
пропущено: {{ visibleSyncResult.skipped }}
</div>
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
{{ syncError || visibleSyncResult?.error }}
</div>
<div class="form-actions"> <div class="form-actions">
<button class="btn-primary" type="button">Сохранить</button> <button class="btn-primary" type="submit" :disabled="syncingSchedule">
<button class="btn-secondary" type="button">Отменить</button> {{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
</button>
<button class="btn-secondary" type="button" :disabled="syncingRooms" @click="runRoomsSync">
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
</button>
</div> </div>
</form> </form>
</GlassCard> </GlassCard>
@@ -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 { 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; } .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; } .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; } .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 label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); }
.form-actions { display: flex; gap: 10px; } .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; }
</style> </style>