From 595ad6456b6172e117e5f00064aa92f4bf18e380 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 16 Sep 2026 21:55:50 +0300 Subject: [PATCH] feat: add schedule import functionality and quality control page - Implemented the schedule import feature with a dedicated page for importing schedules from the transport website. - Added a new QualityPage to check the quality of routes, stops, schedules, and notifications without making automatic changes. - Created CSS styles for both the ScheduleImportPage and QualityPage to enhance the user interface. - Introduced API calls for fetching schedule data and quality reports. - Added error handling and loading states for better user experience during data fetching. - Included functionality for filtering quality issues and displaying detailed reports. --- backend/.dockerignore | 7 + backend/migrations/008_schedule_imports.sql | 24 ++ backend/migrations/009_alert_scheduling.sql | 19 ++ backend/package.json | 1 + backend/src/controllers/qualityController.js | 21 ++ .../src/controllers/schedulesController.js | 33 +- backend/src/index.js | 4 + backend/src/routes/alerts.js | 26 +- backend/src/routes/api.js | 5 + backend/src/routes/scheduleImport.js | 29 ++ backend/src/services/alertCreationService.js | 21 +- backend/src/services/alertSchedulerService.js | 109 +++++++ backend/src/services/alertTemplates.js | 40 +++ backend/src/services/qualityReport.js | 285 +++++++++++++++++ backend/src/services/scheduleImportService.js | 300 ++++++++++++++++++ backend/src/services/scheduleSource.js | 220 +++++++++++++ backend/test/alertCreationService.test.js | 19 +- backend/test/alertSchedulerService.test.js | 57 ++++ .../fixtures/schedule-import/catalog.html | 11 + .../test/fixtures/schedule-import/news.html | 94 ++++++ .../test/fixtures/schedule-import/route1.html | 42 +++ .../test/fixtures/schedule-import/route2.html | 58 ++++ .../test/fixtures/schedule-import/route9.html | 40 +++ backend/test/qualityReport.test.js | 22 ++ .../test/scheduleImport.integration.test.js | 162 ++++++++++ backend/test/scheduleImport.test.js | 164 ++++++++++ docs/schedule-import.md | 58 ++++ frontend/src/App.jsx | 4 + frontend/src/components/AppLayout.jsx | 4 +- frontend/src/pages/AlertsPage.jsx | 172 ++++++---- frontend/src/pages/QualityPage.css | 52 +++ frontend/src/pages/QualityPage.jsx | 142 +++++++++ frontend/src/pages/ScheduleImportPage.css | 18 ++ frontend/src/pages/ScheduleImportPage.jsx | 252 +++++++++++++++ frontend/src/pages/SchedulesPage.jsx | 35 +- frontend/src/services/api.js | 18 +- 36 files changed, 2496 insertions(+), 72 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/migrations/008_schedule_imports.sql create mode 100644 backend/migrations/009_alert_scheduling.sql create mode 100644 backend/src/controllers/qualityController.js create mode 100644 backend/src/routes/scheduleImport.js create mode 100644 backend/src/services/alertSchedulerService.js create mode 100644 backend/src/services/alertTemplates.js create mode 100644 backend/src/services/qualityReport.js create mode 100644 backend/src/services/scheduleImportService.js create mode 100644 backend/src/services/scheduleSource.js create mode 100644 backend/test/alertSchedulerService.test.js create mode 100644 backend/test/fixtures/schedule-import/catalog.html create mode 100644 backend/test/fixtures/schedule-import/news.html create mode 100644 backend/test/fixtures/schedule-import/route1.html create mode 100644 backend/test/fixtures/schedule-import/route2.html create mode 100644 backend/test/fixtures/schedule-import/route9.html create mode 100644 backend/test/qualityReport.test.js create mode 100644 backend/test/scheduleImport.integration.test.js create mode 100644 backend/test/scheduleImport.test.js create mode 100644 docs/schedule-import.md create mode 100644 frontend/src/pages/QualityPage.css create mode 100644 frontend/src/pages/QualityPage.jsx create mode 100644 frontend/src/pages/ScheduleImportPage.css create mode 100644 frontend/src/pages/ScheduleImportPage.jsx 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 @@ + +

+

Городские маршруты

+
+
Выберите маршрут для просмотра расписания:
+
1234678891122232427
+28э
+
ДЕЖУРНЫЕ

+
◄ к списку маршрутов

+
+
\ No newline at end of file diff --git a/backend/test/fixtures/schedule-import/news.html b/backend/test/fixtures/schedule-import/news.html new file mode 100644 index 0000000..bc500d6 --- /dev/null +++ b/backend/test/fixtures/schedule-import/news.html @@ -0,0 +1,94 @@ + +
+ +
+

Изменения в расписании движения городского транспорта 21.09.2026 (г.Полоцк)

+ +
+ +
+

В связи с ремонтом ж/д переезда по ул. Красноармейской 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 (г. Полоцк)

+ +
+ +
+

С 18.09.2026 по пятницам отменяется рейс автобуса по пригородному сезонному маршруту №256с Полоцк – Казимирово, отправлением с автовокзала в 18:40, с остановки Казимирово в 18:25.

+
+ +
+
+ +
+

С 14.09.2026 вносятся изменения в расписание движения по следующим городским маршрутам:

+ +
+ +
+

Маршрут №22 «Мариненко – Больница (Новка) ч/з Аэропорт» по будним и выходным дням:
+— рейс, отправлением от остановочного пункта «Больница (Новка)» в 23:20, переносится на 23:00.

+
+ +
\ No newline at end of file diff --git a/backend/test/fixtures/schedule-import/route1.html b/backend/test/fixtures/schedule-import/route1.html new file mode 100644 index 0000000..38eb29d --- /dev/null +++ b/backend/test/fixtures/schedule-import/route1.html @@ -0,0 +1,42 @@ + +
+

Маршрут №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
+
Остановочные пункты:
+

Прямое направление: «Швейная фабрика», «Типография», «Суворова», «Комсомольская», «Громы», «Некрасова», «Сортировочная», «КимаБел», «Центральная», «Богатырская».

+

Обратное направление: «Богатырская», «Центральная», «КимаБел», «Сортировочная», «Некрасова», «Громы», «Комсомольская», «Суворова», «Типография», «Союзпечать», «Автовокзал».

+
Схема движения
+

+
+
\ No newline at end of file diff --git a/backend/test/fixtures/schedule-import/route2.html b/backend/test/fixtures/schedule-import/route2.html new file mode 100644 index 0000000..e34bfda --- /dev/null +++ b/backend/test/fixtures/schedule-import/route2.html @@ -0,0 +1,58 @@ + +
+

Маршрут №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
+
Остановочные пункты:
+

Прямое направление: «Больница (Ксты)», «Курган Бессмертия», «Экимань», «Заполотье», «Энергосбыт», «Спортклуб», «Олимпиец», «Экономический колледж», «Педагогический колледж», «Райисполком», «Поликлиника», «Дом торговли», «Швейная фабрика», «Типография», «Красное кладбище», «Гагарина», «Ремонтник», «Проезд Гагарина», «Центр детей и молодежи», «Калиновского», «Больница (Новка)».

+

Обратное направление: «Больница (Новка)», «Калиновского», «Центр детей и молодежи», «Проезд Гагарина», «Ремонтник», «Гагарина», «Типография», «Союзпечать», «Автовокзал», «Дом торговли», «Поликлиника», «Педагогический колледж», «Экономический колледж», «Олимпиец», «Спортклуб», «Энергосбыт», «Заполотье», «Экимань», «Курган Бессмертия», «Больница (Ксты)».

+
Схема движения
+

+
+
\ No newline at end of file diff --git a/backend/test/fixtures/schedule-import/route9.html b/backend/test/fixtures/schedule-import/route9.html new file mode 100644 index 0000000..1d3e25c --- /dev/null +++ b/backend/test/fixtures/schedule-import/route9.html @@ -0,0 +1,40 @@ +

Маршрут №9

+ + + + + + + + + + + + + + + +
◄ к списку маршрутов
Рабочий день
Швейная фабрика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 Гвардейцев», «Селянская», «Кульнева», «Юбилейная», «Автовокзал».

+
Схема движения
+

+
\ No newline at end of file diff --git a/backend/test/qualityReport.test.js b/backend/test/qualityReport.test.js new file mode 100644 index 0000000..14e8fdb --- /dev/null +++ b/backend/test/qualityReport.test.js @@ -0,0 +1,22 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { buildQualityReport, qualityChecks } = require('../src/services/qualityReport'); + +test('quality report makes actionable, stable issues and summary', () => { + const rows = Object.fromEntries(qualityChecks.map(({ name }) => [name, []])); + rows.routesWithoutStops.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', active_stops_count: 0 }); + rows.missingSchedules.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', day_type: 'weekday' }); + rows.overlappingSchedules.push({ route_id: 9, route_number: '5', route_name: 'А → Б', day_type: 'sunday', schedule_id: 12, conflicting_schedule_id: 10 }); + rows.unusedStops.push({ stop_id: 3, stop_name: 'Резервная' }); + rows.expiredAlerts.push({ alert_id: 15, title: 'Временный объезд' }); + + const report = buildQualityReport(rows, '2026-09-16T10:00:00.000Z'); + + assert.deepEqual(report.summary, { total: 5, critical: 3, warning: 1, info: 1 }); + assert.equal(report.checked_at, '2026-09-16T10:00:00.000Z'); + assert.deepEqual(report.issues.map((issue) => issue.severity), ['critical', 'critical', 'critical', 'warning', 'info']); + assert.deepEqual(report.issues.find((issue) => issue.code === 'missing_schedule').action, { + to: '/schedules?route=8', label: 'Открыть расписания', + }); + assert.match(report.issues.find((issue) => issue.code === 'overlapping_schedules').description, /№12 и №10/); +}); diff --git a/backend/test/scheduleImport.integration.test.js b/backend/test/scheduleImport.integration.test.js new file mode 100644 index 0000000..fb56942 --- /dev/null +++ b/backend/test/scheduleImport.integration.test.js @@ -0,0 +1,162 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { Pool } = require('pg'); +const { ScheduleImportService } = require('../src/services/scheduleImportService'); +const { getLocalDateTime } = require('../src/utils/etaTime'); +const { parseSchedule, CATALOG_URL, HOME_URL } = require('../src/services/scheduleSource'); + +// Opt-in only. Use a disposable database; every run gets its own temporary schema. +test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news review', { + skip: !process.env.IMPORT_TEST_DATABASE_URL, +}, async () => { + const url = process.env.IMPORT_TEST_DATABASE_URL; + assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database'); + const admin = new Pool({ connectionString: url }); + const schema = `import_test_${Date.now()}`; + let pool; + try { + await admin.query('CREATE EXTENSION IF NOT EXISTS postgis'); + await admin.query(`CREATE SCHEMA ${schema}`); + pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` }); + for (const file of (await fs.readdir(path.join(__dirname, '../migrations'))).filter(f => f.endsWith('.sql')).sort()) { + await pool.query(await fs.readFile(path.join(__dirname, '../migrations', file), 'utf8')); + } + const fixture = name => fs.readFile(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8'); + let sourceHtml = await fixture('route1'); + let newsHtml = await fixture('news'); + const catalogHtml = await fixture('catalog'); + const sourceUrl = 'https://ap2polotsk.of.by/ap2/m-1/'; + const load = async url => ({ url, html: url === CATALOG_URL ? catalogHtml : url === HOME_URL ? newsHtml : sourceHtml }); + const invalidations = []; + const service = new ScheduleImportService({ pool, load, invalidate: async id => invalidations.push(id) }); + const source = parseSchedule(sourceHtml, sourceUrl); + sourceHtml = sourceHtml.replace('05:20', `05:20 через «${source.directions[0].stops[1]}»`) + .replace('06:30', '06:30 по пятницам').replace('06:50', '06:50 кроме пятницы') + .replace('07:30', '07:30 через «Нет в базе»'); + const routeIds = []; + for (const direction of source.directions) { + const routeId = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('1',$1,'bus') RETURNING id", [direction.stops[0] + ' → ' + direction.stops.at(-1)])).rows[0].id; + routeIds.push(routeId); + for (const [i, name] of direction.stops.entries()) { + const stopId = (await pool.query("INSERT INTO stops (name,location) VALUES ($1,ST_GeogFromText('POINT(28.77 55.48)')) RETURNING id", [name])).rows[0].id; + await pool.query('INSERT INTO route_stops (route_id,stop_id,sequence,time_offset_minutes) VALUES ($1,$2,$3,$4)', [routeId, stopId, i + 1, i * 2]); + } + } + const routeId = routeIds[0]; + const validFrom = getLocalDateTime().date; + const prepare = async () => { + const inspection = await service.inspect({ routeIds, sourceUrl, userId: 1 }); + return service.preview({ inspectionId: inspection.id, mappings: inspection.mappings, validFrom, userId: 1 }); + }; + + const single = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 }); + assert.equal(single.directions.length, 1); + assert.equal(single.mappings[routeId], 'Швейная фабрика'); + await assert.rejects(service.preview({ inspectionId: single.id, mappings: { ...single.mappings, [routeIds[1]]: 'Богатырская' }, validFrom, userId: 1 }), /каждое выбранное/); + await assert.rejects(service.inspect({ routeIds: [], sourceUrl, userId: 1 }), /направления/); + await assert.rejects(service.inspect({ routeIds: [routeId, routeId], sourceUrl, userId: 1 }), /направления/); + const other = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('99','Другой маршрут','bus') RETURNING id")).rows[0].id; + await assert.rejects(service.inspect({ routeIds: [routeId, other], sourceUrl, userId: 1 }), /тот же номер/); + const first = await prepare(); + assert.equal(first.hasChanges, true); + assert.deepEqual(first.plans.map(p => p.changes.map(c => c.after.length)), [[18, 14, 14], [18, 14, 14]]); + assert.deepEqual(first.plans.map(p => p.sourceStopName), ['Швейная фабрика', 'Богатырская']); + const concurrent = await Promise.all([service.apply(first.id), service.apply(first.id)]); + assert.equal(concurrent.filter(r => r.alreadyApplied).length, 1); + assert.equal(Number((await pool.query('SELECT count(*) FROM schedules')).rows[0].count), 6); + assert.deepEqual(invalidations.sort((a,b) => a-b), routeIds); + const savedTrips = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [routeId])).rows[0].trips; + assert.equal(savedTrips.find(t => t.departureTime === '05:20:00').viaStopIds.length, 1); + assert.deepEqual(savedTrips.find(t => t.departureTime === '06:30:00').daysOfWeek, [5]); + assert.deepEqual(savedTrips.find(t => t.departureTime === '06:50:00').exceptDaysOfWeek, [5]); + assert.equal(savedTrips.find(t => t.departureTime === '07:30:00').note, 'через «Нет в базе»'); + assert.equal((await service.history()).length, 1); + assert.equal((await service.history())[0].directions.length, 2); + assert.equal((await prepare()).hasChanges, false); + assert.equal((await service.apply(first.id)).alreadyApplied, true); + assert.equal(Number((await pool.query("SELECT count(*) FROM sync_log WHERE table_name='schedules'")).rows[0].count), 6); + + sourceHtml = sourceHtml.replace('05:20', '05:21'); + const changed = await prepare(); + sourceHtml = sourceHtml.replace('05:21', '05:22'); + await assert.rejects(service.apply(changed.id), /на сайте изменилось/); + const stale = await prepare(); + await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Ручная правка\"'::jsonb) WHERE day_type = 'weekday'"); + const beforeConflict = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows); + await assert.rejects(service.apply(stale.id), /после предпросмотра/); + assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeConflict); + + // Failure while writing the second direction must also roll back the first direction. + sourceHtml = sourceHtml.replace('05:40', '05:41'); + const toFail = await prepare(); + const beforeFailure = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows); + const failingPool = { query: (...args) => pool.query(...args), connect: async () => { + const client = await pool.connect(); + let updates = 0; + return { release: () => client.release(), query: (sql, args) => { + if (sql.startsWith('UPDATE schedules SET departure_times') && ++updates === 2) throw new Error('Injected failure'); + return client.query(sql, args); + } }; + } }; + await assert.rejects(new ScheduleImportService({ pool: failingPool, load }).apply(toFail.id), /Injected failure/); + assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeFailure); + assert.equal((await service.getDraft(toFail.id)).status, 'ready'); + await service.apply(toFail.id); + + const reverseBefore = JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows); + await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Правка только прямого направления\"'::jsonb) WHERE day_type = 'weekday' AND route_id=$1", [routeId]); + const oneInspection = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 }); + const onePreview = await service.preview({ inspectionId: oneInspection.id, mappings: oneInspection.mappings, validFrom, userId: 1 }); + assert.equal(onePreview.plans.length, 1); + assert.equal(onePreview.hasChanges, true); + await service.apply(onePreview.id); + assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows), reverseBefore); + + // A future import closes old versions on the preceding day, keeping today's data intact. + sourceHtml = sourceHtml.replace('05:22', '05:23'); + const inspected = await service.inspect({ routeIds, sourceUrl, userId: 1 }); + const tomorrow = new Date(`${validFrom}T12:00:00Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + const future = await service.preview({ inspectionId: inspected.id, mappings: inspected.mappings, validFrom: tomorrow.toISOString().slice(0, 10), userId: 1 }); + await service.apply(future.id); + const versions = (await pool.query("SELECT to_char(valid_until,'YYYY-MM-DD') AS until FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id", [routeId])).rows; + assert.equal(versions.length, 2); + assert.equal(versions[0].until, validFrom); + assert.equal(versions[1].until, null); + + // Copying the selected version must not choose a newer future version or overwrite history. + await pool.query(`INSERT INTO schedules (route_id,day_type,departure_times,trips,valid_from,valid_until) + VALUES ($1,'holiday',ARRAY['04:00'::time],'[]','2020-01-01','2020-01-02')`, [routeId]); + const vm = require('node:vm'); + const moduleStub = { exports: {} }; + vm.runInNewContext(await fs.readFile(path.join(__dirname, '../src/controllers/schedulesController.js'), 'utf8'), { + module: moduleStub, require: name => { + if (name.endsWith('/database')) return pool; + if (name.endsWith('/redis')) return { del: async () => {} }; + if (name.endsWith('/logger')) return { info() {}, error() {} }; + throw new Error(`Unexpected dependency: ${name}`); + } + }); + const selectedId = (await pool.query("SELECT id FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id LIMIT 1", [routeId])).rows[0].id; + let responseBody; + const response = { status(code) { assert.ok(code < 400); return this; }, json(body) { responseBody = body; } }; + await moduleStub.exports.copySchedule({ params: { routeId }, body: { fromDayType: 'weekday', toDayType: 'holiday', sourceScheduleId: selectedId } }, response); + assert.equal(responseBody.trips.some(t => t.departureTime === '05:22:00'), true); + assert.equal(responseBody.trips.some(t => t.departureTime === '05:23:00'), false); + assert.equal(Number((await pool.query("SELECT count(*) FROM schedules WHERE day_type='holiday'")).rows[0].count), 2); + + let news = await service.news(true); + assert.equal(news.length, 3); + await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash }); + assert.equal((await service.news(true))[0].reviewed, true); + newsHtml = newsHtml.replace('08:50', '08:51'); + news = await service.news(true); + assert.equal(news[0].reviewed, false); + assert.equal(news.length, 3); + } finally { + if (pool) await pool.end(); + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + await admin.end(); + } +}); diff --git a/backend/test/scheduleImport.test.js b/backend/test/scheduleImport.test.js new file mode 100644 index 0000000..bf2baf3 --- /dev/null +++ b/backend/test/scheduleImport.test.js @@ -0,0 +1,164 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseSchedule, parseCatalog, parseNews, safeUrl, fetchHtml } = require('../src/services/scheduleSource'); +const { buildPlan, compareTrips, buildDirectionPlan, departureStops } = require('../src/services/scheduleImportService'); + +const fixture = name => fs.readFileSync(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8'); +const source = parseSchedule(fixture('route2'), 'https://ap2polotsk.of.by/ap2/m-2/'); +const snapshot = { route: { id: 2, route_number: '2' }, schedules: [], stops: [ + { stop_id: 1, name: 'Больница (Ксты)' }, { stop_id: 2, name: 'Швейная фабрика' }, + { stop_id: 3, name: 'Больница (Новка)' }, { stop_id: 4, name: 'Автовокзал' }, +] }; +const mappings = { 'Больница (Ксты)': { mode: 'departure', startStopId: 1, endStopId: 3 }, + 'Больница (Новка)': { mode: 'departure', startStopId: 3, endStopId: 1 }, + 'Швейная фабрика': { mode: 'checkpoint' }, 'Автовокзал': { mode: 'checkpoint' } }; +const plan = (data = source, map = mappings, state = snapshot, date = '2026-09-16') => buildPlan(data, map, state, date, '2026-09-16'); + +test('each admin direction imports only its departures and derives its endpoints from stop order', () => { + const outbound = { ...snapshot, stops: snapshot.stops.slice(0, 3) }; + const inbound = { ...snapshot, route: { id: 5, route_number: '2' }, stops: [...outbound.stops].reverse() }; + const forward = buildDirectionPlan(source, 'Больница (Ксты)', outbound, '2026-09-16', '2026-09-16'); + const backward = buildDirectionPlan(source, 'Больница (Новка)', inbound, '2026-09-16', '2026-09-16'); + assert.deepEqual(forward.changes.map(c => c.after.length), [17, 16, 16]); + assert.deepEqual(backward.changes.map(c => c.after.length), [16, 14, 14]); + assert.ok(forward.changes.every(c => c.after.every(t => t.startStopId === 1 && t.endStopId === 3))); + assert.ok(backward.changes.every(c => c.after.every(t => t.startStopId === 3 && t.endStopId === 1))); + assert.deepEqual(departureStops(source), ['Больница (Ксты)', 'Больница (Новка)']); + assert.throws(() => buildDirectionPlan(source, 'Швейная фабрика', outbound, '2026-09-16', '2026-09-16'), /строку отправлений/); + assert.throws(() => buildDirectionPlan(source, 'Больница (Ксты)', { ...outbound, stops: [] }, '2026-09-16', '2026-09-16'), /первая и последняя/); +}); + +test('real catalog finds 15 routes and distinguishes wrongly labelled 8A', () => { + const routes = parseCatalog(fixture('catalog')); + assert.equal(routes.length, 15); + assert.equal(routes.find(r => r.url.endsWith('/m-8a/')).number, '8А'); + assert.equal(routes.at(-1).number, '28Э'); +}); + +test('real route tables retain directions, checkpoint rows and weekend categories', () => { + assert.equal(source.rows.length, 8); + assert.equal(source.directions.length, 2); + assert.deepEqual(source.warnings, []); + const changes = plan().changes; + assert.deepEqual(changes.map(c => [c.dayType, c.after.length]), [['weekday', 33], ['saturday', 30], ['sunday', 30]]); + assert.equal(changes[0].after.filter(t => t.startStopId === 1).length, 17); + assert.equal(changes[0].after.filter(t => t.startStopId === 3).length, 16); +}); + +test('every row needs a decision and route stop ids are validated', () => { + assert.throws(() => plan(source, {}), /назначение/); + assert.throws(() => plan(source, { ...mappings, 'Больница (Ксты)': { mode: 'departure', startStopId: 999, endStopId: 3 } }), /остановки/); + assert.throws(() => plan(source, Object.fromEntries(Object.keys(mappings).map(k => [k, { mode: 'checkpoint' }]))), /хотя бы одну/); + assert.throws(() => plan(source, mappings, { ...snapshot, route: { route_number: '3' } }), /не совпадает/); +}); + +test('marked, malformed and footnoted departures block the entire import', () => { + for (const replacement of ['06:15*', '25:15', '06:15', '06:15']) { + const marked = parseSchedule(fixture('route2').replace('06:15', replacement), source.url); + assert.ok(marked.warnings.length); + assert.throws(() => plan(marked), /нераспознанные/); + } + const footnote = parseSchedule(fixture('route2').replace('Остановочные пункты:', 'Примечание: только по пятницам. Остановочные пункты:'), source.url); + assert.ok(footnote.warnings.length); +}); + +test('unrecognized day sections cannot become partial silent imports', () => { + const changed = parseSchedule(fixture('route2').replace('Рабочий день', 'Только по пятницам'), source.url); + assert.ok(changed.warnings.length); + assert.throws(() => plan(changed), /нераспознанные/); +}); + +test('same trips produce no changes; restriction removal is visible in the diff', () => { + const schedules = plan().changes.map((c, i) => ({ id: i + 1, day_type: c.dayType, trips: c.after, valid_from: '2026-09-16' })); + assert.equal(plan(source, mappings, { ...snapshot, schedules }).hasChanges, false); + schedules[0].trips = schedules[0].trips.map((trip, i) => i === 0 ? { ...trip, exceptDaysOfWeek: [5] } : trip); + const diff = plan(source, mappings, { ...snapshot, schedules }).changes[0]; + assert.equal(diff.added.length, 1); + assert.deepEqual(diff.removed[0].exceptDaysOfWeek, [5]); + assert.equal(compareTrips([diff.after[0], diff.after[0]], [diff.after[0]]).removed.length, 1); +}); + +test('future overlaps, invalid and historical dates are rejected', () => { + for (const date of ['2026-09-15', '2026-02-30', 'invalid']) assert.throws(() => plan(source, mappings, snapshot, date), /дату/); + const schedules = [{ id: 1, day_type: 'weekday', valid_from: '2026-09-20', trips: [] }]; + assert.throws(() => plan(source, mappings, { ...snapshot, schedules }), /будущие/); +}); + +test('news keeps cancellation context, publication date and textual changes', () => { + const items = parseNews(fixture('news')); + assert.equal(items.length, 3); + assert.equal(items[0].publishedDate, '2026-09-15'); + assert.match(items[0].content, /отменяются следующие рейсы/); + assert.match(items[0].content, /21.09.2026/); + assert.match(items[2].content, /23:20.*23:00/); + assert.throws(() => parseSchedule(fixture('news'), items[0].url), /не страница/); +}); + +test('source fetch rejects external URLs and external redirect targets', async () => { + for (const url of ['https://example.com/ap2/', 'http://localhost/ap2/', 'https://ap2polotsk.of.by:8443/ap2/', 'https://x:pass@ap2polotsk.of.by/ap2/', 'file:///ap2/']) { + assert.throws(() => safeUrl(url), /Разрешены/); + } + let requests = 0; + await assert.rejects(fetchHtml(source.url, async () => { + requests++; + return new Response(null, { status: 302, headers: { location: 'https://example.com/ap2/' } }); + }), /Разрешены/); + assert.equal(requests, 1); + await assert.rejects(fetchHtml(source.url, async () => new Response('error', { status: 503 })), /503/); + await assert.rejects(fetchHtml(source.url, async () => new Response('pdf', { headers: { 'content-type': 'application/pdf' } })), /не HTML/); +}); + +test('route 9 annotations preserve per-trip weekdays, exclusions and via stops', () => { + const annotated = parseSchedule(fixture('route9'), 'https://ap2polotsk.of.by/ap2/m-9/'); + assert.deepEqual(annotated.warnings, []); + const state = { route: { id: 9, route_number: '9', name: 'Швейная фабрика → Стекловолокно' }, schedules: [], + stops: [{ stop_id: 10, name: 'Швейная фабрика' }, { stop_id: 11, name: 'Мариненко' }, { stop_id: 12, name: 'Полоцк – Стекловолокно' }] }; + const forward = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'); + assert.deepEqual(forward.changes.map(c => c.after.length), [18, 5, 5]); + const trips = forward.changes[0].after; + assert.deepEqual(trips.find(t => t.departureTime === '06:05:00').viaStopIds, [11]); + assert.deepEqual(trips.find(t => t.departureTime === '15:50:00').daysOfWeek, [5]); + for (const time of ['15:55:00', '16:58:00', '18:00:00']) assert.deepEqual(trips.find(t => t.departureTime === time).exceptDaysOfWeek, [5]); + const reverse = buildDirectionPlan(annotated, 'Полоцк – Стекловолокно', { ...state, stops: [...state.stops].reverse() }, '2026-09-16', '2026-09-16'); + assert.deepEqual(reverse.changes.map(c => c.after.length), [17, 5, 5]); + assert.deepEqual(reverse.changes[0].after.find(t => t.departureTime === '16:15:00').daysOfWeek, [5]); + for (const stops of [state.stops.filter(s => s.stop_id !== 11), [...state.stops, { stop_id: 13, name: 'Мариненко' }]]) { + const unbound = buildDirectionPlan(annotated, 'Швейная фабрика', { ...state, stops }, '2026-09-16', '2026-09-16'); + assert.equal(unbound.notices.length, 1); + const via = unbound.changes[0].after.find(t => t.departureTime === '06:05:00'); + assert.deepEqual(via.viaStopIds, []); + assert.equal(via.note, 'через «Мариненко»'); + } +}); + +test('weekday grammar handles all ISO weekdays and combined conditions, rejects unknown suffixes', () => { + const { parseDeparture } = require('../src/services/scheduleSource'); + ['понедельникам', 'вторникам', 'средам', 'четвергам', 'пятницам', 'субботам', 'воскресеньям'].forEach((word, i) => { + assert.deepEqual(parseDeparture(`6:05 по ${word}`).daysOfWeek, [i + 1]); + }); + ['понедельника', 'вторника', 'среды', 'четверга', 'пятницы', 'субботы', 'воскресенья'].forEach((word, i) => { + assert.deepEqual(parseDeparture(`06:05 кроме ${word}`).exceptDaysOfWeek, [i + 1]); + }); + const combined = parseDeparture('06:05 через «Мариненко» только по понедельникам, средам и пятницам кроме среды'); + // Contradictory inclusion/exclusion is not silently normalized. + assert.equal(combined, null); + const valid = parseDeparture('06:05 по понедельникам и пятницам через Мариненко'); + assert.deepEqual(valid.daysOfWeek, [1, 5]); + assert.deepEqual(valid.viaStopNames, ['Мариненко']); + for (const value of ['06:05 кроме праздников', '06:05 по пятницам временно', '06:05*', '06:05 через «Мариненко» до Кстов', '25:00', '06:05 кроме пятницы кроме среды']) { + assert.equal(parseDeparture(value), null, value); + } + const unknown = parseSchedule(fixture('route9').replace('по пятницам', 'кроме праздников'), 'https://ap2polotsk.of.by/ap2/m-9/'); + assert.ok(unknown.warnings.length); +}); + +test('weekend day restrictions split correctly and contradictory day sections are blocked', () => { + const annotated = parseSchedule(fixture('route9').replace('06:10 через «Мариненко»', '06:10 кроме воскресенья'), 'https://ap2polotsk.of.by/ap2/m-9/'); + const state = { route: { id: 9, route_number: '9' }, schedules: [], stops: [{ stop_id: 1, name: 'Швейная фабрика' }, { stop_id: 2, name: 'Полоцк – Стекловолокно' }] }; + const built = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'); + assert.deepEqual(built.changes.map(c => c.after.length), [18, 5, 4]); + const invalid = parseSchedule(fixture('route9').replace('по пятницам', 'по субботам'), annotated.url); + assert.throws(() => buildDirectionPlan(invalid, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'), /противоречит/); +}); diff --git a/docs/schedule-import.md b/docs/schedule-import.md new file mode 100644 index 0000000..4260ac8 --- /dev/null +++ b/docs/schedule-import.md @@ -0,0 +1,58 @@ +# Импорт расписаний автопарка + +В админке: **Транспорт → Импорт расписаний** (`/schedule-import`). Раздел и его API доступны только администратору с JWT; ключ мобильного приложения доступа не даёт. + +## Как пользоваться + +1. Выбрать номер маршрута на сайте. Все направления в админке с этим номером выбираются автоматически; ненужные можно снять. +2. Нажать **Загрузить таблицы**. Каждое направление и его упорядоченные остановки уже должны существовать в базе. +3. Проверить строку отправлений сайта для каждого направления. Она выбирается автоматически по имени первой остановки; если имена отличаются, сопоставить вручную. Времена разных направлений не смешиваются. Начальная и конечная остановки берутся из порядка остановок выбранного направления. +4. Выбрать дату начала. Промежуточные строки сайта показаны отдельно: например, у №2 это «Швейная фабрика» и «Автовокзал». Они сохраняются в снимке источника, но не создают рейсы и не меняют время проезда между остановками. +5. Нажать **Показать изменения**. Для каждого направления проверить добавления, удаления и особые условия старых рейсов. +6. Подтвердить просмотр и нажать **Применить к выбранным направлениям**. Все выбранные направления сохраняются вместе. + +Для представленных в источнике типов дней расписание заменяется целиком. Старые ручные ограничения и заметки показаны среди удалений: они не переносятся автоматически на новые отправления. «Выходной день» разворачивается в субботу и воскресенье. Праздничное расписание не меняется. Отсутствующие на сайте времена промежуточных остановок не вычисляются парсером. + +Предыдущая версия сохраняется до дня перед началом новой. При повторном импорте на ту же дату существующая версия обновляется, а исходный снимок сохраняется в журнале. Версии доступны через селектор в разделе **Расписания**. Конфликтующие будущие версии блокируют импорт, чтобы не затереть запланированные изменения. + +## Новости + +Кнопка **Проверить новости** загружает текущие публикации главной страницы. Это ручной запуск, фонового опроса и обхода архивных страниц в этой версии нет. Загруженные публикации сохраняются; в интерфейсе отображаются последние 50. Полный текст с табличными временами и ссылка доступны для проверки. Дата публикации не считается датой начала изменений: её необходимо прочитать в содержимом. + +Отметка **Проверено** не применяет отмены или переносы. Если текст новости изменится при следующей проверке, отметка сбросится. Новость никогда не подменяет основное расписание. + +## Ограничения первой версии + +Поддерживаются обычные HTML-таблицы с блоками «Рабочий день», «Выходной день», «Суббота», «Воскресенье» и временами через `|`. Поддерживаются примечания у конкретного времени: «по пятницам», «только по понедельникам и средам», «кроме пятницы» и «через «Мариненко»», в том числе их сочетания. Дни недели сохраняются в `daysOfWeek` / `exceptDaysOfWeek` (ISO 1–7). Остановка «через» сопоставляется по имени с остановками направления и сохраняется в `viaStopIds`. Если совпадения нет или оно неоднозначно, условие сохраняется в `note`, а предпросмотр сообщает об отсутствии привязки. Это не добавляет остановку, геометрию или время проезда в маршрут. + +Неизвестные условия, отдельные сноски, зачёркивания, неизвестные заголовки и неоднозначные строки блокируют применение всей страницы и показываются администратору. Это касается, например, отдельных разделов по пятницам и отправлений из автопарка. Их следует редактировать вручную через **Расписания**. + +Проверка живого сайта 16.09.2026: каталог содержит 15 маршрутов. Без замечаний разобраны №1, 2, 4, 6, 8, 9, 11, 27 и 28Э. У остальных показаны блокирующие замечания; №6 требует ручного сопоставления направлений. Разметка сайта может измениться. + +Каждое направление приложения использует свой порядок остановок. Импорт сохраняет `startStopId` и `endStopId` первой и последней остановки, но не создаёт недостающие направления. Корректность порядка остановок и существующих `time_offset_minutes` нужно проверять в редакторе маршрута. + +## Сохранение и защита от конфликтов + +- Предпросмотр действует 30 минут; клиент не передаёт готовые времена в операцию сохранения. +- Перед применением сервер повторно получает источник и сравнивает его с просмотренным снимком. +- Снимок маршрута, остановок и расписаний проверяется внутри транзакции. Ручная правка после предпросмотра вызывает `409`. +- Изменения всех выбранных направлений, дней и отметка применения записываются одной транзакцией. Конфликт или ошибка в одном направлении отменяет весь импорт. Повторный запрос применения того же ID возвращает уже сохранённый результат. +- Триггеры `sync_schedules` фиксируют изменения для синхронизации. После успешного сохранения очищается кеш каждого выбранного маршрута. +- Публичные HTTP-ссылки сайта нормализуются в HTTPS; внешние адреса и перенаправления запрещены. Загрузка ограничена 20 секундами и 2 МиБ. + +Таблицы `schedule_imports` и `schedule_import_news` создаются идемпотентной миграцией `008_schedule_imports.sql`, автоматически при первом запросе к разделу. Снимки применённых импортов хранятся в `source` и `payload` (`version: 2`, массив `plans` с `before` и `changes` для каждого направления); кнопки автоматического отката пока нет. Старые неприменённые черновики удаляются через 7 дней при следующей загрузке таблиц. + +## Установка и проверка + +Установить backend-зависимости (`npm install`), пересобрать frontend и перезапустить backend. При использовании Docker: `docker compose up -d --build backend frontend`. Не запускать все старые миграции поверх существующей базы: первые миграции проекта не идемпотентны. + +```sh +cd backend +node --test +# Только отдельная тестовая PostgreSQL с PostGIS и ролью transit_admin: +IMPORT_TEST_DATABASE_URL=postgresql://.../import_test node --test test/scheduleImport.integration.test.js +cd ../frontend +npm run build +``` + +Интеграционный тест создаёт временную схему в тестовой базе и удаляет её по завершении. Проверяет совместный импорт двух направлений, выбор одного направления, конкурентное применение, повторы, изменения источника и базы, откат при сбое записи, будущую версию, журнал синхронизации и повторную проверку новостей. Дополнительно проверяется сохранение примечаний «через», дней выполнения и исключений. HTML-примеры в `backend/test/fixtures/schedule-import` извлечены из публичных страниц сайта 16.09.2026. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7f073df..ae61edb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ import RoutesPage from './pages/RoutesPage'; import RouteEditorPage from './pages/RouteEditorPage'; import StopsPage from './pages/StopsPage'; import SchedulesPage from './pages/SchedulesPage'; +import ScheduleImportPage from './pages/ScheduleImportPage'; +import QualityPage from './pages/QualityPage'; import HolidaysPage from './pages/HolidaysPage'; import AlertsPage from './pages/AlertsPage'; import UsersPage from './pages/UsersPage'; @@ -59,6 +61,8 @@ function AnimatedRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx index 81a997e..44da8ef 100644 --- a/frontend/src/components/AppLayout.jsx +++ b/frontend/src/components/AppLayout.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Link, NavLink, useLocation } from 'react-router-dom'; -import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User } from 'lucide-react'; +import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import BrandLogo from './BrandLogo'; import ProfileMenu from './ProfileMenu'; @@ -11,9 +11,11 @@ const navigationGroups = [ { to: '/routes', label: 'Маршруты', icon: Bus }, { to: '/stops', label: 'Остановки', icon: MapPin }, { to: '/schedules', label: 'Расписания', icon: CalendarDays }, + { to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true }, { to: '/holidays', label: 'Праздники', icon: Gift }, ] }, { label: 'Управление', items: [ + { to: '/quality', label: 'Контроль качества', icon: ShieldCheck, adminOnly: true }, { to: '/alerts', label: 'Уведомления', icon: Bell }, { to: '/users', label: 'Пользователи', icon: Users, adminOnly: true }, { to: '/profile', label: 'Профиль', icon: User }, diff --git a/frontend/src/pages/AlertsPage.jsx b/frontend/src/pages/AlertsPage.jsx index 362de47..6eb3d57 100644 --- a/frontend/src/pages/AlertsPage.jsx +++ b/frontend/src/pages/AlertsPage.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, RefreshCw } from 'lucide-react'; +import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, RefreshCw, Send, Calendar } from 'lucide-react'; import { alertsApi, routesApi } from '../services/api'; import { AlertTypeBadge } from '../components/AlertComponents'; import { PageLoader } from '../components/AnimatedComponents'; @@ -7,30 +7,27 @@ import { PageLoader } from '../components/AnimatedComponents'; export default function AlertsPage() { const [alerts, setAlerts] = useState([]); const [routes, setRoutes] = useState([]); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingAlert, setEditingAlert] = useState(null); const [filterType, setFilterType] = useState('all'); - const [filterActive, setFilterActive] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); const [refreshing, setRefreshing] = useState(false); - useEffect(() => { - loadData(); - }, [filterType, filterActive]); + useEffect(() => { loadData(); }, []); const loadData = async () => { try { - const params = {}; - if (filterType !== 'all') params.alert_type = filterType; - if (filterActive !== 'all') params.is_active = filterActive === 'active'; - - const [alertsRes, routesRes] = await Promise.all([ - alertsApi.getAll(params), - routesApi.getAll() + const [alertsRes, routesRes, templatesRes] = await Promise.all([ + alertsApi.getAll(), + routesApi.getAll(), + alertsApi.getTemplates() ]); setAlerts(alertsRes.data); setRoutes(routesRes.data); + setTemplates(templatesRes.data); } catch (error) { console.error('Error loading data:', error); } finally { @@ -98,8 +95,14 @@ export default function AlertsPage() { return ; } - const activeAlerts = alerts.filter(a => a.is_active); - const inactiveAlerts = alerts.filter(a => !a.is_active); + const statusCounts = alerts.reduce((counts, alert) => { + counts[getAlertStatus(alert)] += 1; + return counts; + }, { active: 0, scheduled: 0, draft: 0, expired: 0 }); + const filteredAlerts = alerts.filter(alert => + (filterType === 'all' || alert.alert_type === filterType) + && (filterStatus === 'all' || getAlertStatus(alert) === filterStatus) + ); return (
@@ -108,7 +111,7 @@ export default function AlertsPage() {

Уведомления

- Всего: {alerts.length} • Активных: {activeAlerts.length} + Всего: {alerts.length} • В эфире: {statusCounts.active} • Запланировано: {statusCounts.scheduled}

@@ -131,6 +134,7 @@ export default function AlertsPage() { setShowForm(false)} /> @@ -143,8 +147,7 @@ export default function AlertsPage() {

О системе уведомлений

- Создавайте уведомления о задержках, отменах, изменениях маршрутов и другую информацию для пассажиров. - Уведомления могут быть привязаны к конкретному маршруту или быть общими. + Используйте шаблон, затем уточните текст и время. Будущее уведомление появится у пассажиров и получит push-рассылку в момент начала.

@@ -201,31 +204,45 @@ export default function AlertsPage() {

Статус

setFilterActive('all')} + active={filterStatus === 'all'} + onClick={() => setFilterStatus('all')} > Все setFilterActive('active')} + active={filterStatus === 'active'} + onClick={() => setFilterStatus('active')} color="green" > - Активные ({activeAlerts.length}) + В эфире ({statusCounts.active}) setFilterActive('inactive')} + active={filterStatus === 'scheduled'} + onClick={() => setFilterStatus('scheduled')} + color="blue" + > + Запланировано ({statusCounts.scheduled}) + + setFilterStatus('draft')} color="gray" > - Неактивные ({inactiveAlerts.length}) + Черновики ({statusCounts.draft}) + + setFilterStatus('expired')} + color="red" + > + Завершены ({statusCounts.expired})

