From d32c15c85e8e546bb7cc3ab4bfce779d29df4729 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 16 Sep 2026 23:16:35 +0300 Subject: [PATCH] feat: enhance schedule import functionality with support for day sections and restrictions --- backend/src/services/scheduleImportService.js | 33 ++++++++++-- backend/src/services/scheduleSource.js | 34 +++++++++--- .../fixtures/schedule-import/route24.html | 53 +++++++++++++++++++ .../test/scheduleImport.integration.test.js | 21 ++++++++ backend/test/scheduleImport.test.js | 37 +++++++++++++ docs/schedule-import.md | 8 +-- frontend/src/pages/ScheduleImportPage.jsx | 2 +- 7 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 backend/test/fixtures/schedule-import/route24.html diff --git a/backend/src/services/scheduleImportService.js b/backend/src/services/scheduleImportService.js index b858015..a907faf 100644 --- a/backend/src/services/scheduleImportService.js +++ b/backend/src/services/scheduleImportService.js @@ -6,6 +6,8 @@ const { HOME_URL, CATALOG_URL, hash, normalizeName, normalizeNumber, sourceError safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews } = require('./scheduleSource'); const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' }; +const DAY_VALUES = { weekday: [1, 2, 3, 4, 5], saturday: [6], sunday: [7] }; +const activeOn = (trip, day) => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(day)) && !trip.exceptDaysOfWeek.includes(day); const tripKey = trip => JSON.stringify({ departureTime: trip.departureTime?.length === 5 ? `${trip.departureTime}:00` : trip.departureTime, startStopId: trip.startStopId || null, endStopId: trip.endStopId || null, @@ -29,6 +31,25 @@ function compareTrips(before, after) { return { added, removed: remaining.map(item => item.trip), unchanged }; } +function mergeDaySections(trips, dayType) { + const groups = new Map(); + for (const trip of trips) { + const key = tripKey({ ...trip, daysOfWeek: [], exceptDaysOfWeek: [] }); + const activeDays = DAY_VALUES[dayType].filter(day => activeOn(trip, day)); + const existing = groups.get(key); + if (!existing) groups.set(key, { trip, activeDays }); + else { + if (activeDays.some(day => existing.activeDays.includes(day))) { + throw sourceError(`${trip.departureTime.slice(0, 5)}: разделы расписания содержат повторяющийся рейс в один и тот же день. Проверьте источник.`); + } + existing.activeDays = [...existing.activeDays, ...activeDays].sort((a, b) => a - b); + existing.trip = { ...existing.trip, exceptDaysOfWeek: [], + daysOfWeek: existing.activeDays.length === DAY_VALUES[dayType].length ? [] : existing.activeDays }; + } + } + return [...groups.values()].map(group => group.trip); +} + 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 @@ -55,6 +76,12 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi for (const departure of departures) { const trip = { ...blankTrip(departure.departureTime, start, end), daysOfWeek: departure.daysOfWeek || [], exceptDaysOfWeek: departure.exceptDaysOfWeek || [] }; + if (row.daysOfWeek?.length) { + const allowed = row.daysOfWeek.filter(day => activeOn(trip, day)); + if (!allowed.length) throw sourceError(`${departure.departureTime.slice(0, 5)}: примечание противоречит разделу «${row.dayLabel}». Проверьте источник.`); + trip.daysOfWeek = allowed; + trip.exceptDaysOfWeek = []; + } const unresolved = []; for (const name of departure.viaStopNames || []) { const matches = snapshot.stops.filter(stop => normalizeName(stop.name) === normalizeName(name)); @@ -67,8 +94,7 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi 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; + if (!DAY_VALUES[day].some(value => activeOn(trip, value))) continue; groups[day].push(trip); included = true; } @@ -76,7 +102,8 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi } } const changes = []; - for (const [dayType, trips] of Object.entries(groups)) { + for (const [dayType, departures] of Object.entries(groups)) { + const trips = mergeDaySections(departures, dayType); if (!trips.length) throw sourceError(`${DAY_LABELS[dayType]}: выберите хотя бы одну строку отправлений.`); if (trips.length > 2000 || new Set(trips.map(tripKey)).size !== trips.length) { throw sourceError('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.'); diff --git a/backend/src/services/scheduleSource.js b/backend/src/services/scheduleSource.js index 6476436..d8c22c5 100644 --- a/backend/src/services/scheduleSource.js +++ b/backend/src/services/scheduleSource.js @@ -94,6 +94,19 @@ function dayTypes(text) { return null; } +function daySection(text) { + const label = clean(text); + const days = dayTypes(label); + if (days) return { days, daysOfWeek: [], label }; + const weekdays = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница']; + const range = label.toLowerCase().split(/\s*[–—-]\s*/); + if (range.length > 2) return null; + const first = weekdays.indexOf(range[0]) + 1; + const last = weekdays.indexOf(range.at(-1)) + 1; + if (!first || !last || last < first) return null; + return { days: ['weekday'], daysOfWeek: Array.from({ length: last - first + 1 }, (_, i) => first + i), label }; +} + const WEEKDAY_FORMS = [ ['понедельник', 'понедельникам', 'понедельника'], ['вторник', 'вторникам', 'вторника'], ['среда', 'средам', 'среды', 'среду'], ['четверг', 'четвергам', 'четверга'], @@ -144,16 +157,17 @@ function parseSchedule(html, url) { const rows = []; const warnings = []; content.find('table').each((tableIndex, table) => { - let days = null; + let section = 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; } + const parsedSection = daySection(texts.join(' ')); + if (parsedSection) { section = parsedSection; return; } if (texts.join(' ').includes('к списку маршрутов')) return; - if (cells.length !== 2 || !days) { + if (cells.length !== 2 || !section) { warnings.push(`Не распознана строка таблицы: ${texts.join(' — ').slice(0, 240)}`); + section = null; return; } const [stopName, rawTimes] = texts; @@ -167,7 +181,8 @@ function parseSchedule(html, url) { } 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 }); + rows.push({ key: `${tableIndex}:${rowIndex}`, stopName, days: section.days, daysOfWeek: section.daysOfWeek, + dayLabel: section.label, times, departures: invalid ? [] : departures, rawTimes }); }); }); if (!rows.length) throw sourceError('Таблицы расписания не распознаны. Существующие данные сохранены.'); @@ -175,8 +190,13 @@ function parseSchedule(html, url) { 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 }); + // Both directions may share one paragraph, separated only by a
. + for (const part of text.matchAll(/((?:Прямое|Обратное) направление):([\s\S]*?)(?=(?:Прямое|Обратное) направление:|$)/gi)) { + const stops = [...part[2].matchAll(/«\s*([^»]+?)\s*»/g)].map(match => clean(match[1])); + if (stops.length && !directions.some(direction => direction.label === part[1] && hash(direction.stops) === hash(stops))) { + directions.push({ label: part[1], stops }); + } + } }); // Footnotes outside tables must not silently disappear during import. const outside = content.clone(); diff --git a/backend/test/fixtures/schedule-import/route24.html b/backend/test/fixtures/schedule-import/route24.html new file mode 100644 index 0000000..c5211e7 --- /dev/null +++ b/backend/test/fixtures/schedule-import/route24.html @@ -0,0 +1,53 @@ +

