Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign

This commit is contained in:
dipatrik10
2026-08-14 23:41:59 +03:00
10 changed files with 118 additions and 35 deletions

View File

@@ -902,7 +902,7 @@ API возвращает `409 Conflict`; соседние интервалы и
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/edu-office/schedule/quality?semesterId=&versionId=` | Итоговая оценка, метрики и объяснимый список проблем выбранной версии | | `GET` | `/api/edu-office/schedule/quality?semesterId=&versionId=` | Итоговая оценка, метрики и объяснимый список проблемных правил выбранной версии |
| `GET` | `/api/edu-office/schedule/quality/recommendations?semesterId=&versionId=&scheduleRuleSlotId=&lessonDate=` | Проверенные локальные варианты улучшения опубликованного занятия | | `GET` | `/api/edu-office/schedule/quality/recommendations?semesterId=&versionId=&scheduleRuleSlotId=&lessonDate=` | Проверенные локальные варианты улучшения опубликованного занятия |
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. `versionId` необязателен: без него Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. `versionId` необязателен: без него
@@ -929,7 +929,7 @@ override текущей публикации. Для них доступны о
"score": 84, "score": 84,
"rawPenalty": 17, "rawPenalty": 17,
"lessonCount": 36, "lessonCount": 36,
"totalProblemCount": 5, "totalProblemCount": 1,
"problemsTruncated": false, "problemsTruncated": false,
"metrics": [ "metrics": [
{ {
@@ -943,11 +943,16 @@ override текущей публикации. Для них доступны о
], ],
"problems": [ "problems": [
{ {
"id": "GROUP_GAP:2026-09-14:31:18", "id": "RULE:24",
"type": "GROUP_GAP", "type": "GROUP_GAP",
"severity": "MEDIUM", "severity": "MEDIUM",
"title": "Окно в расписании группы", "title": "Правило «Архитектура систем» снижает качество",
"penalty": 2, "description": "3 занятия этого правила снижают оценку: Окно в расписании группы; Избыточно крупная аудитория",
"penalty": 8,
"scheduleRuleId": 24,
"affectedLessonCount": 3,
"problemTypes": ["GROUP_GAP", "ROOM_OVERSIZED"],
"issueLabels": ["Окно в расписании группы", "Избыточно крупная аудитория"],
"scheduleRuleSlotId": 31, "scheduleRuleSlotId": 31,
"lessonDate": "2026-09-14", "lessonDate": "2026-09-14",
"optimizable": true "optimizable": true
@@ -957,8 +962,11 @@ override текущей публикации. Для них доступны о
``` ```
`score` находится в диапазоне от 0 до 100 и нормализует сумму объяснимых штрафов `score` находится в диапазоне от 0 до 100 и нормализует сумму объяснимых штрафов
относительно числа занятий. Поле `totalProblemCount` содержит полный размер результата, относительно числа занятий. Нарушения сначала рассчитываются для каждого фактического
а массив `problems` ограничен 300 элементами; `problemsTruncated` сообщает об усечении. занятия, затем объединяются по `scheduleRuleId`: `penalty` хранит сумму всех штрафов правила,
`affectedLessonCount` — число затронутых занятий, `problemTypes` и `issueLabels` — все его
категории и объяснения. Поле `totalProblemCount` содержит число проблемных правил, а массив
`problems` ограничен 300 элементами; `problemsTruncated` сообщает об усечении.
Рекомендации подбирают другой эффективный временной слот того же учебного дня либо Рекомендации подбирают другой эффективный временной слот того же учебного дня либо
подходящую активную аудиторию. Занятия, уже закреплённые ручным override, исключаются. подходящую активную аудиторию. Занятия, уже закреплённые ручным override, исключаются.

View File

@@ -447,9 +447,11 @@ constraint. При чтении API разворачивает периоды о
Неравномерность дневной нагрузки выводится отдельной диагностической метрикой в процентах Неравномерность дневной нагрузки выводится отдельной диагностической метрикой в процентах
и не добавляет скрытого штрафа к итоговой оценке. и не добавляет скрытого штрафа к итоговой оценке.
В ответе сохраняются исходный штраф, вклад каждого критерия и конкретные проблемы с Внутренне штраф рассчитывается для каждого фактического занятия, поэтому повторяющиеся
датой, занятием и затронутой сущностью. Это делает оценку воспроизводимой и позволяет пары одного правила увеличивают его вклад в оценку. Перед выдачей нарушения объединяются
фильтровать проблемы по типу и серьёзности. по правилу: одна карточка содержит суммарный штраф, число затронутых занятий, все типы и
краткие причины. Это сохраняет воспроизводимость оценки, но направляет пользователя к
изменению базового правила вместо ручного исправления каждой пары.
Для проблемы опубликованной версии помощник перебирает другие слоты эффективной сетки того же дня и Для проблемы опубликованной версии помощник перебирает другие слоты эффективной сетки того же дня и
активные аудитории достаточной вместимости. Каждый вариант повторно проходит активные аудитории достаточной вместимости. Каждый вариант повторно проходит
@@ -457,9 +459,10 @@ constraint. При чтении API разворачивает периоды о
показывает улучшения и компромиссы. Занятия с ручным override считаются закреплёнными и не показывает улучшения и компромиссы. Занятия с ручным override считаются закреплёнными и не
получают рекомендаций. Применение возможно только после подтверждения пользователя через получают рекомендаций. Применение возможно только после подтверждения пользователя через
обычный механизм `schedule_overrides`; автоматической публикации и полного solver в MVP обычный механизм `schedule_overrides`; автоматической публикации и полного solver в MVP
нет. Для черновика доступны те же оценка, метрики и объяснимые проблемы, но рекомендации нет. Интерфейс анализа для опубликованной версии и черновика открывает выбранное правило
не создаются: пользователь исправляет правила в изолированном конструкторе и повторяет в конструкторе соответствующей версии; черновик остаётся изолированным до публикации.
проверку до публикации. Архив анализируется только для чтения. Архив анализируется только для чтения. API локальных рекомендаций сохраняется для
совместимости и точечных сценариев, но реестр качества работает на уровне правил.
## Привязка преподаватель ↔ дисциплина ## Привязка преподаватель ↔ дисциплина

View File

@@ -77,7 +77,7 @@ frontend/
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение │ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение
│ │ ├── schedule-quality.js # Оценка, фильтры и локальные рекомендации │ │ ├── schedule-quality.js # Оценка, фильтры и переход к редактированию правил
│ │ ├── schedule-versions.js # Черновики, публикация, восстановление, diff и аудит │ │ ├── schedule-versions.js # Черновики, публикация, восстановление, diff и аудит
│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей │ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки │ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
@@ -183,7 +183,7 @@ frontend/
| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` | | `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` |
| `schedule` | Конструктор правил: по умолчанию редактирует опубликованное расписание, позволяет переключиться на черновик или создать его из выбранной версии; содержит подсказки пожеланий и визуальную матрицу групп | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `schedule` | Конструктор правил: по умолчанию редактирует опубликованное расписание, позволяет переключиться на черновик или создать его из выбранной версии; содержит подсказки пожеланий и визуальную матрицу групп | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `schedule-versions` | Контур публикации: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` | | `schedule-versions` | Контур публикации: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` |
| `schedule-quality` | Диагностика выбранной версии семестра: оценка, метрики, фильтруемые проблемы; для публикации — подтверждаемое локальное улучшение | `/api/edu-office/schedule/quality`, `/api/edu-office/schedule/quality/recommendations`, `/api/edu-office/schedule/overrides` | | `schedule-quality` | Диагностика выбранной версии семестра: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
@@ -209,14 +209,13 @@ frontend/
действий. Каждая карточка показывает исходное и запрошенное состояние, результат действий. Каждая карточка показывает исходное и запрошенное состояние, результат
предварительной проверки и хронологию решения. предварительной проверки и хронологию решения.
- Вкладка `schedule-quality` доступна администратору и учебному отделу. После выбора - Вкладка `schedule-quality` доступна администратору и учебному отделу. После выбора
семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр проблем с семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр правил
фильтрами по серьёзности и типу. Для проблемы, связанной с конкретным незакреплённым с фильтрами по серьёзности и типу. Все проблемные занятия одного правила показаны одной
занятием опубликованной версии, кнопка `Подобрать улучшение` запрашивает проверенные варианты времени и карточкой; в ней выводятся число затронутых занятий, категории и суммарный штраф. Кнопка
аудитории. Карточка кандидата показывает изменение оценки, улучшения и компромиссы; `Изменить правило` открывает выбранную версию в конструкторе и сразу заполняет форму этим
override создаётся только после явного подтверждения, затем анализ запускается заново. правилом. Сценарий одинаков для `PUBLISHED` и `DRAFT`; `ARCHIVED` анализируется только для
Для `DRAFT` экран рассчитывает те же метрики и проблемы напрямую по правилам черновика, чтения. Переход между вкладками передаёт ID версии и правила через одноразовые ключи
но направляет пользователя в конструктор и не создаёт override. `ARCHIVED` анализируется `magistr.schedule.openVersionId` и `magistr.schedule.openRuleId` в `localStorage`.
только для чтения. Экран не предлагает автоматически менять занятия, уже отредактированные вручную.
- Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как - Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как
отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные
пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал. пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал.

