исправил качество правил и изменение расписания (не черновика)

This commit is contained in:
Zuev
2026-08-12 01:07:35 +03:00
parent 18a97b293a
commit 491e373cc3
23 changed files with 826 additions and 316 deletions

View File

@@ -12,7 +12,7 @@ export async function initScheduleQuality() {
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);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
await loadSemesters();
}
@@ -102,8 +102,8 @@ async function runAnalysis() {
renderAnalysis();
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
'success'
);
} catch (error) {
@@ -131,7 +131,7 @@ function renderAnalysis() {
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
@@ -145,11 +145,11 @@ function renderAnalysis() {
</article>
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRecommendationsEmpty();
renderRulePanelEmpty();
}
function renderProblems() {
@@ -171,6 +171,9 @@ function renderProblems() {
}
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 `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
@@ -178,199 +181,92 @@ function renderProblems() {
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<span class="quality-rule-issues">${issueLabels.map(label => `
<span>${escapeHtml(label)}</span>
`).join('')}</span>
<small>
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку
· суммарный штраф
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
Подобрать улучшения
${canEdit
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
Изменить правило
</button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
: '<span>Только просмотр</span>'}
</div>
</article>
`;
}).join('');
}
async function handleProblemClick(event) {
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();
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
renderRecommendationsMessage(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик остаётся изолированным'
: 'Архивная версия доступна только для проверки',
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
);
return;
}
if (!problem.optimizable) {
renderRecommendationsMessage(
'Ручное решение сохранено',
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
);
return;
}
await loadRecommendations(problem);
renderRuleDetails(problem);
}
async function loadRecommendations(problem) {
const panel = qualityState.root.querySelector('#quality-recommendations');
panel.innerHTML = `
<div class="quality-recommendations-loading">
<span class="quality-loader"></span>
<strong>Проверяем варианты</strong>
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
</div>
`;
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 renderRulePanelEmpty() {
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
renderRulePanelMessage(
archived ? 'Архивная версия' : 'Работа с правилом',
archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
);
}
function renderRecommendations(problem, result) {
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidates = result.candidates || [];
if (!candidates.length) {
renderRecommendationsMessage(
'Свободных улучшений не найдено',
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
);
return;
}
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 = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span>
<h3>${escapeHtml(problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div>
<div class="quality-candidates">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
<div class="quality-rule-detail-list">
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
</div>
<p class="quality-recommendations-note">
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
</p>
`;
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 `
<article class="quality-candidate ${impactClass}">
<div class="quality-candidate-top">
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
<strong>${escapeHtml(candidate.label)}</strong>
</div>
<p>${escapeHtml(candidate.description)}</p>
<div class="quality-candidate-impact">
<span>${escapeHtml(impact)}</span>
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
</div>
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
Применить после подтверждения
${canEdit ? `
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
Открыть правило в конструкторе
</button>
</article>
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
<div class="quality-change-list is-${tone}">
<strong>${escapeHtml(label)}</strong>
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
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);
}
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 openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
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 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) {
function renderRulePanelMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
@@ -380,7 +276,6 @@ function renderRecommendationsMessage(title, message) {
<p>${escapeHtml(message)}</p>
</div>
`;
panel._qualityCandidates = [];
}
function qualityStatus(message, tone = '') {
@@ -393,7 +288,8 @@ function qualityStatus(message, tone = '') {
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const categoryMatches = !category || problemCategory(problem.type) === category;
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
});
}
@@ -448,13 +344,13 @@ function localIsoDate(date) {
return `${year}-${month}-${day}`;
}
function problemWord(count) {
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 'проблем';
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function versionStatusLabel(status) {