From 666a0b268ff22644d994dead79d8388e3f6caaaf Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sat, 3 Oct 2026 01:31:11 +0300 Subject: [PATCH] feat: implement stop alias management for Telegram import - Added TelegramStopAliasService for handling stop aliases in the database. - Created API endpoints for managing stop aliases (list, create, update, delete). - Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases. - Updated TelegramImportService to utilize stop aliases for message processing. - Enhanced telegramCancellationParser to resolve stop names using aliases. - Added migration script to create telegram_stop_aliases table and seed initial data. - Updated documentation to include details about stop alias functionality. --- .../migrations/014_telegram_stop_aliases.sql | 26 +++++ backend/src/routes/telegramImport.js | 15 ++- .../services/telegramCancellationParser.js | 46 +++++++-- backend/src/services/telegramImportService.js | 49 ++++++++-- .../src/services/telegramStopAliasService.js | 46 +++++++++ .../test/telegramCancellationParser.test.js | 30 +++++- .../test/telegramImport.integration.test.js | 80 +++++++++++++++- backend/test/telegramImportApi.test.js | 19 +++- docs/telegram-cancellation-import.md | 29 +++++- .../src/components/TelegramImportPanel.jsx | 24 ++++- .../src/components/TelegramStopAliases.jsx | 94 +++++++++++++++++++ frontend/src/services/api.js | 6 +- 12 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 backend/migrations/014_telegram_stop_aliases.sql create mode 100644 backend/src/services/telegramStopAliasService.js create mode 100644 frontend/src/components/TelegramStopAliases.jsx diff --git a/backend/migrations/014_telegram_stop_aliases.sql b/backend/migrations/014_telegram_stop_aliases.sql new file mode 100644 index 0000000..e6933b6 --- /dev/null +++ b/backend/migrations/014_telegram_stop_aliases.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS telegram_stop_aliases ( + id SERIAL PRIMARY KEY, + city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE CASCADE, + alias VARCHAR(200) NOT NULL, + normalized_alias VARCHAR(200) NOT NULL CHECK (length(normalized_alias) > 0), + stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (city_id, normalized_alias) +); + +-- Known carrier abbreviations are seeded only when the Polotsk stop is unique. +-- Ambiguous platforms must be selected explicitly by an administrator. +WITH defaults(alias, normalized_alias, stop_name) AS ( + VALUES ('АВ/ВОКЗАЛ', 'аввокзал', 'Автовокзал'), + ('ЗОДЧЕГО', 'зодчего', 'Зодчего Иоанна') +), candidates AS ( + SELECT c.id AS city_id, d.alias, d.normalized_alias, min(s.id) AS stop_id + FROM defaults d JOIN cities c ON c.slug = 'polotsk' + JOIN stops s ON s.city_id = c.id AND s.is_active = true + AND lower(trim(s.name)) = lower(d.stop_name) + GROUP BY c.id, d.alias, d.normalized_alias HAVING count(*) = 1 +) +INSERT INTO telegram_stop_aliases(city_id, alias, normalized_alias, stop_id) +SELECT city_id, alias, normalized_alias, stop_id FROM candidates +ON CONFLICT (city_id, normalized_alias) DO NOTHING; diff --git a/backend/src/routes/telegramImport.js b/backend/src/routes/telegramImport.js index 4eb3238..e537257 100644 --- a/backend/src/routes/telegramImport.js +++ b/backend/src/routes/telegramImport.js @@ -7,9 +7,22 @@ function createTelegramImportRouter(service = getTelegramImportService()) { router.use(authenticateToken, requireAdmin); const handle = action => async (req, res) => { try { res.json(await action(req)); } - catch (error) { res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось обработать импорт Telegram. Проверьте миграцию 013 и подключение базы.' }); } + catch (error) { res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось обработать импорт Telegram. Проверьте миграции 013–014 и подключение базы.' }); } + }; + const aliasId = req => { + const id = Number(req.params.id); + if (!/^\d+$/.test(req.params.id) || !Number.isSafeInteger(id) || id <= 0) { + const error = new Error('Некорректный идентификатор соответствия.'); + error.status = 400; + throw error; + } + return id; }; router.get('/', handle(async req => ({ status: service.getStatus(), messages: await service.list(req.city.id) }))); + router.get('/stop-aliases', handle(req => service.stopAliases.list(req.city.id))); + router.post('/stop-aliases', handle(req => service.stopAliases.save(req.city.id, req.body))); + router.put('/stop-aliases/:id', handle(req => service.stopAliases.save(req.city.id, req.body, aliasId(req)))); + router.delete('/stop-aliases/:id', handle(req => service.stopAliases.remove(aliasId(req), req.city.id))); router.post('/sync', handle(req => { if (req.city.slug !== service.config.citySlug) { const error = new Error('Источник Telegram настроен для другого города.'); diff --git a/backend/src/services/telegramCancellationParser.js b/backend/src/services/telegramCancellationParser.js index 10794e6..2dd6c90 100644 --- a/backend/src/services/telegramCancellationParser.js +++ b/backend/src/services/telegramCancellationParser.js @@ -61,13 +61,16 @@ function parseDate(text, publishedAt) { } function normalizeRoute(value) { return String(value).trim().toLowerCase(); } +function normalizeStopKey(value) { + return String(value || '').toLowerCase().replace(/ё/g, 'е').replace(/[^\p{L}\p{N}]/gu, ''); +} function normalizeStop(value) { - const key = String(value).toLowerCase().replace(/ё/g, 'е').replace(/[^а-яa-z0-9]/g, ''); + const key = normalizeStopKey(value); return key === 'аввокзал' ? 'автовокзал' : key; } function canonicalStop(value) { return normalizeStop(value) === 'автовокзал' ? 'Автовокзал' : value; } -function parseCancellation({ text = '', publishedAt, hasMedia = false }) { +function parseCancellation({ text = '', publishedAt, hasMedia = false }, { preserveSourceStops = false } = {}) { const normalized = text.replace(/\u00a0/g, ' ').replace(/\r/g, '').trim(); let serviceDate; const review = reason => ({ status: 'review', reasons: [reason], groups: [], ...(serviceDate ? { serviceDate } : {}) }); @@ -99,7 +102,7 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }) { if (times.some(t => Number(t.slice(0, 2)) > 23 || Number(t.slice(3)) > 59)) return review('Некорректное время отправления.'); const stopName = stop[1].replace(/^[\s«"“]+|[\s»"”.,:;]+$/g, '').trim(); if (!stopName || !times.length) return review('Не удалось определить остановку и время.'); - departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)] }); + departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)], ...(preserveSourceStops ? { sourceStop: stopName } : {}) }); } if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) { return review('Не удалось однозначно связать все времена с остановками.'); @@ -109,12 +112,32 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }) { return { status: 'ready', reasons: [], serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups }; } +function resolveCancellationStops(parsed, stops, aliases) { + const byId = new Map(stops.map(stop => [Number(stop.id), stop])); + const byAlias = new Map(aliases.map(alias => [alias.normalized_alias, alias])); + const groups = parsed.groups.map(group => ({ ...group, departures: group.departures.map(departure => { + const sourceStop = departure.sourceStop || departure.stop; + const alias = byAlias.get(normalizeStopKey(sourceStop)); + const matches = alias ? [byId.get(Number(alias.stop_id))].filter(Boolean) + : stops.filter(stop => normalizeStop(stop.name) === normalizeStop(sourceStop)); + const stop = matches.length === 1 ? matches[0] : null; + const readable = departure.stop === departure.stop.toUpperCase() + ? departure.stop.charAt(0).toUpperCase() + departure.stop.slice(1).toLowerCase() : departure.stop; + return { ...departure, sourceStop, stop: stop?.name || readable, + ...(stop ? { stopId: Number(stop.id) } : {}), + ...(alias && !stop ? { invalidAlias: true } : {}) }; + }) })); + return { ...parsed, groups }; +} + function matchCancellationRoutes(parsed, routes) { const plans = new Map(); for (const group of parsed.groups) { const candidates = routes.filter(r => normalizeRoute(r.route_number) === group.routeNumber); for (const departure of group.departures) { - const matches = candidates.length === 1 ? candidates : candidates.filter(r => normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop)); + if (departure.invalidAlias) return { reasons: [`Остановка для «${departure.sourceStop}» отключена. Обновите словарь названий.`], plans: [] }; + const matches = candidates.length === 1 ? candidates : candidates.filter(r => departure.stopId + ? Number(r.origin_stop_id) === departure.stopId : normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop)); if (matches.length !== 1) return { reasons: [`Маршрут №${group.routeNumber}, остановка «${departure.stop}»: направление не определено однозначно.`], plans: [] }; const route = matches[0]; if (!plans.has(route.id)) plans.set(route.id, { routeId: route.id, routeNumber: route.route_number, departures: [] }); @@ -124,16 +147,23 @@ function matchCancellationRoutes(parsed, routes) { return { reasons: [], plans: [...plans.values()] }; } +function buildCancellationDraft(plan, serviceDate) { + return { + title: `Отмена отдельных рейсов №${plan.routeNumber}`, + message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plan.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}` + }; +} + function buildCancellationAlert(plan, serviceDate, sourceUrl, publishedAt, cityId) { const bounds = dayBounds(serviceDate); - const dateLabel = serviceDate.split('-').reverse().join('.'); + const draft = buildCancellationDraft(plan, serviceDate); const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString(); return { city_id: cityId, route_id: plan.routeId, alert_type: 'cancellation', - title: `Отмена отдельных рейсов маршрута №${plan.routeNumber}`, - message: `${dateLabel}: не выполняются отправления ${plan.departures.map(d => `с остановки «${d.stop}» в ${d.times.join(', ')}`).join('; ')}.\nИсточник: ${sourceUrl}`, + ...draft, + message: `${draft.message}\nИсточник: ${sourceUrl}`, start_time: start, end_time: bounds.end, is_active: true }; } -module.exports = { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds, validDate, addDays }; +module.exports = { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, normalizeStopKey, dayBounds, validDate, addDays }; diff --git a/backend/src/services/telegramImportService.js b/backend/src/services/telegramImportService.js index 3f896aa..50fdf92 100644 --- a/backend/src/services/telegramImportService.js +++ b/backend/src/services/telegramImportService.js @@ -2,8 +2,9 @@ const crypto = require('node:crypto'); const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram'); const { getLocalDateTime } = require('../utils/etaTime'); const { TelegramSource } = require('./telegramSource'); -const { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser'); +const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser'); const { AlertCreationService } = require('./alertCreationService'); +const { TelegramStopAliasService } = require('./telegramStopAliasService'); function httpError(status, message) { return Object.assign(new Error(message), { status }); } function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; } @@ -11,6 +12,19 @@ function contentHash(message) { return crypto.createHash('sha256').update(JSON.stringify([message.text, message.hasMedia, message.publishedAt])).digest('hex'); } +function messageDrafts(parsed) { + if (!validDate(parsed.serviceDate)) return []; + const groups = new Map(); + for (const group of parsed.groups || []) { + if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] }); + groups.get(group.routeNumber).departures.push(...group.departures); + } + return [ + ...(parsed.plans || []).map(plan => ({ routeId: plan.routeId, routeNumber: plan.routeNumber, ...buildCancellationDraft(plan, parsed.serviceDate) })), + ...[...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) })) + ]; +} + function publicError(error) { const known = ['AUTH_REQUIRED', 'USER_ACCOUNT_REQUIRED', 'SESSION_MISSING', 'SESSION_FILE_INVALID', 'SESSION_FILE_IN_CHECKOUT', 'SESSION_FILE_PERMISSIONS', 'CHANNEL_INVALID', 'CHANNEL_PRIVATE', 'USERNAME_NOT_OCCUPIED', 'HISTORY_LIMIT', 'AUTH_KEY_UNREGISTERED', 'SESSION_REVOKED']; const value = error.errorMessage || error.message; @@ -31,6 +45,7 @@ class TelegramImportService { this.stopped = false; this.state = { connection: config.enabled ? 'waiting' : 'disabled', lastSyncAt: null, lastError: null, retryAt: null }; this.alerts = new AlertCreationService({ pool: this.pool, logger: this.logger }); + this.stopAliases = new TelegramStopAliasService(this.pool); } getStatus() { @@ -77,17 +92,23 @@ class TelegramImportService { } async getRoutes(db, cityId) { - return (await db.query(`SELECT r.id, r.route_number, r.name, origin.name AS origin_stop + return (await db.query(`SELECT r.id, r.route_number, r.name, origin.name AS origin_stop, origin.id AS origin_stop_id FROM routes r LEFT JOIN LATERAL ( - SELECT s.name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id + SELECT s.id, s.name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id AND s.city_id = r.city_id AND s.is_active=true WHERE rs.route_id = r.id ORDER BY rs.sequence LIMIT 1 - ) origin ON true WHERE r.city_id = $1 AND r.is_active = true`, [cityId])).rows; + ) origin ON true WHERE r.city_id = $1 AND r.is_active = true ORDER BY r.id`, [cityId])).rows; + } + + async getStopContext(db, cityId) { + const stops = (await db.query('SELECT id,name FROM stops WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows; + const aliases = await this.stopAliases.list(cityId, db); + return { stops, aliases }; } async processMessage(message, cityId) { if (!Number.isSafeInteger(message.messageId) || message.messageId <= 0 || !/^\d+$/.test(message.channelId) || message.channelUsername.toLowerCase() !== this.config.channel.toLowerCase()) throw new Error('CHANNEL_INVALID'); - const parsed = parseCancellation(message); + let parsed = parseCancellation(message, { preserveSourceStops: true }); const hash = contentHash(message); const db = await this.pool.connect(); try { @@ -103,16 +124,29 @@ class TelegramImportService { WHERE city_id = $1 AND channel_id = $2 AND message_id = $3 FOR UPDATE`, [cityId, message.channelId, message.messageId])).rows[0]; if (!row) { await db.query('COMMIT'); return { status: 'ignored' }; } const initialized = row.parsed && Object.keys(row.parsed).length > 0; - if ((initialized && row.content_hash === hash) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) { + const sameContent = initialized && row.content_hash === hash; + if ((sameContent && (row.status !== 'review' || parsed.status !== 'ready')) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) { await db.query('COMMIT'); return { status: 'unchanged' }; } + let routes = []; + if (parsed.status === 'ready') { + const context = await this.getStopContext(db, cityId); + routes = await this.getRoutes(db, cityId); + const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([ + context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish + ])).digest('hex'); + if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) { + await db.query('COMMIT'); return { status: 'unchanged' }; + } + parsed = { ...resolveCancellationStops(parsed, context.stops, context.aliases), resolutionHash }; + } if (initialized) await this.deactivate(db, row.id); let status = row.status === 'dismissed' ? 'dismissed' : 'review'; let reasons = parsed.reasons; let plans = []; if (parsed.status === 'ignored') reasons = ['Текст исходной публикации изменился: отмена больше не распознаётся.']; if (parsed.status === 'ready') { - const matched = matchCancellationRoutes(parsed, await this.getRoutes(db, cityId)); + const matched = matchCancellationRoutes(parsed, routes); plans = matched.plans; reasons = matched.reasons; if (Date.parse(dayBounds(parsed.serviceDate).end) <= this.now().getTime()) status = status === 'dismissed' ? status : 'expired'; @@ -156,6 +190,7 @@ class TelegramImportService { WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') AS alert_ids FROM telegram_import_messages m WHERE m.city_id=$1 ORDER BY (m.status='review') DESC,m.published_at DESC,m.id DESC LIMIT 100`, [cityId])).rows.map(row => ({ ...row, source_url: sourceUrl(row), + drafts: messageDrafts(row.parsed), status: row.status === 'published' && validDate(row.parsed.serviceDate) && Date.parse(dayBounds(row.parsed.serviceDate).end) <= this.now().getTime() ? 'expired' : row.status })); } diff --git a/backend/src/services/telegramStopAliasService.js b/backend/src/services/telegramStopAliasService.js new file mode 100644 index 0000000..8c7b7cd --- /dev/null +++ b/backend/src/services/telegramStopAliasService.js @@ -0,0 +1,46 @@ +const { normalizeStopKey } = require('./telegramCancellationParser'); + +function httpError(status, message) { return Object.assign(new Error(message), { status }); } + +class TelegramStopAliasService { + constructor(pool) { this.pool = pool || require('../config/database'); } + + async list(cityId, db = this.pool) { + return (await db.query(`SELECT a.*, s.name AS stop_name, s.address AS stop_address, s.is_active AS stop_active + FROM telegram_stop_aliases a JOIN stops s ON s.id=a.stop_id AND s.city_id=a.city_id + WHERE a.city_id=$1 ORDER BY a.alias,a.id`, [cityId])).rows; + } + + async save(cityId, input = {}, id = null) { + const alias = typeof input.alias === 'string' ? input.alias.trim() : ''; + const key = normalizeStopKey(alias); + const stopId = Number(input.stop_id); + if (!alias || alias.length > 200 || !key || /[\r\n]/.test(alias)) throw httpError(400, 'Укажите название из канала (до 200 символов).'); + if (!Number.isSafeInteger(stopId) || stopId <= 0) throw httpError(400, 'Выберите остановку.'); + const db = await this.pool.connect(); + try { + await db.query('BEGIN'); + const stop = (await db.query('SELECT id FROM stops WHERE id=$1 AND city_id=$2 AND is_active=true FOR KEY SHARE', [stopId, cityId])).rows[0]; + if (!stop) throw httpError(400, 'Выберите действующую остановку выбранного города.'); + const result = id === null + ? await db.query(`INSERT INTO telegram_stop_aliases(city_id,alias,normalized_alias,stop_id) VALUES ($1,$2,$3,$4) RETURNING id`, [cityId, alias, key, stopId]) + : await db.query(`UPDATE telegram_stop_aliases SET alias=$3,normalized_alias=$4,stop_id=$5,updated_at=now() + WHERE id=$1 AND city_id=$2 RETURNING id`, [id, cityId, alias, key, stopId]); + if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.'); + await db.query('COMMIT'); + return { id: result.rows[0].id }; + } catch (error) { + await db.query('ROLLBACK'); + if (error.code === '23505') throw httpError(409, 'Такое название уже есть в словаре. Измените существующее соответствие.'); + throw error; + } finally { db.release(); } + } + + async remove(id, cityId) { + const result = await this.pool.query('DELETE FROM telegram_stop_aliases WHERE id=$1 AND city_id=$2 RETURNING id', [id, cityId]); + if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.'); + return { id }; + } +} + +module.exports = { TelegramStopAliasService }; diff --git a/backend/test/telegramCancellationParser.test.js b/backend/test/telegramCancellationParser.test.js index f8fc81b..eb3f73b 100644 --- a/backend/test/telegramCancellationParser.test.js +++ b/backend/test/telegramCancellationParser.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds } = require('../src/services/telegramCancellationParser'); +const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser'); const text = `Уважаемые пассажиры! Не будут выполняться рейсы:  @@ -87,3 +87,31 @@ test('midnight expiry is an explicit UTC instant, including year boundary', () = }); module.exports = { carrierText: text }; + +test('dictionary selects the actual stop ID and uses its canonical name in concise alerts', () => { + const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }, { id: 23, name: 'Зодчего Иоанна' }]; + const aliases = [{ normalized_alias: normalizeStopKey('ЗОДЧЕГО'), stop_id: 22 }]; + const parsed = resolveCancellationStops(parse(), stops, aliases); + const routes = [ + { id: 1, route_number: '27', origin_stop_id: 11, origin_stop: 'Автовокзал' }, + { id: 2, route_number: '27', origin_stop_id: 22, origin_stop: 'Зодчего Иоанна' }, + { id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' } + ]; + const matched = matchCancellationRoutes(parsed, routes); + assert.deepEqual(matched.plans.map(plan => plan.routeId), [1, 2]); + const alert = buildCancellationAlert(matched.plans[1], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1); + assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166'); + assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/); + assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего')); +}); + +test('canonical names are taken from the catalog; inactive aliases and ambiguous platforms require review', () => { + const parsed = parse(text.replace('ЗОДЧЕГО', 'ЗОДЧЕГО ИОАННА')); + const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }]; + assert.equal(resolveCancellationStops(parsed, stops, []).groups[0].departures[1].stop, 'Зодчего Иоанна'); + const inactive = resolveCancellationStops(parse(), [stops[0]], [{ normalized_alias: 'зодчего', stop_id: 22 }]); + assert.match(matchCancellationRoutes(inactive, [{ id: 1, route_number: '27' }]).reasons[0], /отключена/); + const ambiguous = resolveCancellationStops(parsed, [...stops, { id: 23, name: 'Зодчего Иоанна' }], []); + const result = matchCancellationRoutes(ambiguous, [{ id: 1, route_number: '27', origin_stop: 'Зодчего Иоанна' }, { id: 2, route_number: '27', origin_stop: 'Зодчего Иоанна' }]); + assert.equal(result.plans.length, 0); +}); diff --git a/backend/test/telegramImport.integration.test.js b/backend/test/telegramImport.integration.test.js index 0713d93..75678b3 100644 --- a/backend/test/telegramImport.integration.test.js +++ b/backend/test/telegramImport.integration.test.js @@ -18,20 +18,23 @@ async function setup() { await db.exec(`CREATE TABLE cities(id serial PRIMARY KEY,slug text,is_active boolean DEFAULT true); CREATE TABLE users(id serial PRIMARY KEY); CREATE TABLE routes(id serial PRIMARY KEY,route_number text,name text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true); - CREATE TABLE stops(id serial PRIMARY KEY,name text); + CREATE TABLE stops(id serial PRIMARY KEY,name text,address text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true); CREATE TABLE route_stops(route_id int,stop_id int,sequence int); CREATE TABLE alerts(id serial PRIMARY KEY,route_id int REFERENCES routes(id),alert_type text,title text,message text, city_id int,start_time timestamp,end_time timestamp,is_active boolean DEFAULT true,created_at timestamp DEFAULT now(), notification_sent_at timestamptz,notification_claimed_at timestamptz,notification_attempts int DEFAULT 0); INSERT INTO cities(slug) VALUES ('polotsk'),('novopolotsk'); INSERT INTO users DEFAULT VALUES; INSERT INTO routes(route_number,name,city_id) VALUES ('27','Автовокзал → Зодчего',1),('27','Зодчего → Автовокзал',1),('27','Другой город',2); - INSERT INTO stops(name) VALUES ('Автовокзал'),('Зодчего'); + INSERT INTO stops(name,city_id) VALUES ('Автовокзал',1),('Зодчего Иоанна',1),('Остановка другого города',2); INSERT INTO route_stops VALUES (1,1,1),(2,2,1); INSERT INTO alerts(route_id,city_id,start_time,end_time,title,message,alert_type) VALUES (1,1,'2026-10-02 12:00','2026-10-02 21:00','Old','Old','info'); SET TIME ZONE 'Europe/Minsk';`); const migration = await fs.readFile(path.join(__dirname, '../migrations/013_telegram_alert_import.sql'), 'utf8'); await db.exec(migration); await db.exec(migration); // idempotent upgrades + const aliasesMigration = await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8'); + await db.exec(aliasesMigration); + await db.exec(aliasesMigration); // A session is leased for the whole transaction, just like a pg Pool client. let tail = Promise.resolve(); async function lease() { @@ -128,3 +131,76 @@ test('Telegram SQL import rolls back all alerts if a later route insert fails', assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts')).rows[0].n, 1); } finally { await db.close(); } }); + +test('stop dictionary validates city and uniqueness, resolves waiting messages and preserves published text', { skip: !modulePath }, async () => { + const { db, pool, service, clock } = await setup(); + try { + const dictionary = service.stopAliases; + let aliases = await dictionary.list(1); + assert.equal(aliases.length, 2, 'migration seeds known abbreviations once'); + const zodchego = aliases.find(a => a.alias === 'ЗОДЧЕГО'); + assert.equal(zodchego.stop_name, 'Зодчего Иоанна'); + await assert.rejects(dictionary.save(1, { alias: ' зод-чего ', stop_id: 2 }), error => error.status === 409); + await assert.rejects(dictionary.save(1, { alias: 'Другой город', stop_id: 3 }), error => error.status === 400); + await assert.rejects(dictionary.save(1, { alias: '?!', stop_id: 2 }), error => error.status === 400); + await assert.rejects(dictionary.remove(zodchego.id, 2), error => error.status === 404); + await dictionary.remove(zodchego.id, 1); + + const original = message(1200); + assert.equal((await service.processMessage(original, 1)).status, 'review'); + assert.equal((await service.processMessage(original, 1)).status, 'unchanged'); + let row = (await service.list(1)).find(m => m.message_id === 1200); + assert.match(row.drafts.find(d => d.routeId === null).message, /Автовокзал — 15:43, 16:43/); + assert.doesNotMatch(row.drafts[0].message, /Уважаемые|С УВАЖЕНИЕМ/); + const saved = await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 }); + assert.equal((await service.processMessage(original, 1)).status, 'published', 'unchanged Telegram post can be resolved after adding an alias'); + const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_import_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY route_id', [row.id])).rows; + assert.equal(alerts.length, 2); + assert.match(alerts[1].message, /Зодчего Иоанна — 15:58, 16:58/); + row = (await service.list(1)).find(m => m.message_id === 1200); + assert.ok(row.drafts.some(d => d.routeId === 2 && d.message.includes('Зодчего Иоанна'))); + await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 1 }, saved.id); + assert.equal((await service.processMessage(original, 1)).status, 'unchanged'); + assert.equal((await pool.query('SELECT message FROM alerts WHERE id=$1', [alerts[1].id])).rows[0].message, alerts[1].message); + await assert.rejects(dictionary.save(2, { alias: 'Чужое', stop_id: 3 }, saved.id), error => error.status === 404); + + // Inactive mappings never auto-publish, even for a route with one direction. + await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 }, saved.id); + await pool.query('UPDATE stops SET is_active=false WHERE id=2'); + assert.equal((await service.processMessage(message(1201), 1)).status, 'review'); + const inactive = (await service.list(1)).find(m => m.message_id === 1201); + assert.match(inactive.reasons[0], /отключена/); + await assert.rejects(dictionary.save(1, { alias: 'Новое', stop_id: 2 }), error => error.status === 400); + await pool.query('UPDATE stops SET is_active=true WHERE id=2'); + clock('2026-10-02T21:00:00Z'); + assert.equal((await service.processMessage(message(1201), 1)).status, 'expired', 'dictionary changes cannot publish yesterday after midnight'); + assert.equal((await dictionary.list(2)).length, 0); + + // Re-running the migration never picks one of two identically named platforms. + await dictionary.remove(saved.id, 1); + await pool.query("INSERT INTO stops(name,city_id) VALUES ('Зодчего Иоанна',1)"); + await db.exec(await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8')); + assert.equal((await dictionary.list(1)).some(a => a.alias === 'ЗОДЧЕГО'), false); + } finally { await db.close(); } +}); + +test('reviewed snapshots respect auto-publish configuration changes and midnight without requiring a source edit', { skip: !modulePath }, async () => { + const { db, pool, service, clock } = await setup(); + // This test uses its own configuration so it cannot change other tests. + service.config = { ...config, autoPublish: false }; + try { + assert.equal((await service.processMessage(message(1300), 1)).status, 'review'); + assert.equal((await service.processMessage(message(1300), 1)).status, 'unchanged'); + service.config.autoPublish = true; + assert.equal((await service.processMessage(message(1300), 1)).status, 'published'); + service.config.autoPublish = false; + assert.equal((await service.processMessage(message(1301), 1)).status, 'review'); + await service.processMessage(message(1302, carrierText.replace('\nС ОСТАНОВКИ ЗОДЧЕГО', '\nПО МАРШРУТУ № 27 С ОСТАНОВКИ ЗОДЧЕГО')), 1); + const draft = (await service.list(1)).find(m => m.message_id === 1302).drafts.filter(d => d.routeId === null); + assert.equal(draft.length, 1); + assert.match(draft[0].message, /Автовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58/); + clock('2026-10-02T21:00:00Z'); + assert.equal((await service.processMessage(message(1301), 1)).status, 'expired'); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_alerts t JOIN telegram_import_messages m ON m.id=t.message_id WHERE m.message_id=1301')).rows[0].n, 0); + } finally { await db.close(); } +}); diff --git a/backend/test/telegramImportApi.test.js b/backend/test/telegramImportApi.test.js index 5623a30..59eb646 100644 --- a/backend/test/telegramImportApi.test.js +++ b/backend/test/telegramImportApi.test.js @@ -15,7 +15,12 @@ test('Telegram admin API rejects anonymous/non-admin access and preserves select getStatus: () => ({ enabled: false }), list: async cityId => { calls.push(['list', cityId]); return []; }, sync: async () => { calls.push(['sync']); return {}; }, - review: async (...args) => { calls.push(['review', ...args]); return { status: 'dismissed' }; } + review: async (...args) => { calls.push(['review', ...args]); return { status: 'dismissed' }; }, + stopAliases: { + list: async (...args) => { calls.push(['aliases', ...args]); return []; }, + save: async (...args) => { calls.push(['saveAlias', ...args]); return { id: 9 }; }, + remove: async (...args) => { calls.push(['removeAlias', ...args]); return { id: 9 }; } + } }; const app = express(); app.use(express.json()); @@ -36,5 +41,17 @@ test('Telegram admin API rejects anonymous/non-admin access and preserves select assert.equal((await fetch(`${url}/4/review`, { method: 'POST', headers, body: JSON.stringify({ action: 'dismiss', content_hash: 'hash' }) })).status, 200); assert.deepEqual(calls[1], ['review', 4, 2, { action: 'dismiss', content_hash: 'hash' }, 7]); assert.equal((await fetch(`${url}/not-an-id/review`, { method: 'POST', headers, body: '{}' })).status, 400); + for (const method of ['GET', 'POST', 'PUT', 'DELETE']) { + const endpoint = `${url}/stop-aliases${['PUT', 'DELETE'].includes(method) ? '/9' : ''}`; + assert.equal((await fetch(endpoint, { method, headers: { Authorization: `Bearer ${token('user')}` } })).status, 403); + const result = await fetch(endpoint, { method, headers, ...(method === 'POST' || method === 'PUT' ? { body: JSON.stringify({ alias: 'ЗОДЧЕГО', stop_id: 22 }) } : {}) }); + assert.equal(result.status, 200); + } + assert.deepEqual(calls.slice(-4), [ + ['aliases', 2], ['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }], + ['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }, 9], ['removeAlias', 9, 2] + ]); + assert.equal((await fetch(`${url}/stop-aliases/0`, { method: 'DELETE', headers })).status, 400); + assert.equal((await fetch(`${url}/stop-aliases/999999999999999999`, { method: 'PUT', headers, body: '{}' })).status, 400); } finally { await new Promise(resolve => server.close(resolve)); } }); diff --git a/docs/telegram-cancellation-import.md b/docs/telegram-cancellation-import.md index 7a2f549..979c23f 100644 --- a/docs/telegram-cancellation-import.md +++ b/docs/telegram-cancellation-import.md @@ -12,6 +12,25 @@ Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор проверяет маршрут, текст и день отмены; срок до полуночи назначается сервером. +### Словарь остановок и текст уведомления + +В блоке **«Отмены из Telegram» → «Словарь названий остановок»** можно добавлять, изменять и удалять соответствия: название из канала → конкретная остановка из каталога выбранного города. Например, `ЗОДЧЕГО` → `Зодчего Иоанна`. Для разных сокращений одной остановки создайте несколько записей. Регистр, пробелы, знаки препинания и различие `ё/е` не учитываются; два эквивалентных сокращения в одном городе запрещены. Если есть несколько платформ с одним названием, выберите нужную остановку по адресу/ID. Отключённая остановка требует исправления соответствия и ручной проверки. + +Миграция **014** добавляет словарь и начальные соответствия `АВ/ВОКЗАЛ` → `Автовокзал`, `ЗОДЧЕГО` → `Зодчего Иоанна` только для Полоцка и только при единственной действующей остановке с таким названием. При неоднозначности соответствие задаёт администратор. Уже сохранённые соответствия при повторном запуске миграции не перезаписываются. + +Изменения словаря учитываются при следующем чтении канала, включая сообщения **на проверке с неизменившимся исходным текстом** в трёхдневном окне. Уже опубликованные или отклонённые сообщения повторно из-за изменения словаря не публикуются. Для распознанных отмен backend формирует лаконичный текст без приветствия и подписи перевозчика. Например: + +```text +Отмена отдельных рейсов №27 + +02.10.2026: не выполняются рейсы: +Автовокзал — 15:43, 16:43 +Зодчего Иоанна — 15:58, 16:58 +Источник: https://t.me/avp2Polotsk/1166 +``` + +При нескольких направлениях формируются отдельные уведомления с соответствующими отправлениями. Исходный текст остаётся в истории импорта. В ручной проверке распознанный текст также подставляется в удобочитаемом виде; выбор направления подставляет его отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным. + ## Установка Для существующей базы применить миграцию **013** один раз, до запуска новой версии backend. Она также переводит UTC-значения времени существующих алертов из `timestamp` в `timestamptz`. Это соответствует принятому формату хранения UTC в админке. При наличии вручную внесённых локальных времен сначала проверить их и сделать резервную копию базы. @@ -19,9 +38,13 @@ ```bash docker compose exec -T postgres psql -U transit_admin -d polotsk_transit \ -f /docker-entrypoint-initdb.d/013_telegram_alert_import.sql + +docker compose exec -T postgres \ + psql -v ON_ERROR_STOP=1 --single-transaction -U transit_admin -d polotsk_transit \ + < backend/migrations/014_telegram_stop_aliases.sql ``` -На чистой базе Docker применяет миграцию автоматически. **Не запускайте `npm run migrate` на существующей базе**: старые init-миграции проекта не поддерживают повторный запуск. +Если миграция 013 уже установлена, для обновления словаря достаточно миграции **014** и пересборки backend/frontend. На чистой базе Docker применяет обе миграции автоматически. **Не запускайте `npm run migrate` на существующей базе**: старые init-миграции проекта не поддерживают повторный запуск. ### Авторизация аккаунта @@ -85,6 +108,10 @@ FCM TTL ограничен остатком срока действия алер - `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки. - `POST /api/v1/telegram-import/sync` — чтение настроенного канала; запрещено в другом городе. - `POST /api/v1/telegram-import/:id/review` — `content_hash`, `action: publish|dismiss`; для публикации также `service_date`, `route_id`, `title`, `message`. +- `GET /api/v1/telegram-import/stop-aliases` — словарь выбранного города. +- `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие. +- `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие. +- `DELETE /api/v1/telegram-import/stop-aliases/:id` — удалить соответствие. Обычные unit-тесты: `cd backend && npm test`. SQL-тесты используют временный PostgreSQL runtime PGlite и не обращаются к рабочей базе: diff --git a/frontend/src/components/TelegramImportPanel.jsx b/frontend/src/components/TelegramImportPanel.jsx index 521c057..5a0b629 100644 --- a/frontend/src/components/TelegramImportPanel.jsx +++ b/frontend/src/components/TelegramImportPanel.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { ExternalLink, RefreshCw, Send, X } from 'lucide-react'; import { telegramImportApi } from '../services/api'; import { useCity } from '../contexts/CityContext'; +import TelegramStopAliases from './TelegramStopAliases'; const connectionLabels = { disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта', @@ -80,6 +81,7 @@ export default function TelegramImportPanel({ routes, onChanged }) { {error &&