View File

@@ -503,6 +503,38 @@
font-size: 0.7rem; font-size: 0.7rem;
} }
.quality-rule-issues,
.quality-rule-detail-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.quality-rule-issues > span,
.quality-rule-detail-list > span {
padding: 0.22rem 0.45rem;
color: var(--text-secondary);
background: color-mix(in srgb, var(--quality-amber) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--quality-amber) 24%, var(--bg-card-border));
border-radius: 999px;
font-size: 0.66rem;
line-height: 1.2;
}
.quality-rule-detail-list {
display: grid;
margin: 1rem 0;
}
.quality-rule-detail-list > span {
border-radius: var(--radius-sm);
font-size: 0.76rem;
}
.quality-edit-rule {
width: 100%;
}
.quality-penalty { .quality-penalty {
color: var(--quality-red); color: var(--quality-red);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;

View File

@@ -186,7 +186,7 @@ function renderProblems() {
`).join('')}</span> `).join('')}</span>
<small> <small>
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))} Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку · затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}
· суммарный штраф · суммарный штраф
</small> </small>
</span> </span>
@@ -238,7 +238,7 @@ function renderRuleDetails(problem) {
<div class="quality-recommendations-head"> <div class="quality-recommendations-head">
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span> <span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3> <h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p> <p>Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div> </div>
<div class="quality-rule-detail-list"> <div class="quality-rule-detail-list">
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')} ${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}

View File

@@ -491,12 +491,25 @@ export async function initSchedule() {
} }
renderRules(); renderRules();
renderVisualSchedule(); renderVisualSchedule();
openRequestedRule();
} catch (error) { } catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания'); setVisualState('Ошибка загрузки правил расписания');
} }
} }
function openRequestedRule() {
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
if (!requestedRuleId) return;
localStorage.removeItem('magistr.schedule.openRuleId');
const rule = rules.find(item => String(item.id) === requestedRuleId);
if (!rule) {
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
return;
}
fillRuleForm(rule);
}
function renderRules() { function renderRules() {
if (!rules.length) { if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
@@ -2079,6 +2092,13 @@ export async function initSchedule() {
return new Date(year, month - 1, day); return new Date(year, month - 1, day);
} }
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 compactWeekRanges(weeks, maxWeeks, parity) { function compactWeekRanges(weeks, maxWeeks, parity) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b); const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return ''; if (!uniqueWeeks.length) return '';

View File

@@ -5,8 +5,8 @@
<h2 id="quality-lab-title">Качество видно до перестановок</h2> <h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p> <p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Черновик можно оценить до публикации, не показывая промежуточное пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
расписание студентам и преподавателям. суммируются. Черновик можно оценить до публикации.
</p> </p>
</div> </div>
<div class="quality-run-panel"> <div class="quality-run-panel">
@@ -55,8 +55,8 @@
<div class="quality-problems-column"> <div class="quality-problems-column">
<div class="quality-section-heading"> <div class="quality-section-heading">
<div> <div>
<span class="quality-kicker">Карта неудобств</span> <span class="quality-kicker">Карта правил</span>
<h3>Что снижает оценку</h3> <h3>Правила, снижающие оценку</h3>
</div> </div>
<span class="quality-problem-total" id="quality-problem-total"></span> <span class="quality-problem-total" id="quality-problem-total"></span>
</div> </div>
@@ -89,8 +89,8 @@
<aside class="quality-recommendations" id="quality-recommendations" aria-live="polite"> <aside class="quality-recommendations" id="quality-recommendations" aria-live="polite">
<div class="quality-recommendations-empty"> <div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span> <span class="quality-recommendations-mark">↗</span>
<h3>Локальные улучшения</h3> <h3>Работа с правилом</h3>
<p>Выберите проблему, чтобы проверить свободные слоты и аудитории.</p> <p>Выберите правило, чтобы увидеть суммарное влияние и открыть его в конструкторе.</p>
</div> </div>
</aside> </aside>
</section> </section>

View File

@@ -17,7 +17,7 @@ test('оценка качества получает понятный диапа
test('фильтр проблем одновременно учитывает важность и категорию', () => { test('фильтр проблем одновременно учитывает важность и категорию', () => {
const problems = [ const problems = [
{ type: 'GROUP_GAP', severity: 'HIGH' }, { type: 'GROUP_GAP', problemTypes: ['GROUP_GAP', 'ROOM_OVERSIZED'], severity: 'HIGH' },
{ type: 'ROOM_OVERSIZED', severity: 'LOW' }, { type: 'ROOM_OVERSIZED', severity: 'LOW' },
{ type: 'TEACHER_SOFT_UNWANTED', severity: 'HIGH' } { type: 'TEACHER_SOFT_UNWANTED', severity: 'HIGH' }
]; ];
@@ -28,7 +28,7 @@ test('фильтр проблем одновременно учитывает в
); );
assert.deepEqual( assert.deepEqual(
filterQualityProblems(problems, '', 'ROOM'), filterQualityProblems(problems, '', 'ROOM'),
[problems[1]] [problems[0], problems[1]]
); );
}); });
@@ -47,6 +47,8 @@ test('метрики и вкладка анализа подключены к SP
assert.doesNotMatch(capabilities, /DEPARTMENT:[\s\S]*schedule-quality/); assert.doesNotMatch(capabilities, /DEPARTMENT:[\s\S]*schedule-quality/);
assert.match(main, /'schedule-quality':[\s\S]*schedule-quality\.js/); assert.match(main, /'schedule-quality':[\s\S]*schedule-quality\.js/);
assert.match(index, /data-tab="schedule-quality"/); assert.match(index, /data-tab="schedule-quality"/);
assert.match(view, /Локальные улучшения/); assert.match(view, /Правила, снижающие оценку/);
assert.match(script, /Применить после подтверждения/); assert.match(script, /Изменить правило/);
assert.match(script, /magistr\.schedule\.openRuleId/);
assert.doesNotMatch(script, /quality\/recommendations/);
}); });

View File

@@ -48,6 +48,8 @@ test('контур версий подключён к SPA, а конструкт
assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/); assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/);
assert.match(constructor, /basedOnVersionId:\s*Number\(base\.id\)/); assert.match(constructor, /basedOnVersionId:\s*Number\(base\.id\)/);
assert.match(constructor, /magistr\.schedule\.openVersionId/); assert.match(constructor, /magistr\.schedule\.openVersionId/);
assert.match(constructor, /magistr\.schedule\.openRuleId/);
assert.match(constructor, /function localIsoDate\(date\)/);
assert.match(constructor, /data-slot-id/); assert.match(constructor, /data-slot-id/);
assert.match(view, /id="schedule-version-create-draft"/); assert.match(view, /id="schedule-version-create-draft"/);
assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/); assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/);

View File

@@ -0,0 +1,17 @@
---
type: "query"
date: "2026-08-12T18:41:53.854324+00:00"
question: "в качестве расписания если правило снижает оценку качества расписания - то отображаются все пары которые это качество снижают, а должно отображаться одно правило, чтобы можно было поменять его, а не каждую пару по отдельности. баллы при этом должны суммироваться в зависимости от количества пар в правиле"
contributor: "graphify"
outcome: "dead_end"
---
# Q: в качестве расписания если правило снижает оценку качества расписания - то отображаются все пары которые это качество снижают, а должно отображаться одно правило, чтобы можно было поменять его, а не каждую пару по отдельности. баллы при этом должны суммироваться в зависимости от количества пар в правиле
## Answer
Query expanded to (from graph vocab, 7 tokens): [schedule, rule, lesson, service, dto, controller, generator]. Graph traversal returned generic Service nodes about authentication and tenant infrastructure and did not identify the quality-analysis flow, so the result was not used as source evidence. The implementation was verified directly in ScheduleQualityService, ScheduleQualityAnalysisDto, schedule-quality.js and schedule.js.
## Outcome
- Signal: dead_end