215 lines
8.6 KiB
JavaScript
215 lines
8.6 KiB
JavaScript
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}`
|
||
};
|
||
}
|