Files
ridebus-admin/backend/src/services/qualityReport.js
Zakhar Timoshenko d13cc92d82 feat: implement multi-city support with city management features
- Added a new cities table to the database to manage multiple cities independently.
- Created a middleware to resolve city context based on X-City-Id header.
- Updated routes and services to handle city-specific data for routes, stops, and alerts.
- Implemented a CityContext in the frontend to manage active city state and provide city data across the application.
- Added a CitySelector component for users to switch between cities.
- Created a CitiesPage for admins to manage cities, including adding new cities with validation.
- Updated various components and pages to reflect the active city context, ensuring data is displayed correctly based on the selected city.
- Enhanced the schedule import functionality to restrict imports based on the active city.
2026-09-17 19:41:17 +03:00

300 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };