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.
This commit is contained in:
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
logs
|
||||
test
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
24
backend/migrations/008_schedule_imports.sql
Normal file
24
backend/migrations/008_schedule_imports.sql
Normal file
@@ -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()
|
||||
);
|
||||
19
backend/migrations/009_alert_scheduling.sql
Normal file
19
backend/migrations/009_alert_scheduling.sql
Normal file
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
21
backend/src/controllers/qualityController.js
Normal file
21
backend/src/controllers/qualityController.js
Normal file
@@ -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;
|
||||
@@ -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]));
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
29
backend/src/routes/scheduleImport.js
Normal file
29
backend/src/routes/scheduleImport.js
Normal file
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
|
||||
109
backend/src/services/alertSchedulerService.js
Normal file
109
backend/src/services/alertSchedulerService.js
Normal file
@@ -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 };
|
||||
40
backend/src/services/alertTemplates.js
Normal file
40
backend/src/services/alertTemplates.js
Normal file
@@ -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 };
|
||||
285
backend/src/services/qualityReport.js
Normal file
285
backend/src/services/qualityReport.js
Normal file
@@ -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 };
|
||||
300
backend/src/services/scheduleImportService.js
Normal file
300
backend/src/services/scheduleImportService.js
Normal file
@@ -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 };
|
||||
220
backend/src/services/scheduleSource.js
Normal file
220
backend/src/services/scheduleSource.js
Normal file
@@ -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($('<p>').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 };
|
||||
@@ -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;
|
||||
|
||||
57
backend/test/alertSchedulerService.test.js
Normal file
57
backend/test/alertSchedulerService.test.js
Normal file
@@ -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);
|
||||
});
|
||||
11
backend/test/fixtures/schedule-import/catalog.html
vendored
Normal file
11
backend/test/fixtures/schedule-import/catalog.html
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-52" class="post-52 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Городские маршруты</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<div><center>Выберите маршрут для просмотра расписания:</center></div>
|
||||
<div><center><a href="http://ap2polotsk.of.by/ap2/m-1/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png'" alt="1" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-2/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png'" alt="2" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-3/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png'" alt="3" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-4/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png'" alt="4" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-6/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png'" alt="6" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-7/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png'" alt="7" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8a/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-9/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png'" alt="9" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-11/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png'" alt="11" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-22/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png'" alt="22" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-23/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png'" alt="23" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-24/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png'" alt="24" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-27/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png'" alt="27" hspace="5" vspace="5"></a><br>
|
||||
<a href="http://ap2polotsk.of.by/ap2/rasp/gorod/m-28-00/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png'" alt="28э" hspace="5" vspace="5"></a><br>
|
||||
<center><b><a href="http://ap2polotsk.of.by/ap2/deg/">ДЕЖУРНЫЕ</a></b></center><br>
|
||||
<center><a href="http://ap2polotsk.of.by/ap2/rasp-polotsk/">◄ к списку маршрутов</a></center><p></p>
|
||||
</center></div><!-- .entry-content -->
|
||||
</div></article>
|
||||
94
backend/test/fixtures/schedule-import/news.html
vendored
Normal file
94
backend/test/fixtures/schedule-import/news.html
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-8492" class="post-8492 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark">Изменения в расписании движения городского транспорта 21.09.2026 (г.Полоцк)</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-15T13:12:35+03:00">15.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>В связи с ремонтом ж/д переезда по ул. Красноармейской 21.09.2026 года с 09:00 до 18:00 в схему и расписание движения городских автобусов вносятся следующие изменения:<br>
|
||||
– отменяются следующие рейсы:</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr bgcolor="#00B7FF">
|
||||
<td colspan="2">
|
||||
<p><strong>Маршрут №8 «Автовокзал – Бабушкина»</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Швейная фабрика</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>08:50 | 09:30 | 10:00 | 10:30 | 12:10 | 13:40 | 14:40 | 15:10 |</p>
|
||||
<p>15:50 | 16:44 | 17:20 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Бабушкина</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>09:06 | 09:46 | 10:20 | 10:50 | 11:40 | 12:30 | 14:00 | 15:00 |</p>
|
||||
<p>15:30 | 16:10 | 17:00 | 17:40 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr bgcolor="#00B7FF">
|
||||
<td colspan="2">
|
||||
<p><strong>Маршрут №8А«Автовокзал – Бабушкина ч/з Аэропорт»</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Автовокзал</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>10:55 | 13:20 | 16:30 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Бабушкина</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>14:20 | 17:20 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
<article id="post-8486" class="post-8486 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark">Изменения в расписании пригородного сезонного транспорта с 18.09.2026 (г. Полоцк)</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-14T10:46:05+03:00">14.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>С 18.09.2026 по пятницам отменяется рейс автобуса по пригородному сезонному маршруту №256с Полоцк – Казимирово, отправлением с автовокзала в 18:40, с остановки Казимирово в 18:25.</p>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
<article id="post-8468" class="post-8468 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark">С 14.09.2026 вносятся изменения в расписание движения по следующим городским маршрутам:</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark"><time class="entry-date" datetime="2026-09-08T10:25:51+03:00">08.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>Маршрут №22 «Мариненко – Больница (Новка) ч/з Аэропорт» по будним и выходным дням:<br>
|
||||
— рейс, отправлением от остановочного пункта «Больница (Новка)» в 23:20, переносится на 23:00.</p>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
42
backend/test/fixtures/schedule-import/route1.html
vendored
Normal file
42
backend/test/fixtures/schedule-import/route1.html
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-153" class="post-153 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Маршрут №1</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Богатырская</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Богатырская</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"></a></p>
|
||||
</div><!-- .entry-content -->
|
||||
</article>
|
||||
58
backend/test/fixtures/schedule-import/route2.html
vendored
Normal file
58
backend/test/fixtures/schedule-import/route2.html
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-167" class="post-167 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Маршрут №2</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Ксты)</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Новка)</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Автовокзал</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table id="table1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Ксты)</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Новка)</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Автовокзал</td>
|
||||
<td><span class="notranslate">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</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Райисполком<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Красное кладбище<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"></a></p>
|
||||
</div><!-- .entry-content -->
|
||||
</article>
|
||||
40
backend/test/fixtures/schedule-import/route9.html
vendored
Normal file
40
backend/test/fixtures/schedule-import/route9.html
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
<h1 class="entry-title">Маршрут №9</h1><div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:05 </span> через «Мариненко» <span class="notranslate"> | 06:20 | 07:08 | 07:40 | 07:55 | 08:55 | 11:00 | 13:00 | 14:00 | 14:10 | 15:10 | 15:50 </span> по пятницам <span class="notranslate"> | 15:55 </span> кроме пятницы <span class="notranslate"> | 16:52 | 16:58 </span> кроме пятницы <span class="notranslate"> | 18:00 </span> кроме пятницы <span class="notranslate"> | 19:10 | 22:05 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Полоцк – Стекловолокно</td>
|
||||
<td><span class="notranslate">06:40 | 07:15 | 07:31 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 09:20 | 11:25 | 13:25 | 14:40 | 15:25 | 15:35 </span> кроме пятницы <span class="notranslate"> | 16:15 </span> по пятницам <span class="notranslate"> | 16:25 | 17:25 </span> кроме пятницы <span class="notranslate"> | 17:30 | 18:30 </span> кроме пятницы <span class="notranslate"> | 20:20 | 23:30 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"><b><center>Выходной день</center></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:10 </span> через «Мариненко» <span class="notranslate"> | 07:20 | 14:00 | 19:10 | 22:10 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Полоцк – Стекловолокно</td>
|
||||
<td><span class="notranslate">07:25 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 15:35 | 20:20 | 23:25 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>ТРЦ Манеж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"></a></p>
|
||||
</div>
|
||||
22
backend/test/qualityReport.test.js
Normal file
22
backend/test/qualityReport.test.js
Normal file
@@ -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/);
|
||||
});
|
||||
162
backend/test/scheduleImport.integration.test.js
Normal file
162
backend/test/scheduleImport.integration.test.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
164
backend/test/scheduleImport.test.js
Normal file
164
backend/test/scheduleImport.test.js
Normal file
@@ -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', '<sup>06:15</sup>', '<del>06:15</del>']) {
|
||||
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 </span> через «Мариненко»', '06:10 </span> кроме воскресенья'), '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'), /противоречит/);
|
||||
});
|
||||
58
docs/schedule-import.md
Normal file
58
docs/schedule-import.md
Normal file
@@ -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.
|
||||
@@ -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() {
|
||||
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
||||
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
||||
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
||||
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
|
||||
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
|
||||
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
||||
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 <PageLoader />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -108,7 +111,7 @@ export default function AlertsPage() {
|
||||
<div>
|
||||
<h1 className="page-title">Уведомления</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Всего: {alerts.length} • Активных: {activeAlerts.length}
|
||||
Всего: {alerts.length} • В эфире: {statusCounts.active} • Запланировано: {statusCounts.scheduled}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -131,6 +134,7 @@ export default function AlertsPage() {
|
||||
<AlertForm
|
||||
alert={editingAlert}
|
||||
routes={routes}
|
||||
templates={templates}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
@@ -143,8 +147,7 @@ export default function AlertsPage() {
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">О системе уведомлений</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Создавайте уведомления о задержках, отменах, изменениях маршрутов и другую информацию для пассажиров.
|
||||
Уведомления могут быть привязаны к конкретному маршруту или быть общими.
|
||||
Используйте шаблон, затем уточните текст и время. Будущее уведомление появится у пассажиров и получит push-рассылку в момент начала.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,31 +204,45 @@ export default function AlertsPage() {
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Статус</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterButton
|
||||
active={filterActive === 'all'}
|
||||
onClick={() => setFilterActive('all')}
|
||||
active={filterStatus === 'all'}
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
Все
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterActive === 'active'}
|
||||
onClick={() => setFilterActive('active')}
|
||||
active={filterStatus === 'active'}
|
||||
onClick={() => setFilterStatus('active')}
|
||||
color="green"
|
||||
>
|
||||
Активные ({activeAlerts.length})
|
||||
В эфире ({statusCounts.active})
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterActive === 'inactive'}
|
||||
onClick={() => setFilterActive('inactive')}
|
||||
active={filterStatus === 'scheduled'}
|
||||
onClick={() => setFilterStatus('scheduled')}
|
||||
color="blue"
|
||||
>
|
||||
Запланировано ({statusCounts.scheduled})
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterStatus === 'draft'}
|
||||
onClick={() => setFilterStatus('draft')}
|
||||
color="gray"
|
||||
>
|
||||
Неактивные ({inactiveAlerts.length})
|
||||
Черновики ({statusCounts.draft})
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterStatus === 'expired'}
|
||||
onClick={() => setFilterStatus('expired')}
|
||||
color="red"
|
||||
>
|
||||
Завершены ({statusCounts.expired})
|
||||
</FilterButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-gray-600">
|
||||
Найдено: <span className="font-medium">{alerts.length}</span>
|
||||
Найдено: <span className="font-medium">{filteredAlerts.length}</span>
|
||||
</p>
|
||||
<button onClick={handleCleanup} className="text-sm text-primary-600 hover:underline">
|
||||
Очистить истёкшие
|
||||
@@ -234,9 +251,9 @@ export default function AlertsPage() {
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{alerts.length > 0 ? (
|
||||
{filteredAlerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert, index) => (
|
||||
{filteredAlerts.map((alert, index) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
@@ -250,11 +267,11 @@ export default function AlertsPage() {
|
||||
<div className="empty-state">
|
||||
<AlertCircle className="empty-state-icon" />
|
||||
<p className="empty-state-text">
|
||||
{filterType !== 'all' || filterActive !== 'all'
|
||||
{filterType !== 'all' || filterStatus !== 'all'
|
||||
? 'Уведомления не найдены. Измените фильтры.'
|
||||
: 'Нет уведомлений. Создайте первое!'}
|
||||
</p>
|
||||
{filterType === 'all' && filterActive === 'all' && (
|
||||
{filterType === 'all' && filterStatus === 'all' && (
|
||||
<button onClick={handleCreate} className="btn-primary">
|
||||
<Plus className="h-5 w-5 mr-2 inline" />
|
||||
Создать уведомление
|
||||
@@ -275,6 +292,13 @@ export default function AlertsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function getAlertStatus(alert, now = new Date()) {
|
||||
if (!alert.is_active) return 'draft';
|
||||
if (alert.end_time && new Date(alert.end_time) < now) return 'expired';
|
||||
if (alert.start_time && new Date(alert.start_time) > now) return 'scheduled';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function FilterButton({ active, onClick, icon: Icon, color = 'primary', children }) {
|
||||
const colors = {
|
||||
primary: active ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
@@ -307,8 +331,14 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
const config = alertIcons[alert.alert_type] || alertIcons.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
const isExpired = alert.end_time && new Date(alert.end_time) < new Date();
|
||||
const isScheduled = new Date(alert.start_time) > new Date();
|
||||
const status = getAlertStatus(alert);
|
||||
const statusLabels = {
|
||||
active: { label: 'В эфире', className: 'bg-green-100 text-green-700' },
|
||||
scheduled: { label: 'Запланировано', className: 'bg-blue-100 text-blue-600' },
|
||||
draft: { label: 'Черновик', className: 'bg-gray-100 text-gray-600' },
|
||||
expired: { label: 'Завершено', className: 'bg-red-100 text-red-600' }
|
||||
};
|
||||
const statusLabel = statusLabels[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -328,21 +358,7 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="font-bold text-gray-900">{alert.title}</h3>
|
||||
<AlertTypeBadge type={alert.alert_type} />
|
||||
{!alert.is_active && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
Неактивно
|
||||
</span>
|
||||
)}
|
||||
{isExpired && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full">
|
||||
Истекло
|
||||
</span>
|
||||
)}
|
||||
{isScheduled && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full">
|
||||
Запланировано
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
|
||||
</div>
|
||||
{alert.route_name && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -372,6 +388,8 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
Конец: {new Date(alert.end_time).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
{status === 'scheduled' && <div className="mt-1 text-blue-700">Push будет отправлен в момент начала</div>}
|
||||
{alert.notification_sent_at && <div className="mt-1 text-green-700">Push отправлен: {new Date(alert.notification_sent_at).toLocaleString('ru-RU')}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-2xl sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col animate-[slideUp_0.3s_ease-out] sm:animate-[scaleIn_0.2s_ease-out]">
|
||||
@@ -447,6 +487,18 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="label">Шаблон</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{templates.map(template => <button type="button" key={template.id} onClick={() => applyTemplate(template)}
|
||||
className={`rounded-lg border p-3 text-left transition-colors ${selectedTemplateId === template.id ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:bg-gray-50'}`}>
|
||||
<span className="block text-sm font-medium text-gray-900">{template.label}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-gray-500">{template.description}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
|
||||
</div>
|
||||
|
||||
{/* Route */}
|
||||
<div>
|
||||
<label className="label">
|
||||
@@ -536,10 +588,24 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||
value={formData.end_time}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
min={formData.start_time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-sm text-blue-900">
|
||||
<div className="flex items-center gap-2 font-medium"><Calendar className="h-4 w-4" />Планирование публикации</div>
|
||||
<p className="mt-1 text-xs leading-5 text-blue-800">
|
||||
{!formData.is_active ? 'Это черновик: пассажиры и push-рассылка его не увидят.' : isScheduled
|
||||
? `Публикация и push-рассылка начнутся ${startDate.toLocaleString('ru-RU')}.`
|
||||
: 'Уведомление будет опубликовано сразу после сохранения.'}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" className="text-xs font-medium underline" onClick={() => setFormData(current => ({ ...current, start_time: toDateTimeInput() }))}>Начать сейчас</button>
|
||||
<button type="button" className="text-xs font-medium underline" onClick={() => setFormData(current => ({ ...current, start_time: toDateTimeInput(new Date(Date.now() + 60 * 60 * 1000)) }))}>Через час</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Checkbox */}
|
||||
<div>
|
||||
<label className="flex items-center p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
@@ -551,9 +617,9 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||
className="h-5 w-5 text-primary-600 border-gray-300 rounded mr-3"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-900 font-medium">Уведомление активно</span>
|
||||
<span className="text-gray-900 font-medium">Опубликовать уведомление</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Неактивные уведомления не отображаются пользователям
|
||||
Снимите отметку, чтобы сохранить черновик без публикации и рассылки
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -574,7 +640,7 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Сохранение…' : 'Сохранить'}
|
||||
{submitting ? 'Сохранение…' : <><Send className="mr-2 inline h-4 w-4" />{saveLabel}</>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
52
frontend/src/pages/QualityPage.css
Normal file
52
frontend/src/pages/QualityPage.css
Normal file
@@ -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; }
|
||||
}
|
||||
142
frontend/src/pages/QualityPage.jsx
Normal file
142
frontend/src/pages/QualityPage.jsx
Normal file
@@ -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 <div className="quality-page">
|
||||
<header className="quality-heading">
|
||||
<div>
|
||||
<h1 className="page-title">Контроль качества</h1>
|
||||
<p>Проверяет данные маршрутов, остановок, расписаний и уведомлений. Ничего не изменяет автоматически.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary quality-refresh" onClick={() => loadReport({ refresh: true })} disabled={loading || refreshing}>
|
||||
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />
|
||||
{refreshing ? 'Проверяем…' : 'Проверить снова'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading && !report ? <PageLoader /> : <>
|
||||
{error && <div className="quality-message quality-message-error" role="alert">
|
||||
<AlertCircle size={20} aria-hidden="true" />
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => loadReport()}>Повторить</button>
|
||||
</div>}
|
||||
|
||||
{report && <>
|
||||
<section className="quality-summary" aria-label="Сводка проверки">
|
||||
<SummaryCard label="Проблем найдено" value={summary.total} icon={ShieldCheck} />
|
||||
<SummaryCard label="Критичных" value={summary.critical} icon={AlertCircle} severity="critical" />
|
||||
<SummaryCard label="Требуют внимания" value={summary.warning} icon={AlertTriangle} severity="warning" />
|
||||
<SummaryCard label="Рекомендаций" value={summary.info} icon={Info} severity="info" />
|
||||
</section>
|
||||
|
||||
<section className="quality-results" aria-labelledby="quality-results-title">
|
||||
<div className="quality-results-heading">
|
||||
<div>
|
||||
<h2 id="quality-results-title">Результаты проверки</h2>
|
||||
<p>{summary.total ? `Проверено: ${formatDate(report.checked_at)}` : `Данные проверены: ${formatDate(report.checked_at)}`}</p>
|
||||
</div>
|
||||
<div className="segmented-control" role="group" aria-label="Фильтр результатов">
|
||||
{FILTERS.map(({ value, label }) => <button type="button" key={value} className="segmented-option"
|
||||
aria-pressed={filter === value} onClick={() => setFilter(value)}>
|
||||
{label}{value !== 'all' && ` · ${summary[value]}`}
|
||||
</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!summary.total ? <div className="quality-empty">
|
||||
<CheckCircle2 size={48} aria-hidden="true" />
|
||||
<div><h3>Критичных проблем не найдено</h3><p>Маршруты, расписания, остановки и уведомления прошли текущие проверки.</p></div>
|
||||
</div> : filteredIssues.length ? <div className="quality-list">
|
||||
{filteredIssues.map((issue) => <QualityIssue key={issue.id} issue={issue} />)}
|
||||
</div> : <div className="quality-empty quality-empty-filtered">
|
||||
<CheckCircle2 size={40} aria-hidden="true" />
|
||||
<div><h3>В этой категории всё в порядке</h3><p>Выберите другой фильтр, чтобы увидеть остальные результаты.</p></div>
|
||||
</div>}
|
||||
</section>
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon: Icon, severity }) {
|
||||
return <article className={`quality-summary-card ${severity ? `is-${severity}` : ''}`}>
|
||||
<span className="quality-summary-icon"><Icon size={20} aria-hidden="true" /></span>
|
||||
<div><p>{label}</p><strong>{value}</strong></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function QualityIssue({ issue }) {
|
||||
const { label, icon: Icon } = severityMeta[issue.severity] || severityMeta.info;
|
||||
return <article className={`quality-issue is-${issue.severity}`}>
|
||||
<div className="quality-issue-icon"><Icon size={20} aria-hidden="true" /></div>
|
||||
<div className="quality-issue-content">
|
||||
<div className="quality-issue-heading"><h3>{issue.title}</h3><span>{label}</span></div>
|
||||
<p>{issue.description}</p>
|
||||
{issue.entity?.label && <span className="quality-issue-entity">{issue.entity.label}</span>}
|
||||
</div>
|
||||
{issue.action?.to && <Link className="quality-issue-action" to={issue.action.to}>
|
||||
{issue.action.label}<ArrowRight size={16} aria-hidden="true" />
|
||||
</Link>}
|
||||
</article>;
|
||||
}
|
||||
18
frontend/src/pages/ScheduleImportPage.css
Normal file
18
frontend/src/pages/ScheduleImportPage.css
Normal file
@@ -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; } }
|
||||
252
frontend/src/pages/ScheduleImportPage.jsx
Normal file
252
frontend/src/pages/ScheduleImportPage.jsx
Normal file
@@ -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 <a href={url} target="_blank" rel="noreferrer" className="import-source-link">
|
||||
{children}<ExternalLink size={14} aria-hidden="true" />
|
||||
</a>;
|
||||
}
|
||||
|
||||
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 <p className="text-sm text-gray-500">{empty}</p>;
|
||||
return [...groups.entries()].map(([label, items]) => <div key={label} className="mt-3">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<div className="import-times mt-2">{items.map((trip, index) => <span key={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}`}
|
||||
</span>)}</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
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 <div className="schedule-import-page space-y-6" aria-busy={Boolean(busy)}>
|
||||
<header>
|
||||
<h1 className="page-title">Импорт расписаний</h1>
|
||||
<p className="mt-2 text-gray-500">Расписания и новости официального сайта автопарка Полоцка.</p>
|
||||
</header>
|
||||
<div className="segmented-control" aria-label="Раздел импорта">
|
||||
{[['schedule', 'Расписания'], ['news', `Новости${news.filter(item => !item.reviewed).length ? ` · ${news.filter(item => !item.reviewed).length}` : ''}`], ['history', 'История']].map(([value, label]) =>
|
||||
<button type="button" key={value} className="segmented-option" aria-pressed={tab === value}
|
||||
disabled={Boolean(busy)} onClick={() => setTab(value)}>{label}</button>)}
|
||||
</div>
|
||||
{error && <div className="import-message import-message-error" role="alert"><AlertCircle size={20} /><div>{error}</div></div>}
|
||||
{notice && <div className="import-message import-message-success" role="status"><Check size={20} /><div>{notice}</div></div>}
|
||||
{busy && <p className="text-sm text-gray-500" role="status">{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}</p>}
|
||||
|
||||
{tab === 'schedule' && <>
|
||||
<section className="card space-y-4">
|
||||
<div className="import-section-heading"><h2>1. Выберите маршрут</h2>
|
||||
<button type="button" className="btn-secondary" disabled={Boolean(busy)} onClick={() => run('loading', loadInitial)}><RefreshCw size={16} /> Обновить список</button></div>
|
||||
<div className="import-grid">
|
||||
<label className="block"><span className="label">Маршрут на сайте</span>
|
||||
<select className="input" value={sourceUrl} disabled={Boolean(busy)} onChange={event => {
|
||||
resetDraft(); setSourceUrl(event.target.value);
|
||||
const source = catalog.find(item => item.url === event.target.value);
|
||||
const matching = routes.filter(route => normNumber(route.route_number) === normNumber(source?.number));
|
||||
setRouteIds(matching.map(route => route.id));
|
||||
}}><option value="">Выберите маршрут</option>{catalog.map(item => <option key={item.url} value={item.url}>№{item.number}</option>)}</select>
|
||||
</label>
|
||||
<fieldset className="min-w-0"><legend className="label">Направления в админке</legend>
|
||||
{!sourceUrl ? <p className="text-sm text-gray-500">Сначала выберите номер на сайте.</p> : matchingRoutes.length ? <>
|
||||
<p className="text-sm text-gray-500 mb-2">Все направления №{selectedSource.number} выбираются автоматически. Оставьте отмеченными те, которые нужно обновить.</p>
|
||||
{matchingRoutes.map(route => <label key={route.id} className="flex items-start gap-3 py-2 text-sm">
|
||||
<input type="checkbox" className="mt-1" checked={routeIds.includes(route.id)} disabled={Boolean(busy)} onChange={event => {
|
||||
resetDraft(); setRouteIds(current => event.target.checked ? [...current, route.id] : current.filter(id => id !== route.id));
|
||||
}} /><span>{route.name} <span className="text-gray-500">· №{route.route_number}</span></span>
|
||||
</label>)}
|
||||
</> : <p className="text-sm text-gray-500">Маршрутов с этим номером нет. Добавьте нужные направления в разделе <Link className="text-primary-600" to="/routes">«Маршруты»</Link>.</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="import-actions">
|
||||
<button type="button" className="btn-primary" disabled={Boolean(busy) || !sourceUrl || !routeIds.length} onClick={() => run('inspect', async () => {
|
||||
setPreview(null); setInspection(null); setConfirmed(false);
|
||||
const { data } = await scheduleImportApi.inspect({ routeIds, sourceUrl });
|
||||
if (!alive.current) return;
|
||||
setInspection(data); setMappings(data.mappings); setValidFrom(data.today);
|
||||
})}><Download size={16} /> Загрузить таблицы</button>
|
||||
{sourceUrl && <SourceLink url={sourceUrl} />}
|
||||
</div>
|
||||
{!routes.length && !busy && <p className="text-sm text-gray-500">Сначала создайте маршрут и добавьте его остановки в разделе <Link className="text-primary-600" to="/routes">«Маршруты»</Link>.</p>}
|
||||
</section>
|
||||
|
||||
{inspection && <section className="card space-y-5">
|
||||
<div className="import-section-heading"><h2>2. Проверьте направления</h2><span className="text-sm text-gray-500">{inspection.source.title}</span></div>
|
||||
<p className="text-sm text-gray-600">Каждое направление получит времена отправления со своей начальной остановки. Начало и конец уже заданы порядком остановок в админке — выбирать их заново не нужно.</p>
|
||||
{inspection.source.rows.some(row => row.departures?.some(trip => trip.viaStopNames.length || trip.daysOfWeek.length || trip.exceptDaysOfWeek.length)) &&
|
||||
<p className="text-sm text-gray-600">Примечания «по дням недели», «кроме» и «через остановку» распознаны и будут сохранены у соответствующих рейсов. Проверьте условия в предпросмотре. Если остановка не найдена в направлении, условие «через» сохранится текстом.</p>}
|
||||
{inspection.source.warnings.length > 0 && <div className="import-message import-message-error" role="alert"><div>
|
||||
<strong>Нужна ручная проверка источника</strong><ul className="list-disc pl-5 mt-2">{inspection.source.warnings.map((warning, i) => <li key={i}>{warning}</li>)}</ul>
|
||||
</div></div>}
|
||||
{inspection.source.directions.length > 0 && <details><summary className="cursor-pointer text-sm font-medium">Остановки и направления на сайте</summary>
|
||||
{inspection.source.directions.map((direction, i) => <p key={i} className="text-sm text-gray-500 mt-2"><strong>{direction.label}:</strong> {direction.stops.join(' → ')}</p>)}
|
||||
</details>}
|
||||
{inspection.directions.map(({ route, stops }) => <div key={route.id} className="import-mapping space-y-3">
|
||||
<h3 className="font-semibold">№{route.route_number} · {route.name}</h3>
|
||||
{stops.length > 1 ? <p className="text-sm text-gray-600">Направление в админке: <strong>{stops[0].name} → {stops.at(-1).name}</strong></p>
|
||||
: <p className="text-sm text-red-700">Добавьте остановки этого направления в редакторе маршрута.</p>}
|
||||
<label className="block"><span className="label">Строка отправлений на сайте · {route.name}</span>
|
||||
<select className="input" disabled={Boolean(busy)} value={mappings[route.id] || ''} onChange={event => changeMapping(route.id, event.target.value)}>
|
||||
<option value="">Выберите начальную остановку на сайте</option>
|
||||
{inspection.departureStops.map(name => <option key={name} value={name}>Отправления: {name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{mappings[route.id] && <p className="text-sm text-gray-600">В этот маршрут попадут времена из строки «{mappings[route.id]}».</p>}
|
||||
{inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row => <details key={row.key}>
|
||||
<summary className="text-sm text-gray-600 cursor-pointer">{row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён</summary>
|
||||
<p className="text-sm text-gray-600 mt-2 leading-7">{row.rawTimes}</p>
|
||||
</details>)}
|
||||
</div>)}
|
||||
{checkpointNames.length > 0 && <div className="rounded-lg bg-gray-50 p-4 text-sm text-gray-600">
|
||||
<h3 className="font-medium text-gray-900">Времена на промежуточных остановках</h3>
|
||||
<p className="mt-1">{checkpointNames.join(', ')} — строки прохождения уже отправившихся автобусов. Они не создают дополнительные рейсы. В этой версии импорта времена на промежуточных остановках не переносятся; используется время проезда между остановками, заданное в маршруте.</p>
|
||||
</div>}
|
||||
{unusedOrigins.length > 0 && <p className="text-sm text-amber-800">Не выбраны отправления от: {unusedOrigins.join(', ')}. Они не будут импортированы. Чтобы загрузить их, выберите соответствующие направления в шаге 1.</p>}
|
||||
<label className="block max-w-xs"><span className="label">Расписание действует с</span><input type="date" className="input" value={validFrom} min={inspection.today} disabled={Boolean(busy)} onChange={event => { setValidFrom(event.target.value); setPreview(null); setConfirmed(false); }} /></label>
|
||||
<p className="text-sm text-gray-500">«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.</p>
|
||||
<button type="button" className="btn-primary" disabled={Boolean(busy) || !complete || !validFrom || inspection.source.warnings.length > 0} onClick={() => run('preview', async () => {
|
||||
setPreview(null); setConfirmed(false);
|
||||
const { data } = await scheduleImportApi.preview({ inspectionId: inspection.id, mappings, validFrom });
|
||||
if (alive.current) setPreview(data);
|
||||
})}>Показать изменения</button>
|
||||
</section>}
|
||||
|
||||
{preview && <section className="card space-y-5">
|
||||
<div className="import-section-heading"><h2>3. Проверьте изменения</h2><span className="text-sm text-gray-500">С {dateLabel(preview.validFrom)}</span></div>
|
||||
<p className="text-sm text-gray-600">Для перечисленных типов дней расписание будет заменено целиком. Удаляемые рейсы и их особые условия показаны ниже. Праздничное расписание не меняется.</p>
|
||||
{preview.plans.map(plan => <section key={plan.routeId} className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">№{plan.route.route_number} · {plan.route.name}</h3>
|
||||
<p className="text-sm text-gray-500">Источник отправлений: {plan.sourceStopName}</p>
|
||||
{plan.notices?.map(notice => <p key={notice} className="text-sm text-amber-800">{notice}</p>)}
|
||||
{plan.changes.map(change => <div key={change.dayType} className="import-mapping">
|
||||
<h3 className="font-semibold">{change.label}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Было {change.before.length} · Станет {change.after.length} · Без изменений {change.unchanged}</p>
|
||||
<div className="import-grid mt-4">
|
||||
<details open={change.added.length > 0}><summary className="text-sm font-medium text-green-800 cursor-pointer">Добавится: {change.added.length}</summary><TripList trips={change.added} stops={plan.stops} empty="Новых рейсов нет" /></details>
|
||||
<details open={change.removed.length > 0}><summary className="text-sm font-medium text-red-700 cursor-pointer">Удалится: {change.removed.length}</summary><TripList trips={change.removed} stops={plan.stops} empty="Удалений нет" /></details>
|
||||
</div>
|
||||
</div>)}</section>)}
|
||||
{preview.hasChanges ? <>
|
||||
<label className="flex items-start gap-3 text-sm"><input className="mt-1" type="checkbox" checked={confirmed} disabled={Boolean(busy)} onChange={event => setConfirmed(event.target.checked)} />
|
||||
<span>Я проверил отправления для каждого направления, дату начала и замену расписаний.</span></label>
|
||||
<button type="button" className="btn-primary" disabled={Boolean(busy) || !confirmed} onClick={() => run('apply', async () => {
|
||||
const { data } = await scheduleImportApi.apply(preview.id);
|
||||
if (!alive.current) return;
|
||||
setNotice(data.cacheWarning || 'Расписание сохранено. Импорт добавлен в историю.');
|
||||
setPreview(null); setInspection(null); setConfirmed(false);
|
||||
try { const result = await scheduleImportApi.history(); if (alive.current) setHistory(result.data); } catch { /* The save already succeeded. */ }
|
||||
})}><Check size={16} /> Применить к выбранным направлениям</button>
|
||||
</> : <p className="import-message import-message-success">Расписание уже совпадает с источником. Сохранять повторно не нужно.</p>}
|
||||
</section>}
|
||||
</>}
|
||||
|
||||
{tab === 'news' && <section className="space-y-4">
|
||||
<div className="card space-y-3"><div className="import-section-heading"><h2>Новости автопарка</h2>
|
||||
<button type="button" className="btn-primary" disabled={Boolean(busy)} onClick={() => run('news', async () => { const { data } = await scheduleImportApi.refreshNews(); if (alive.current) setNews(data); })}><RefreshCw size={16} /> Проверить новости</button></div>
|
||||
<p className="text-sm text-gray-600">Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.</p>
|
||||
<SourceLink url="https://ap2polotsk.of.by/ap2/" />
|
||||
</div>
|
||||
{!news.length && !busy && <div className="card text-gray-500">Новостей пока нет. Нажмите «Проверить новости».</div>}
|
||||
{news.map(item => <article className="card space-y-3" key={item.url}>
|
||||
<div className="flex items-center flex-wrap gap-3 text-sm text-gray-500"><span>Опубликовано {dateLabel(item.published_date)}</span><span className={item.reviewed ? 'text-green-800' : 'text-amber-700'}>{item.reviewed ? 'Проверено' : 'Требует проверки'}</span></div>
|
||||
<h3 className="font-semibold text-lg">{item.title}</h3>
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">{item.content}</p>
|
||||
<div className="import-actions"><SourceLink url={item.url} />
|
||||
{!item.reviewed && <button type="button" className="btn-secondary" disabled={Boolean(busy)} onClick={() => run('review', async () => {
|
||||
await scheduleImportApi.reviewNews({ url: item.url, contentHash: item.content_hash });
|
||||
if (alive.current) setNews(current => current.map(n => n.url === item.url ? { ...n, reviewed: true } : n));
|
||||
})}><Check size={16} /> Отметить проверенной</button>}
|
||||
</div>
|
||||
</article>)}
|
||||
</section>}
|
||||
|
||||
{tab === 'history' && <section className="card space-y-4"><h2>Применённые импорты</h2>
|
||||
{!history.length && <p className="text-gray-500">После первого применения здесь появится запись об импорте.</p>}
|
||||
{history.map(item => <div key={item.id} className="import-history-row">
|
||||
<div><p className="font-medium">{item.directions?.length ? item.directions.map(route => `№${route.route_number} · ${route.name}`).join('; ') : `Маршрут №${item.route_number || '—'} · ${item.route_name || 'Удалён'}`}</p>
|
||||
<p className="text-sm text-gray-500">Действует с {dateLabel(item.valid_from)} · Импорт {new Date(item.applied_at).toLocaleString('ru-RU')}</p></div>
|
||||
<SourceLink url={item.source_url} />
|
||||
</div>)}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@@ -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() {
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4 sm:mb-6">
|
||||
<h1 className="page-title">Расписания</h1>
|
||||
{isAdmin && <Link to="/schedule-import" className="btn-secondary">Импорт с сайта</Link>}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Holidays Info */}
|
||||
@@ -255,6 +270,7 @@ export default function SchedulesPage() {
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{dayType.label}</h3>
|
||||
<p className="text-sm text-gray-500">{dayType.description}</p>
|
||||
{schedule?.valid_from && <p className="text-xs text-gray-500">С {new Date(schedule.valid_from).toLocaleDateString('ru-RU')}</p>}
|
||||
</div>
|
||||
{schedule ? (
|
||||
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
||||
@@ -269,6 +285,19 @@ export default function SchedulesPage() {
|
||||
|
||||
{schedule ? (
|
||||
<>
|
||||
{schedules.filter(s => s.day_type === dayType.value).length > 1 && (
|
||||
<label className="block mb-3">
|
||||
<span className="label">Версия расписания · {dayType.label}</span>
|
||||
<select className="input" value={schedule.id} onChange={event => setSelectedVersions(current => ({ ...current, [dayType.value]: Number(event.target.value) }))}>
|
||||
{schedules.filter(s => s.day_type === dayType.value)
|
||||
.sort((a, b) => (b.valid_from || '').localeCompare(a.valid_from || ''))
|
||||
.map(version => <option key={version.id} value={version.id}>
|
||||
{version.valid_from ? new Date(version.valid_from).toLocaleDateString('ru-RU') : 'Без даты начала'}
|
||||
{' — '}{version.valid_until ? new Date(version.valid_until).toLocaleDateString('ru-RU') : 'бессрочно'}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedSchedule(isExpanded ? null : dayType.value)}
|
||||
className="w-full text-left text-sm text-gray-600 hover:text-gray-900 flex items-center justify-between py-2 border-t border-gray-100"
|
||||
|
||||
@@ -128,6 +128,10 @@ export const syncApi = {
|
||||
getStatus: () => api.get('/sync/status')
|
||||
};
|
||||
|
||||
export const qualityApi = {
|
||||
getReport: () => api.get('/quality')
|
||||
};
|
||||
|
||||
// Schedules API (расписания)
|
||||
export const schedulesApi = {
|
||||
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
||||
@@ -136,7 +140,18 @@ export const schedulesApi = {
|
||||
create: (data) => api.post('/schedules', data),
|
||||
update: (id, data) => api.put(`/schedules/${id}`, data),
|
||||
delete: (id) => api.delete(`/schedules/${id}`),
|
||||
copy: (routeId, fromDayType, toDayType) => api.post(`/routes/${routeId}/schedules/copy`, { fromDayType, toDayType })
|
||||
copy: (routeId, fromDayType, toDayType, sourceScheduleId) => api.post(`/routes/${routeId}/schedules/copy`, { fromDayType, toDayType, sourceScheduleId })
|
||||
};
|
||||
|
||||
export const scheduleImportApi = {
|
||||
catalog: () => api.get('/schedule-import/catalog'),
|
||||
inspect: (data) => api.post('/schedule-import/inspect', data),
|
||||
preview: (data) => api.post('/schedule-import/preview', data),
|
||||
apply: (id) => api.post(`/schedule-import/${id}/apply`),
|
||||
history: () => api.get('/schedule-import/history'),
|
||||
news: () => api.get('/schedule-import/news'),
|
||||
refreshNews: () => api.post('/schedule-import/news/refresh'),
|
||||
reviewNews: (data) => api.post('/schedule-import/news/review', data),
|
||||
};
|
||||
|
||||
// Holidays API (праздники)
|
||||
@@ -154,6 +169,7 @@ export const holidaysApi = {
|
||||
export const alertsApi = {
|
||||
getAll: (params) => api.get('/alerts', { params }),
|
||||
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
||||
getTemplates: () => api.get('/alerts/templates'),
|
||||
getById: (id) => api.get(`/alerts/${id}`),
|
||||
create: (data, idempotencyKey) => api.post('/alerts', data, {
|
||||
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined
|
||||
|
||||
Reference in New Issue
Block a user