feat: enhance Telegram import functionality to support delays and cancellations

- Updated telegramCancellationParser to include parsing for delay notices.
- Added new test cases for handling delay messages and stop-point cancellations.
- Modified telegramImport integration tests to validate new delay handling.
- Enhanced documentation to reflect the addition of delay notifications.
- Updated TelegramImportPanel component to accommodate new alert types.
- Introduced new fixture files for testing delay and cancellation messages.
This commit is contained in:
2026-10-09 02:59:52 +03:00
parent c0d40f9b90
commit 249c338c2a
8 changed files with 390 additions and 69 deletions

View File

@@ -63,19 +63,19 @@ export default function TelegramImportPanel({ routes, onChanged }) {
const pending = data?.messages.filter(m => m.status === 'review') || [];
const rows = showHistory ? data?.messages || [] : pending;
return (
<section className="card mb-6" aria-label="Отмены из Telegram">
<section className="card mb-6" aria-label="Уведомления из Telegram">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Отмены из Telegram</h2>
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Уведомления из Telegram</h2>
<p className="text-sm text-gray-600 mt-1">
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
{status && <> · @{status.channel}</>}
</p>
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Отмены будут поступать автоматически.</p>}
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные отмены публикуются автоматически' : 'Публикация после проверки'}</p>}
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Сообщения об отменах и опозданиях будут поступать автоматически.</p>}
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные сообщения публикуются автоматически' : 'Публикация после проверки'}</p>}
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи дня отмены по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи указанного дня по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
</div>
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
@@ -92,7 +92,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
{rows.map(row => (
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День отмены завершён' }[row.status]}</span>
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День уведомления завершён' }[row.status]}</span>
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
</div>
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
@@ -105,7 +105,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
</>}
</div>
))}
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах и опозданиях пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
</div>
)}
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
@@ -119,40 +119,49 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
}).format(new Date(row.published_at));
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
.map(normalizeRouteNumber))];
const draftFor = (numbers, date) => {
const initialType = row.parsed?.alertType || 'cancellation';
const draftFor = (numbers, date, alertType) => {
const delay = alertType === 'delay';
const possible = delay && row.parsed?.delayPossible;
const prefix = delay ? possible ? 'Возможное опоздание' : 'Задержка' : 'Отмена';
const description = delay ? possible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы';
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
const complete = drafts.length && drafts.every(Boolean);
return {
title: `Отмена отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
message: complete ? `${date.split('-').reverse().join('.')}: не выполняются рейсы:\n${drafts.map((d, i) =>
title: `${prefix} отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
message: complete ? `${date.split('-').reverse().join('.')}: ${description}:\n${drafts.map((d, i) =>
`${drafts.length > 1 ? `Маршрут №${numbers[i]}:\n` : ''}${d.message.replace(/^[^\n]*\n/, '')}`).join('\n\n')}` : row.raw_text.slice(0, 5000)
};
};
const initialDate = row.parsed?.serviceDate || publicationDate;
const [form, setForm] = useState({
action: 'publish', service_date: initialDate, route_numbers: initialNumbers,
...draftFor(initialNumbers, initialDate)
action: 'publish', alert_type: initialType, service_date: initialDate, route_numbers: initialNumbers,
...draftFor(initialNumbers, initialDate, initialType)
});
const [editedText, setEditedText] = useState(false);
const change = e => {
const { name, value } = e.target;
if (name === 'title' || name === 'message') setEditedText(true);
setForm(current => ({ ...current, [name]: value,
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) }));
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value, current.alert_type) : {}),
...(name === 'alert_type' && !editedText ? draftFor(current.route_numbers, current.service_date, value) : {}) }));
};
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
...(!editedText ? draftFor(route_numbers, current.service_date) : {}) }));
...(!editedText ? draftFor(route_numbers, current.service_date, current.alert_type) : {}) }));
return (
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка уведомления</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
<label className="text-sm block">Тип уведомления<select className="input mt-1 w-full" name="alert_type" value={form.alert_type} onChange={change} disabled={busy}>
<option value="cancellation">Отмена рейсов</option><option value="delay">Опоздание рейсов</option>
</select></label>
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
<div>
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
<label className="text-sm">День уведомления<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
</div>
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать уведомление</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
</form>
);
}