import { api } from '../api.js'; import { escapeHtml } from '../utils.js'; let qualityState = null; export async function initScheduleQuality() { const root = document.querySelector('.quality-lab'); if (!root) return; qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false }; root.querySelector('#quality-run')?.addEventListener('click', runAnalysis); 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', handleRecommendationClick); await loadSemesters(); } async function loadSemesters() { const select = qualityState?.root.querySelector('#quality-semester'); if (!select) return; try { const semesters = await api.get('/api/schedule/semesters'); select.innerHTML = (semesters || []).map(semester => ` `).join(''); if (!semesters?.length) { select.innerHTML = ''; qualityStatus('Сначала создайте учебный год и семестр.', 'warning'); return; } const today = localIsoDate(new Date()); const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today ); select.value = String(current?.id ?? semesters[0].id); const savedSemesterId = localStorage.getItem('magistr.quality.semesterId'); if (semesters.some(semester => String(semester.id) === savedSemesterId)) { select.value = savedSemesterId; } await loadVersions(); } catch (error) { select.innerHTML = ''; qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error'); } } 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.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(); 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} ${problemWord(analysis.totalProblemCount)}`; qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel; renderProblems(); renderRecommendationsEmpty(); } 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; return `
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED' ? `` : `${escapeHtml(problemActionLabel(problem))}`}
`; }).join(''); } async 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; qualityState.selectedProblemId = problem.id; renderProblems(); if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') { renderRecommendationsMessage( qualityState.analysis.scheduleVersionStatus === 'DRAFT' ? 'Черновик остаётся изолированным' : 'Архивная версия доступна только для проверки', qualityState.analysis.scheduleVersionStatus === 'DRAFT' ? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.' : 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.' ); return; } if (!problem.optimizable) { renderRecommendationsMessage( 'Ручное решение сохранено', 'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.' ); return; } await loadRecommendations(problem); } async function loadRecommendations(problem) { const panel = qualityState.root.querySelector('#quality-recommendations'); panel.innerHTML = `
Проверяем варианты

Каждый кандидат проходит полную проверку конфликтов.

`; try { const params = new URLSearchParams({ semesterId: String(qualityState.analysis.semesterId), versionId: String(qualityState.analysis.scheduleVersionId), scheduleRuleSlotId: String(problem.scheduleRuleSlotId), lessonDate: problem.lessonDate }); const result = await api.get( `/api/edu-office/schedule/quality/recommendations?${params.toString()}` ); renderRecommendations(problem, result); } catch (error) { renderRecommendationsMessage( 'Не удалось подобрать варианты', error.message || 'Повторите попытку после обновления анализа.' ); } } function renderRecommendations(problem, result) { const panel = qualityState.root.querySelector('#quality-recommendations'); const candidates = result.candidates || []; if (!candidates.length) { renderRecommendationsMessage( 'Свободных улучшений не найдено', 'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.' ); return; } panel.innerHTML = `
Проверенные варианты

${escapeHtml(problem.title)}

${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}

${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}

Применение создаёт обычное точечное изменение и не переписывает правило семестра.

`; panel._qualityCandidates = candidates; } function recommendationCard(candidate, index) { const impactClass = candidate.penaltyDelta > 0 ? 'is-positive' : candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral'; const impact = candidate.scoreDelta > 0 ? `+${candidate.scoreDelta} к оценке` : candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений'; return `
${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'} ${escapeHtml(candidate.label)}

${escapeHtml(candidate.description)}

${escapeHtml(impact)} результат ${escapeHtml(String(candidate.resultingScore))}/100
${changeList('Улучшится', candidate.improvements, 'positive')} ${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
`; } function changeList(label, items, tone) { if (!items?.length) return ''; return `
${escapeHtml(label)} ${items.map(item => `${escapeHtml(item)}`).join('')}
`; } async function handleRecommendationClick(event) { const button = event.target.closest('[data-candidate-index]'); if (!button || qualityState?.applying) return; const panel = qualityState.root.querySelector('#quality-recommendations'); const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)]; if (!candidate) return; const confirmed = window.confirm( `Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.` ); if (!confirmed) return; qualityState.applying = true; button.disabled = true; button.textContent = 'Применяем…'; try { await api.post('/api/edu-office/schedule/overrides', candidate.override); qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success'); await runAnalysis(); } catch (error) { qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error'); button.disabled = false; button.textContent = 'Применить после подтверждения'; } finally { qualityState.applying = false; } } function renderRecommendationsEmpty() { if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') { const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT'; renderRecommendationsMessage( isDraft ? 'Режим проверки черновика' : 'Режим проверки архива', isDraft ? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.' : 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.' ); return; } renderRecommendationsMessage( 'Локальные улучшения', 'Выберите проблему, чтобы проверить свободные слоты и аудитории.' ); } function problemActionLabel(problem) { if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') { return 'Исправляется в конструкторе'; } if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') { return 'Только просмотр'; } return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную'; } function renderRecommendationsMessage(title, message) { const panel = qualityState?.root.querySelector('#quality-recommendations'); if (!panel) return; panel.innerHTML = `
↗

${escapeHtml(title)}

${escapeHtml(message)}

`; panel._qualityCandidates = []; } 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 categoryMatches = !category || problemCategory(problem.type) === category; return severityMatches && categoryMatches; }); } 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 problemWord(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] || 'Версия'; }