import { api } from '../api.js'; import { academicYearsFromSemesters, resolveAcademicPeriod, semestersForAcademicYear } from '../academic-period.js'; import { escapeHtml } from '../utils.js'; import { trackUxEvent } from '../../../telemetry.js'; let qualityState = null; export async function initScheduleQuality() { const root = document.querySelector('.quality-lab'); if (!root) return; qualityState = { root, analysis: null, semesters: [], academicYearId: '', versions: [], visibleProblems: [], selectedProblemId: null, applying: false }; root.querySelector('#quality-run')?.addEventListener('click', runAnalysis); root.querySelector('#quality-year')?.addEventListener('change', handleYearChange); root.querySelector('#quality-semester')?.addEventListener('change', loadVersions); root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems); root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems); root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick); root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick); await loadSemesters(); } async function loadSemesters() { const select = qualityState?.root.querySelector('#quality-semester'); if (!select) return; try { qualityState.semesters = await api.get('/api/schedule/semesters'); if (!qualityState.semesters?.length) { renderPeriodSelects('', ''); qualityStatus('Сначала создайте учебный год и семестр.', 'warning'); return; } const selected = resolveAcademicPeriod(qualityState.semesters, { academicYearId: localStorage.getItem('magistr.quality.yearId'), semesterId: localStorage.getItem('magistr.quality.semesterId'), date: localIsoDate(new Date()) }); renderPeriodSelects(selected.academicYearId, selected.semesterId); await loadVersions(); } catch (error) { select.innerHTML = ''; qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error'); } } async function handleYearChange() { const yearId = qualityState?.root.querySelector('#quality-year')?.value || ''; const selected = resolveAcademicPeriod(qualityState.semesters, { academicYearId: yearId, date: localIsoDate(new Date()) }); renderPeriodSelects(yearId, selected.semesterId); await loadVersions(); } function renderPeriodSelects(academicYearId, semesterId) { const yearSelect = qualityState?.root.querySelector('#quality-year'); const semesterSelect = qualityState?.root.querySelector('#quality-semester'); if (!yearSelect || !semesterSelect) return; const years = academicYearsFromSemesters(qualityState.semesters); qualityState.academicYearId = String(academicYearId || years[0]?.id || ''); yearSelect.innerHTML = years.length ? years.map(year => ``).join('') : ''; yearSelect.value = qualityState.academicYearId; const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId); semesterSelect.innerHTML = yearSemesters.length ? yearSemesters.map(semester => ` `).join('') : ''; const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId)) || yearSemesters[0]; semesterSelect.value = String(selectedSemester?.id || ''); localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId); } async function loadVersions() { const semesterId = qualityState?.root.querySelector('#quality-semester')?.value; const select = qualityState?.root.querySelector('#quality-version'); if (!select || !semesterId) return; select.innerHTML = ''; try { qualityState.versions = await api.get( `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}` ); select.innerHTML = qualityState.versions.map(version => ` `).join(''); if (!qualityState.versions.length) { select.innerHTML = ''; return; } const savedVersionId = localStorage.getItem('magistr.quality.versionId'); const saved = qualityState.versions.find(version => String(version.id) === savedVersionId); const published = qualityState.versions.find(version => version.status === 'PUBLISHED'); select.value = String((saved || published || qualityState.versions[0]).id); localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId); localStorage.setItem('magistr.quality.semesterId', semesterId); } catch (error) { select.innerHTML = ''; qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error'); } } async function runAnalysis() { const semesterId = qualityState?.root.querySelector('#quality-semester')?.value; const versionId = qualityState?.root.querySelector('#quality-version')?.value; if (!semesterId) { qualityStatus('Выберите семестр для анализа.', 'warning'); return; } if (!versionId) { qualityStatus('Выберите версию расписания для анализа.', 'warning'); return; } localStorage.setItem('magistr.quality.semesterId', semesterId); localStorage.setItem('magistr.quality.versionId', versionId); const button = qualityState.root.querySelector('#quality-run'); button.disabled = true; button.textContent = 'Анализируем…'; qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading'); qualityState.root.querySelector('#quality-results').hidden = true; try { qualityState.analysis = await api.get( `/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}` ); qualityState.selectedProblemId = null; renderAnalysis(); trackUxEvent('качество_расписания_проверено', { semesterId, versionId, score: Number(qualityState.analysis.score || 0) }); qualityStatus( qualityState.analysis.scheduleVersionStatus === 'DRAFT' ? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.' : 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.', 'success' ); } catch (error) { qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error'); } finally { button.disabled = false; button.textContent = 'Выполнить анализ'; } } function renderAnalysis() { const analysis = qualityState?.analysis; if (!analysis) return; qualityState.root.querySelector('#quality-results').hidden = false; const band = qualityBand(analysis.score); const orbit = qualityState.root.querySelector('#quality-score-orbit'); orbit.dataset.band = band.code; qualityState.root.querySelector('#quality-score-ring-value') .setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`); qualityState.root.querySelector('#quality-score').textContent = String(analysis.score); qualityState.root.querySelector('#quality-score-band').textContent = band.label; qualityState.root.querySelector('#quality-score-band').dataset.band = band.code; qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel; qualityState.root.querySelector('#quality-score-summary').textContent = band.summary; qualityState.root.querySelector('#quality-score-meta').innerHTML = ` ${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))} ${escapeHtml(String(analysis.lessonCount))} занятий правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))} штраф ${escapeHtml(String(analysis.rawPenalty))} `; qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || []) .map((metric, index) => `
${String(index + 1).padStart(2, '0')} ${escapeHtml(formatMetricValue(metric))}

${escapeHtml(metric.label)}

${escapeHtml(metric.explanation)}

штраф ${escapeHtml(String(metric.penalty))}
`).join(''); const totalLabel = analysis.problemsTruncated ? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил` : `Правил с проблемами: ${analysis.totalProblemCount}`; qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel; renderProblems(); renderRulePanelEmpty(); } function renderProblems() { const container = qualityState?.root.querySelector('#quality-problems'); if (!container || !qualityState.analysis) return; const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || ''; const category = qualityState.root.querySelector('#quality-type-filter')?.value || ''; qualityState.visibleProblems = filterQualityProblems( qualityState.analysis.problems || [], severity, category ); if (!qualityState.visibleProblems.length) { container.innerHTML = `
По выбранному фильтру проблем нет Измените уровень важности или категорию.
`; return; } container.innerHTML = qualityState.visibleProblems.map((problem, index) => { const selected = problem.id === qualityState.selectedProblemId; const affectedLessonCount = Number(problem.affectedLessonCount || 1); const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title]; const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId; return `
${canEdit ? `` : 'Только просмотр'}
`; }).join(''); } function handleProblemClick(event) { const button = event.target.closest('[data-problem-index]'); if (!button) return; const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)]; if (!problem) return; if (button.dataset.action === 'edit-rule') { openRuleInConstructor(problem); return; } qualityState.selectedProblemId = problem.id; renderProblems(); renderRuleDetails(problem); } function renderRulePanelEmpty() { const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED'; renderRulePanelMessage( archived ? 'Архивная версия' : 'Работа с правилом', archived ? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.' : 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.' ); } function renderRuleDetails(problem) { const panel = qualityState?.root.querySelector('#quality-recommendations'); if (!panel) return; const affectedLessonCount = Number(problem.affectedLessonCount || 1); const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title]; const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId; panel.innerHTML = `
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}

