fix: синхронизации аудиторий
This commit is contained in:
@@ -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;
|
||||||
|
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();
|
response.EnsureSuccessStatusCode();
|
||||||
return await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new(new());
|
|
||||||
|
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();
|
await _db.SaveChangesAsync();
|
||||||
return new SyncResultDto(created, updated, 0, null);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
|
public async Task<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
|
||||||
|
|||||||
@@ -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' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncScheduleRequest {
|
||||||
|
specialtyCode?: string | null
|
||||||
|
timeMin?: string | null
|
||||||
|
timeMax?: string | null
|
||||||
|
typeId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResultDto {
|
||||||
created: number
|
created: number
|
||||||
updated: number
|
updated: number
|
||||||
skipped: number
|
skipped: number
|
||||||
error?: string | null
|
error?: string | null
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAchievementDto {
|
export interface UserAchievementDto {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user