Маршрут №24

+ + + + + + + + + + + + + + + +
◄ к списку маршрутов
Понедельник – четверг
Автовокзал06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 16:02 | 17:00 | 22:05
Полоцк – Стекловолокно06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:30 | 17:25 | 23:30
+ + + + + + + + + + + + + + +
Пятница
Автовокзал06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 15:45 | 16:02 | 22:05
Полоцк – Стекловолокно06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:15 | 16:30 | 23:30
+ + + + + + + + + + + + + + +
Выходной день
Автовокзал06:00 | 14:05 | 22:05
Полоцк – Стекловолокно07:30 | 15:30 | 23:30
+
Остановочные пункты:
+

Прямое направление: «Автовокзал», «Железнодорожный вокзал», «Экономический колледж», «Олимпиец», «Спортклуб», «Энергосбыт», «Заполотье», «Экимань», «Курган Бессмертия», «Вильнюсское шоссе», «Ксты», «Полоцкгаз», «Полоцк – Стекловолокно».
+Обратное направление: «Полоцк – Стекловолокно», «Полоцкгаз», «Ксты», «Вильнюсское шоссе», «ТРЦ Манеж», «Курган Бессмертия», «Экимань», «Заполотье», «Энергосбыт», «Спортклуб», «Олимпиец», «Экономический колледж», «Железнодорожный вокзал», «Автовокзал»

+
Схема движения
+

