{device.name}
{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}
diff --git a/README.md b/README.md index 8190a03..6671913 100644 --- a/README.md +++ b/README.md @@ -404,3 +404,14 @@ MIT License - используйте свободно! Поля находятся в существующем JSONB настроек устройств; новая SQL-миграция не требуется. Старые сохранённые конфигурации нормализуются при чтении (`auto / 60`) и не изменяются в базе автоматически. Для редактирования этих полей разверните обновлённые API, frontend админки и Board вместе. Блок информации «Дальше по маршруту» заменяет первый и последний рейс. Его переключатель сохраняет существующий ключ `infoServiceEnabled`: ранее отключённый блок остаётся отключённым, SQL-миграция и изменение JSON устройства не нужны. Схему следующих остановок и время пути строит обновлённый Board из полного кеша расписания; в админке изменено только название переключателя. + + +### Пассажирские блоки табло + +В форме устройства добавлены переключатели «До важных мест», «Оплата поездки», «Напоминание о последнем рейсе» и «Вероятность осадков». До трёх важных мест задаются конкретной остановкой назначения и коротким названием. По умолчанию места не выбраны; незаполненный блок на табло скрыт. Отправление, путь и прибытие рассчитывает Board из полного действующего расписания с порядком остановок и конечной конкретного рейса. Отмены, направление и переход через полночь учитываются. + +Тарифы по типу транспорта имеют поля `cityId`, `type` (`bus` / `minibus`), `price`, `payment`, `checkedAt`. Для Полоцка (город 1) пользователь подтвердил 11 октября 2026 года: автобус 1,05 BYN, маршрутка 2,10 BYN, оплата QR-кодом или наличными. Для других городов тарифы остаются незаполненными. Допустимы до 32 исключений по `routeId`; исключение имеет приоритет над тарифом типа. Цена и способы оплаты — подтверждённый оператором текст, дата проверки сохраняется и видна пассажиру. Будущий тариф не публикуется до указанной даты. Платёжный QR-код не генерируется. + +Конфигурация: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]`; `fareByType: [{cityId, type, price, payment, checkedAt}]`; `fareRules: [{routeId, price, payment, checkedAt}]`. API проверяет структуру, лимиты, дубликаты, действительность даты и принадлежность городу/площадке. Явные пустые массивы сохраняются. Старые конфигурации нормализуются без записи в БД; ранее отсутствовавший тариф типа подставляется только для города 1. + +Новые поля хранятся в существующем JSONB; дополнительная SQL-миграция не нужна. Разверните обновлённые backend, frontend админки и RideBus Board вместе. Переключатели и содержимое передаются через существующие версии настроек и подтверждения устройством. Проверки пассажирских настроек входят в `test/boardDevices.test.js` и `test/boardDevicesApi.test.js`; визуальный стенд `ridebus-board/test/fixtures/passenger-server.mjs` использует отдельный локальный origin и тестовое расписание. diff --git a/backend/src/services/boardDeviceService.js b/backend/src/services/boardDeviceService.js index 767cecd..5d33f04 100644 --- a/backend/src/services/boardDeviceService.js +++ b/backend/src/services/boardDeviceService.js @@ -1,5 +1,6 @@ const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto'); const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings'); +const { cityFareDefaults } = require('./boardPassengerSettings'); const tokenHash = token => createHash('sha256').update(token).digest('hex'); function credential(token) { if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token'); @@ -11,14 +12,20 @@ function name(value) { } function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; } function publicDevice(row, now) { - return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: validSettings(row.settings), + const settings = validSettings(row.settings); + if (!Object.hasOwn(row.settings, 'fareByType')) settings.fareByType = cityFareDefaults(row.city_id); + return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings, revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at, online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) }; } function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) { async function checked(cityId, input) { const settings = validSettings(input); + if (!Object.hasOwn(input, 'fareByType')) settings.fareByType = cityFareDefaults(cityId); if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке'); + for (const place of settings.places) if (place.stopId === settings.stopId || !await repository.catalog(cityId, { stopId: place.stopId, routeFilter: '' })) throw new DeviceError(400, 'invalid_place', 'Выберите другую остановку назначения в этом городе'); + for (const rule of settings.fareRules) if (!await repository.catalog(cityId, { stopId: settings.stopId, routeFilter: String(rule.routeId) })) throw new DeviceError(400, 'invalid_fare_route', 'Маршрут тарифа не проходит через эту площадку'); + if (settings.fareByType.some(rule => rule.cityId !== cityId)) throw new DeviceError(400, 'invalid_fare_city', 'Тариф относится к другому городу'); return settings; } return { diff --git a/backend/src/services/boardDeviceSettings.js b/backend/src/services/boardDeviceSettings.js index 49995f5..7f2bb60 100644 --- a/backend/src/services/boardDeviceSettings.js +++ b/backend/src/services/boardDeviceSettings.js @@ -1,7 +1,10 @@ +const { passengerDefaults, checkPassengerSettings } = require('./boardPassengerSettings'); const defaults = { + ...passengerDefaults, stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, + journeyTranslitEnabled: false, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.', @@ -17,9 +20,10 @@ function validSettings(input) { for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) { if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key); } - for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key); + for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'journeyTranslitEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key); for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'], nightMode: ['off', 'auto'] })) if (!choices.includes(value[key])) fail(key); if (![45, 60].includes(value.nightWakeMinutes)) fail('nightWakeMinutes'); + checkPassengerSettings(value, fail); if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter'); for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key); return value; diff --git a/backend/src/services/boardPassengerSettings.js b/backend/src/services/boardPassengerSettings.js new file mode 100644 index 0000000..fa6b152 --- /dev/null +++ b/backend/src/services/boardPassengerSettings.js @@ -0,0 +1,21 @@ +const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] }; +const confirmedPolotskFares = [ + { cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }, + { cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }, +]; +const cityFareDefaults = cityId => cityId === 1 ? confirmedPolotskFares : []; +const object = value => value && typeof value === 'object' && !Array.isArray(value); +const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max; +const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max; +const date = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + 'T12:00:00Z')) && new Date(value + 'T12:00:00Z').toISOString().slice(0, 10) === value; +const terms = value => text(value.price, 40) && text(value.payment, 100) && date(value.checkedAt); +function checkPassengerSettings(settings, fail) { + for (const key of ['infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled']) if (typeof settings[key] !== 'boolean') fail(key); + if (!Array.isArray(settings.places) || settings.places.length > 3 || new Set(settings.places.map(p => p?.stopId)).size !== settings.places.length + || !settings.places.every(p => object(p) && Object.keys(p).every(k => ['stopId', 'name'].includes(k)) && id(p.stopId) && text(p.name, 48))) fail('places'); + if (!Array.isArray(settings.fareRules) || settings.fareRules.length > 32 || new Set(settings.fareRules.map(p => p?.routeId)).size !== settings.fareRules.length + || !settings.fareRules.every(p => object(p) && Object.keys(p).every(k => ['routeId', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.routeId, 999999999) && terms(p))) fail('fareRules'); + if (!Array.isArray(settings.fareByType) || settings.fareByType.length > 2 || new Set(settings.fareByType.map(p => p?.type)).size !== settings.fareByType.length + || !settings.fareByType.every(p => object(p) && Object.keys(p).every(k => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.cityId) && ['bus', 'minibus'].includes(p.type) && terms(p))) fail('fareByType'); +} +module.exports = { passengerDefaults, cityFareDefaults, checkPassengerSettings }; diff --git a/backend/test/boardDevices.test.js b/backend/test/boardDevices.test.js index 1ce27a9..8dc2c78 100644 --- a/backend/test/boardDevices.test.js +++ b/backend/test/boardDevices.test.js @@ -59,3 +59,46 @@ test('night preferences validate, survive updates and migrate old stored device await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1); const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2); }); + +test('transliteration migrates old devices, validates booleans and survives a remote update', async () => { + const repo = memoryRepository(), service = createBoardDeviceService(repo); + const pairing = await service.start({}); + const device = await service.claim(1, { code: pairing.code, name: 'Latin', settings: defaults }, 1); + delete repo.devices.get(device.id).settings.journeyTranslitEnabled; + assert.equal((await service.status(pairing.token)).device.settings.journeyTranslitEnabled, false); + assert.equal((await service.list(1))[0].settings.journeyTranslitEnabled, false); + for (const flag of ['true', 1, null]) assert.throws(() => validSettings({ ...defaults, journeyTranslitEnabled: flag }), e => e.status === 400); + await service.update(device.id, 1, { name: 'Latin', revision: 1, settings: { ...defaults, journeyTranslitEnabled: true } }, 1); + const saved = (await service.status(pairing.token)).device; + assert.equal(saved.settings.journeyTranslitEnabled, true); + assert.equal(saved.revision, 2); +}); + +test('passenger configuration validates shape and city/stop/route identity before consuming a code', async () => { + const service = createBoardDeviceService(memoryRepository()), p = await service.start({}); + const rule = { routeId: 1, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' }; + for (const patch of [{ places: [{ stopId: 3, name: 'Другой город' }] }, { places: [{ stopId: 1, name: 'Та же остановка' }] }, + { fareRules: [{ ...rule, routeId: 2 }] }, { fareByType: [{ cityId: 2, type: 'bus', ...rule }] }, { places: null }, { infoFareEnabled: 'true' }]) { + await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, ...patch } }, 1), e => e.status === 400); + } + await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: null }, 1), e => e.status === 400); + await assert.rejects(service.claim(1, { code:p.code, name:'Guide', settings:{...defaults, fareByType:[{cityId:2,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]} }, 1), e => e.code === 'invalid_fare_city'); + assert.equal((await service.status(p.token)).state, 'pending'); + const d = await service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, places: [{ stopId: 2, name: 'Вокзал' }], fareRules: [rule] } }, 1); + const saved = (await service.status(p.token)).device.settings; + assert.equal(saved.places[0].name, 'Вокзал');assert.equal(saved.fareRules[0].price, 'Особый тариф'); + await service.update(d.id,1,{name:'Guide',revision:1,settings:{...saved,weatherHintEnabled:false,lastTripNoticeEnabled:false}},1); + assert.equal((await service.status(p.token)).device.settings.weatherHintEnabled,false); +}); +test('legacy Polotsk tariffs migrate using the operator confirmation; other cities stay unconfigured', async () => { + for(const cityId of [1,2]) { + const repo=memoryRepository(),service=createBoardDeviceService(repo),p=await service.start({}); + const d=await service.claim(cityId,{code:p.code,name:'Legacy',settings:{...defaults,stopId:cityId===1?1:3}},1); + for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete repo.devices.get(d.id).settings[key]; + const saved=(await service.status(p.token)).device.settings; + assert.equal(saved.lastTripNoticeEnabled,true);assert.deepEqual(saved.places,[]); + assert.equal(saved.fareByType.length,cityId===1?2:0); + if(cityId===1) {assert.equal(saved.fareByType[0].price,'1,05 BYN');assert.equal(saved.fareByType[1].price,'2,10 BYN');assert.equal(saved.fareByType[0].checkedAt,'2026-10-11');} + assert.equal(repo.devices.get(d.id).settings.fareByType,undefined); + } +}); diff --git a/frontend/src/components/BoardPassengerSettings.jsx b/frontend/src/components/BoardPassengerSettings.jsx new file mode 100644 index 0000000..04de6f6 --- /dev/null +++ b/frontend/src/components/BoardPassengerSettings.jsx @@ -0,0 +1,40 @@ +export const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] }; +export const cityFareDefaults = cityId => cityId === 1 ? [ + { cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }, + { cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' }, +] : []; +const today = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk' }).format(Date.now()); +export function BoardPassengerSettings({ settings, stops, routes, cityId, set }) { + const editPlace = (index, patch) => set('places', settings.places.map((value, i) => i === index ? { ...value, ...patch } : value)); + const editRule = (index, patch) => set('fareRules', settings.fareRules.map((value, i) => i === index ? { ...value, ...patch } : value)); + return
До трёх остановок назначения. Укажите короткое название для пассажиров; табло найдёт ближайший прямой рейс с учётом его конечной и направления.
+ {settings.places.map((place, index) => )} + +Основные тарифы по типу транспорта. Только подтверждённые цена с валютой, способы оплаты и дата проверки. Без подходящего тарифа карточка скрыта.
+ {['bus', 'minibus'].map(type => { + const rule = settings.fareByType.find(r => r.type === type && r.cityId === cityId), label = type === 'bus' ? 'автобуса' : 'маршрутки'; + const edit = patch => set('fareByType', settings.fareByType.map(r => r.type === type && r.cityId === cityId ? { ...r, ...patch } : r)); + return ; + })} +Индивидуальный тариф заменяет основной тариф типа транспорта.
+ {settings.fareRules.map((rule, index) => )} + +Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. По полному расписанию остановки.
-{error}
} @@ -85,6 +88,6 @@ export default function DevicesPage() { {!loading && !devices.length &&Откройте RideBus Board на телевизоре и введите появившийся код здесь.
Загружаем устройства…
}{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}