- Найдено: {alerts.length} + Найдено: {filteredAlerts.length}

{/* Alerts List */} - {alerts.length > 0 ? ( + {filteredAlerts.length > 0 ? (
- {alerts.map((alert, index) => ( + {filteredAlerts.map((alert, index) => (

- {filterType !== 'all' || filterActive !== 'all' + {filterType !== 'all' || filterStatus !== 'all' ? 'Уведомления не найдены. Измените фильтры.' : 'Нет уведомлений. Создайте первое!'}

- {filterType === 'all' && filterActive === 'all' && ( + {filterType === 'all' && filterStatus === 'all' && (
@@ -398,16 +416,24 @@ function createAlertIdempotencyKey() { return `alert-${Date.now()}-${Math.random().toString(36).slice(2)}`; } -function AlertForm({ alert, routes, onSubmit, onCancel }) { +function toDateTimeInput(value = new Date()) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function AlertForm({ alert, routes, templates, onSubmit, onCancel }) { const [idempotencyKey] = useState(createAlertIdempotencyKey); const [submitting, setSubmitting] = useState(false); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [formData, setFormData] = useState({ route_id: alert?.route_id || '', alert_type: alert?.alert_type || 'info', title: alert?.title || '', message: alert?.message || '', - start_time: alert?.start_time ? new Date(alert.start_time).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16), - end_time: alert?.end_time ? new Date(alert.end_time).toISOString().slice(0, 16) : '', + start_time: alert?.start_time ? toDateTimeInput(alert.start_time) : toDateTimeInput(), + end_time: alert?.end_time ? toDateTimeInput(alert.end_time) : '', is_active: alert?.is_active !== false }); @@ -436,6 +462,20 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) { })); }; + const applyTemplate = (template) => { + setSelectedTemplateId(template.id); + setFormData((current) => ({ + ...current, + alert_type: template.alert_type, + title: template.title, + message: template.message + })); + }; + + const startDate = new Date(formData.start_time); + const isScheduled = formData.is_active && !Number.isNaN(startDate.getTime()) && startDate > new Date(); + const saveLabel = alert ? 'Сохранить' : !formData.is_active ? 'Сохранить черновик' : isScheduled ? 'Запланировать' : 'Опубликовать'; + return (
@@ -447,6 +487,18 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
+
+ +
+ {templates.map(template => )} +
+

Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.

+
+ {/* Route */}
+
+
Планирование публикации
+

+ {!formData.is_active ? 'Это черновик: пассажиры и push-рассылка его не увидят.' : isScheduled + ? `Публикация и push-рассылка начнутся ${startDate.toLocaleString('ru-RU')}.` + : 'Уведомление будет опубликовано сразу после сохранения.'} +

+
+ + +
+
+ {/* Active Checkbox */}
@@ -574,7 +640,7 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) { className="btn-primary w-full sm:w-auto" disabled={submitting} > - {submitting ? 'Сохранение…' : 'Сохранить'} + {submitting ? 'Сохранение…' : <>{saveLabel}}
diff --git a/frontend/src/pages/QualityPage.css b/frontend/src/pages/QualityPage.css new file mode 100644 index 0000000..d4040ee --- /dev/null +++ b/frontend/src/pages/QualityPage.css @@ -0,0 +1,52 @@ +.quality-page { display: flex; flex-direction: column; gap: 24px; } +.quality-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.quality-heading p { max-width: 720px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; } +.quality-refresh { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; } +.quality-message { display: flex; align-items: center; gap: 12px; border-radius: 12px; padding: 14px 16px; font-size: 14px; } +.quality-message-error { background: #fef2f2; color: #b91c1c; } +.quality-message button { margin-left: auto; color: inherit; font-weight: 600; text-decoration: underline; } +.quality-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.quality-summary-card { display: flex; min-height: 108px; align-items: flex-start; gap: 12px; border-radius: 12px; background: #fff; padding: 18px; } +.quality-summary-icon { display: flex; flex: 0 0 auto; width: 40px; height: 40px; align-items: center; justify-content: center; border-radius: 8px; background: #f5f5f5; color: #525252; } +.quality-summary-card.is-critical .quality-summary-icon { background: #fef2f2; color: #dc2626; } +.quality-summary-card.is-warning .quality-summary-icon { background: #fffbeb; color: #d97706; } +.quality-summary-card.is-info .quality-summary-icon { background: #eff6ff; color: #2563eb; } +.quality-summary-card p { color: #737373; font-size: 13px; line-height: 18px; } +.quality-summary-card strong { display: block; margin-top: 5px; font-size: 28px; line-height: 32px; font-weight: 600; } +.quality-results { border-radius: 12px; background: #fff; padding: 20px; } +.quality-results-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid #f0f0f0; } +.quality-results-heading h2 { font-size: 18px; line-height: 25px; font-weight: 600; } +.quality-results-heading p { margin-top: 4px; color: #8c8c8c; font-size: 13px; line-height: 18px; } +.quality-list { display: flex; flex-direction: column; } +.quality-issue { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 14px; padding: 18px 0; border-bottom: 1px solid #f0f0f0; } +.quality-issue:last-child { border-bottom: 0; padding-bottom: 0; } +.quality-issue-icon { display: flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 8px; } +.quality-issue.is-critical .quality-issue-icon { background: #fef2f2; color: #dc2626; } +.quality-issue.is-warning .quality-issue-icon { background: #fffbeb; color: #d97706; } +.quality-issue.is-info .quality-issue-icon { background: #eff6ff; color: #2563eb; } +.quality-issue-content { min-width: 0; } +.quality-issue-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.quality-issue-heading h3 { font-size: 15px; line-height: 21px; font-weight: 600; } +.quality-issue-heading span, .quality-issue-entity { border-radius: 999px; background: #f5f5f5; padding: 2px 8px; color: #737373; font-size: 12px; line-height: 18px; } +.quality-issue-content > p { margin-top: 5px; color: #525252; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; } +.quality-issue-entity { display: inline-block; max-width: 100%; margin-top: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.quality-issue-action { display: inline-flex; min-height: 40px; align-items: center; gap: 7px; border-radius: 8px; background: #f5f5f5; padding: 10px 12px; color: #262626; font-size: 13px; line-height: 20px; font-weight: 500; white-space: nowrap; } +.quality-issue-action:hover { background: #ebebeb; } +.quality-empty { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 16px; color: #16a34a; text-align: left; } +.quality-empty h3 { color: #262626; font-size: 16px; line-height: 24px; font-weight: 600; } +.quality-empty p { max-width: 460px; margin-top: 4px; color: #737373; font-size: 14px; line-height: 21px; } +.quality-empty-filtered { min-height: 140px; color: #737373; } + +@media (max-width: 899px) { + .quality-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .quality-results-heading { flex-direction: column; } +} +@media (max-width: 639px) { + .quality-heading { flex-direction: column; } + .quality-refresh { width: 100%; justify-content: center; } + .quality-summary-card { min-height: 96px; padding: 14px; } + .quality-results { padding: 16px; } + .quality-issue { grid-template-columns: auto minmax(0, 1fr); } + .quality-issue-action { grid-column: 2; justify-self: start; } + .quality-empty { flex-direction: column; padding: 24px 8px; text-align: center; } +} diff --git a/frontend/src/pages/QualityPage.jsx b/frontend/src/pages/QualityPage.jsx new file mode 100644 index 0000000..ab4d680 --- /dev/null +++ b/frontend/src/pages/QualityPage.jsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { AlertCircle, AlertTriangle, ArrowRight, CheckCircle2, Info, RefreshCw, ShieldCheck } from 'lucide-react'; +import { qualityApi } from '../services/api'; +import { PageLoader } from '../components/AnimatedComponents'; +import './QualityPage.css'; + +const FILTERS = [ + { value: 'all', label: 'Все' }, + { value: 'critical', label: 'Критичные' }, + { value: 'warning', label: 'Требуют внимания' }, + { value: 'info', label: 'Рекомендации' }, +]; + +const severityMeta = { + critical: { label: 'Критично', icon: AlertCircle }, + warning: { label: 'Требует внимания', icon: AlertTriangle }, + info: { label: 'Рекомендация', icon: Info }, +}; + +function formatDate(value) { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('ru-RU', { + timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit', + }); +} + +export default function QualityPage() { + const [report, setReport] = useState(null); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(''); + const requestId = useRef(0); + + const loadReport = useCallback(async ({ refresh = false } = {}) => { + const id = ++requestId.current; + setError(''); + if (refresh) setRefreshing(true); + else setLoading(true); + try { + const { data } = await qualityApi.getReport(); + if (id === requestId.current) setReport(data); + } catch (requestError) { + if (id === requestId.current) { + setError(requestError.response?.data?.error || 'Не удалось проверить данные. Попробуйте ещё раз.'); + } + } finally { + if (id === requestId.current) { + setLoading(false); + setRefreshing(false); + } + } + }, []); + + useEffect(() => { + loadReport(); + return () => { requestId.current += 1; }; + }, [loadReport]); + + const issues = report?.issues || []; + const filteredIssues = filter === 'all' ? issues : issues.filter((issue) => issue.severity === filter); + const summary = report?.summary || { total: 0, critical: 0, warning: 0, info: 0 }; + + return
+
+
+

Контроль качества

+

Проверяет данные маршрутов, остановок, расписаний и уведомлений. Ничего не изменяет автоматически.

+
+ +
+ + {loading && !report ? : <> + {error &&
+
} + + {report && <> +
+ + + + +
+ +
+
+
+

Результаты проверки

+

{summary.total ? `Проверено: ${formatDate(report.checked_at)}` : `Данные проверены: ${formatDate(report.checked_at)}`}

+
+
+ {FILTERS.map(({ value, label }) => )} +
+
+ + {!summary.total ?
+
: filteredIssues.length ?
+ {filteredIssues.map((issue) => )} +
:
+
} +
+ } + } +
; +} + +function SummaryCard({ label, value, icon: Icon, severity }) { + return
+ +

{label}

{value}
+
; +} + +function QualityIssue({ issue }) { + const { label, icon: Icon } = severityMeta[issue.severity] || severityMeta.info; + return
+
+
+

{issue.title}

{label}
+

{issue.description}

+ {issue.entity?.label && {issue.entity.label}} +
+ {issue.action?.to && + {issue.action.label}
; +} diff --git a/frontend/src/pages/ScheduleImportPage.css b/frontend/src/pages/ScheduleImportPage.css new file mode 100644 index 0000000..858554d --- /dev/null +++ b/frontend/src/pages/ScheduleImportPage.css @@ -0,0 +1,18 @@ +.schedule-import-page { overflow-wrap: anywhere; } +.schedule-import-page h2 { font-size: 18px; font-weight: 600; } +.schedule-import-page button:disabled { opacity: .5; } +.schedule-import-page .btn-primary, .schedule-import-page .btn-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; } +.import-section-heading, .import-actions { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.import-actions { justify-content: flex-start; } +.import-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +.import-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.import-mapping { padding: 20px 0; border-top: 1px solid #e5e7eb; } +.import-source-link { display: inline-flex; align-items: center; gap: 6px; color: #0958d9; font-size: 14px; min-height: 36px; } +.import-message { display: flex; align-items: flex-start; gap: 12px; border-radius: 8px; padding: 16px; font-size: 14px; } +.import-message svg { flex-shrink: 0; } +.import-message-error { background: #fff1f0; color: #a61d24; } +.import-message-success { background: #edf8f1; color: #166534; } +.import-times { display: flex; flex-wrap: wrap; gap: 6px; } +.import-times span { border-radius: 6px; background: #f3f4f6; padding: 4px 8px; font-size: 13px; font-variant-numeric: tabular-nums; } +.import-history-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 16px 0; border-top: 1px solid #e5e7eb; } +@media (max-width: 767px) { .import-grid, .import-grid-three { grid-template-columns: minmax(0, 1fr); gap: 12px; } } diff --git a/frontend/src/pages/ScheduleImportPage.jsx b/frontend/src/pages/ScheduleImportPage.jsx new file mode 100644 index 0000000..b47bbaa --- /dev/null +++ b/frontend/src/pages/ScheduleImportPage.jsx @@ -0,0 +1,252 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Download, ExternalLink, RefreshCw, Check, AlertCircle } from 'lucide-react'; +import { routesApi, scheduleImportApi } from '../services/api'; +import './ScheduleImportPage.css'; + +const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' }; +const WEEKDAY_LABELS = { 1: 'пн', 2: 'вт', 3: 'ср', 4: 'чт', 5: 'пт', 6: 'сб', 7: 'вс' }; +const weekdayLabels = days => days.map(day => WEEKDAY_LABELS[day] || day).join(', '); +const normNumber = value => String(value).toLowerCase().replace(/a/g, 'а').replace(/\s/g, ''); +const dateLabel = value => value ? new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString('ru-RU') : '—'; + +function SourceLink({ url, children = 'Открыть источник' }) { + return + {children}; +} + +function TripList({ trips, stops, empty }) { + const groups = new Map(); + const stopName = id => stops.find(stop => Number(stop.stop_id) === Number(id))?.name || (id ? `Остановка №${id}` : 'Без привязки'); + trips.forEach(trip => { + const label = `${stopName(trip.startStopId)} → ${stopName(trip.endStopId)}`; + if (!groups.has(label)) groups.set(label, []); + groups.get(label).push(trip); + }); + if (!trips.length) return

{empty}

; + return [...groups.entries()].map(([label, items]) =>
+

{label}

+
{items.map((trip, index) => + {trip.departureTime?.slice(0, 5)} + {trip.daysOfWeek?.length > 0 && ` · только: ${weekdayLabels(trip.daysOfWeek)}`} + {trip.exceptDaysOfWeek?.length > 0 && ` · кроме: ${weekdayLabels(trip.exceptDaysOfWeek)}`} + {trip.viaStopIds?.length > 0 && ` · через: ${trip.viaStopIds.map(stopName).join(', ')}`} + {trip.note && ` · ${trip.note}`} + )}
+
); +} + +export default function ScheduleImportPage() { + const [tab, setTab] = useState('schedule'); + const [catalog, setCatalog] = useState([]); + const [routes, setRoutes] = useState([]); + const [sourceUrl, setSourceUrl] = useState(''); + const [routeIds, setRouteIds] = useState([]); + const [inspection, setInspection] = useState(null); + const [mappings, setMappings] = useState({}); + const [validFrom, setValidFrom] = useState(''); + const [preview, setPreview] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [news, setNews] = useState([]); + const [history, setHistory] = useState([]); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const alive = useRef(true); + const operation = useRef(false); + + async function run(name, action) { + if (operation.current) return; + operation.current = true; + setBusy(name); setError(''); setNotice(''); + try { await action(); } catch (e) { + if (alive.current) setError(e.response?.data?.error || 'Не удалось выполнить запрос. Проверьте соединение и повторите попытку.'); + } finally { + operation.current = false; + if (alive.current) setBusy(''); + } + } + + async function loadInitial() { + const results = await Promise.allSettled([ + scheduleImportApi.catalog(), routesApi.getAll(), scheduleImportApi.news(), scheduleImportApi.history(), + ]); + if (!alive.current) return; + [setCatalog, setRoutes, setNews, setHistory].forEach((setter, i) => { + if (results[i].status === 'fulfilled') setter(results[i].value.data); + }); + const failed = results.find(result => result.status === 'rejected'); + if (failed) throw failed.reason; + } + + useEffect(() => { + alive.current = true; + run('loading', loadInitial); + return () => { alive.current = false; }; + }, []); + + function resetDraft() { setInspection(null); setPreview(null); setConfirmed(false); setError(''); setNotice(''); } + function changeMapping(routeId, sourceStopName) { + setMappings(current => ({ ...current, [routeId]: sourceStopName })); + setPreview(null); setConfirmed(false); + } + const names = [...new Set(inspection?.source.rows.map(row => row.stopName) || [])]; + const selectedSource = catalog.find(item => item.url === sourceUrl); + const matchingRoutes = selectedSource ? routes.filter(route => normNumber(route.route_number) === normNumber(selectedSource.number)) : []; + const complete = inspection?.directions.length > 0 && inspection.directions.every(({ route, stops }) => + mappings[route.id] && stops.length > 1 && stops[0].stop_id !== stops.at(-1).stop_id); + const usedNames = new Set(Object.values(mappings)); + const checkpointNames = names.filter(name => !inspection?.departureStops.includes(name)); + const unusedOrigins = (inspection?.departureStops || []).filter(name => !usedNames.has(name)); + + return
+
+

Импорт расписаний

+

Расписания и новости официального сайта автопарка Полоцка.

+
+
+ {[['schedule', 'Расписания'], ['news', `Новости${news.filter(item => !item.reviewed).length ? ` · ${news.filter(item => !item.reviewed).length}` : ''}`], ['history', 'История']].map(([value, label]) => + )} +
+ {error &&
{error}
} + {notice &&
{notice}
} + {busy &&

{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}

} + + {tab === 'schedule' && <> +
+

1. Выберите маршрут

+
+
+ +
Направления в админке + {!sourceUrl ?

Сначала выберите номер на сайте.

: matchingRoutes.length ? <> +

Все направления №{selectedSource.number} выбираются автоматически. Оставьте отмеченными те, которые нужно обновить.

+ {matchingRoutes.map(route => )} + :

Маршрутов с этим номером нет. Добавьте нужные направления в разделе «Маршруты».

} +
+
+
+ + {sourceUrl && } +
+ {!routes.length && !busy &&

Сначала создайте маршрут и добавьте его остановки в разделе «Маршруты».

} +
+ + {inspection &&
+

2. Проверьте направления

{inspection.source.title}
+

Каждое направление получит времена отправления со своей начальной остановки. Начало и конец уже заданы порядком остановок в админке — выбирать их заново не нужно.

+ {inspection.source.rows.some(row => row.departures?.some(trip => trip.viaStopNames.length || trip.daysOfWeek.length || trip.exceptDaysOfWeek.length)) && +

Примечания «по дням недели», «кроме» и «через остановку» распознаны и будут сохранены у соответствующих рейсов. Проверьте условия в предпросмотре. Если остановка не найдена в направлении, условие «через» сохранится текстом.

} + {inspection.source.warnings.length > 0 &&
+ Нужна ручная проверка источника
    {inspection.source.warnings.map((warning, i) =>
  • {warning}
  • )}
+
} + {inspection.source.directions.length > 0 &&
Остановки и направления на сайте + {inspection.source.directions.map((direction, i) =>

{direction.label}: {direction.stops.join(' → ')}

)} +
} + {inspection.directions.map(({ route, stops }) =>
+

№{route.route_number} · {route.name}

+ {stops.length > 1 ?

Направление в админке: {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.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён +

{row.rawTimes}

+
)} +
)} + {checkpointNames.length > 0 &&
+

Времена на промежуточных остановках

+

{checkpointNames.join(', ')} — строки прохождения уже отправившихся автобусов. Они не создают дополнительные рейсы. В этой версии импорта времена на промежуточных остановках не переносятся; используется время проезда между остановками, заданное в маршруте.

+
} + {unusedOrigins.length > 0 &&

Не выбраны отправления от: {unusedOrigins.join(', ')}. Они не будут импортированы. Чтобы загрузить их, выберите соответствующие направления в шаге 1.

} + +

«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.

+ +
} + + {preview &&
+

3. Проверьте изменения

С {dateLabel(preview.validFrom)}
+

Для перечисленных типов дней расписание будет заменено целиком. Удаляемые рейсы и их особые условия показаны ниже. Праздничное расписание не меняется.

+ {preview.plans.map(plan =>
+

№{plan.route.route_number} · {plan.route.name}

+

Источник отправлений: {plan.sourceStopName}

+ {plan.notices?.map(notice =>

{notice}

)} + {plan.changes.map(change =>
+

{change.label}

+

Было {change.before.length} · Станет {change.after.length} · Без изменений {change.unchanged}

+
+
0}>Добавится: {change.added.length}
+
0}>Удалится: {change.removed.length}
+
+
)}
)} + {preview.hasChanges ? <> + + + :

Расписание уже совпадает с источником. Сохранять повторно не нужно.

} +
} + } + + {tab === 'news' &&
+

Новости автопарка

+
+

Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.

+ +
+ {!news.length && !busy &&
Новостей пока нет. Нажмите «Проверить новости».
} + {news.map(item =>
+
Опубликовано {dateLabel(item.published_date)}{item.reviewed ? 'Проверено' : 'Требует проверки'}
+

{item.title}

+

{item.content}

+
+ {!item.reviewed && } +
+
)} +
} + + {tab === 'history' &&

Применённые импорты

+ {!history.length &&

После первого применения здесь появится запись об импорте.

} + {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')}

+ +
)} +
} +
; +} diff --git a/frontend/src/pages/SchedulesPage.jsx b/frontend/src/pages/SchedulesPage.jsx index dcb5adc..d3154d9 100644 --- a/frontend/src/pages/SchedulesPage.jsx +++ b/frontend/src/pages/SchedulesPage.jsx @@ -1,4 +1,6 @@ import { useState, useEffect, useRef } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; import { Calendar, Plus, Edit, Trash2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services/api'; import { PageLoader } from '../components/AnimatedComponents'; @@ -23,6 +25,9 @@ const WEEKDAYS = [ ]; export default function SchedulesPage() { + const { isAdmin } = useAuth(); + const [searchParams] = useSearchParams(); + const requestedRouteId = searchParams.get('route'); const [routes, setRoutes] = useState([]); const [selectedRoute, setSelectedRoute] = useState(null); const [routeStops, setRouteStops] = useState([]); @@ -32,6 +37,7 @@ export default function SchedulesPage() { const [editingSchedule, setEditingSchedule] = useState(null); const [upcomingHolidays, setUpcomingHolidays] = useState([]); const [expandedSchedule, setExpandedSchedule] = useState(null); + const [selectedVersions, setSelectedVersions] = useState({}); const [detailsLoading, setDetailsLoading] = useState(false); const [detailsError, setDetailsError] = useState(''); const detailsRequest = useRef(0); @@ -47,6 +53,7 @@ export default function SchedulesPage() { setSchedules([]); setRouteStops([]); setExpandedSchedule(null); + setSelectedVersions({}); setShowEditor(false); if (selectedRoute) loadRouteDetails(selectedRoute.id); return () => { activeRouteId.current = null; detailsRequest.current += 1; }; @@ -57,7 +64,7 @@ export default function SchedulesPage() { const response = await routesApi.getAll(); setRoutes(response.data); if (response.data.length > 0) { - setSelectedRoute(response.data[0]); + setSelectedRoute(response.data.find(route => String(route.id) === requestedRouteId) || response.data[0]); } } catch (error) { console.error('Error loading routes:', error); @@ -126,7 +133,7 @@ export default function SchedulesPage() { const handleCopySchedule = async (fromDayType, toDayType) => { try { - await schedulesApi.copy(selectedRoute.id, fromDayType, toDayType); + await schedulesApi.copy(selectedRoute.id, fromDayType, toDayType, getScheduleForDayType(fromDayType)?.id); loadRouteDetails(selectedRoute.id); } catch (error) { console.error('Error copying schedule:', error); @@ -151,7 +158,14 @@ export default function SchedulesPage() { }; const getScheduleForDayType = (dayType) => { - return schedules.find(s => s.day_type === dayType); + const selected = schedules.find(s => s.day_type === dayType && s.id === selectedVersions[dayType]); + if (selected) return selected; + // Imports retain past versions; choose the current version, then the nearest future one. + const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); + const versions = schedules.filter(s => s.day_type === dayType && (!s.valid_until || s.valid_until.slice(0, 10) >= today)); + return versions.filter(s => !s.valid_from || s.valid_from.slice(0, 10) <= today) + .sort((a, b) => (b.valid_from || '').localeCompare(a.valid_from || ''))[0] + || versions.sort((a, b) => (a.valid_from || '').localeCompare(b.valid_from || ''))[0]; }; if (loading) { @@ -162,6 +176,7 @@ export default function SchedulesPage() {

Расписания

+ {isAdmin && Импорт с сайта}
{/* Upcoming Holidays Info */} @@ -255,6 +270,7 @@ export default function SchedulesPage() {

{dayType.label}

{dayType.description}

+ {schedule?.valid_from &&

С {new Date(schedule.valid_from).toLocaleDateString('ru-RU')}

}
{schedule ? ( @@ -269,6 +285,19 @@ export default function SchedulesPage() { {schedule ? ( <> + {schedules.filter(s => s.day_type === dayType.value).length > 1 && ( + + )}