feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management
- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates. - Added vehicle icons, status labels, and filtering options for routes. - Created ScheduleCenterPage to display schedule versions, conflicts, and gaps. - Introduced responsive design for both pages with dedicated CSS styles. - Integrated API calls for fetching routes and vehicle data.
This commit is contained in:
@@ -65,6 +65,38 @@ class CitiesController {
|
||||
}
|
||||
}
|
||||
|
||||
async updateCity(req, res) {
|
||||
const cityId = Number(req.params.cityId);
|
||||
if (!Number.isInteger(cityId) || cityId <= 0) {
|
||||
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
|
||||
}
|
||||
|
||||
const { name, slug, emblem, center_lat, center_lon } = req.body;
|
||||
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
UPDATE cities
|
||||
SET name = $1, slug = $2, emblem = $3, center_lat = $4, center_lon = $5
|
||||
WHERE id = $6
|
||||
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
|
||||
`, [
|
||||
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
|
||||
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
|
||||
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
|
||||
cityId
|
||||
]);
|
||||
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Город не найден.' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
|
||||
logger.error('Error updating city', { cityId, message: error.message });
|
||||
res.status(500).json({ error: 'Не удалось сохранить изменения города.' });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadCityAvatar(req, res) {
|
||||
const cityId = Number(req.params.cityId);
|
||||
if (!Number.isInteger(cityId) || cityId <= 0) {
|
||||
|
||||
48
backend/src/controllers/scheduleCenterController.js
Normal file
48
backend/src/controllers/scheduleCenterController.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const logger = require('../config/logger');
|
||||
const { buildScheduleCenter, formatMinskDate, parseDate } = require('../services/scheduleCenterReport');
|
||||
|
||||
const SCHEDULE_CENTER_QUERY = `
|
||||
SELECT
|
||||
r.id AS route_id,
|
||||
r.route_number,
|
||||
r.name AS route_name,
|
||||
r.color,
|
||||
s.id AS schedule_id,
|
||||
s.day_type,
|
||||
s.valid_from,
|
||||
s.valid_until,
|
||||
GREATEST(
|
||||
COALESCE(cardinality(s.departure_times), 0),
|
||||
CASE WHEN jsonb_typeof(s.trips) = 'array' THEN jsonb_array_length(s.trips) ELSE 0 END
|
||||
)::integer AS departure_count
|
||||
FROM routes r
|
||||
LEFT JOIN schedules s ON s.route_id = r.id
|
||||
WHERE r.city_id = $1 AND r.is_active = true
|
||||
ORDER BY r.route_number, r.name, s.day_type, s.valid_from NULLS FIRST, s.id
|
||||
`;
|
||||
|
||||
class ScheduleCenterController {
|
||||
constructor({ pool, report = buildScheduleCenter } = {}) {
|
||||
this.pool = pool || require('../config/database');
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
async getOverview(req, res) {
|
||||
const targetDate = req.query.date || formatMinskDate();
|
||||
if (!parseDate(targetDate)) {
|
||||
return res.status(400).json({ error: 'Дата должна быть в формате ГГГГ-ММ-ДД.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await this.pool.query(SCHEDULE_CENTER_QUERY, [req.city.id]);
|
||||
return res.json(this.report(rows, targetDate));
|
||||
} catch (error) {
|
||||
logger.error('Error building schedule center report', { message: error.message, cityId: req.city?.id });
|
||||
return res.status(500).json({ error: 'Не удалось загрузить центр расписаний. Повторите попытку.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ScheduleCenterController();
|
||||
module.exports.ScheduleCenterController = ScheduleCenterController;
|
||||
module.exports.SCHEDULE_CENTER_QUERY = SCHEDULE_CENTER_QUERY;
|
||||
@@ -15,6 +15,7 @@ const { createPassengerTelemetryRateLimiter } = require('../middleware/passenger
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { resolveCity } = require('../middleware/city');
|
||||
const qualityController = require('../controllers/qualityController');
|
||||
const scheduleCenterController = require('../controllers/scheduleCenterController');
|
||||
|
||||
// City catalogue is global. Every transport-data endpoint below receives a
|
||||
// verified city context from X-City-Id (or falls back to the legacy Polotsk city).
|
||||
@@ -23,6 +24,7 @@ router.use(resolveCity);
|
||||
|
||||
router.use('/schedule-import', require('./scheduleImport'));
|
||||
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
|
||||
router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController));
|
||||
|
||||
// Routes
|
||||
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
||||
|
||||
@@ -7,6 +7,7 @@ const router = express.Router();
|
||||
|
||||
router.get('/', citiesController.getAllCities.bind(citiesController));
|
||||
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
|
||||
router.put('/:cityId', authenticateToken, requireAdmin, citiesController.updateCity.bind(citiesController));
|
||||
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
194
backend/src/services/scheduleCenterReport.js
Normal file
194
backend/src/services/scheduleCenterReport.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const DAY_TYPES = [
|
||||
{ value: 'weekday', label: 'Будние дни', shortLabel: 'Пн–Пт' },
|
||||
{ value: 'saturday', label: 'Суббота', shortLabel: 'Сб' },
|
||||
{ value: 'sunday', label: 'Воскресенье', shortLabel: 'Вс' },
|
||||
];
|
||||
|
||||
const DAY_TYPE_BY_VALUE = new Map(DAY_TYPES.map((dayType) => [dayType.value, dayType]));
|
||||
|
||||
function toDateKey(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') return value.slice(0, 10);
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDate(date) {
|
||||
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
const parsed = new Date(`${date}T00:00:00.000Z`);
|
||||
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date ? null : parsed;
|
||||
}
|
||||
|
||||
function addDays(date, amount) {
|
||||
const parsed = parseDate(date);
|
||||
parsed.setUTCDate(parsed.getUTCDate() + amount);
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatMinskDate(date = new Date()) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function versionState(version, targetDate) {
|
||||
if (version.valid_from && version.valid_until && version.valid_until < version.valid_from) return 'invalid';
|
||||
if ((!version.valid_from || version.valid_from <= targetDate)
|
||||
&& (!version.valid_until || version.valid_until >= targetDate)) return 'active';
|
||||
if (version.valid_from && version.valid_from > targetDate) return 'upcoming';
|
||||
return 'past';
|
||||
}
|
||||
|
||||
function overlaps(left, right) {
|
||||
return (!left.valid_until || !right.valid_from || left.valid_until >= right.valid_from)
|
||||
&& (!right.valid_until || !left.valid_from || right.valid_until >= left.valid_from);
|
||||
}
|
||||
|
||||
function buildCoverageGaps(versions, targetDate, windowEnd) {
|
||||
const periods = versions
|
||||
.filter((version) => version.state !== 'invalid')
|
||||
.filter((version) => !version.valid_until || version.valid_until >= targetDate)
|
||||
.filter((version) => !version.valid_from || version.valid_from <= windowEnd)
|
||||
.map((version) => ({
|
||||
start: version.valid_from && version.valid_from > targetDate ? version.valid_from : targetDate,
|
||||
end: version.valid_until && version.valid_until < windowEnd ? version.valid_until : windowEnd,
|
||||
}))
|
||||
.sort((left, right) => left.start.localeCompare(right.start));
|
||||
|
||||
const gaps = [];
|
||||
let cursor = targetDate;
|
||||
|
||||
for (const period of periods) {
|
||||
if (period.start > cursor) {
|
||||
gaps.push({ starts_on: cursor, ends_on: addDays(period.start, -1) });
|
||||
}
|
||||
if (period.end >= cursor) cursor = addDays(period.end, 1);
|
||||
if (cursor > windowEnd) break;
|
||||
}
|
||||
|
||||
if (cursor <= windowEnd) gaps.push({ starts_on: cursor, ends_on: windowEnd });
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function makeIssue({ code, severity, route, dayType, description, versionIds = [], range = null }) {
|
||||
const idParts = [code, route.route_id, dayType?.value || 'all', ...versionIds, range?.starts_on || ''];
|
||||
return {
|
||||
id: idParts.join(':'),
|
||||
code,
|
||||
severity,
|
||||
title: code === 'schedule_overlap' ? 'Пересекаются версии'
|
||||
: code === 'invalid_period' ? 'Некорректный период версии'
|
||||
: 'Нет действующей версии',
|
||||
description,
|
||||
route_id: route.route_id,
|
||||
route_label: `№${route.route_number} · ${route.route_name}`,
|
||||
day_type: dayType?.value || null,
|
||||
day_type_label: dayType?.label || null,
|
||||
version_ids: versionIds,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
function createRoute(row) {
|
||||
return {
|
||||
route_id: row.route_id,
|
||||
route_number: row.route_number,
|
||||
route_name: row.route_name,
|
||||
color: row.color || '#525252',
|
||||
day_types: DAY_TYPES.map((dayType) => ({
|
||||
...dayType,
|
||||
versions: [],
|
||||
gaps: [],
|
||||
status: 'gap',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildScheduleCenter(rows, targetDate = formatMinskDate(), { windowDays = 90 } = {}) {
|
||||
if (!parseDate(targetDate)) throw new Error('Invalid target date');
|
||||
const windowEnd = addDays(targetDate, windowDays);
|
||||
const routesById = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!routesById.has(row.route_id)) routesById.set(row.route_id, createRoute(row));
|
||||
if (!row.schedule_id || !DAY_TYPE_BY_VALUE.has(row.day_type)) continue;
|
||||
|
||||
const route = routesById.get(row.route_id);
|
||||
const dayType = route.day_types.find((item) => item.value === row.day_type);
|
||||
const version = {
|
||||
id: row.schedule_id,
|
||||
valid_from: toDateKey(row.valid_from),
|
||||
valid_until: toDateKey(row.valid_until),
|
||||
departure_count: Number(row.departure_count || 0),
|
||||
state: 'past',
|
||||
has_conflict: false,
|
||||
};
|
||||
version.state = versionState(version, targetDate);
|
||||
dayType.versions.push(version);
|
||||
}
|
||||
|
||||
const routes = [...routesById.values()]
|
||||
.sort((left, right) => String(left.route_number).localeCompare(String(right.route_number), 'ru', { numeric: true })
|
||||
|| left.route_name.localeCompare(right.route_name, 'ru'));
|
||||
const issues = [];
|
||||
let active = 0;
|
||||
let upcoming = 0;
|
||||
let conflicts = 0;
|
||||
let gaps = 0;
|
||||
|
||||
for (const route of routes) {
|
||||
for (const dayType of route.day_types) {
|
||||
dayType.versions.sort((left, right) => (left.valid_from || '').localeCompare(right.valid_from || '') || left.id - right.id);
|
||||
|
||||
for (const version of dayType.versions) {
|
||||
if (version.state === 'active') active += 1;
|
||||
if (version.state === 'upcoming') upcoming += 1;
|
||||
if (version.state !== 'invalid') continue;
|
||||
issues.push(makeIssue({
|
||||
code: 'invalid_period', severity: 'critical', route, dayType, versionIds: [version.id],
|
||||
description: `Версия №${version.id}: дата окончания раньше даты начала.`,
|
||||
}));
|
||||
}
|
||||
|
||||
for (let index = 0; index < dayType.versions.length; index += 1) {
|
||||
for (let otherIndex = index + 1; otherIndex < dayType.versions.length; otherIndex += 1) {
|
||||
const left = dayType.versions[index];
|
||||
const right = dayType.versions[otherIndex];
|
||||
if (left.state === 'invalid' || right.state === 'invalid' || !overlaps(left, right)) continue;
|
||||
left.has_conflict = true;
|
||||
right.has_conflict = true;
|
||||
conflicts += 1;
|
||||
issues.push(makeIssue({
|
||||
code: 'schedule_overlap', severity: 'critical', route, dayType, versionIds: [left.id, right.id],
|
||||
description: `Версии №${left.id} и №${right.id} действуют одновременно.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
dayType.gaps = buildCoverageGaps(dayType.versions, targetDate, windowEnd);
|
||||
if (dayType.versions.some((version) => version.state === 'active')) dayType.status = 'active';
|
||||
else if (dayType.versions.some((version) => version.state === 'upcoming')) dayType.status = 'upcoming';
|
||||
|
||||
for (const gap of dayType.gaps) {
|
||||
gaps += 1;
|
||||
issues.push(makeIssue({
|
||||
code: 'schedule_gap', severity: 'warning', route, dayType, range: gap,
|
||||
description: gap.starts_on === gap.ends_on
|
||||
? `На ${gap.starts_on} нет версии расписания.`
|
||||
: `С ${gap.starts_on} по ${gap.ends_on} нет версии расписания.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
target_date: targetDate,
|
||||
window_end: windowEnd,
|
||||
summary: { routes: routes.length, active, upcoming, conflicts, gaps },
|
||||
routes,
|
||||
issues: issues.sort((left, right) => left.severity.localeCompare(right.severity)
|
||||
|| left.route_label.localeCompare(right.route_label, 'ru') || left.id.localeCompare(right.id)),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { DAY_TYPES, addDays, buildScheduleCenter, formatMinskDate, parseDate };
|
||||
36
backend/test/scheduleCenterReport.test.js
Normal file
36
backend/test/scheduleCenterReport.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { buildScheduleCenter, parseDate } = require('../src/services/scheduleCenterReport');
|
||||
|
||||
test('schedule center separates active and future versions and detects gaps and overlaps', () => {
|
||||
const report = buildScheduleCenter([
|
||||
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 1, day_type: 'weekday', valid_from: null, valid_until: null, departure_count: 12 },
|
||||
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 2, day_type: 'saturday', valid_from: '2026-10-01', valid_until: null, departure_count: 6 },
|
||||
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 3, day_type: 'sunday', valid_from: null, valid_until: '2026-09-30', departure_count: 5 },
|
||||
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 4, day_type: 'sunday', valid_from: '2026-09-25', valid_until: null, departure_count: 5 },
|
||||
], '2026-09-18');
|
||||
|
||||
assert.deepEqual(report.summary, { routes: 1, active: 2, upcoming: 2, conflicts: 1, gaps: 1 });
|
||||
assert.equal(report.window_end, '2026-12-17');
|
||||
const saturday = report.routes[0].day_types.find((dayType) => dayType.value === 'saturday');
|
||||
assert.equal(saturday.status, 'upcoming');
|
||||
assert.deepEqual(saturday.gaps, [{ starts_on: '2026-09-18', ends_on: '2026-09-30' }]);
|
||||
assert.equal(report.issues.find((issue) => issue.code === 'schedule_overlap').route_id, 7);
|
||||
assert.deepEqual(report.issues.find((issue) => issue.code === 'schedule_overlap').version_ids, [3, 4]);
|
||||
});
|
||||
|
||||
test('schedule center reports uncovered day types for routes without versions', () => {
|
||||
const report = buildScheduleCenter([
|
||||
{ route_id: 3, route_number: '2', route_name: 'Рынок — Больница', color: '#dc2626', schedule_id: null },
|
||||
], '2026-09-18', { windowDays: 7 });
|
||||
|
||||
assert.equal(report.summary.gaps, 3);
|
||||
assert.equal(report.issues.filter((issue) => issue.code === 'schedule_gap').length, 3);
|
||||
assert.equal(report.routes[0].day_types.every((dayType) => dayType.status === 'gap'), true);
|
||||
});
|
||||
|
||||
test('date validation accepts calendar dates only', () => {
|
||||
assert.ok(parseDate('2026-02-28'));
|
||||
assert.equal(parseDate('2026-02-29'), null);
|
||||
assert.equal(parseDate('18.09.2026'), null);
|
||||
});
|
||||
Reference in New Issue
Block a user