fix: синхронизации аудиторий
This commit is contained in:
@@ -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<ModeusEvent> Events);
|
||||
public record ModeusRoom(string Id, string Name, string? Building);
|
||||
public record ModeusRoomsResponse(List<ModeusRoom> 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<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);
|
||||
|
||||
@@ -29,9 +29,34 @@ public class ModeusApiClient : IModeusApiClient
|
||||
|
||||
public async Task<ModeusRoomsResponse> SearchRoomsAsync()
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { });
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new(new());
|
||||
const int pageSize = 100;
|
||||
var allRooms = new List<ModeusRoom>();
|
||||
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)
|
||||
|
||||
@@ -53,16 +53,53 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
|
||||
public async Task<SyncResultDto> 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<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
|
||||
|
||||
@@ -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<SyncStatusDto>('/sync/status'),
|
||||
schedule: (request: SyncScheduleRequest) =>
|
||||
apiRequest<SyncResultDto>('/sync/schedule', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
rooms: () => apiRequest<SyncResultDto>('/sync/rooms', { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { coursesApi, lecturesApi, locationsApi, tagsApi } from '@/api'
|
||||
import type { CourseDto, LectureDto, LocationDto, TagDto } from '@/api/types'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
|
||||
import type { CourseDto, LectureDto, LocationDto, SyncResultDto, SyncStatusDto, TagDto } from '@/api/types'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
@@ -19,6 +19,35 @@ const courses = ref<CourseDto[]>([])
|
||||
const locations = ref<LocationDto[]>([])
|
||||
const tags = ref<TagDto[]>([])
|
||||
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> = {
|
||||
lectures: {
|
||||
@@ -107,19 +136,78 @@ const current = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
const [lecturesResult, coursesResult, locationsResult, tagsResult] = await Promise.allSettled([
|
||||
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] = await Promise.allSettled([
|
||||
lecturesApi.list({ PageSize: 100 }),
|
||||
coursesApi.list(),
|
||||
locationsApi.list(),
|
||||
tagsApi.list(),
|
||||
syncApi.status(),
|
||||
])
|
||||
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
||||
if (coursesResult.status === 'fulfilled') courses.value = coursesResult.value
|
||||
if (locationsResult.status === 'fulfilled') locations.value = locationsResult.value
|
||||
if (tagsResult.status === 'fulfilled') tags.value = tagsResult.value
|
||||
if (syncStatusResult.status === 'fulfilled') syncStatus.value = syncStatusResult.value
|
||||
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>
|
||||
|
||||
@@ -127,7 +215,7 @@ onMounted(async () => {
|
||||
<div class="admin-lectures page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Управление лекциями и справочниками</h1>
|
||||
<button class="btn-primary">Создать запись</button>
|
||||
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -145,21 +233,40 @@ onMounted(async () => {
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Создать / редактировать</div>
|
||||
<form class="form">
|
||||
<label>Название</label>
|
||||
<input class="glass-input" placeholder="Введите название" />
|
||||
<label>Описание</label>
|
||||
<textarea rows="4" placeholder="Описание записи"></textarea>
|
||||
<label>Статус синхронизации</label>
|
||||
<select class="glass-input">
|
||||
<option>Синхронизировано</option>
|
||||
<option>Ожидает</option>
|
||||
<option>Ошибка</option>
|
||||
</select>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="section-title">Синхронизация расписания</div>
|
||||
<div class="sync-meta">{{ syncMeta }}</div>
|
||||
</div>
|
||||
<span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{ syncStatus?.status ?? 'idle' }}</span>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="runScheduleSync">
|
||||
<label>Период с</label>
|
||||
<input v-model="syncForm.timeMin" class="glass-input" type="datetime-local" />
|
||||
<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">
|
||||
<button class="btn-primary" type="button">Сохранить</button>
|
||||
<button class="btn-secondary" type="button">Отменить</button>
|
||||
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
|
||||
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
|
||||
</button>
|
||||
<button class="btn-secondary" type="button" :disabled="syncingRooms" @click="runRoomsSync">
|
||||
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user