Задача Егора 2ч.
This commit is contained in:
214
frontend/admin/js/dashboard-conflicts.js
Normal file
214
frontend/admin/js/dashboard-conflicts.js
Normal file
@@ -0,0 +1,214 @@
|
||||
export const CONFLICT_CHECK_STATE = Object.freeze({
|
||||
COMPLETE: 'COMPLETE',
|
||||
PARTIAL: 'PARTIAL',
|
||||
NOT_RUN: 'NOT_RUN'
|
||||
});
|
||||
|
||||
export const CONFLICT_CHECK_REASON = Object.freeze({
|
||||
NO_DEPARTMENTS: 'NO_DEPARTMENTS',
|
||||
ALL_REQUESTS_FAILED: 'ALL_REQUESTS_FAILED'
|
||||
});
|
||||
|
||||
/** Формирует date-only строку из локальных компонентов без UTC-конвертации. */
|
||||
export function formatLocalDate(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new TypeError('Для форматирования требуется корректная дата');
|
||||
}
|
||||
return [
|
||||
String(date.getFullYear()).padStart(4, '0'),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
/** Возвращает локальные границы недели с понедельника по воскресенье включительно. */
|
||||
export function currentWeekDateRange(referenceDate = new Date()) {
|
||||
if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) {
|
||||
throw new TypeError('Для расчёта недели требуется корректная дата');
|
||||
}
|
||||
|
||||
const localDate = new Date(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate()
|
||||
);
|
||||
const dayOfWeek = localDate.getDay();
|
||||
const daysFromMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
const monday = addLocalDays(localDate, daysFromMonday);
|
||||
const sunday = addLocalDays(monday, 6);
|
||||
|
||||
return {
|
||||
startDate: formatLocalDate(monday),
|
||||
endDate: formatLocalDate(sunday)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает расписания независимо и сохраняет факт каждого отказа.
|
||||
* Технические причины намеренно не возвращаются в UI-модель.
|
||||
*/
|
||||
export async function loadDepartmentSchedules(departments, loadSchedule) {
|
||||
if (!Array.isArray(departments)) {
|
||||
throw new TypeError('Список кафедр должен быть массивом');
|
||||
}
|
||||
if (typeof loadSchedule !== 'function') {
|
||||
throw new TypeError('Функция загрузки расписания обязательна');
|
||||
}
|
||||
|
||||
if (departments.length === 0) {
|
||||
return {
|
||||
state: CONFLICT_CHECK_STATE.NOT_RUN,
|
||||
reason: CONFLICT_CHECK_REASON.NO_DEPARTMENTS,
|
||||
lessons: [],
|
||||
totalDepartmentCount: 0,
|
||||
checkedDepartmentCount: 0,
|
||||
failedDepartments: []
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(departments.map(async department => {
|
||||
const lessons = await loadSchedule(department);
|
||||
if (!Array.isArray(lessons)) {
|
||||
throw new TypeError('API расписания вернул некорректный формат данных');
|
||||
}
|
||||
return lessons;
|
||||
}));
|
||||
|
||||
const lessons = [];
|
||||
const failedDepartments = [];
|
||||
let checkedDepartmentCount = 0;
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
checkedDepartmentCount += 1;
|
||||
lessons.push(...result.value);
|
||||
return;
|
||||
}
|
||||
failedDepartments.push(publicDepartment(departments[index], index));
|
||||
});
|
||||
|
||||
const state = checkedDepartmentCount === 0
|
||||
? CONFLICT_CHECK_STATE.NOT_RUN
|
||||
: failedDepartments.length > 0
|
||||
? CONFLICT_CHECK_STATE.PARTIAL
|
||||
: CONFLICT_CHECK_STATE.COMPLETE;
|
||||
|
||||
return {
|
||||
state,
|
||||
reason: state === CONFLICT_CHECK_STATE.NOT_RUN
|
||||
? CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED
|
||||
: null,
|
||||
lessons,
|
||||
totalDepartmentCount: departments.length,
|
||||
checkedDepartmentCount,
|
||||
failedDepartments
|
||||
};
|
||||
}
|
||||
|
||||
/** Возвращает безопасную UI-модель итогового состояния проверки. */
|
||||
export function conflictCheckPresentation(scheduleLoad, conflictCount, range) {
|
||||
assertPresentationInput(scheduleLoad, conflictCount, range);
|
||||
|
||||
const count = conflictCount;
|
||||
const startDate = range.startDate;
|
||||
const endDate = range.endDate;
|
||||
const rangeLabel = `${startDate} — ${endDate}`;
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
const noDepartments = scheduleLoad.reason === CONFLICT_CHECK_REASON.NO_DEPARTMENTS;
|
||||
return {
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: noDepartments
|
||||
? 'Нет кафедр, расписание которых можно проверить.'
|
||||
: 'Не удалось получить расписание ни одной кафедры. Подтвердить отсутствие конфликтов нельзя.',
|
||||
meta: `Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Проверка выполнена частично',
|
||||
description: count > 0
|
||||
? 'Показаны найденные конфликты, но подтвердить полноту результата нельзя.'
|
||||
: 'В доступной части расписания конфликтов не найдено, но подтвердить их отсутствие во всём расписании нельзя.',
|
||||
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.COMPLETE && count === 0) {
|
||||
return {
|
||||
tone: 'success',
|
||||
title: 'Конфликты расписания не обнаружены',
|
||||
description: 'Проверены накладки преподавателей и аудиторий, а также вместимость помещений.',
|
||||
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function addLocalDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertPresentationInput(scheduleLoad, conflictCount, range) {
|
||||
if (!Number.isInteger(conflictCount) || conflictCount < 0) {
|
||||
throw new TypeError('Количество конфликтов должно быть целым неотрицательным числом');
|
||||
}
|
||||
|
||||
if (!isValidDateOnly(range?.startDate) || !isValidDateOnly(range?.endDate)
|
||||
|| range.startDate > range.endDate) {
|
||||
throw new TypeError('Для результата проверки требуется корректный локальный диапазон дат');
|
||||
}
|
||||
|
||||
const total = scheduleLoad?.totalDepartmentCount;
|
||||
const checked = scheduleLoad?.checkedDepartmentCount;
|
||||
if (!Number.isInteger(total) || total < 0
|
||||
|| !Number.isInteger(checked) || checked < 0 || checked > total) {
|
||||
throw new TypeError('Количество проверенных кафедр не соответствует результату проверки');
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.COMPLETE) {
|
||||
if (total === 0 || checked !== total) {
|
||||
throw new TypeError('Полная проверка должна охватывать все кафедры');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||
if (checked === 0 || checked === total) {
|
||||
throw new TypeError('Частичная проверка должна охватывать только часть кафедр');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN && checked === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new TypeError('Неизвестное состояние проверки конфликтов');
|
||||
}
|
||||
|
||||
function isValidDateOnly(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
|
||||
if (!match) return false;
|
||||
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.getFullYear() === year
|
||||
&& date.getMonth() === month - 1
|
||||
&& date.getDate() === day;
|
||||
}
|
||||
|
||||
function publicDepartment(department, index) {
|
||||
return {
|
||||
id: department?.id ?? null,
|
||||
name: department?.departmentName || department?.name || `Кафедра ${index + 1}`
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user