- 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.
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
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 };
|