const DAY_TYPES = [ { value: 'weekday', label: 'будних дней' }, { value: 'saturday', label: 'субботы' }, { value: 'sunday', label: 'воскресенья' }, ]; const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 }; const qualityChecks = [ { name: 'routesWithoutStops', query: ` SELECT r.id AS route_id, r.route_number, r.name AS route_name, COUNT(s.id)::integer AS active_stops_count FROM routes r LEFT JOIN route_stops rs ON rs.route_id = r.id LEFT JOIN stops s ON s.id = rs.stop_id AND s.is_active = true WHERE r.is_active = true GROUP BY r.id HAVING COUNT(s.id) < 2 ORDER BY r.route_number, r.name `, }, { name: 'missingSchedules', query: ` SELECT r.id AS route_id, r.route_number, r.name AS route_name, day_types.day_type FROM routes r CROSS JOIN (VALUES ('weekday'), ('saturday'), ('sunday')) AS day_types(day_type) LEFT JOIN schedules s ON s.route_id = r.id AND s.day_type = day_types.day_type AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE) AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE) WHERE r.is_active = true GROUP BY r.id, r.route_number, r.name, day_types.day_type HAVING COUNT(s.id) = 0 ORDER BY r.route_number, r.name, day_types.day_type `, }, { name: 'inactiveRouteStops', query: ` SELECT r.id AS route_id, r.route_number, r.name AS route_name, s.id AS stop_id, s.name AS stop_name FROM route_stops rs JOIN routes r ON r.id = rs.route_id AND r.is_active = true JOIN stops s ON s.id = rs.stop_id AND s.is_active = false ORDER BY r.route_number, r.name, rs.sequence `, }, { name: 'invalidStopOrder', query: ` SELECT r.id AS route_id, r.route_number, r.name AS route_name, COUNT(*)::integer AS stops_count, MIN(rs.sequence)::integer AS first_sequence, MAX(rs.sequence)::integer AS last_sequence FROM routes r JOIN route_stops rs ON rs.route_id = r.id WHERE r.is_active = true GROUP BY r.id HAVING MIN(rs.sequence) <> 1 OR MAX(rs.sequence) <> COUNT(*) OR COUNT(DISTINCT rs.sequence) <> COUNT(*) ORDER BY r.route_number, r.name `, }, { name: 'invalidTimeOffsets', query: ` SELECT r.id AS route_id, r.route_number, r.name AS route_name, s.id AS stop_id, s.name AS stop_name, rs.sequence, rs.time_offset_minutes FROM route_stops rs JOIN routes r ON r.id = rs.route_id AND r.is_active = true JOIN stops s ON s.id = rs.stop_id WHERE rs.time_offset_minutes < 0 OR (rs.sequence = 1 AND rs.time_offset_minutes <> 0) ORDER BY r.route_number, r.name, rs.sequence `, }, { name: 'overlappingSchedules', query: ` SELECT newer.route_id, r.route_number, r.name AS route_name, newer.day_type, newer.id AS schedule_id, older.id AS conflicting_schedule_id FROM schedules newer JOIN schedules older ON older.route_id = newer.route_id AND older.day_type = newer.day_type AND older.id < newer.id AND daterange( COALESCE(older.valid_from, '-infinity'::date), COALESCE(older.valid_until, 'infinity'::date), '[]' ) && daterange( COALESCE(newer.valid_from, '-infinity'::date), COALESCE(newer.valid_until, 'infinity'::date), '[]' ) JOIN routes r ON r.id = newer.route_id AND r.is_active = true ORDER BY r.route_number, r.name, newer.day_type, newer.id `, }, { name: 'invalidSchedulePeriods', query: ` SELECT s.id AS schedule_id, s.route_id, r.route_number, r.name AS route_name, s.day_type FROM schedules s JOIN routes r ON r.id = s.route_id AND r.is_active = true WHERE s.valid_from IS NOT NULL AND s.valid_until IS NOT NULL AND s.valid_until < s.valid_from ORDER BY r.route_number, r.name, s.day_type, s.id `, }, { name: 'emptySchedules', query: ` SELECT s.id AS schedule_id, s.route_id, r.route_number, r.name AS route_name, s.day_type FROM schedules s JOIN routes r ON r.id = s.route_id AND r.is_active = true WHERE COALESCE(cardinality(s.departure_times), 0) = 0 AND COALESCE(jsonb_array_length(s.trips), 0) = 0 ORDER BY r.route_number, r.name, s.day_type, s.id `, }, { name: 'unusedStops', query: ` SELECT s.id AS stop_id, s.name AS stop_name FROM stops s WHERE s.is_active = true AND NOT EXISTS ( SELECT 1 FROM route_stops rs JOIN routes r ON r.id = rs.route_id AND r.is_active = true WHERE rs.stop_id = s.id ) ORDER BY s.name `, }, { name: 'expiredAlerts', query: ` SELECT a.id AS alert_id, a.title FROM alerts a WHERE a.is_active = true AND a.end_time IS NOT NULL AND a.end_time < CURRENT_TIMESTAMP ORDER BY a.end_time DESC `, }, ]; const dayTypeLabel = (value) => DAY_TYPES.find((dayType) => dayType.value === value)?.label || value; function routeLabel(row) { return `№${row.route_number} · ${row.route_name}`; } function routeAction(routeId, label) { return { to: `/routes/${routeId}/edit`, label }; } function scheduleAction(routeId) { return { to: `/schedules?route=${routeId}`, label: 'Открыть расписания' }; } function createIssue({ code, severity, title, description, entity, action }) { return { id: `${code}:${entity.type}:${entity.id}`, code, severity, title, description, entity, action }; } function toIssues(rowsByCheck) { const issues = []; for (const row of rowsByCheck.routesWithoutStops || []) { issues.push(createIssue({ code: 'route_without_stops', severity: 'critical', title: 'Маршрут не готов к работе', description: `${routeLabel(row)}: активных остановок — ${row.active_stops_count}. Для направления нужны как минимум две.`, entity: { type: 'route', id: row.route_id, label: routeLabel(row) }, action: routeAction(row.route_id, 'Настроить остановки'), })); } for (const row of rowsByCheck.missingSchedules || []) { issues.push(createIssue({ code: 'missing_schedule', severity: 'critical', title: 'Нет действующего расписания', description: `${routeLabel(row)}: не задана версия для ${dayTypeLabel(row.day_type)}.`, entity: { type: 'route-day-type', id: `${row.route_id}:${row.day_type}`, label: routeLabel(row) }, action: scheduleAction(row.route_id), })); } for (const row of rowsByCheck.inactiveRouteStops || []) { issues.push(createIssue({ code: 'inactive_stop_in_route', severity: 'warning', title: 'В маршруте неактивная остановка', description: `${routeLabel(row)}: «${row.stop_name}» исключена из данных, которые получают пассажиры.`, entity: { type: 'route-stop', id: `${row.route_id}:${row.stop_id}`, label: row.stop_name }, action: routeAction(row.route_id, 'Проверить маршрут'), })); } for (const row of rowsByCheck.invalidStopOrder || []) { issues.push(createIssue({ code: 'invalid_stop_order', severity: 'warning', title: 'Нарушен порядок остановок', description: `${routeLabel(row)}: порядковые номера должны идти без пропусков от 1 до ${row.stops_count}.`, entity: { type: 'route', id: row.route_id, label: routeLabel(row) }, action: routeAction(row.route_id, 'Исправить порядок'), })); } for (const row of rowsByCheck.invalidTimeOffsets || []) { issues.push(createIssue({ code: 'invalid_time_offset', severity: 'warning', title: 'Проверьте время проезда', description: `${routeLabel(row)}: у остановки «${row.stop_name}» задано смещение ${row.time_offset_minutes} мин. Первая остановка должна иметь 0, остальные — неотрицательное значение.`, entity: { type: 'route-stop', id: `${row.route_id}:${row.stop_id}:${row.sequence}`, label: row.stop_name }, action: routeAction(row.route_id, 'Исправить время'), })); } for (const row of rowsByCheck.overlappingSchedules || []) { issues.push(createIssue({ code: 'overlapping_schedules', severity: 'critical', title: 'Пересекаются версии расписания', description: `${routeLabel(row)}: версии №${row.schedule_id} и №${row.conflicting_schedule_id} одновременно действуют для ${dayTypeLabel(row.day_type)}.`, entity: { type: 'schedule-pair', id: `${row.schedule_id}:${row.conflicting_schedule_id}`, label: routeLabel(row) }, action: scheduleAction(row.route_id), })); } for (const row of rowsByCheck.invalidSchedulePeriods || []) { issues.push(createIssue({ code: 'invalid_schedule_period', severity: 'critical', title: 'Некорректный период расписания', description: `${routeLabel(row)}: у версии №${row.schedule_id} дата окончания раньше даты начала.`, entity: { type: 'schedule', id: row.schedule_id, label: routeLabel(row) }, action: scheduleAction(row.route_id), })); } for (const row of rowsByCheck.emptySchedules || []) { issues.push(createIssue({ code: 'empty_schedule', severity: 'critical', title: 'Пустое расписание', description: `${routeLabel(row)}: в версии №${row.schedule_id} для ${dayTypeLabel(row.day_type)} нет ни одного рейса.`, entity: { type: 'schedule', id: row.schedule_id, label: routeLabel(row) }, action: scheduleAction(row.route_id), })); } for (const row of rowsByCheck.unusedStops || []) { issues.push(createIssue({ code: 'unused_stop', severity: 'info', title: 'Остановка не используется', description: `«${row.stop_name}» не привязана ни к одному активному маршруту.`, entity: { type: 'stop', id: row.stop_id, label: row.stop_name }, action: { to: '/stops', label: 'Открыть остановки' }, })); } for (const row of rowsByCheck.expiredAlerts || []) { issues.push(createIssue({ code: 'expired_alert', severity: 'warning', title: 'Истёкшее уведомление активно', description: `«${row.title}» уже завершилось, но продолжает быть помечено активным.`, entity: { type: 'alert', id: row.alert_id, label: row.title }, action: { to: '/alerts', label: 'Открыть уведомления' }, })); } return issues.sort((left, right) => SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity] || left.title.localeCompare(right.title, 'ru') || left.id.localeCompare(right.id) ); } function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) { const issues = toIssues(rowsByCheck); const summary = issues.reduce((result, issue) => { result[issue.severity] += 1; result.total += 1; return result; }, { total: 0, critical: 0, warning: 0, info: 0 }); return { checked_at: checkedAt, summary, issues }; } function withCityScope(query, cityId) { if (!cityId) return { query, params: [] }; return { query: query .replaceAll('r.is_active = true', 'r.is_active = true AND r.city_id = $1') .replace('WHERE s.is_active = true', 'WHERE s.is_active = true AND s.city_id = $1') .replace('WHERE a.is_active = true', 'WHERE a.is_active = true AND a.city_id = $1'), params: [cityId] }; } async function runQualityChecks(pool, cityId = null) { const results = await Promise.all(qualityChecks.map(async ({ name, query }) => { const scoped = withCityScope(query, cityId); return [name, (await pool.query(scoped.query, scoped.params)).rows]; })); return buildQualityReport(Object.fromEntries(results)); } module.exports = { buildQualityReport, qualityChecks, runQualityChecks, withCityScope };