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:
2026-09-16 21:55:50 +03:00
parent 6804ca5cdb
commit 595ad6456b
36 changed files with 2496 additions and 72 deletions

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
logs
test
.env
.env.*
*.log
.DS_Store

View 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()
);

View 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;

View File

@@ -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",

View 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;

View File

@@ -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]));

View File

@@ -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}`);

View File

@@ -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),

View File

@@ -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));

View 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;

View File

@@ -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
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View File

@@ -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;

View 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);
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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/);
});

View 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();
}
});

View 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
View 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.

View File

@@ -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>} />

View File

@@ -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 },

View File

@@ -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>

View 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; }
}

View 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>;
}

View 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; } }

View 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>;
}

View File

@@ -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"

View File

@@ -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