{error}

} + {data && (
@@ -114,11 +116,27 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) { const publicationDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(row.published_at)); + const initialRouteId = row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : ''; + const draftFor = routeId => { + const route = routes.find(r => Number(r.id) === Number(routeId)); + return row.drafts?.find(d => d.routeId !== null && Number(d.routeId) === Number(routeId)) + || row.drafts?.find(d => d.routeId === null && (!route || String(d.routeNumber).toLowerCase() === String(route.route_number).toLowerCase())); + }; + const initialDraft = draftFor(initialRouteId); const [form, setForm] = useState({ - action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '', - title: 'Отмена отдельных рейсов', message: row.raw_text.slice(0, 5000) + action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: initialRouteId, + title: initialDraft?.title || 'Отмена отдельных рейсов', message: initialDraft?.message || row.raw_text.slice(0, 5000) }); - const change = e => setForm({ ...form, [e.target.name]: e.target.value }); + const [editedText, setEditedText] = useState(false); + const change = e => { + const { name, value } = e.target; + if (name === 'title' || name === 'message') setEditedText(true); + const draft = name === 'route_id' && !editedText ? draftFor(value) : null; + const message = name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) + ? form.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${value.split('-').reverse().join('.')}:`) : form.message; + const draftMessage = draft?.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${form.service_date.split('-').reverse().join('.')}:`); + setForm({ ...form, message, [name]: value, ...(draft ? { title: draft.title, message: draftMessage } : {}) }); + }; return (
{ e.preventDefault(); onSubmit(form); }}>

