исправил качество правил и изменение расписания (не черновика)
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user