













+

+
+
diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..75c4f55 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +logs +test +.env +.env.* +*.log +.DS_Store diff --git a/backend/migrations/008_schedule_imports.sql b/backend/migrations/008_schedule_imports.sql new file mode 100644 index 0000000..0c774b7 --- /dev/null +++ b/backend/migrations/008_schedule_imports.sql @@ -0,0 +1,24 @@ +-- Import previews are immutable snapshots; applying one is an explicit admin action. +CREATE TABLE IF NOT EXISTS schedule_imports ( + id UUID PRIMARY KEY, + route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, + source_url TEXT NOT NULL, + source_hash TEXT NOT NULL, + source JSONB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('inspection', 'ready', 'applied')), + payload JSONB, + created_by INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + applied_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_schedule_imports_created ON schedule_imports(created_at DESC); + +CREATE TABLE IF NOT EXISTS schedule_import_news ( + url TEXT PRIMARY KEY, + title TEXT NOT NULL, + published_date TEXT, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + reviewed_hash TEXT, + checked_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/009_alert_scheduling.sql b/backend/migrations/009_alert_scheduling.sql new file mode 100644 index 0000000..1f05350 --- /dev/null +++ b/backend/migrations/009_alert_scheduling.sql @@ -0,0 +1,19 @@ +-- Delivery state is separate from visibility. A scheduled alert becomes visible +-- at start_time; this state makes sure its push notification is sent once. +ALTER TABLE alerts + ADD COLUMN IF NOT EXISTS notification_sent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS notification_claimed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS notification_attempts INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_alerts_pending_notification + ON alerts (start_time) + WHERE is_active = true AND notification_sent_at IS NULL; + +-- Earlier versions sent every active alert immediately on creation. Mark those +-- already-created future alerts as dispatched, so upgrading does not notify +-- passengers a second time when the scheduler starts. +UPDATE alerts +SET notification_sent_at = created_at +WHERE notification_sent_at IS NULL + AND is_active = true + AND start_time > created_at; diff --git a/backend/package.json b/backend/package.json index e998cb5..ea6d6d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "dependencies": { "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", + "cheerio": "^1.2.0", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", diff --git a/backend/src/controllers/qualityController.js b/backend/src/controllers/qualityController.js new file mode 100644 index 0000000..021c78f --- /dev/null +++ b/backend/src/controllers/qualityController.js @@ -0,0 +1,21 @@ +const logger = require('../config/logger'); +const { runQualityChecks } = require('../services/qualityReport'); + +class QualityController { + constructor({ pool, report = runQualityChecks } = {}) { + this.pool = pool || require('../config/database'); + this.report = report; + } + + async getReport(req, res) { + try { + res.json(await this.report(this.pool)); + } catch (error) { + logger.error('Error building quality report', { message: error.message }); + res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' }); + } + } +} + +module.exports = new QualityController(); +module.exports.QualityController = QualityController; diff --git a/backend/src/controllers/schedulesController.js b/backend/src/controllers/schedulesController.js index 5c973b1..bd551ab 100644 --- a/backend/src/controllers/schedulesController.js +++ b/backend/src/controllers/schedulesController.js @@ -277,7 +277,7 @@ class SchedulesController { // Copy schedule from one day type to another async copySchedule(req, res) { const { routeId } = req.params; - const { fromDayType, toDayType } = req.body; + const { fromDayType, toDayType, sourceScheduleId } = req.body; if (!fromDayType || !toDayType) { return res.status(400).json({ @@ -289,11 +289,14 @@ class SchedulesController { try { // Get source schedule const source = await pool.query(` - SELECT departure_times, trips FROM schedules + SELECT departure_times, trips, valid_from, valid_until FROM schedules WHERE route_id = $1 AND day_type = $2 - ORDER BY created_at DESC + AND ($3::integer IS NULL OR id = $3) + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + ORDER BY CASE WHEN valid_from IS NULL OR valid_from <= CURRENT_DATE THEN 0 ELSE 1 END, + valid_from DESC NULLS LAST, created_at DESC LIMIT 1 - `, [routeId, fromDayType]); + `, [routeId, fromDayType, sourceScheduleId || null]); if (source.rows.length === 0) { return res.status(404).json({ @@ -307,34 +310,42 @@ class SchedulesController { const existing = await pool.query(` SELECT id FROM schedules WHERE route_id = $1 AND day_type = $2 + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) `, [routeId, toDayType]); + if (existing.rows.length > 1) { + return res.status(409).json({ error: 'У типа дня несколько версий расписания. Измените нужную версию вручную.' }); + } + if (existing.rows.length > 0) { // Update existing const result = await pool.query(` UPDATE schedules - SET departure_times = $1, trips = $2::jsonb, updated_at = CURRENT_TIMESTAMP - WHERE route_id = $3 AND day_type = $4 + SET departure_times = $1, trips = $2::jsonb, valid_from = $3, valid_until = $4, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 RETURNING * `, [ sourceSchedule.departure_times, JSON.stringify(sourceSchedule.trips), - routeId, - toDayType + sourceSchedule.valid_from, + sourceSchedule.valid_until, + existing.rows[0].id ]); res.json(this.withScheduleTrips(result.rows[0])); } else { // Create new const result = await pool.query(` - INSERT INTO schedules (route_id, day_type, departure_times, trips) - VALUES ($1, $2, $3, $4::jsonb) + INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until) + VALUES ($1, $2, $3, $4::jsonb, $5, $6) RETURNING * `, [ routeId, toDayType, sourceSchedule.departure_times, - JSON.stringify(sourceSchedule.trips) + JSON.stringify(sourceSchedule.trips), + sourceSchedule.valid_from, + sourceSchedule.valid_until ]); res.status(201).json(this.withScheduleTrips(result.rows[0])); diff --git a/backend/src/index.js b/backend/src/index.js index f1ed475..3cd8be3 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -12,10 +12,12 @@ const createRateLimiter = require('./middleware/rateLimiter'); const { ensureRealtimeSchema } = require('./services/realtimeSchema'); const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService'); const { ensureBootstrapAdmin } = require('./utils/bootstrapAdmin'); +const { startAlertScheduler } = require('./services/alertSchedulerService'); const app = express(); const PORT = process.env.PORT || 3000; let server; +let stopAlertScheduler = () => {}; // Security middleware app.use(helmet({ @@ -121,6 +123,7 @@ function shutdown(signal) { logger.info(`${signal} signal received: closing HTTP server`); closeRealtimeServer(); + stopAlertScheduler(); if (!server) { process.exit(0); @@ -160,6 +163,7 @@ async function startServer() { await waitForDatabase(); await ensureRealtimeSchema(); await ensureBootstrapAdmin(); + stopAlertScheduler = startAlertScheduler(); server = app.listen(PORT, () => { logger.info(`Server is running on port ${PORT}`); diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js index 6c74bff..5f10fad 100644 --- a/backend/src/routes/alerts.js +++ b/backend/src/routes/alerts.js @@ -3,6 +3,17 @@ const router = express.Router(); const pool = require('../config/database'); const logger = require('../config/logger'); const { getAlertCreationService } = require('../services/alertCreationService'); +const { listAlertTemplates } = require('../services/alertTemplates'); + +function validateAlertTimes({ start_time, end_time }) { + const start = new Date(start_time); + if (Number.isNaN(start.getTime())) return 'Укажите корректное время начала.'; + if (!end_time) return null; + const end = new Date(end_time); + if (Number.isNaN(end.getTime())) return 'Укажите корректное время окончания.'; + if (end <= start) return 'Время окончания должно быть позже времени начала.'; + return null; +} // Get all alerts (with optional filters) router.get('/', async (req, res) => { @@ -76,6 +87,10 @@ router.get('/active', async (req, res) => { } }); +router.get('/templates', (req, res) => { + res.json(listAlertTemplates()); +}); + // Get alert by ID router.get('/:id', async (req, res) => { try { @@ -109,6 +124,9 @@ function createAlertHandler({ alertCreationService = null } = {}) { return res.status(400).json({ error: 'Missing required fields' }); } + const timeError = validateAlertTimes({ start_time, end_time }); + if (timeError) return res.status(400).json({ error: timeError }); + const validTypes = ['delay', 'cancellation', 'detour', 'info']; if (!validTypes.includes(alert_type)) { return res.status(400).json({ error: 'Invalid alert_type' }); @@ -145,7 +163,7 @@ router.put('/:id', async (req, res) => { const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body; // Check if alert exists - const checkResult = await pool.query('SELECT id FROM alerts WHERE id = $1', [id]); + const checkResult = await pool.query('SELECT * FROM alerts WHERE id = $1', [id]); if (checkResult.rows.length === 0) { return res.status(404).json({ error: 'Alert not found' }); } @@ -158,6 +176,12 @@ router.put('/:id', async (req, res) => { } } + const timeError = validateAlertTimes({ + start_time: start_time || checkResult.rows[0].start_time, + end_time: end_time === undefined ? checkResult.rows[0].end_time : end_time + }); + if (timeError) return res.status(400).json({ error: timeError }); + const result = await pool.query( `UPDATE alerts SET route_id = COALESCE($1, route_id), diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js index 9b3bef8..d9eee59 100644 --- a/backend/src/routes/api.js +++ b/backend/src/routes/api.js @@ -12,6 +12,11 @@ const syncController = require('../controllers/syncController'); const telemetryController = require('../controllers/telemetryController'); const passengerTelemetryController = require('../controllers/passengerTelemetryController'); const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const qualityController = require('../controllers/qualityController'); + +router.use('/schedule-import', require('./scheduleImport')); +router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController)); // Routes router.get('/routes', routesController.getAllRoutes.bind(routesController)); diff --git a/backend/src/routes/scheduleImport.js b/backend/src/routes/scheduleImport.js new file mode 100644 index 0000000..44bfa0d --- /dev/null +++ b/backend/src/routes/scheduleImport.js @@ -0,0 +1,29 @@ +const express = require('express'); +const pool = require('../config/database'); +const redis = require('../config/redis'); +const logger = require('../config/logger'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const { ScheduleImportService } = require('../services/scheduleImportService'); + +const service = new ScheduleImportService({ pool, invalidate: async routeId => { + if (!redis.isReady) throw new Error('Redis unavailable'); + await redis.del([`route:${routeId}`, 'routes:all']); +} }); +const router = express.Router(); +router.use(authenticateToken, requireAdmin); +const handle = action => async (req, res) => { + try { res.json(await action(req)); } catch (error) { + logger.error('Schedule import failed', { message: error.message }); + res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось выполнить импорт. Попробуйте ещё раз.' }); + } +}; +router.get('/catalog', handle(() => service.catalog())); +router.post('/inspect', handle(req => service.inspect({ ...req.body, userId: req.user.id }))); +router.post('/preview', handle(req => service.preview({ ...req.body, userId: req.user.id }))); +router.post('/:id/apply', handle(req => service.apply(req.params.id))); +router.get('/history', handle(() => service.history())); +router.get('/news', handle(() => service.news())); +router.post('/news/refresh', handle(() => service.news(true))); +router.post('/news/review', handle(req => service.reviewNews(req.body))); + +module.exports = router; diff --git a/backend/src/services/alertCreationService.js b/backend/src/services/alertCreationService.js index 5e60fa0..558c3b8 100644 --- a/backend/src/services/alertCreationService.js +++ b/backend/src/services/alertCreationService.js @@ -96,6 +96,24 @@ function parseAlertSnapshot(value) { return value; } +function isAlertDue(alert, now = new Date()) { + if (alert.is_active === false) { + return false; + } + + const startTime = new Date(alert.start_time); + if (Number.isNaN(startTime.getTime()) || startTime > now) { + return false; + } + + if (!alert.end_time) { + return true; + } + + const endTime = new Date(alert.end_time); + return !Number.isNaN(endTime.getTime()) && endTime >= now; +} + class AlertCreationService { constructor({ pool, notificationService, logger }) { this.pool = pool; @@ -110,7 +128,7 @@ class AlertCreationService { ? await this.createIdempotentAlert(alertInput, idempotencyKey) : await this.insertAlert(this.pool, alertInput); - if (result.created && result.alert.is_active !== false) { + if (result.created && isAlertDue(result.alert)) { await this.sendNotification(result.notificationAlert); } @@ -263,5 +281,6 @@ module.exports = { normalizeAlertInput, normalizeIdempotencyKey, parseAlertSnapshot, + isAlertDue, setAlertCreationServiceForTesting }; diff --git a/backend/src/services/alertSchedulerService.js b/backend/src/services/alertSchedulerService.js new file mode 100644 index 0000000..faaf30f --- /dev/null +++ b/backend/src/services/alertSchedulerService.js @@ -0,0 +1,109 @@ +const CLAIM_DUE_ALERTS_SQL = ` + WITH due AS ( + SELECT a.id + FROM alerts a + WHERE a.is_active = true + AND a.start_time > a.created_at + AND a.start_time <= CURRENT_TIMESTAMP + AND (a.end_time IS NULL OR a.end_time >= CURRENT_TIMESTAMP) + AND a.notification_sent_at IS NULL + AND a.notification_attempts < 5 + AND ( + a.notification_claimed_at IS NULL + OR a.notification_claimed_at < CURRENT_TIMESTAMP - INTERVAL '5 minutes' + ) + ORDER BY a.start_time ASC, a.id ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE alerts a + SET notification_claimed_at = CURRENT_TIMESTAMP, + notification_attempts = a.notification_attempts + 1 + FROM due + WHERE a.id = due.id + RETURNING a.*, ( + SELECT r.route_number + FROM routes r + WHERE r.id = a.route_id + ) AS notification_route_number +`; + +class AlertSchedulerService { + constructor({ pool, notificationService, logger, batchSize = 50 } = {}) { + this.pool = pool || require('../config/database'); + this.notificationService = notificationService || require('./alertNotificationService').getAlertNotificationService(); + this.logger = logger || require('../config/logger'); + this.batchSize = batchSize; + this.running = false; + } + + async dispatchDueAlerts() { + if (this.running || !this.notificationService.enabled) { + return { dispatched: 0, skipped: this.running ? 'running' : 'disabled' }; + } + + this.running = true; + try { + const claimed = await this.pool.query(CLAIM_DUE_ALERTS_SQL, [this.batchSize]); + let dispatched = 0; + + for (const row of claimed.rows) { + const { notification_route_number: routeNumber, ...alert } = row; + try { + const result = await this.notificationService.sendAlertCreated({ + ...alert, + route_number: routeNumber || '' + }); + + if (result.sent) { + await this.pool.query( + `UPDATE alerts + SET notification_sent_at = CURRENT_TIMESTAMP, + notification_claimed_at = NULL + WHERE id = $1`, + [alert.id] + ); + dispatched += 1; + } else { + await this.releaseClaim(alert.id); + } + } catch (error) { + await this.releaseClaim(alert.id); + this.logger.error('Failed to dispatch scheduled alert notification', { + alertId: String(alert.id), + reason: error.message || 'Unknown notification error', + firebaseCode: error.code || 'unknown' + }); + } + } + + return { dispatched, claimed: claimed.rows.length }; + } finally { + this.running = false; + } + } + + async releaseClaim(alertId) { + await this.pool.query( + `UPDATE alerts + SET notification_claimed_at = NULL + WHERE id = $1 AND notification_sent_at IS NULL`, + [alertId] + ); + } +} + +function startAlertScheduler({ intervalMs = 30_000, service } = {}) { + const scheduler = service || new AlertSchedulerService(); + const run = () => scheduler.dispatchDueAlerts().catch((error) => { + scheduler.logger.error('Scheduled alert delivery cycle failed', { + reason: error.message || 'Unknown scheduler error' + }); + }); + run(); + const timer = setInterval(run, intervalMs); + timer.unref?.(); + return () => clearInterval(timer); +} + +module.exports = { AlertSchedulerService, CLAIM_DUE_ALERTS_SQL, startAlertScheduler }; diff --git a/backend/src/services/alertTemplates.js b/backend/src/services/alertTemplates.js new file mode 100644 index 0000000..5b49b94 --- /dev/null +++ b/backend/src/services/alertTemplates.js @@ -0,0 +1,40 @@ +const ALERT_TEMPLATES = Object.freeze([ + Object.freeze({ + id: 'delay', + label: 'Задержка', + description: 'Сообщить о задержке движения', + alert_type: 'delay', + title: 'Задержка движения', + message: 'На маршруте возможна задержка до 15 минут. Приносим извинения за неудобства.' + }), + Object.freeze({ + id: 'cancellation', + label: 'Отмена рейса', + description: 'Сообщить об отмене или переносе', + alert_type: 'cancellation', + title: 'Отмена рейса', + message: 'Один из рейсов временно отменён. Пожалуйста, выберите ближайшее подходящее отправление.' + }), + Object.freeze({ + id: 'detour', + label: 'Изменение маршрута', + description: 'Предупредить об объезде', + alert_type: 'detour', + title: 'Временное изменение маршрута', + message: 'Маршрут временно следует по изменённой схеме. Уточните детали ниже перед публикацией.' + }), + Object.freeze({ + id: 'info', + label: 'Информация', + description: 'Нейтральное сообщение пассажирам', + alert_type: 'info', + title: 'Информация для пассажиров', + message: 'Пожалуйста, ознакомьтесь с важной информацией о работе транспорта.' + }) +]); + +function listAlertTemplates() { + return ALERT_TEMPLATES.map((template) => ({ ...template })); +} + +module.exports = { ALERT_TEMPLATES, listAlertTemplates }; diff --git a/backend/src/services/qualityReport.js b/backend/src/services/qualityReport.js new file mode 100644 index 0000000..238828b --- /dev/null +++ b/backend/src/services/qualityReport.js @@ -0,0 +1,285 @@ +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 }; +} + +async function runQualityChecks(pool) { + const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows])); + return buildQualityReport(Object.fromEntries(results)); +} + +module.exports = { buildQualityReport, qualityChecks, runQualityChecks }; diff --git a/backend/src/services/scheduleImportService.js b/backend/src/services/scheduleImportService.js new file mode 100644 index 0000000..b858015 --- /dev/null +++ b/backend/src/services/scheduleImportService.js @@ -0,0 +1,300 @@ +const { randomUUID } = require('node:crypto'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { getLocalDateTime } = require('../utils/etaTime'); +const { HOME_URL, CATALOG_URL, hash, normalizeName, normalizeNumber, sourceError, + safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews } = require('./scheduleSource'); + +const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' }; +const tripKey = trip => JSON.stringify({ + departureTime: trip.departureTime?.length === 5 ? `${trip.departureTime}:00` : trip.departureTime, + startStopId: trip.startStopId || null, endStopId: trip.endStopId || null, + viaStopIds: trip.viaStopIds || [], daysOfWeek: trip.daysOfWeek || [], + exceptDaysOfWeek: trip.exceptDaysOfWeek || [], note: trip.note || '' +}); +const blankTrip = (time, startStopId = null, endStopId = null) => ({ + departureTime: time, startStopId, endStopId, viaStopIds: [], daysOfWeek: [], exceptDaysOfWeek: [], note: '' +}); + +function compareTrips(before, after) { + // Multisets preserve duplicate departures in legacy data in the removal preview. + const remaining = before.map(trip => ({ trip, key: tripKey(trip) })); + const added = []; + let unchanged = 0; + for (const trip of after) { + const index = remaining.findIndex(item => item.key === tripKey(trip)); + if (index === -1) added.push(trip); + else { remaining.splice(index, 1); unchanged += 1; } + } + return { added, removed: remaining.map(item => item.trip), unchanged }; +} + +function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTime().date) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom || '') + || Number.isNaN(Date.parse(validFrom)) || new Date(validFrom).toISOString().slice(0, 10) !== validFrom + || validFrom < today) throw sourceError('Выберите дату начала не раньше сегодняшнего дня.'); + if (source.warnings.length) throw sourceError('В источнике есть нераспознанные условия. Автоматический импорт заблокирован.'); + if (normalizeNumber(source.number) !== normalizeNumber(snapshot.route.route_number)) { + throw sourceError('Номер маршрута в базе не совпадает с номером на сайте.'); + } + const stops = new Set(snapshot.stops.map(stop => Number(stop.stop_id))); + const groups = {}; + const notices = new Set(); + for (const row of source.rows) { + if (!mappings || !Object.hasOwn(mappings, row.stopName)) throw sourceError(`Укажите назначение строки «${row.stopName}».`); + const mapping = mappings[row.stopName]; + for (const day of row.days) groups[day] ||= []; + if (mapping?.mode === 'checkpoint') continue; + if (mapping?.mode !== 'departure') throw sourceError(`Укажите назначение строки «${row.stopName}».`); + const start = Number(mapping.startStopId); + const end = Number(mapping.endStopId); + if (!stops.has(start) || !stops.has(end) || start === end) { + throw sourceError(`Для «${row.stopName}» выберите разные начальную и конечную остановки этого маршрута.`); + } + const departures = row.departures || row.times.map(departureTime => ({ departureTime })); + for (const departure of departures) { + const trip = { ...blankTrip(departure.departureTime, start, end), + daysOfWeek: departure.daysOfWeek || [], exceptDaysOfWeek: departure.exceptDaysOfWeek || [] }; + const unresolved = []; + for (const name of departure.viaStopNames || []) { + const matches = snapshot.stops.filter(stop => normalizeName(stop.name) === normalizeName(name)); + if (matches.length === 1) trip.viaStopIds.push(Number(matches[0].stop_id)); + else { + unresolved.push(name); + notices.add(`«${name}»: ${matches.length ? 'найдено несколько остановок с этим именем' : 'нет в остановках направления'}. Условие «через» сохранится текстовым примечанием без привязки к остановке.`); + } + } + trip.note = unresolved.map(name => `через «${name}»`).join('; '); + let included = false; + for (const day of row.days) { + const activeDays = { weekday: [1, 2, 3, 4, 5], saturday: [6], sunday: [7] }[day]; + if (!activeDays.some(value => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(value)) && !trip.exceptDaysOfWeek.includes(value))) continue; + groups[day].push(trip); + included = true; + } + if (!included) throw sourceError(`${departure.departureTime.slice(0, 5)}: условие по дням противоречит разделу «${row.days.map(day => DAY_LABELS[day]).join(', ')}». Проверьте источник.`); + } + } + const changes = []; + for (const [dayType, trips] of Object.entries(groups)) { + if (!trips.length) throw sourceError(`${DAY_LABELS[dayType]}: выберите хотя бы одну строку отправлений.`); + if (trips.length > 2000 || new Set(trips.map(tripKey)).size !== trips.length) { + throw sourceError('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.'); + } + trips.sort((a, b) => a.departureTime.localeCompare(b.departureTime) || a.startStopId - b.startStopId); + const candidates = snapshot.schedules.filter(s => s.day_type === dayType && (!s.valid_until || s.valid_until >= validFrom)); + if (candidates.length > 1 || candidates.some(s => s.valid_from && s.valid_from > validFrom)) { + throw sourceError(`${DAY_LABELS[dayType]}: есть пересекающиеся или будущие расписания. Сначала проверьте их в разделе «Расписания».`); + } + const current = candidates[0]; + const before = current?.trips?.length ? current.trips : (current?.departure_times || []).map(time => blankTrip(time)); + changes.push({ dayType, label: DAY_LABELS[dayType], existingId: current?.id || null, + previousValidFrom: current?.valid_from || null, validFrom, validUntil: current?.valid_until || null, + before, after: trips, ...compareTrips(before, trips) }); + } + return { changes, mappings, validFrom, notices: [...notices], before: snapshot, beforeHash: hash(snapshot), + hasChanges: changes.some(change => change.added.length || change.removed.length) }; +} + +function departureStops(source) { + const names = [...new Set(source.rows.map(row => row.stopName))]; + const origins = names.filter(name => source.directions.some(direction => normalizeName(direction.stops[0]) === normalizeName(name))); + return origins.length ? origins : names; +} + +function buildDirectionPlan(source, sourceStopName, snapshot, validFrom, today) { + if (!departureStops(source).includes(sourceStopName)) throw sourceError('Выберите строку отправлений для каждого направления.'); + const first = snapshot.stops[0]; + const last = snapshot.stops.at(-1); + if (!first || !last || first.stop_id === last.stop_id) { + throw sourceError(`В маршруте «${snapshot.route.name}» должны быть заданы разные первая и последняя остановки.`); + } + const rows = source.rows.filter(row => row.stopName === sourceStopName); + const mappings = { [sourceStopName]: { mode: 'departure', startStopId: first.stop_id, endStopId: last.stop_id } }; + return { ...buildPlan({ ...source, rows }, mappings, snapshot, validFrom, today), + routeId: snapshot.route.id, route: snapshot.route, stops: snapshot.stops, sourceStopName }; +} + +class ScheduleImportService { + constructor({ pool, load = fetchHtml, invalidate = async () => {} }) { + this.pool = pool; + this.load = load; + this.invalidate = invalidate; + this.schemaPromise = null; + this.catalogCache = null; + } + + ensureSchema() { + this.schemaPromise ||= fs.readFile(path.join(__dirname, '../../migrations/008_schedule_imports.sql'), 'utf8') + .then(sql => this.pool.query(sql)).catch(error => { this.schemaPromise = null; throw error; }); + return this.schemaPromise; + } + + async catalog() { + if (this.catalogCache && this.catalogCache.expires > Date.now()) return this.catalogCache.value; + const page = await this.load(CATALOG_URL); + const value = parseCatalog(page.html); + this.catalogCache = { value, expires: Date.now() + 5 * 60000 }; + return value; + } + + async snapshot(routeId, db = this.pool) { + const route = (await db.query('SELECT id, route_number, name FROM routes WHERE id = $1', [routeId])).rows[0]; + if (!route) throw sourceError('Маршрут не найден.', 404); + const stops = (await db.query(`SELECT rs.stop_id, rs.sequence, s.name FROM route_stops rs + JOIN stops s ON s.id = rs.stop_id WHERE rs.route_id = $1 ORDER BY rs.sequence, rs.id`, [routeId])).rows; + const schedules = (await db.query(`SELECT id, day_type, departure_times, trips, + to_char(valid_from, 'YYYY-MM-DD') AS valid_from, to_char(valid_until, 'YYYY-MM-DD') AS valid_until, + updated_at::text FROM schedules WHERE route_id = $1 ORDER BY id`, [routeId])).rows; + return { route, stops, schedules }; + } + + async store({ routeId, source, status, payload = null, userId }) { + const id = randomUUID(); + await this.pool.query(`INSERT INTO schedule_imports + (id, route_id, source_url, source_hash, source, status, payload, created_by) + VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7::jsonb,$8)`, + [id, routeId, source.url, hash(source), JSON.stringify(source), status, JSON.stringify(payload), userId]); + return id; + } + + async getDraft(id, db = this.pool, lock = false) { + if (!/^[0-9a-f-]{36}$/i.test(id || '')) throw sourceError('Некорректный идентификатор импорта.', 400); + const draft = (await db.query(`SELECT * FROM schedule_imports WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, [id])).rows[0]; + if (!draft) throw sourceError('Предпросмотр не найден. Загрузите расписание заново.', 404); + if (draft.status !== 'applied' && Date.now() - new Date(draft.created_at).getTime() > 30 * 60000) { + throw sourceError('Предпросмотр устарел. Загрузите расписание заново.', 409); + } + return draft; + } + + async inspect({ routeIds, sourceUrl, userId }) { + await this.ensureSchema(); + if (!Array.isArray(routeIds) || !routeIds.length || routeIds.length > 30 + || routeIds.some(id => !Number.isInteger(Number(id)) || Number(id) <= 0) + || new Set(routeIds.map(Number)).size !== routeIds.length) throw sourceError('Выберите направления в админке.', 400); + routeIds = routeIds.map(Number).sort((a, b) => a - b); + const url = safeUrl(sourceUrl); + if (!(await this.catalog()).some(route => route.url === url)) throw sourceError('Выберите маршрут из списка сайта.'); + const page = await this.load(url); + const source = parseSchedule(page.html, page.url); + const snapshots = await Promise.all(routeIds.map(id => this.snapshot(id))); + if (snapshots.some(snapshot => normalizeNumber(source.number) !== normalizeNumber(snapshot.route.route_number))) { + throw sourceError('Все выбранные направления должны иметь тот же номер, что и маршрут на сайте.'); + } + const mappings = {}; + const origins = departureStops(source); + for (const snapshot of snapshots) { + const matches = origins.filter(name => normalizeName(name) === normalizeName(snapshot.stops[0]?.name)); + mappings[snapshot.route.id] = matches.length === 1 ? matches[0] : ''; + } + await this.pool.query("DELETE FROM schedule_imports WHERE status <> 'applied' AND created_at < now() - interval '7 days'"); + const id = await this.store({ routeId: null, source, status: 'inspection', payload: { version: 2, routeIds }, userId }); + return { id, source, mappings, directions: snapshots.map(({ route, stops }) => ({ route, stops })), + departureStops: origins, today: getLocalDateTime().date }; + } + + async preview({ inspectionId, mappings, validFrom, userId }) { + await this.ensureSchema(); + const inspection = await this.getDraft(inspectionId); + if (inspection.status !== 'inspection') throw sourceError('Загрузите исходную таблицу заново.', 409); + if (inspection.payload?.version !== 2) throw sourceError('Формат импорта обновлён. Загрузите таблицы заново.', 409); + const routeIds = inspection.payload.routeIds; + if (!mappings || Object.keys(mappings).length !== routeIds.length + || routeIds.some(id => !Object.hasOwn(mappings, id))) throw sourceError('Сопоставьте каждое выбранное направление.'); + const snapshots = await Promise.all(routeIds.map(id => this.snapshot(id))); + const plans = snapshots.map(snapshot => buildDirectionPlan(inspection.source, mappings[snapshot.route.id], snapshot, validFrom)); + const payload = { version: 2, plans, validFrom, hasChanges: plans.some(plan => plan.hasChanges) }; + const id = await this.store({ routeId: null, source: inspection.source, status: 'ready', payload, userId }); + return { id, ...payload, sourceUrl: inspection.source_url }; + } + + async apply(id) { + await this.ensureSchema(); + const draft = await this.getDraft(id); + if (draft.status === 'applied') return { id, applied: true, alreadyApplied: true }; + if (draft.payload?.version !== 2) throw sourceError('Формат импорта обновлён. Загрузите таблицы заново.', 409); + if (draft.status !== 'ready' || !draft.payload?.hasChanges) throw sourceError('Нет подготовленных изменений.', 409); + // A changed website invalidates the preview rather than silently importing a different table. + const page = await this.load(draft.source_url); + if (hash(parseSchedule(page.html, page.url)) !== draft.source_hash) throw sourceError('Расписание на сайте изменилось. Загрузите новый предпросмотр.', 409); + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const locked = await this.getDraft(id, client, true); + if (locked.status === 'applied') { + await client.query('COMMIT'); + return { id, applied: true, alreadyApplied: true }; + } + // Existing CRUD endpoints do not share an advisory lock; table locks also cover inserts. + await client.query('LOCK TABLE schedules, route_stops IN SHARE ROW EXCLUSIVE MODE'); + for (const plan of locked.payload.plans) { + const snapshot = await this.snapshot(plan.routeId, client); + if (hash(snapshot) !== plan.beforeHash) throw sourceError(`Направление «${plan.route.name}» изменилось после предпросмотра. Постройте его заново.`, 409); + } + if (locked.payload.validFrom < getLocalDateTime().date) throw sourceError('Дата начала уже прошла. Постройте новый предпросмотр.', 409); + for (const plan of locked.payload.plans) { + for (const change of plan.changes) { + if (!change.added.length && !change.removed.length) continue; + if (change.existingId && change.previousValidFrom === change.validFrom) { + await client.query(`UPDATE schedules SET departure_times = $1::time[], trips = $2::jsonb, + updated_at = CURRENT_TIMESTAMP WHERE id = $3`, + [change.after.map(trip => trip.departureTime), JSON.stringify(change.after), change.existingId]); + } else { + if (change.existingId) await client.query(`UPDATE schedules SET valid_until = $1::date - 1, + updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [change.validFrom, change.existingId]); + await client.query(`INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until) + VALUES ($1,$2,$3::time[],$4::jsonb,$5,$6)`, + [plan.routeId, change.dayType, change.after.map(trip => trip.departureTime), JSON.stringify(change.after), change.validFrom, change.validUntil]); + } + } + } + await client.query("UPDATE schedule_imports SET status = 'applied', applied_at = now() WHERE id = $1", [id]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { client.release(); } + let cacheWarning = null; + const cacheResults = await Promise.allSettled(draft.payload.plans.map(plan => this.invalidate(plan.routeId))); + if (cacheResults.some(result => result.status === 'rejected')) { + cacheWarning = 'Расписание сохранено, но кеш не удалось обновить. Данные в кеше обновятся после истечения его срока.'; + } + return { id, applied: true, cacheWarning }; + } + + async history() { + await this.ensureSchema(); + return (await this.pool.query(`SELECT i.id, i.source_url, i.applied_at, i.payload->>'validFrom' AS valid_from, + (SELECT jsonb_agg(p->'route') FROM jsonb_array_elements(COALESCE(i.payload->'plans', '[]'::jsonb)) p) AS directions, + r.route_number, r.name AS route_name FROM schedule_imports i LEFT JOIN routes r ON r.id = i.route_id + WHERE i.status = 'applied' ORDER BY i.applied_at DESC LIMIT 30`)).rows; + } + + async news(refresh = false) { + await this.ensureSchema(); + if (refresh) { + const page = await this.load(HOME_URL); + const items = parseNews(page.html); + for (const item of items) { + await this.pool.query(`INSERT INTO schedule_import_news (url,title,published_date,content,content_hash) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (url) DO UPDATE SET title = EXCLUDED.title, + published_date = EXCLUDED.published_date, content = EXCLUDED.content, + content_hash = EXCLUDED.content_hash, checked_at = now()`, + [item.url, item.title, item.publishedDate, item.content, item.contentHash]); + } + } + return (await this.pool.query(`SELECT *, (reviewed_hash = content_hash) IS TRUE AS reviewed + FROM schedule_import_news ORDER BY published_date DESC NULLS LAST, url DESC LIMIT 50`)).rows; + } + + async reviewNews({ url, contentHash }) { + await this.ensureSchema(); + const result = await this.pool.query(`UPDATE schedule_import_news SET reviewed_hash = content_hash + WHERE url = $1 AND content_hash = $2 RETURNING url`, [safeUrl(url), contentHash]); + if (!result.rows.length) throw sourceError('Новость изменилась. Обновите список перед проверкой.', 409); + return { reviewed: true }; + } +} + +module.exports = { ScheduleImportService, buildPlan, compareTrips, buildDirectionPlan, departureStops }; diff --git a/backend/src/services/scheduleSource.js b/backend/src/services/scheduleSource.js new file mode 100644 index 0000000..6476436 --- /dev/null +++ b/backend/src/services/scheduleSource.js @@ -0,0 +1,220 @@ +const cheerio = require('cheerio'); +const { createHash } = require('node:crypto'); + +const HOME_URL = 'https://ap2polotsk.of.by/ap2/'; +const CATALOG_URL = `${HOME_URL}rasp/rasp-polotsk/polotsk-gorod/`; +const clean = value => String(value || '').replace(/\s+/g, ' ').trim(); +// JSONB can reorder object keys, so content hashes must be canonical across DB round trips. +const canonical = value => Array.isArray(value) ? value.map(canonical) + : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; +const hash = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); +const normalizeName = value => clean(value).toLowerCase().replace(/ё/g, 'е').replace(/[«»"()]/g, '').replace(/\s*[–—-]\s*/g, '-').trim(); +const normalizeNumber = value => clean(value).toLowerCase().replace(/a/g, 'а').replace(/\s/g, ''); + +function sourceError(message, status = 422) { + return Object.assign(new Error(message), { status }); +} + +function safeUrl(value, base = HOME_URL) { + let url; + try { url = new URL(value, base); } catch { throw sourceError('Некорректная ссылка источника.'); } + if (!['http:', 'https:'].includes(url.protocol) || url.hostname !== 'ap2polotsk.of.by' + || url.port || url.username || url.password || !url.pathname.startsWith('/ap2/')) { + throw sourceError('Разрешены только страницы официального сайта автопарка.'); + } + url.protocol = 'https:'; + url.hash = ''; + return url.href; +} + +// Bound redirects, total response time and decoded body size. Never forward credentials. +async function fetchHtml(value, fetchImpl = fetch) { + let url = safeUrl(value); + const signal = AbortSignal.timeout(20000); + try { + for (let redirects = 0; redirects <= 5; redirects += 1) { + const response = await fetchImpl(url, { + redirect: 'manual', signal, + headers: { 'User-Agent': 'PolotskTransitAdmin/1.0', Accept: 'text/html' } + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + if (!response.headers.get('location')) throw sourceError('Пустое перенаправление источника.', 502); + url = safeUrl(response.headers.get('location'), url); + continue; + } + if (!response.ok) { + await response.body?.cancel(); + throw sourceError(`Сайт автопарка ответил HTTP ${response.status}. Попробуйте позже.`, 502); + } + if (!/text\/html|application\/xhtml\+xml/i.test(response.headers.get('content-type') || '')) { + await response.body?.cancel(); + throw sourceError('Источник вернул не HTML-страницу.', 502); + } + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > 2 * 1024 * 1024) throw sourceError('Страница источника слишком большая.', 502); + chunks.push(chunk); + } + return { url, html: Buffer.concat(chunks).toString('utf8') }; + } + throw sourceError('Слишком много перенаправлений источника.', 502); + } catch (error) { + if (error.status) throw error; + throw sourceError('Не удалось загрузить сайт автопарка. Проверьте доступ сервера к интернету и повторите попытку.', 502); + } +} + +function parseCatalog(html) { + const $ = cheerio.load(html); + const routes = new Map(); + $('.entry-content a[href]').each((_, node) => { + const a = $(node); + let url; + try { url = safeUrl(a.attr('href')); } catch { return; } + const match = new URL(url).pathname.match(/\/m-(\d+)([aаэ]?)(?:-\d+)?\/$/i); + if (!match) return; + const label = clean(a.text() || a.find('img').attr('alt')); + // The site's image for 8A is incorrectly labelled "8". + const number = match[2] ? `${match[1]}${match[2]}` : (/^\d+[аaэ]?$/i.test(label) ? label : match[1]); + routes.set(url, { url, number: normalizeNumber(number).toUpperCase() }); + }); + if (!routes.size) throw sourceError('На сайте не найден список городских маршрутов.'); + return [...routes.values()]; +} + +function dayTypes(text) { + const label = clean(text).toLowerCase(); + if (/^рабочи[йе] (день|дни)$|^будни[йе] (день|дни)$/.test(label)) return ['weekday']; + if (/^выходно[йе] (день|дни)$|^выходные дни$/.test(label)) return ['saturday', 'sunday']; + if (/^суббота$/.test(label)) return ['saturday']; + if (/^воскресенье$/.test(label)) return ['sunday']; + return null; +} + +const WEEKDAY_FORMS = [ + ['понедельник', 'понедельникам', 'понедельника'], ['вторник', 'вторникам', 'вторника'], + ['среда', 'средам', 'среды', 'среду'], ['четверг', 'четвергам', 'четверга'], + ['пятница', 'пятницам', 'пятницы', 'пятницу'], ['суббота', 'субботам', 'субботы', 'субботу'], + ['воскресенье', 'воскресеньям', 'воскресенья'], +]; + +function parseDeparture(value) { + const match = clean(value).match(/^([01]?\d|2[0-3]):([0-5]\d)(?:\s+(.+))?$/); + if (!match) return null; + const trip = { departureTime: `${match[1].padStart(2, '0')}:${match[2]}:00`, + viaStopNames: [], daysOfWeek: [], exceptDaysOfWeek: [] }; + let rest = match[3] || ''; + // Consume the entire annotation. Unknown suffixes must never silently lose conditions. + while (rest) { + const clause = rest.match(/^(через|(?:только\s+)?по|кроме)\s+(.+?)(?=\s+(?:через|(?:только\s+)?по|кроме)\s+|$)/i); + if (!clause) return null; + const kind = clause[1].toLowerCase(); + const content = clause[2]; + if (kind === 'через') { + // Quoted names allow punctuation; unquoted names must contain no unexplained syntax. + const quoted = content.match(/^(?:«([^«»]+)»|"([^"\n]+)"|“([^“”]+)”|„([^„“]+)“)$/); + const name = clean(quoted ? quoted.slice(1).find(Boolean) : content); + if (!name || (!quoted && !/^[а-яёa-z][а-яёa-z\s–—-]*$/i.test(name)) || trip.viaStopNames.length) return null; + trip.viaStopNames.push(name); + } else { + const field = kind === 'кроме' ? 'exceptDaysOfWeek' : 'daysOfWeek'; + if (trip[field].length) return null; + const words = content.toLowerCase().split(/\s*,\s*|\s+и\s+/); + const days = words.map(word => WEEKDAY_FORMS.findIndex(forms => forms.includes(word)) + 1); + if (!days.length || days.includes(0)) return null; + trip[field] = [...new Set(days)].sort((a, b) => a - b); + } + rest = clean(rest.slice(clause[0].length)); + } + if (trip.daysOfWeek.some(day => trip.exceptDaysOfWeek.includes(day))) return null; + return trip; +} + +function parseSchedule(html, url) { + const $ = cheerio.load(html); + const content = $('.entry-content').first(); + const title = clean($('.entry-title').first().text()); + const number = title.match(/маршрут\s*№\s*(\d+[аaэ]?)/i)?.[1]; + if (!number || /\/\d{4}\/\d{2}\/\d{2}\//.test(new URL(safeUrl(url)).pathname)) { + throw sourceError('Выбрана не страница основного расписания маршрута.'); + } + const rows = []; + const warnings = []; + content.find('table').each((tableIndex, table) => { + let days = null; + $(table).find('tr').each((rowIndex, tr) => { + const cells = $(tr).children('td, th').toArray(); + const texts = cells.map(cell => clean($(cell).text())); + if (!texts.some(Boolean)) return; + const parsedDays = dayTypes(texts.join(' ')); + if (parsedDays) { days = parsedDays; return; } + if (texts.join(' ').includes('к списку маршрутов')) return; + if (cells.length !== 2 || !days) { + warnings.push(`Не распознана строка таблицы: ${texts.join(' — ').slice(0, 240)}`); + return; + } + const [stopName, rawTimes] = texts; + const tokens = rawTimes.split('|').map(clean).filter(Boolean); + const departures = tokens.map(parseDeparture); + const invalid = !tokens.length || departures.some(trip => !trip); + // Marked/crossed-out times require human interpretation, even if their text looks normal. + const marked = $(tr).find('sup, del, s, strike, [style*="line-through"]').length > 0; + if (!stopName || invalid || marked) { + warnings.push(`«${stopName}»: есть примечания или нераспознанные времена: ${rawTimes.slice(0, 240)}`); + } + const times = invalid ? [] : departures.map(trip => trip.departureTime); + if (new Set(times).size !== times.length) warnings.push(`«${stopName}»: повторяющиеся времена требуют проверки.`); + rows.push({ key: `${tableIndex}:${rowIndex}`, stopName, days, times, departures: invalid ? [] : departures, rawTimes }); + }); + }); + if (!rows.length) throw sourceError('Таблицы расписания не распознаны. Существующие данные сохранены.'); + const directions = []; + content.find('p, div').each((_, node) => { + const text = clean($(node).text()); + if (!/^(Прямое|Обратное) направление:/i.test(text)) return; + const stops = [...text.matchAll(/«\s*([^»]+?)\s*»/g)].map(match => clean(match[1])); + if (stops.length) directions.push({ label: text.split(':')[0], stops }); + }); + // Footnotes outside tables must not silently disappear during import. + const outside = content.clone(); + outside.find('table').remove(); + const outsideText = clean(outside.text()); + if (/кроме|только|примечани|отмен|перенос|по пятниц|по понедельник|с \d{1,2}\.\d{1,2}|\d{1,2}:\d{2}/i.test(outsideText)) { + warnings.push('Вне таблиц есть условия или изменения расписания. Проверьте их на сайте; автоматическое применение заблокировано.'); + } + return { title, number: normalizeNumber(number).toUpperCase(), url: safeUrl(url), rows, directions, warnings }; +} + +function parseNews(html) { + const $ = cheerio.load(html); + const news = []; + $('article').each((_, node) => { + const article = $(node); + const title = clean(article.find('.entry-title').text()); + const href = article.find('.entry-title a').attr('href'); + if (!title || !href) return; + let url; + try { url = safeUrl(href); } catch { return; } + const content = article.find('.entry-content').clone(); + content.find('script, style').remove(); + content.find('table').each((_, table) => { + const rows = $(table).find('tr').toArray().map(tr => $(tr).children('td, th').toArray() + .map(cell => clean($(cell).text())).filter(Boolean).join(' — ')).filter(Boolean); + $(table).replaceWith($('
').text(rows.join('\n')));
+ });
+ content.find('br').replaceWith('\n');
+ content.find('p, tr, div').append('\n');
+ const text = content.text().split('\n').map(clean).filter(Boolean).join('\n').slice(0, 30000);
+ const publishedDate = article.find('time').first().attr('datetime')?.slice(0, 10)
+ || url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//)?.slice(1).join('-') || null;
+ news.push({ url, title, content: text, publishedDate, contentHash: hash({ title, text }) });
+ });
+ if (!news.length) throw sourceError('Не удалось распознать новости автопарка.');
+ return news;
+}
+
+module.exports = { HOME_URL, CATALOG_URL, clean, hash, normalizeName, normalizeNumber,
+ sourceError, safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews, parseDeparture };
diff --git a/backend/test/alertCreationService.test.js b/backend/test/alertCreationService.test.js
index acb22e9..92fa712 100644
--- a/backend/test/alertCreationService.test.js
+++ b/backend/test/alertCreationService.test.js
@@ -4,7 +4,8 @@ const test = require('node:test');
const {
AlertCreationService,
createAlertRequestHash,
- normalizeAlertInput
+ normalizeAlertInput,
+ isAlertDue
} = require('../src/services/alertCreationService');
const ALERT_INPUT = {
@@ -96,6 +97,22 @@ test('inactive alert is saved without dispatching a notification', async () => {
assert.equal(notificationCalls, 0);
});
+test('future alert is saved but does not dispatch before its start time', async () => {
+ let notificationCalls = 0;
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ const service = new AlertCreationService({
+ pool: { async query() { return { rows: [createStoredAlert({ start_time: new Date(future) })] }; } },
+ notificationService: { async sendAlertCreated() { notificationCalls += 1; } },
+ logger: createLogger()
+ });
+
+ const result = await service.createAlert({ ...ALERT_INPUT, start_time: future });
+
+ assert.equal(result.created, true);
+ assert.equal(notificationCalls, 0);
+ assert.equal(isAlertDue(result.alert), false);
+});
+
test('database failure prevents notification dispatch', async () => {
const databaseError = new Error('database unavailable');
let notificationCalls = 0;
diff --git a/backend/test/alertSchedulerService.test.js b/backend/test/alertSchedulerService.test.js
new file mode 100644
index 0000000..30f2d79
--- /dev/null
+++ b/backend/test/alertSchedulerService.test.js
@@ -0,0 +1,57 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const { AlertSchedulerService } = require('../src/services/alertSchedulerService');
+
+function createLogger() {
+ return { error() {} };
+}
+
+test('scheduled delivery claims a due alert, sends it, then records delivery', async () => {
+ const calls = [];
+ const pool = {
+ async query(sql, params = []) {
+ const normalized = String(sql).replace(/\s+/g, ' ').trim();
+ calls.push({ normalized, params });
+ if (normalized.startsWith('WITH due AS')) {
+ return { rows: [{
+ id: 17, route_id: 3, route_number: '4', notification_route_number: '4',
+ alert_type: 'detour', title: 'Объезд', message: 'Через центр',
+ start_time: new Date(), end_time: null, is_active: true
+ }] };
+ }
+ return { rows: [] };
+ }
+ };
+ const notifications = [];
+ const service = new AlertSchedulerService({
+ pool,
+ notificationService: {
+ enabled: true,
+ async sendAlertCreated(alert) {
+ notifications.push(alert);
+ return { sent: true, messageId: 'test-message' };
+ }
+ },
+ logger: createLogger()
+ });
+
+ const result = await service.dispatchDueAlerts();
+
+ assert.deepEqual(result, { dispatched: 1, claimed: 1 });
+ assert.equal(notifications.length, 1);
+ assert.equal(notifications[0].route_number, '4');
+ assert.equal(calls.length, 2);
+ assert.match(calls[1].normalized, /^UPDATE alerts SET notification_sent_at/);
+});
+
+test('scheduler leaves the database untouched while notifications are disabled', async () => {
+ let queries = 0;
+ const service = new AlertSchedulerService({
+ pool: { async query() { queries += 1; return { rows: [] }; } },
+ notificationService: { enabled: false },
+ logger: createLogger()
+ });
+
+ assert.deepEqual(await service.dispatchDueAlerts(), { dispatched: 0, skipped: 'disabled' });
+ assert.equal(queries, 0);
+});
diff --git a/backend/test/fixtures/schedule-import/catalog.html b/backend/test/fixtures/schedule-import/catalog.html
new file mode 100644
index 0000000..8d1f079
--- /dev/null
+++ b/backend/test/fixtures/schedule-import/catalog.html
@@ -0,0 +1,11 @@
+
+ В связи с ремонтом ж/д переезда по ул. Красноармейской 21.09.2026 года с 09:00 до 18:00 в схему и расписание движения городских автобусов вносятся следующие изменения: Маршрут №8 «Автовокзал – Бабушкина» Швейная фабрика 08:50 | 09:30 | 10:00 | 10:30 | 12:10 | 13:40 | 14:40 | 15:10 | 15:50 | 16:44 | 17:20 | Бабушкина 09:06 | 09:46 | 10:20 | 10:50 | 11:40 | 12:30 | 14:00 | 15:00 | 15:30 | 16:10 | 17:00 | 17:40 | Маршрут №8А«Автовокзал – Бабушкина ч/з Аэропорт» Автовокзал 10:55 | 13:20 | 16:30 | Бабушкина 14:20 | 17:20 | С 18.09.2026 по пятницам отменяется рейс автобуса по пригородному сезонному маршруту №256с Полоцк – Казимирово, отправлением с автовокзала в 18:40, с остановки Казимирово в 18:25. Маршрут №22 «Мариненко – Больница (Новка) ч/з Аэропорт» по будним и выходным дням: Прямое направление: «Швейная фабрика», «Типография», «Суворова», «Комсомольская», «Громы», «Некрасова», «Сортировочная», «КимаБел», «Центральная», «Богатырская». Обратное направление: «Богатырская», «Центральная», «КимаБел», «Сортировочная», «Некрасова», «Громы», «Комсомольская», «Суворова», «Типография», «Союзпечать», «Автовокзал». Прямое направление: «Больница (Ксты)», «Курган Бессмертия», «Экимань», «Заполотье», «Энергосбыт», «Спортклуб», «Олимпиец», «Экономический колледж», «Педагогический колледж», «Райисполком», «Поликлиника», «Дом торговли», «Швейная фабрика», «Типография», «Красное кладбище», «Гагарина», «Ремонтник», «Проезд Гагарина», «Центр детей и молодежи», «Калиновского», «Больница (Новка)». Обратное направление: «Больница (Новка)», «Калиновского», «Центр детей и молодежи», «Проезд Гагарина», «Ремонтник», «Гагарина», «Типография», «Союзпечать», «Автовокзал», «Дом торговли», «Поликлиника», «Педагогический колледж», «Экономический колледж», «Олимпиец», «Спортклуб», «Энергосбыт», «Заполотье», «Экимань», «Курган Бессмертия», «Больница (Ксты)».Городские маршруты














+
+
+Изменения в расписании движения городского транспорта 21.09.2026 (г.Полоцк)
+
+
+– отменяются следующие рейсы:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Изменения в расписании пригородного сезонного транспорта с 18.09.2026 (г. Полоцк)
+
+ С 14.09.2026 вносятся изменения в расписание движения по следующим городским маршрутам:
+
+
+— рейс, отправлением от остановочного пункта «Больница (Новка)» в 23:20, переносится на 23:00.Маршрут №1
+
+
+
+
+
+
+ Швейная фабрика
+05:20 | 06:30 | 06:50 | 07:30 | 08:00 | 08:38 | 09:05 | 09:55 | 11:25 | 13:45 | 14:55 | 15:35 | 16:10 | 16:45 | 17:35 | 18:15 | 19:10 | 20:20
+
+
+
+ Богатырская
+05:40 | 06:50 | 07:20 | 08:05 | 08:33 | 09:10 | 09:37 | 10:25 | 11:55 | 14:10 | 15:25 | 16:10 | 16:40 | 17:10 | 17:55 | 18:35 | 19:30 | 20:37
+
+
+
+
+
+
+
+
+ Швейная фабрика
+06:05 | 07:06 | 08:10 | 09:12 | 10:10 | 11:10 | 12:12 | 13:10 | 14:20 | 15:30 | 16:45 | 17:50 | 19:12 | 20:20
+
+
+
+ Богатырская
+06:35 | 07:25 | 08:40 | 09:45 | 10:40 | 11:40 | 12:42 | 13:45 | 14:50 | 16:05 | 17:15 | 18:25 | 19:33 | 20:37
+Маршрут №2
+
+
+
+
+
+
+ Больница (Ксты)
+06:15 | 06:45 | 07:15 | 07:45 | 08:15 | 08:45 | 09:35 | 10:30 | 11:55 | 13:35 | 14:25 | 15:25 | 16:20 | 16:55 | 17:10 | 18:10 | 20:10
+
+
+ Швейная фабрика
+06:36 | 07:06 | 07:36 | 08:06 | 08:36 | 09:06 | 09:56 | 10:51 | 12:16 | 13:56 | 14:46 | 15:46 | 16:41 | 17:16 | 17:31 | 18:31 | 20:31
+
+
+ Больница (Новка)
+05:50 | 06:50 | 07:20 | 08:00 | 08:25 | 09:00 | 09:30 | 10:15 | 11:15 | 12:45 | 14:15 | 15:15 | 16:15 | 17:00 | 17:35 | 18:55
+
+
+
+ Автовокзал
+06:02 | 07:02 | 07:32 | 08:12 | 08:37 | 09:12 | 09:42 | 10:27 | 11:27 | 12:57 | 14:27 | 15:27 | 16:27 | 17:12 | 17:47 | 19:07
+
+
+
+
+
+
+
+
+ Больница (Ксты)
+05:23 | 07:07 | 07:57 | 08:47 | 09:43 | 10:37 | 11:34 | 12:30 | 13:31 | 14:24 | 15:31 | 16:25 | 17:22 | 18:18 | 18:55 | 20:10
+
+
+ Швейная фабрика
+05:44 | 07:28 | 08:18 | 09:08 | 10:04 | 10:58 | 11:55 | 12:51 | 13:52 | 14:45 | 15:52 | 16:46 | 17:43 | 18:39 | 19:16 | 20:31
+
+
+ Больница (Новка)
+05:50 | 07:10 | 08:10 | 10:00 | 10:57 | 11:56 | 12:48 | 13:45 | 14:12 | 15:40 | 16:37 | 17:42 | 18:04 | 19:37
+
+
+
+ Автовокзал
+06:02 | 07:22 | 08:22 | 10:12 | 11:09 | 12:08 | 13:00 | 13:57 | 14:24 | 15:52 | 16:49 | 17:54 | 18:16 | 19:49
+
| |
+|
| Швейная фабрика | +06:05 через «Мариненко» | 06:20 | 07:08 | 07:40 | 07:55 | 08:55 | 11:00 | 13:00 | 14:00 | 14:10 | 15:10 | 15:50 по пятницам | 15:55 кроме пятницы | 16:52 | 16:58 кроме пятницы | 18:00 кроме пятницы | 19:10 | 22:05 через «Мариненко» | +
| Полоцк – Стекловолокно | +06:40 | 07:15 | 07:31 через «Мариненко» | 08:25 | 09:20 | 11:25 | 13:25 | 14:40 | 15:25 | 15:35 кроме пятницы | 16:15 по пятницам | 16:25 | 17:25 кроме пятницы | 17:30 | 18:30 кроме пятницы | 20:20 | 23:30 через «Мариненко» | +
| + Швейная фабрика | +06:10 через «Мариненко» | 07:20 | 14:00 | 19:10 | 22:10 через «Мариненко» | +
| + Полоцк – Стекловолокно | +07:25 через «Мариненко» | 08:25 | 15:35 | 20:20 | 23:25 через «Мариненко» | +
Прямое направление: «Швейная фабрика», «Юбилейная», «Кульнева», «Селянская», «23 Гвардейцев», «Петруся Бровки», «Развилка», «Вильнюсское шоссе», «Ксты», «Полоцкгаз», «Полоцк-Стекловолокно».
+Обратное направление: «Полоцк-Стекловолокно», «Полоцкгаз», «Ксты», «Вильнюсское шоссе», «ТРЦ Манеж», «Развилка», «Петруся Бровки», «23 Гвардейцев», «Селянская», «Кульнева», «Юбилейная», «Автовокзал».
+- Всего: {alerts.length} • Активных: {activeAlerts.length} + Всего: {alerts.length} • В эфире: {statusCounts.active} • Запланировано: {statusCounts.scheduled}
- Создавайте уведомления о задержках, отменах, изменениях маршрутов и другую информацию для пассажиров. - Уведомления могут быть привязаны к конкретному маршруту или быть общими. + Используйте шаблон, затем уточните текст и время. Будущее уведомление появится у пассажиров и получит push-рассылку в момент начала.
- Найдено: {alerts.length} + Найдено: {filteredAlerts.length}
- {filterType !== 'all' || filterActive !== 'all' + {filterType !== 'all' || filterStatus !== 'all' ? 'Уведомления не найдены. Измените фильтры.' : 'Нет уведомлений. Создайте первое!'}
- {filterType === 'all' && filterActive === 'all' && ( + {filterType === 'all' && filterStatus === 'all' && (Проверяет данные маршрутов, остановок, расписаний и уведомлений. Ничего не изменяет автоматически.
+{summary.total ? `Проверено: ${formatDate(report.checked_at)}` : `Данные проверены: ${formatDate(report.checked_at)}`}
+Маршруты, расписания, остановки и уведомления прошли текущие проверки.
Выберите другой фильтр, чтобы увидеть остальные результаты.
{label}
{value}{issue.description}
+ {issue.entity?.label && {issue.entity.label}} +{empty}
; + return [...groups.entries()].map(([label, items]) =>{label}
+Расписания и новости официального сайта автопарка Полоцка.
+{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}
} + + {tab === 'schedule' && <> +Сначала создайте маршрут и добавьте его остановки в разделе «Маршруты».
} +Каждое направление получит времена отправления со своей начальной остановки. Начало и конец уже заданы порядком остановок в админке — выбирать их заново не нужно.
+ {inspection.source.rows.some(row => row.departures?.some(trip => trip.viaStopNames.length || trip.daysOfWeek.length || trip.exceptDaysOfWeek.length)) && +Примечания «по дням недели», «кроме» и «через остановку» распознаны и будут сохранены у соответствующих рейсов. Проверьте условия в предпросмотре. Если остановка не найдена в направлении, условие «через» сохранится текстом.
} + {inspection.source.warnings.length > 0 &&{direction.label}: {direction.stops.join(' → ')}
)} +Направление в админке: {stops[0].name} → {stops.at(-1).name}
+ :Добавьте остановки этого направления в редакторе маршрута.
} + + {mappings[route.id] &&В этот маршрут попадут времена из строки «{mappings[route.id]}».
} + {inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row =>{row.rawTimes}
+{checkpointNames.join(', ')} — строки прохождения уже отправившихся автобусов. Они не создают дополнительные рейсы. В этой версии импорта времена на промежуточных остановках не переносятся; используется время проезда между остановками, заданное в маршруте.
+Не выбраны отправления от: {unusedOrigins.join(', ')}. Они не будут импортированы. Чтобы загрузить их, выберите соответствующие направления в шаге 1.
} + +«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.
+ +Для перечисленных типов дней расписание будет заменено целиком. Удаляемые рейсы и их особые условия показаны ниже. Праздничное расписание не меняется.
+ {preview.plans.map(plan =>Источник отправлений: {plan.sourceStopName}
+ {plan.notices?.map(notice =>{notice}
)} + {plan.changes.map(change =>Было {change.before.length} · Станет {change.after.length} · Без изменений {change.unchanged}
+Расписание уже совпадает с источником. Сохранять повторно не нужно.
} +Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.
+{item.content}
+После первого применения здесь появится запись об импорте.
} + {history.map(item =>{item.directions?.length ? item.directions.map(route => `№${route.route_number} · ${route.name}`).join('; ') : `Маршрут №${item.route_number || '—'} · ${item.route_name || 'Удалён'}`}
+Действует с {dateLabel(item.valid_from)} · Импорт {new Date(item.applied_at).toLocaleString('ru-RU')}
{dayType.description}
+ {schedule?.valid_from &&С {new Date(schedule.valid_from).toLocaleDateString('ru-RU')}
}