Проверка отмены

diff --git a/frontend/src/components/TelegramStopAliases.jsx b/frontend/src/components/TelegramStopAliases.jsx new file mode 100644 index 0000000..26b4683 --- /dev/null +++ b/frontend/src/components/TelegramStopAliases.jsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react'; +import { BookOpen, Pencil, Trash2, X } from 'lucide-react'; +import { stopsApi, telegramImportApi } from '../services/api'; +import { useCity } from '../contexts/CityContext'; + +const emptyForm = { alias: '', stop_id: '' }; + +export default function TelegramStopAliases({ onChanged }) { + const { activeCity } = useCity(); + const [open, setOpen] = useState(false); + const [aliases, setAliases] = useState([]); + const [stops, setStops] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + + useEffect(() => { + setEditingId(null); setForm(emptyForm); setNotice(''); setError(''); + setAliases([]); setStops([]); + if (!open) return; + let current = true; + setLoading(true); + Promise.all([telegramImportApi.getStopAliases(), stopsApi.getAll()]) + .then(([dictionary, catalog]) => { if (current) { setAliases(dictionary.data); setStops(catalog.data); } }) + .catch(err => { if (current) setError(err.response?.data?.error || 'Не удалось загрузить словарь остановок.'); }) + .finally(() => { if (current) setLoading(false); }); + return () => { current = false; }; + }, [open, activeCity?.id]); + + const refresh = async () => { setAliases((await telegramImportApi.getStopAliases()).data); onChanged(); }; + const save = async event => { + event.preventDefault(); setBusy(true); setError(''); setNotice(''); + try { + if (editingId === null) await telegramImportApi.createStopAlias(form); + else await telegramImportApi.updateStopAlias(editingId, form); + setEditingId(null); setForm(emptyForm); await refresh(); + setNotice('Соответствие сохранено. При следующем чтении канала сообщения на проверке за последние три дня будут разобраны заново.'); + } catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить соответствие.'); } + finally { setBusy(false); } + }; + const remove = async id => { + setBusy(true); setError(''); setNotice(''); + try { + await telegramImportApi.deleteStopAlias(id); + if (editingId === id) { setEditingId(null); setForm(emptyForm); } + await refresh(); setNotice('Соответствие удалено.'); + } catch (err) { setError(err.response?.data?.error || 'Не удалось удалить соответствие.'); } + finally { setBusy(false); } + }; + + return
+ + {open &&
+

Свяжите название из канала с остановкой {activeCity?.name ? `города ${activeCity.name}` : 'выбранного города'}. Например: ЗОДЧЕГО → Зодчего Иоанна. Регистр, пробелы и знаки препинания не учитываются.

+ {error &&

{error}

} + {notice &&

{notice}

} + {loading ?

Загрузка словаря…

: <> + +

{editingId === null ? 'Новое соответствие' : 'Изменить соответствие'}

+ {editingId !== null && } +
+
+ + +
+ + +
+ {aliases.map(alias =>
+
{alias.alias} → {alias.stop_name} + {!alias.stop_active && Остановка отключена} +

{alias.stop_address ? `${alias.stop_address} · ` : ''}Остановка #{alias.stop_id}

+
+
+ + +
+
)} + {!aliases.length &&

Добавьте первое соответствие. Для нескольких вариантов одного названия можно создать отдельные записи.

} +
+

Изменения применяются к новым сообщениям и сообщениям на проверке. Опубликованные уведомления сохраняют свой текст.

+ } +
} +
; +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index cbf68cf..9799404 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -191,7 +191,11 @@ export const holidaysApi = { export const telegramImportApi = { getAll: () => api.get('/telegram-import'), sync: () => api.post('/telegram-import/sync'), - review: (id, data) => api.post(`/telegram-import/${id}/review`, data) + review: (id, data) => api.post(`/telegram-import/${id}/review`, data), + getStopAliases: () => api.get('/telegram-import/stop-aliases'), + createStopAlias: data => api.post('/telegram-import/stop-aliases', data), + updateStopAlias: (id, data) => api.put(`/telegram-import/stop-aliases/${id}`, data), + deleteStopAlias: id => api.delete(`/telegram-import/stop-aliases/${id}`) }; export const alertsApi = {