+
\ No newline at end of file diff --git a/backend/test/scheduleImport.integration.test.js b/backend/test/scheduleImport.integration.test.js index fb56942..cb1cea3 100644 --- a/backend/test/scheduleImport.integration.test.js +++ b/backend/test/scheduleImport.integration.test.js @@ -146,6 +146,27 @@ test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news rev 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); + // Route 24 uses separate Mon–Thu and Friday tables; persist their restrictions together. + sourceHtml = await fixture('route24'); + const source24 = parseSchedule(sourceHtml, 'https://ap2polotsk.of.by/ap2/m-24/'); + const ids24 = []; + for (const direction of source24.directions) { + const id = (await pool.query("INSERT INTO routes(route_number,name,type) VALUES('24',$1,'bus') RETURNING id", [direction.label])).rows[0].id; + ids24.push(id); + for (const [index, name] of [direction.stops[0], direction.stops.at(-1)].entries()) { + const stop = (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) VALUES($1,$2,$3)', [id,stop,index+1]); + } + } + const inspection24 = await service.inspect({ routeIds: ids24, sourceUrl: 'https://ap2polotsk.of.by/ap2/m-24/', userId:1 }); + const preview24 = await service.preview({ inspectionId:inspection24.id, mappings:inspection24.mappings, validFrom, userId:1 }); + await service.apply(preview24.id); + const saved24 = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [ids24[0]])).rows[0].trips; + assert.equal(saved24.filter(t => t.departureTime === '06:00:00').length, 1); + assert.deepEqual(saved24.find(t => t.departureTime === '17:00:00').daysOfWeek, [1,2,3,4]); + assert.deepEqual(saved24.find(t => t.departureTime === '15:45:00').daysOfWeek, [5]); + assert.equal(Number((await pool.query('SELECT count(*) FROM schedules WHERE route_id=ANY($1::int[])', [ids24])).rows[0].count), 6); + let news = await service.news(true); assert.equal(news.length, 3); await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash }); diff --git a/backend/test/scheduleImport.test.js b/backend/test/scheduleImport.test.js index bf2baf3..73d9d09 100644 --- a/backend/test/scheduleImport.test.js +++ b/backend/test/scheduleImport.test.js @@ -162,3 +162,40 @@ test('weekend day restrictions split correctly and contradictory day sections ar const invalid = parseSchedule(fixture('route9').replace('по пятницам', 'по субботам'), annotated.url); assert.throws(() => buildDirectionPlan(invalid, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'), /противоречит/); }); + +test('route 24 splits Monday–Thursday, Friday and weekends without duplicate daily departures', () => { + const source24 = parseSchedule(fixture('route24'), 'https://ap2polotsk.of.by/ap2/m-24/'); + assert.deepEqual(source24.warnings, []); + assert.equal(source24.directions.length, 2); + assert.deepEqual(departureStops(source24), ['Автовокзал', 'Полоцк – Стекловолокно']); + assert.deepEqual(source24.rows.map(row => row.daysOfWeek), [[1,2,3,4], [1,2,3,4], [5], [5], [], []]); + for (const direction of source24.directions) { + const snapshot24 = { route: { id: 24, route_number: '24' }, schedules: [], stops: direction.stops.map((name,i) => ({ name, stop_id: i + 1 })) }; + const built = buildDirectionPlan(source24, direction.stops[0], snapshot24, '2026-09-16', '2026-09-16'); + const weekdayTrips = built.changes.find(change => change.dayType === 'weekday').after; + assert.equal(new Set(weekdayTrips.map(trip => trip.departureTime)).size, weekdayTrips.length); + for (let day = 1; day <= 7; day++) { + const dayType = day <= 5 ? 'weekday' : day === 6 ? 'saturday' : 'sunday'; + const active = built.changes.find(change => change.dayType === dayType).after + .filter(trip => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(day)) && !trip.exceptDaysOfWeek.includes(day)) + .map(trip => trip.departureTime).sort(); + const row = source24.rows.find(row => row.stopName === direction.stops[0] && row.days.includes(dayType) + && (!row.daysOfWeek.length || row.daysOfWeek.includes(day))); + assert.deepEqual(active, [...row.times].sort(), `${direction.label}, ISO day ${day}`); + } + const again = buildDirectionPlan(source24, direction.stops[0], { ...snapshot24, schedules: built.changes.map((change,i) => ({ id:i+1, day_type:change.dayType, trips:change.after, valid_from:'2026-09-16' })) }, '2026-09-16', '2026-09-16'); + assert.equal(again.hasChanges, false); + } +}); + +test('section restrictions intersect trip notes and overlapping sections are rejected', () => { + const parsed = html => parseSchedule(html, 'https://ap2polotsk.of.by/ap2/m-24/'); + const state = { route:{id:24,route_number:'24'}, schedules:[], stops:[{stop_id:1,name:'Автовокзал'},{stop_id:2,name:'Полоцк – Стекловолокно'}] }; + const build = html => buildDirectionPlan(parsed(html), 'Автовокзал', state, '2026-09-16', '2026-09-16'); + const narrowed = build(fixture('route24').replace('17:00', '17:00 кроме среды')); + assert.deepEqual(narrowed.changes[0].after.find(t => t.departureTime === '17:00:00').daysOfWeek, [1,2,4]); + assert.throws(() => build(fixture('route24').replace('17:00','17:00 по пятницам')), /противоречит/); + assert.throws(() => build(fixture('route24').replace('Понедельник – четверг','Понедельник – пятница')), /повторяющийся/); + for (const label of ['Понедельник-четверг', 'Понедельник — четверг']) assert.deepEqual(parsed(fixture('route24').replace('Понедельник – четверг',label)).warnings, []); + assert.ok(parsed(fixture('route24').replace('Понедельник – четверг','Четверг – понедельник')).warnings.length); +}); diff --git a/docs/schedule-import.md b/docs/schedule-import.md index 4260ac8..9e6b7d7 100644 --- a/docs/schedule-import.md +++ b/docs/schedule-import.md @@ -23,11 +23,13 @@ ## Ограничения первой версии -Поддерживаются обычные HTML-таблицы с блоками «Рабочий день», «Выходной день», «Суббота», «Воскресенье» и временами через `|`. Поддерживаются примечания у конкретного времени: «по пятницам», «только по понедельникам и средам», «кроме пятницы» и «через «Мариненко»», в том числе их сочетания. Дни недели сохраняются в `daysOfWeek` / `exceptDaysOfWeek` (ISO 1–7). Остановка «через» сопоставляется по имени с остановками направления и сохраняется в `viaStopIds`. Если совпадения нет или оно неоднозначно, условие сохраняется в `note`, а предпросмотр сообщает об отсутствии привязки. Это не добавляет остановку, геометрию или время проезда в маршрут. +Поддерживаются обычные HTML-таблицы с блоками «Рабочий день», «Выходной день», «Суббота», «Воскресенье», отдельными будними днями и диапазонами (например, «Понедельник – четверг», «Пятница» у №24), и временами через `|`. Поддерживаются примечания у конкретного времени: «по пятницам», «только по понедельникам и средам», «кроме пятницы» и «через «Мариненко»», в том числе их сочетания. Дни недели сохраняются в `daysOfWeek` / `exceptDaysOfWeek` (ISO 1–7). Остановка «через» сопоставляется по имени с остановками направления и сохраняется в `viaStopIds`. Если совпадения нет или оно неоднозначно, условие сохраняется в `note`, а предпросмотр сообщает об отсутствии привязки. Это не добавляет остановку, геометрию или время проезда в маршрут. -Неизвестные условия, отдельные сноски, зачёркивания, неизвестные заголовки и неоднозначные строки блокируют применение всей страницы и показываются администратору. Это касается, например, отдельных разделов по пятницам и отправлений из автопарка. Их следует редактировать вручную через **Расписания**. +Одинаковые отправления из непересекающихся разделов объединяются с сохранением дней выполнения. Примечание рейса дополнительно сужает дни его раздела; противоречия и повтор одного рейса в один день блокируют импорт. -Проверка живого сайта 16.09.2026: каталог содержит 15 маршрутов. Без замечаний разобраны №1, 2, 4, 6, 8, 9, 11, 27 и 28Э. У остальных показаны блокирующие замечания; №6 требует ручного сопоставления направлений. Разметка сайта может измениться. +Неизвестные условия, отдельные сноски, зачёркивания, неизвестные заголовки и неоднозначные строки блокируют применение всей страницы и показываются администратору. Это касается, например, нестандартных отправлений из автопарка. Их следует редактировать вручную через **Расписания**. + +Проверка живого сайта 16.09.2026: каталог содержит 15 маршрутов. Без замечаний разобраны №1, 2, 4, 6, 8, 9, 11, 24, 27 и 28Э. У остальных показаны блокирующие замечания; №6 требует ручного сопоставления направлений. Разметка сайта может измениться. Каждое направление приложения использует свой порядок остановок. Импорт сохраняет `startStopId` и `endStopId` первой и последней остановки, но не создаёт недостающие направления. Корректность порядка остановок и существующих `time_offset_minutes` нужно проверять в редакторе маршрута. diff --git a/frontend/src/pages/ScheduleImportPage.jsx b/frontend/src/pages/ScheduleImportPage.jsx index b47bbaa..5ca8d61 100644 --- a/frontend/src/pages/ScheduleImportPage.jsx +++ b/frontend/src/pages/ScheduleImportPage.jsx @@ -173,7 +173,7 @@ export default function ScheduleImportPage() { {mappings[route.id] &&

В этот маршрут попадут времена из строки «{mappings[route.id]}».

} {inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row =>
- {row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён + {row.dayLabel || row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён

{row.rawTimes}

)} )}