${escapeHtml(problem.subjectName || problem.title)}

Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.

${issueLabels.map(label => `${escapeHtml(label)}`).join('')}
${canEdit ? ` ` : '

Архивное расписание нельзя изменить.

'} `; } function handleRulePanelClick(event) { const button = event.target.closest('[data-edit-rule-id]'); if (!button) return; const problem = (qualityState?.analysis?.problems || []) .find(item => String(item.scheduleRuleId) === button.dataset.editRuleId); if (problem) openRuleInConstructor(problem); } function openRuleInConstructor(problem) { if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return; localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId)); localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId)); localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId)); document.querySelector('.nav-item[data-tab="schedule"]')?.click(); } function renderRulePanelMessage(title, message) { const panel = qualityState?.root.querySelector('#quality-recommendations'); if (!panel) return; panel.innerHTML = `
↗

${escapeHtml(title)}

${escapeHtml(message)}

`; } function qualityStatus(message, tone = '') { const status = qualityState?.root.querySelector('#quality-status'); if (!status) return; status.textContent = message; status.dataset.tone = tone; } export function filterQualityProblems(problems, severity = '', category = '') { return (problems || []).filter(problem => { const severityMatches = !severity || problem.severity === severity; const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type]; const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category); return severityMatches && categoryMatches; }).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0)); } function problemCategory(type = '') { if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS'; if (type.includes('LOAD')) return 'LOAD'; if (type.includes('ROOM')) return 'ROOM'; return 'PREFERENCE'; } export function qualityBand(score) { if (score >= 90) { return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' }; } if (score >= 75) { return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' }; } if (score >= 55) { return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' }; } return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' }; } export function formatMetricValue(metric) { const value = Number(metric?.value || 0); const formatted = Number.isInteger(value) ? String(value) : value.toLocaleString('ru-RU', { maximumFractionDigits: 1 }); return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`; } function severityLabel(severity) { return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity; } function semesterLabel(semester) { const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний'; return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`; } function formatDate(value) { if (!value) return 'дата не указана'; const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } function localIsoDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function lessonWord(count) { const mod100 = count % 100; const mod10 = count % 10; if (mod100 >= 11 && mod100 <= 14) return 'занятий'; if (mod10 === 1) return 'занятие'; if (mod10 >= 2 && mod10 <= 4) return 'занятия'; return 'занятий'; } export function versionStatusLabel(status) { return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия'; }