尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗Цифровая биржа грузоперевозок. Оператор платформы — Aivomaa Oy (Финляндия), роль оператора — принципал-посредник: заказчик платит оператору полную ставку, оператор платит перевозчику ставку за вычетом комиссии.
Продуктовое ТЗ — PROJECT.md.
Визуальный прототип-референс — rahti.jsx (в сборку не входит, только для сверки).
| Слой | Решение |
|---|---|
| Фронтенд | Next.js 16 (App Router), React 19, TypeScript strict |
| Стили | Tailwind CSS v4, собственный UI-кит |
| Бэкенд, БД, авторизация, файлы | Supabase (Postgres + Auth + Storage + RLS) |
| Автоматизации | n8n (рассылки, таймауты, отчёты), pg_cron и pg_net в базе |
| Хостинг | Vercel + Supabase |
npm install
cp .env.example .env.local # заполнить ключами Supabase
npm run dev
| Команда | Что делает |
|---|---|
npm run dev | Дев-сервер на http://localhost:3000 |
npm run build | Продакшен-сборка |
npm run check | Линт + проверка типов |
npm run typecheck | Только проверка типов |
Каркас поднимается и без ключей Supabase — до подключения проекта клиенты просто не используются.
Проект использует ключи нового формата (sb_publishable_… / sb_secret_…).
Это не JWT; legacy-ключи anon и service_role не применяются.
| Переменная | Ключ | Где живёт |
|---|---|---|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | sb_publishable_… | Браузер и сервер. Доступ ограничивает RLS, а не секретность ключа |
SUPABASE_SECRET_KEY | sb_secret_… | Только сервер. Обходит RLS полностью |
TOMTOM_KEY | ключ TomTom | Только сервер. Расчёт пробега и подсказка адресов |
NEXT_PUBLIC_MAPTILER_KEY | ключ MapTiler | Браузер. Фон карты; ограничивается доменом в панели MapTiler |
Разделение держится кодом, а не памятью:
src/lib/env.server.ts с import 'server-only' —
импорт из клиентского компонента роняет сборку;supabasePublishableKey() проверяет, что в публичную переменную не вписан
sb_secret_…, и падает с объяснением, если вписан.src/
├─ app/
│ └─ [locale]/ все маршруты живут под префиксом языка
│ ├─ layout.tsx корневой layout: шрифты, тема, провайдер локали
│ └─ ui/ витрина UI-кита — /ru/ui
├─ components/ui/ UI-кит: Badge, Button, Card, Table, Waypoint…
├─ lib/
│ ├─ config.ts комиссия, таймауты, регионы, часовой пояс
│ ├─ format.ts деньги, даты, обратный отсчёт
│ ├─ i18n/ локали, словари, провайдер
│ ├─ env.ts публичные переменные окружения
│ ├─ env.server.ts секреты, защищено server-only
│ ├─ routing/ расчёт пробега и подсказка адресов (TomTom)
│ └─ supabase/ клиенты: браузер, сервер, proxy, admin
├─ types/database.ts генерируется Supabase CLI
└─ proxy.ts локаль + продление сессии на каждом запросе
supabase/
├─ migrations/ схема БД, версионируется
└─ bootstrap/ разовые скрипты под конкретное окружение
Схема живёт в supabase/migrations/ и применяется через Supabase CLI.
npx supabase link --project-ref <ref>
npx supabase db push
npm run types
Последняя команда обязательна после каждого db push, и это не
формальность. src/types/database.ts генерируется, поэтому устаревает
молча: однажды он разошёлся с базой на тысячу строк и всё это время
прятал два настоящих несоответствия — обращение к полю, которое может
быть пустым, и вызов функции с аргументами, которых она не объявляла.
Проверка Типы совпадают с базой в CI гоняет ту же команду и валит
сборку при расхождении.
.github/workflows/check.yml, три работы. Первая — линтер и tsc —
идёт всегда и ничего не требует. Две другие просят доступ к боевому
проекту и пропускаются, если секретов нет: сборка, красная из-за
незаполненной настройки, приучает смотреть мимо красного.
| Секрет репозитория | Для чего |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | сборка: NEXT_PUBLIC_* вшиваются в бандл на этапе сборки |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | то же |
NEXT_PUBLIC_MAPTILER_KEY | то же, фон карты |
NEXT_PUBLIC_SITE_URL | то же, адреса в письмах |
SUPABASE_ACCESS_TOKEN | сверка типов: токен из личного кабинета Supabase |
SUPABASE_PROJECT_REF | сверка типов: ref проекта |
Секретного ключа базы в этом списке нет и быть не должно: сборке он не нужен, а секрет, попавший в CI без надобности, — это лишнее место, из которого он может утечь.
Порядок миграций Этапа 1 продиктован зависимостями и разорвать его нельзя:
| Файл | Что делает |
|---|---|
…_foundation.sql | схема app, перечисления party_role и company_status, touch_updated_at |
…_companies.sql | таблица компаний, индексы, гранты, RLS включён без политик |
…_profiles.sql | таблица профилей, триггер создания из auth.users, гранты |
…_rls.sql | хелперы app.* и все политики доступа |
…_company_events.sql | журнал модерации и триггер на смену статуса |
…_profile_trigger_on_metadata.sql | профиль создаётся и при обновлении app_metadata |
Последняя миграция исправляет неочевидное: GoTrue не пишет метаданные одной
вставкой. Строка в auth.users появляется раньше, чем в неё попадает
raw_app_meta_data, поэтому триггер AFTER INSERT видел пустые метаданные и
профиль не создавал. Опираться на внутренний порядок записи чужого сервиса
нельзя — профиль создаётся по факту появления роли, с какой бы операцией она
ни пришла.
Кольцо зависимостей здесь неочевидное: политикам companies нужны хелперы,
хелперам нужна таблица profiles, а profiles ссылается на companies.
Функции на LANGUAGE sql проверяются при создании, поэтому хелперы не могут
идти первыми. Разрывается это разделением «сначала таблицы, потом политики».
При этом RLS включается прямо в миграции таблицы: таблица с включённым RLS
и без единой политики не отдаёт ни строки, так что окна открытых данных не
возникает.
| Кто | companies | profiles | company_events |
|---|---|---|---|
| Перевозчик / заказчик | своя компания: чтение; правка только name и contact_email | свой профиль и коллеги по компании: чтение; правка своих full_name, phone | своя история: чтение |
| Администратор | всё: чтение | всё: чтение | всё: чтение |
| Аноним | ничего | ничего | ничего |
Три вещи, которые стоит знать до правки политик:
Статус компании нельзя сменить из браузера. RLS работает построчно и не
отличает правку названия от попытки выписать себе ACTIVE. Поэтому запрет
держится колоночными грантами: в GRANT UPDATE (…) статуса просто нет.
Переходы выполняют функции moderate_company (решение оператора) и
activate_company (компания заполнила реквизиты). Обе SECURITY DEFINER,
обе проверяют права внутри себя и обе вызываются под сессией пользователя —
иначе auth.uid() был бы пуст и журнал остался без автора решения.
Активная компания имеет полные реквизиты — это ограничение, а не
соглашение. companies_active_requires_requisites не даст выставить
ACTIVE без юридического адреса, ALV-номера и — по типу компании — IBAN или
адреса для счетов. Проверено: ограничение отклоняет даже запись секретным
ключом.
IBAN проверяется контрольной суммой mod-97 в базе (app.is_valid_iban),
а не только в форме. Опечатка в счёте означает неушедшую выплату, и ловить
её нужно на всех путях записи, включая Admin API.
Лицензия и страховка лежат в приватном бакете company-docs. Граница
безопасности — первый сегмент пути:
company-docs/{company_id}/{kind}/{uuid}-{имя файла}
Политики сравнивают (storage.foldername(name))[1] с компанией вызывающего,
то есть проверяют, где объект физически лежит, а не что попросил клиент.
Компания не может ни читать, ни писать за пределами своей папки; оператор
видит всё; аноним — ничего.
Удаление своим не даётся. Документы — основание, по которому оператор выдал допуск машинам. Замена делается загрузкой новой версии: прежняя остаётся в хранилище и перестаёт быть текущей в базе. Удалять может только оператор, для запросов на стирание персональных данных.
Файлы наружу отдаются подписанными ссылками на пять минут, которые выписывает клиент пользователя, а не секретный ключ, — политики проверяются ещё раз в момент выдачи.
vehicles.access хранит решение оператора об этой машине и по времени не
меняется никогда. Действительность документов — факт о компании. Право
выйти на стол вычисляется:
машина едет = access = APPROVED И документы компании действуют сегодня
Планировщик, снимающий допуск по расписанию, был бы хуже по двум причинам. Между истечением и запуском задания существует окно, в котором машина ещё считается допущенной. И после продления страховки стало бы не отличить машину, которую оператор допускал, от той, которую он не допускал никогда. Здесь же дыра закрывается в момент истечения, а доступ возвращается сразу после загрузки продлённого документа.
Оператор видит проблему заранее: documents_needing_attention показывает
компании с допущенными машинами, чьи документы просрочены или истекают в
ближайшие 30 дней. Допустить машину при просроченных документах
decide_vehicle не даст.
Правка существенных полей допущенной машины — номера, водителя, числа осей или эко-класса — возвращает её на проверку триггером: оператор допускал конкретную связку, и молчаливая подмена водителя обесценила бы решение.
Пять сущностей из ТЗ §5 — забор, выгрузка, доп.точка, продолжение рейса,
возврат прицепа — это одно и то же: место в определённое время.
Различия сводятся к роли, поэтому они лежат одной таблицей order_stops
с ролью и ограничениями по ролям, а не пятью наборами колонок и не блоком
JSON. Единственность обеспечена частичными уникальными индексами: один
PICKUP, одна DELIVERY, не больше одного CONTINUATION и
TRAILER_RETURN; доп.точек сколько угодно.
Время местное настенное, а не момент. scheduled_date + scheduled_time
вместо timestamptz. «Быть на терминале в 08:00» означает 08:00 по месту:
Хельсинки живёт в EET, Стокгольм в CET, и timestamptz потребовал бы
угадывать пояс каждой исторической строки при выходе за Финляндию.
Два номера. ref генерирует платформа и он глобально уникален
(RS-2026-0001); shipper_ref — необязательный внутренний номер заказчика.
Один общий номер столкнулся бы у двух заказчиков.
Контакты получателя перевозчику на столе не видны. У перевозчика нет
политики чтения orders и order_stops вовсе — стол он видит через
public.desk_orders, которая проверяет допуск и не выбирает контактные
колонки. Это даёт одно очевидное место, где определено «что перевозчик
видит до того, как взял заказ», вместо россыпи условий по политикам.
Колоночные гранты для этого не годятся: они действуют на роль целиком,
а заказчику контакты своих же заказов нужны.
Гейт стола — тот самый app.has_dispatchable_vehicle из Этапа 3: нет
допущенной машины или просрочены документы — стол пуст, а интерфейс
объясняет, что именно нужно исправить.
ТЗ §6: заказ появляется на столе и рассылается перевозчикам. Стол был сделан, рассылки не было. Перевозчик узнавал о заказе, только если сам в эту минуту смотрел на стол, — а пятнадцатиминутные сроки матчинга рассчитаны на то, что он узнаёт сразу.
Уведомления пишет триггер, а не код публикации. Причина та же, что у
остальных событий заказа: функцию можно переписать и забыть про рассылку,
триггер видит саму строку. Условие ловит только DRAFT → OPEN, потому
что заказ, вернувшийся на стол по сроку или отказу, уже поднимает
order.released; рассылать его заново каждому — это выучить перевозчика
не читать наши уведомления.
Порядок внутри create_order позволяет обойтись обычным триггером на
смену статуса: заказ вставляется черновиком, пишутся точки и только потом
статус переводится в OPEN. К моменту срабатывания маршрут на месте, и
города забора и выгрузки попадают в уведомление без отложенных ухищрений.
Письмо — дубль, и оно ждётся, а не бросается вдогонку. В серверном действии работа после ответа не гарантирована, а отправка занимает доли секунды. Ни одна ошибка рассылки не отменяет публикацию: заказ опубликован, даже если не ушло ни одного письма.
Финские заказы — финским компаниям, шведские — шведским. Заказ из Эсбьерга, приходящий в финскую компанию, не просто бесполезен: письмо, которое стабильно не про тебя, отучает читать все остальные, включая то единственное, ради которого рассылка и заведена.
Страна, а не город. Город на сверку не годится — база машины —
свободный текст, набранный руками, и в боевой базе лежит «Heslinki».
Сверка base_city с городом забора вычеркнула бы эту компанию из
рассылок навсегда и молча. Страна с обеих сторон — код из двух букв под
ограничением: companies.country заведена с первого дня именно на этот
случай, order_stops.country приходит от геокодера вместе с координатой.
Страна неизвестна — шлём всем. Это не мягкость, а выбор меньшего зла: пустая страна у забора превратила бы строгую сверку в тишину, и заказ не ушёл бы никому. Лишнее письмо человек удалит, ненаписанного не заметит никто. Правило это не теоретическое: восемь заказов боевой базы созданы 2–3 сентября, а колонка страны заведена 5-го, и у всех них она пуста.
Стол по-прежнему показывает всё. Рассылка — это толчок, и она прицельная; стол — витрина, и на неё ходят смотреть сами. Финн, которому понадобился датский рейс, найдёт его руками.
Круг получателей считает база, и считает один раз. Прежде «кто
получает» стояло дважды — в триггере и в функции для почтового слоя. Пока
условие было одно слово, это сходило с рук; со страной они разошлись бы
на первой же правке и дали бы письмо тому, кому колокольчик не звонил.
Теперь круг считает app.dispatch_audience, а order_dispatch_recipients
и триггер только читают из неё. Полей в письме ровно столько же, сколько
на столе: контактов получателя груза там нет, пока заказ не закреплён.
Отклики принимаются до трёх, по одному от компании. ТЗ говорит «до трёх машин», и буквально это допускало бы, что один перевозчик занимает все три места своими тягачами. Но заказчик «выбирает одного из трёх» по рейтингу, а рейтинг копится на компанию: три машины одного перевозчика — это один и тот же выбор, показанный трижды, а двум другим перевозчикам мест не остаётся.
Пятнадцать минут на решение (ТЗ §6) действуют сразу и без планировщика:
app.release_expired_order;Планировщик pg_cron раз в минуту вызывает expire_order_deadlines(), но
его работа — прибраться и дать повод для уведомлений, а не обеспечить
правильность. Если бы состояние меняло только расписание, между истечением
срока и запуском задания заказ оставался бы запертым, а остановка
планировщика заперла бы его навсегда.
| До отклика | Свой отклик | Выбран | В работе | |
|---|---|---|---|---|
| Маршрут без контактов | ✓ | ✓ | ✓ | ✓ |
| Контакты получателя | — | — | ✓ | ✓ |
| Чужие отклики | — | — | — | — |
Контакты открываются политикой orders_select_assigned в момент выбора
заказчиком. Чужие отклики перевозчик не видит никогда: сколько мест занято,
ему сообщает стол числом, а кто именно откликнулся — не его дело.
Aivomaa — принципал-посредник (ТЗ §1): заказчик платит оператору полную ставку, оператор платит перевозчику за вычетом комиссии. Контрагент заказчика — Aivomaa, и в его кабинете исполнителем значится Aivomaa.
Закрыто в трёх местах сразу, потому что одного мало:
| Где | Как закрыто |
|---|---|
offers_for_shipper | ни названия компании, ни carrier_company_id в списке колонок |
order_offers | политика order_offers_select_shipper удалена — прямого чтения нет |
orders | табличный грант заменён поимённым, без assigned_* и chosen_offer_id |
Голый идентификатор без названия закрывать так же обязательно: он позволяет сопоставлять заказы между собой — «этот перевозчик у нас уже был».
Колоночный грант здесь работает там, где RLS бессилен: политика решает
про строку целиком, а заказчик имеет полное право читать свой заказ — но
не ту его часть, где записан исполнитель. Перевозчик от этого не теряет
ничего: свои рейсы он читает через my_assignments, а security definer
колоночные гранты не ограничивают.
Колоночный грант ломает политики, которые делают select 1. Грабли
неочевидные и стоили одного бага. Выражения политик выполняются с правами
вызывающего, а exists (select 1 from public.orders …) не ссылается ни на
одну колонку — в этом случае Postgres требует именно табличную
привилегию, и колоночные её не заменяют. После замены гранта заказчик
перестал видеть точки собственных заказов: заказ отображался без
маршрута. Проверки принадлежности вынесены в app.owns_order и
app.carries_order с security definer — внутри них права вызывающего не
действуют. Тот же приём заодно позволил политике перевозчика сверяться с
assigned_company_id, которого у authenticated больше нет вовсе.
Заказчик выбирает из обезличенных карточек: «Вариант 1 · рейтинг · Scania, 3 оси · Euro 6 · база Хельсинки». Рейтинг остаётся, потому что по нему и выбирают (ТЗ §6); характеристики машины — потому что по ним видно, пройдёт ли сцепка по площадке.
Госномер и водитель открываются только после выбора. До него это опознавательный признак без пользы, после — без них не выписать пропуск в порт и не предупредить терминал, кого ждать.
Полной анонимности это не даёт, и обещать её нельзя. Госномер пробивается по реестру Traficom, в CMR на закрытии рейса (ТЗ §9) стоит настоящий перевозчик, водитель физически приезжает. Закрыт лёгкий обход — собрать список подрядчиков одним взглядом из интерфейса. От целенаправленного обхода защищает оговорка о непереманивании в договоре, а это вне кода. Если задача — чтобы заказчик не узнал перевозчика никогда, то на Этапе 9 CMR придётся обезличивать, и это отдельное решение.
Отклики читаются функцией offers_for_shipper, а не связями PostgREST.
Причина не в удобстве. RLS применяется к вложенным ресурсам отдельно, а
политик чтения vehicles у заказчика нет и быть не должно: строка машины
содержит WhatsApp водителя. Вложенный select из-за этого возвращал
null, а заглушки вида ?? '—' превращали отказ доступа в прочерки —
выглядело это как незаполненная карточка машины, и чинить тянуло данные,
а не код.
Поэтому здесь тот же приём, что у desk_orders и my_assignments: одна
функция, где явным списком колонок записано, что заказчик видит о
перевозчике. Телефона водителя в списке нет. Заглушек на месте
недостающих данных тоже нет — пустое поле означало бы, что отказ доступа
снова можно принять за незаполненную форму.
Назначенная машина берётся из самого заказа, а не только из откликов:
связь «кто везёт» обязана переживать любую уборку order_offers. До
этого заказчик после подтверждения водителем вообще переставал видеть,
кому отдал груз, — заказ уходил в IN_PROGRESS, а панель откликов
показывалась только до старта.
ТЗ §7 задаёт шесть этапов: принял, забрал прицеп, загрузился, в пути, выгрузился, сдал. Список написан под модель «забор → выгрузка», где каждый этап случается один раз. В нынешней модели рейс — последовательность точек, и выгрузок с загрузками бывает несколько: «Загрузился» перестал быть одиночным событием, и какое из двух показывать в статусе — ответа нет.
Поэтому этап не хранится, а выводится из пройденных точек. Все шесть состояний остаются, но обретают место:
| ТЗ | Откуда берётся |
|---|---|
| принял | заказ в работе |
| забрал прицеп | пройдена точка забора |
| загрузился · Tampere | пройдена точка загрузки |
| выгрузился · Turku | пройдена точка выгрузки |
| в пути · Kotka | между пройденной и следующей |
| сдал | пройдена отцепка |
Место в подписи обязательно: при двух выгрузках «Выгрузился» без названия
площадки не отвечает на вопрос «где». Вычисление живёт в
src/lib/orders/progress.ts — один источник на все три роли, как требует
ТЗ §7. Оно же питает отметки на карте.
Точки проходятся по порядку. Нельзя выгрузиться в Котке, не забрав прицеп в Ханко. Проверка не формальность: пропущенная точка означает либо ошибку нажатия, либо что водитель не там, где думает диспетчер, и молча принять такую отметку — значит потерять единственный признак.
Снять отметку можно только с последней. Ошибочные нажатия неизбежны, а рейс без возможности исправить заставляет звонить оператору. Снятие из середины оставило бы дыру, и «по порядку» перестало бы что-либо значить.
Повреждения пишутся на точку, а не на рейс (ТЗ §9). Прицеп могут поцарапать при заборе, а груз повредить на конкретной выгрузке — при разборе претензии важно, на чьём плече это случилось. Отметка ставится в момент прохождения: тогда её делает тот, кто там стоял. Отдельного флага «проверено» нет — сам факт прохождения и есть проверка, а пустое описание означает «без повреждений».
Отмечает пока перевозчик из кабинета: у водителя веб-кабинета нет, по
ТЗ §7 он общается через WhatsApp-агента. На Этапе 8 сюда же будет ходить
n8n от его имени — сигнатура complete_stop для этого и рассчитана.
Пока рейс идёт, заказчик меняет непройденные точки, вставляет новые и убирает отменённые (ТЗ §8): порт перенёс окно, склад закрылся, по дороге добавилась вторая выгрузка. До этого такая правка была звонком диспетчеру.
Правка — это запись, а не новое значение. Точку можно было бы менять
обычным update по политике, и заказчик получил бы ровно то, что просит
ТЗ. Но тогда никто не смог бы ответить, чем идущий рейс отличается от
заказа, который взял перевозчик, — а отличается он именно этим: водитель
поехал по одному маршруту, а везёт по другому. Поэтому правка идёт через
amend_stop / add_stop / remove_stop, каждая пишет строку в
order_amendments, и прямого update на точки у заказчика нет.
Пройденную точку не трогает никто. Там стояла машина, там ставилась отметка, там могло записаться повреждение — изменить её задним числом значит переписать доказательную базу рейса. Вставить новую точку перед пройденной нельзя по той же причине: маршрут начал бы утверждать, что водитель проехал мимо места, которого в маршруте не было.
Позиция новой точки задаётся соседом, а не номером. «Вставить перед отцепкой» — осмысленная фраза, «вставить пятой» — нет: номер меняется от каждой вставки. Заодно этим исключается вставка после конца рейса. Убираются только загрузки и выгрузки: без забора и отцепки перецепа не бывает.
Журнал хранит факты, а не фразу. В changes лежит
{поле: {"from": было, "to": стало}}, и предложение из этого собирает
словарь интерфейса. Готовая строка вроде «перенёс выгрузку в Турку на
14:00» была бы написана по-русски навсегда — см. правила i18n ниже.
Добавление и удаление описаны тем же форматом: у появившейся точки всё
«стало», у убранной всё «было», и разборщик в интерфейсе один.
Правка стирает кэш линии и плечи, а distance_km и ставку не трогает.
Это главное решение здесь: пробег и цена согласованы, перевозчик взял
рейс за эти деньги, и молча переписать число, из которого они посчитаны,
платформа не вправе. Новый расчёт ложится в distance_auto_km,
расхождение видно обеим сторонам, а договариваются о нём люди.
Линия при этом обязана исчезнуть, а не остаться прежней: карта, ведущая
мимо новой точки, врёт убедительнее, чем её отсутствие — пустая карта
заставляет посмотреть в список точек, нарисованная не заставляет. Сразу
после правки кабинет заказчика заказывает пересчёт тем же вызовом TomTom,
что и при публикации, и кладёт результат через store_route. Если у
какой-то точки нет координат — адрес набран руками, — пересчёт не
делается: маршрут по неполному набору точек не короче настоящего, а
просто другой.
ТЗ §8 требует, чтобы правка ушла водителю автоматически. Очередь
неотправленного — это сами строки журнала: пока acknowledged_at пуст,
правка не доведена. Перевозчик видит её в кабинете пометкой «Изменения от
заказчика» и снимает кнопкой; на Этапе 8 то же самое будет делать n8n
после того, как WhatsApp-агент написал водителю. Подтверждается рейс
целиком, а не каждая строка: пометка отвечает на вопрос «я в курсе, что
поменялось», и подтверждение по одной приучило бы нажимать не глядя.
Вставка и удаление сдвигают номера соседей, а sequence уникален в
пределах заказа — обычный update … set sequence = sequence + 1 падает на
том, что вторая точка на мгновение совпадает со третьей. Ограничение
пересоздано как deferrable initially immediate: проверка откладывается
до конца транзакции только у того, кто действительно сдвигает нумерацию,
а обычная вставка точки ловит ошибку там же, где ловила раньше.
Опубликованный, но не начатый заказ этими функциями не правится. Он лежит на столе, по нему поданы отклики, и тихая подмена адреса означала бы, что перевозчик согласился на один рейс, а везёт другой. Такая правка — это снятие с публикации, и она будет отдельной.
Закрытый рейс исчезает со стола перевозчика и из «Моих заказов» заказчика — он живёт во вкладке выполненных, с документами, суммами и оценкой.
Оба списка отвечают на вопрос «что требует действия»: подтвердить, проехать, закрыть, выбрать из откликов. Выполненный рейс не требует ничего и каждый день удлиняет список ровно на величину оборота компании; через полгода сегодняшний рейс пришлось бы искать среди двухсот вчерашних.
Смена мгновенная и без промежуточного состояния: закрыл — исчез отсюда и появился там. Держать его в двух местах хуже, чем в одном, потому что рано или поздно два места разойдутся.
Следствие, о котором стоит знать: дозагрузить фото после закрытия больше нельзя — панель закрытия уходит вместе с рейсом. Накладная к тому моменту приложена обязательно, иначе рейс бы не закрылся, но если понадобится добавлять фото повреждений задним числом, загрузку придётся вернуть в карточку выполненного.
Заглушка Этапа 1 заменена полноценным лендингом: первый экран со съёмкой порта и восемь секций под ним — что мы делаем, цена времени, две двери, путь рейса, поведение при сбоях, помощник водителя, направления, заявка.
Главная показывается всем одинаково, включая вошедших. Уводить вошедшего редиректом в кабинет значило бы, что сотрудник заказчика не может открыть сайт своей платформы и посмотреть, что там написано. Вход стоит в шапке первого экрана.
Восемь секций в одном файле намеренно. Это одна страница с одним рассказом, и разносить её по восьми файлам значит потерять из виду порядок, в котором она читается. Разделим, когда секции научатся жить порознь.
Реквизиты оператора в подвале: Aivomaa Oy, Y-tunnus 3592993-6, [email protected].
Заглушка Этапа 1 заменена на секцию со съёмкой порта: видео фоном, белый заголовок, светлая карточка рейса справа и живой счётчик парка.
Одно исключение из светлой темы, и оно на одну секцию. Поверх съёмки чернильная типографика не читается ни при каком затемнении, а полупрозрачная плёнка под текстом возвращает ту самую кашу, из-за которой мы отказались от сетки и вуали. Поэтому здесь белый текст на настоящей темноте, а не на плёнке.
Поверх видео ровно два эффекта: расфокус 3,5 пикселя и затемнение.
Ни инженерной сетки, ни световой вуали — в макете именно они,
накладываясь друг на друга, превращали порт в размытое пятно. Затемнение
с уклоном в тёмно-зелёный под цвет лендинга: rgba(0,0,0,0.45) плюс
диагональный градиент.
Видео пережато и обрезано заранее. Исходные 66 МБ при 1920×1080 и
20 Мбит/с — для фона, который всё равно уходит в расфокус, это чистая
потеря трафика. В репозитории лежит 720p без звука, CRF 30, faststart:
1,2 МБ, в пятьдесят три раза меньше. Рядом постер первым кадром — он
же единственное, что видит браузер при prefers-reduced-motion и когда
видео не запустилось.
На экране стоит живое число: сколько машин имеет допуск прямо сейчас.
Читать его напрямую из vehicles посетитель не может — RLS закрывает
таблицу от анонима целиком, и счётчик молча не рисовался бы на живом
сайте. Худший вид поломки: страница выглядит исправной, а числа нет.
Поэтому public.fleet_size() с security definer отдаёт ровно два
агрегата — сколько машин и в скольких городах, — и доступна без входа.
Ни номеров, ни компаний, ни водителей: посетителю нужно знать, что
перевозчики есть, а не кто они.
Считаются машины с допуском, а не все заведённые: без допуска машина на стол не выходит и груз не увезёт. Пустой парк не показывается вовсе — ноль машин отговаривает вернее, чем отсутствие цифры.
Под кнопками стоит полоса из трёх пунктов — автоматическая рассылка, пошаговая видимость рейса, документы в одном месте, — и счётчик парка. Дальше первый экран не продолжается.
Раньше под счётчиком было ещё две строки. Первая объясняла, что числа живые, — то же самое говорит пульсирующая точка рядом с ними. Вторая слово в слово повторяла абзац у формы заявки внизу страницы: проверка по реестрам и доступ после одобрения. Обе убраны, и из них в нижний абзац перенесено то, чего там не было: что зарегистрироваться напрямую нельзя и что реестры — PRH и YTJ.
Разделители полос рисуются вёрсткой, а не пишутся в текст. Символ, вписанный в строку, повисает в её конце при переносе; псевдоэлемент переносится вместе со своим пунктом и встаёт в начало новой строки — то же уродство с другого края. Поэтому ниже 640 пикселей разделителя нет вовсе: три пункта становятся столбцом, страны переносятся с зазором.
Полоса под первым экраном перечисляла двадцать девять городов четырьмя центрированными строками разной длины: в строке FI двенадцать городов, в строке DK четыре. Список был честный, но читался как перенос по ширине, а не как география, и рваный край съедал то, ради чего полосу заводили.
Осталась страна — четыре слова в строку. Выводятся из справочника
площадок (placeCountries()), а не переписываются в разметку: правило то
же, ради которого сюда переехали города — убранная из справочника страна
не должна остаться на витрине обещанием, за которым уже нет ни одного
порта.
Здесь напрашивались логотипы клиентов, но выдуманные логотипы были бы враньём, а пустая полоса читалась бы как «клиентов нет». География — то же социальное доказательство, только проверяемое.
Маркер один на всю страницу: квадратик 7×7 акцентом. Их было три — тире, квадратик акцентом и квадратик бледным акцентом, — и списки из-за этого читались как разные по важности, хотя они равные. Кегль тоже один: 14 пикселей в секциях с одной и двумя колонками, 13 — только там, где колонок четыре и карточка узкая.
Цветные полоски в «Сбоях», моно-нумерация в «Пути рейса» и таблетки в «Направлениях» под это правило не подпадают: там форма несёт смысл — вид происшествия, порядок шага, — а не украшает список.
«Время» — пары, а не два списка. Секция сравнивает, как работает логистика сейчас и как на платформе. Раньше это были две колонки по шесть законченных предложений: попарно они говорили об одном и том же, но сопоставить их должен был читатель — удержать шесть пунктов и найти к ним шесть ответов. Теперь пара стоит на одной строке, сравнение делает раскладка, и поэтому предложение ужалось до выражения: 111 слов против 48. Маркеры здесь не нужны вовсе.
«Две двери» — по четыре пункта. Публикация одним разом, документы сразу и недельный отчёт сказаны строкой выше, в той же таблице. Повторённые, они не усиливают обещание, а удлиняют список; осталось то, что можно сказать только с этой стороны.
После закрытого рейса заказчик ставит оценку 1–5 (ТЗ §10). Она пишется на компанию-перевозчика, показывается при выборе из откликов и по ней отклики сортируются — то есть оценка не украшение профиля, а то, из чего складывается следующий заказ.
Одна оценка на рейс, а не на компанию. Первичный ключ по order_id:
оценивают работу, а не настроение. Иначе один недовольный заказчик поставил
бы десять единиц по одному рейсу.
Средняя считается, а не хранится. Кэш среднего однажды разойдётся с
фактами, и никто об этом не узнает. Пока рейсов тысячи, avg дешевле
поддержки согласованности.
app.company_rating — security definer, и это обязательно. Заказчик
по RLS видит только свои оценки. Посчитай среднюю из того, что видно
спрашивающему — и каждый увидит свою; первый же разговор двух заказчиков об
одном перевозчике кончится вопросом, кто из них видит правду.
Свою оценку можно поправить. Промах по звезде неизбежен, а неисправимая оценка гонит звонить оператору — тому самому, от звонков которому платформа избавляет. Повторный вызов заменяет оценку, второй записи не появляется.
Заказчик не знает, кого оценивает. Его контрагент — Aivomaa (ТЗ §1), а в откликах перевозчики обезличены. Он оценивает рейс, а чей это рейс — знает платформа: компанию в запись подставляет функция, а не клиент.
Отклики сортируются по оценке, но номер варианта считается по времени. «Вариант 2» обязан означать одну и ту же машину при любой сортировке, иначе вернувшийся на страницу заказчик выберет не того. Перевозчики без оценок уходят в конец: новичок не должен вытеснять проверенного.
Право оценить решает база. completed_orders отдаёт can_rate —
истинно у заказчика рейса с назначенным перевозчиком. Условие «если
заказчик» в разметке завело бы второе место, где это решается.
Финская компания — обычная внутренняя продажа со ставкой 25,5 %. Иностранная — обратное начисление (käännetty verovelvollisuus): продавец выставляет ноль, а налог в своей стране считает покупатель.
Правило живёт в vatBpsFor(country), страна берётся из
companies.country — колонки, заведённой с первого дня как раз на этот
случай. В отчёт ставка вмораживается колонкой vat_bps: она меняется
решением государства, а выпущенный документ пересчитывать задним числом
нельзя.
Ноль не значит «налога нет». У обратного начисления налог есть, его считает покупатель, и документ обязан сказать это словами. Пустая строка на месте налога читается как забытое поле, а не как правило.
История здесь поучительная. Сначала в отчётах стояло «alv 0 %, к счетам добавится 25,5 %» — верно для финна, неверно для иностранца. Потом я исправил это на «alv 0 %, обратное начисление» для всех — верно для иностранца, неверно для финна. Обе версии были одинаково уверенными, и обе были неправильны для половины клиентов: ошибка была не в формулировке, а в допущении, что ставка одна.
withVat больше не имеет ставки по умолчанию. Прежний общий ноль
означал, что вызов без аргумента молча отдаёт сумму без налога там, где
налог есть; теперь ставку приходится назвать, а назвать её можно, только
зная страну.
Сводные плитки админки брутто не показывают вовсе: в них сложены контрагенты с разной ставкой, и одно число «в том числе ALV» было бы неверно для части строк. Налог виден там, где виден контрагент.
Компания, отдающая по тридцать прицепов в день, ломает плоский список сразу по трём причинам, и все три были в коде.
Порядок был не тот. Сортировка шла по дате заведения, а диспетчер
думает «что грузится сегодня». Заказ, заведённый вчера на пятницу, стоял
выше заказа, заведённого утром на сегодня. Так же считают и биржи: у
trans.eu в API списка сортировка по loading_date и фильтр по диапазону
дат загрузки, а не по созданию.
Срочность не различалась. В одном списке лежали заказ с пятнадцатиминутным отсчётом и заказ, с которым делать нечего, — и занимали одинаковое место.
Каждая карточка рисовала карту. Тридцать заказов — тридцать карт MapLibre на одной странице.
Отсюда устройство: три полосы по тому, кто кого ждёт — «ждёт вашего решения» (идёт отсчёт, карточка раскрыта всегда), «в пути», «ждёт откликов». Внутри полосы — по дате загрузки, а у идущих рейсов по ближайшей непройденной точке: у едущего заказа загрузка позади, и сортировать его по ней значит прижать самый живой рейс к низу.
Карточка раскрывается по одной. Строка несёт то, по чему заказ узнают с одного взгляда: состояние, номер, свой номер заказчика, единица, направление, час загрузки, ставка.
Казалось бы, город назначения — точка с ролью DELIVERY. На деле форма
публикации такой роли почти не создаёт: выгрузки и загрузки она добавляет
кнопками, то есть ролями EXTRA_UNLOAD и EXTRA_LOAD. В боевой базе
шесть заказов из девяти устроены как
PICKUP → EXTRA_UNLOAD → EXTRA_LOAD → TRAILER_RETURN.
Пока код искал DELIVERY, у этих заказов направление молча пропадало: в
списке оставался один город без стрелки, а в письме-рассылке
coalesce(delivery.city, pickup.city) подменял второй конец первым и
писал «Hanko → Hanko». Ошибка тихая — ничего не падает, просто половина
маршрута неверна.
Теперь «куда» считает routeEnds в коде и app.route_end в базе: это
последняя точка работы, не забор и не отцепка. Отцепка на эту роль не
годится — у перецепа прицеп нередко возвращают туда же, откуда взяли.
Стол при этом показывает своё: поле там называется finish_city и берёт
последнюю точку по порядку. Разница намеренная и видна из имён — у стола
«где рейс кончится», в кабинете «куда идёт груз».
На тридцати однотипных рейсах в день узкое место не поиск, а ввод. «Повторить» открывает форму публикации, заполненную по прошлому рейсу: единица, площадки, адреса с координатами, получатель, контакты, вес, пломба, инструкции, километраж, ставка, комментарий.
Даты и номер прицепа не переносятся. Даты у нового рейса свои, а подставленное прошлое число — приглашение опубликовать заказ на вчера. Номер прицепа — конкретное железо, и перенести его значит почти наверняка отправить водителя не за тем. Форма говорит об этом строкой сверху, иначе человек решит, что она потеряла данные.
Продолжение рейса повтор не переносит и честно об этом пишет: у формы для него нет поля. Молча выбросить точку нельзя.
Координаты подставляются в состояние формы, а не только в скрытые поля. Разница не косметическая: километраж и разрешение публиковать форма считает по состоянию. С заполненными полями и пустым состоянием повторённый заказ выглядел готовым и не публиковался — это и вылезло на первой же живой проверке.
Оси взяты у бирж, а не придуманы: у trans.eu список фильтруется диапазоном дат загрузки и сортируется по ней же. Поэтому в списке строка поиска — по обоим номерам, прицепу, городам, площадкам и получателю — и четыре окна: все, сегодня, завтра, неделя.
Заказ без даты загрузки из окна не выпадает. Его дата неизвестна, а не «не сегодня»; спрятав его, мы потеряли бы именно то, чем стоит заняться в первую очередь.
Панель показывается, когда заказов больше горстки: на трёх строках она занимает больше места, чем экономит. Отбор идёт на клиенте, и пока это честно — в списке лежит только незакрытая работа, а её объём ограничен оборотом, не возрастом. Когда счёт пойдёт на сотни, отбор переедет в запрос.
Парк на двадцать машин закрепляет двадцать рейсов, и карточка каждого рисует маршрут с картой. Полосы там по тому, чего ждут от перевозчика: подтвердить (идёт отсчёт, карточка раскрыта) и то, что уже едет. Снятые рейсы были отделены и раньше — у них нет ни одного действия, и общая карточка предлагала бы нажать то, что уже ничего не изменит.
В строке идущего рейса стоит счётчик пройденных точек: этап рейса одним числом, без разворачивания.
Соблазн есть, и он ошибочный: заказ на загрузку через две недели заведён сегодня, а рейс, идущий третьи сутки, заведён позавчера. Спрятав «вчерашние», мы спрячем идущий рейс и оставим на виду тот, до которого ещё полмесяца. Возраст — ось выполненных, а не работающих.
completed_orders() звалась без аргументов и отдавала все выполненные
рейсы компании за всё время вместе с точками и документами одним ответом.
Тридцать рейсов в день — это семь тысяч записей в год, и страница
перестала бы открываться не постепенно, а в один день.
Кабинет держит COMPLETED_WEEKS последних недель (восемь — два расчётных
периода), у оператора окно вдвое шире: он и правда разбирает споры по
прошлым периодам. Что глубже — ищут по номеру в документах периода или у
агента, и об этом сказано прямо внизу списка: молчаливое окно хуже
отсутствующего, человек решит, что платформа потеряла его рейс.
Неделя и расчётный период — разные вещи, и они намеренно не сведены к
одному. Неделя показывает выработку: отчёт приходит обеим сторонам по
понедельникам и отвечает на «что сделано». Период показывает деньги:
половина месяца, 1–15 и 16 — конец, и он отвечает на «сколько и когда».
Неделя со среды по вторник запросто ложится в два разных периода.
(21.09.2026 на пару часов периоды становились двухнедельными — решение
отменено тем же днём, см. миграцию half_month_periods.)
Раз в период выпускаются два разных документа:
| Кому | Документ | Дата в шапке |
|---|---|---|
| Заказчику | Счёт за период, с номером | оплатить до: конец периода + 15 дней |
| Перевозчику | Сводный отчёт за период | выплата: конец периода + 30 дней |
Счёт уходит сам. Раньше заказчику уходила «сводка к оплате», а номер
счёта оператор вписывал в админку руками. До этого рейсы висели
«невыставленными», хотя документ давно был у заказчика. Теперь у
документа есть номер из сплошной серии (2026-0001, таблица invoices,
функция period_invoice). Номер закрепляется за парой «заказчик +
период» до выпуска PDF, поэтому перевыпуск получает тот же номер, а в
серии нет ни пропусков, ни дублей. В момент выпуска рейсы периода сами
переходят в INVOICED с этим номером. Руками оператор отмечает только
то, чего платформа знать не может: деньги пришли, деньги ушли.
У периода две даты, и обе считаются от его конца. Заказчик платит через пятнадцать дней, перевозчику платим через тридцать. Пятнадцать дней зазора — это то, что отделяет посредника от того, кто платит из своего кармана, и от одной точки отсчёта этот зазор постоянен, а не выводится вычитанием в уме.
Обе живут в базе рядом: app.invoice_due и app.payout_due. Срок
заказчика сначала стоял в config.ts как коммерческое условие — и это
было верно, пока его знал только выпуск документов. Но тот же срок
называет агент, а он живёт в базе и до config.ts не достаёт: не достав,
он ответил заказчику датой выплаты перевозчику. Ответ выглядел как
ответ, и в этом была вся его опасность.
Привязка к периоду, а не ко дню выставления. Документ считал срок как «сегодня плюс пятнадцать»: запустись задание на день позже, и срок уехал бы вместе с ним. А агент, у которого дня выставления нет вовсе, честно писал, что назвать его не может. От конца периода дату знают оба.
Каждой стороне — только её дата. Заказчику не показывается день выплаты перевозчику, перевозчику — срок счёта заказчика. Не потому что секрет, а потому что именно их смешение и породило неверный ответ. Оператор видит обе: его это касается с двух сторон.
Выпуск переиспользует машинерию недельного отчёта, а не повторяет её:
и то и другое — отрезок времени, из которого получаются одни и те же
таблица, PDF, файл в хранилище и письмо. Отличаются три вещи — границы,
заголовок и наличие срока, — и они собраны в один тип Span.
Задание стоит в cron первого и шестнадцатого числа, но без
settlement_url в app.runtime_config оно молча ничего не делает.
Заметить это некому: документов нет, уведомлений о них тоже нет, а первое
число приходит раз в полмесяца — к тому времени пропущено два периода.
Поэтому platform_pulse считает ненастроенный адрес поломкой, а не «ещё
не дошли руки». Проверка окупилась сразу: первая попытка вывести адрес из
reports_url не сработала — там записан другой путь, — и пульс показал
это в ту же минуту, а не первого октября.
| Кто | Платит | Когда |
|---|---|---|
| Перевозчик | с 3.11.2026 первая активная машина (закрывшая в календарном месяце хотя бы один рейс) бесплатно, каждая следующая 14,90 € + ALV в месяц | начисляется 1-го за прошлый месяц (issue_monthly_subscriptions), удерживается из выплаты; не хватило — остаток из следующих (apply_carrier_fees, carrier_fee_deductions) |
| Заказчик | 3 % сверху цены за выполненный заказ со стола | в счёте за период; прямой заказ постоянной машине и отменённый — 0 |
| Все | ничего в первый месяц | остаток месяца одобрения и следующий полный месяц (app.free_until) |
Процента с перевозчика больше нет: app.current_commission_bps() всегда
0, выплата равна цене. Плата заказчика фиксируется в рейсе при закрытии
(orders.shipper_fee_bps, триггер orders_freeze_shipper_fee). Через
стол ли шёл рейс, решает происхождение выбранного отклика
(order_offers.origin): отменённый прямой заказ уходит на стол, а
dispatch_mode остаётся DIRECT. Тестовые компании сбор не платят.
Тарифы — функции в базе (app.subscription_unit_cents,
app.current_shipper_fee_bps); в config.ts их копия для текстов.
Закрытый рейс до этого терялся: у заказчика лежал в общем списке, у
перевозчика — среди закреплённых, у оператора не виден вовсе, а счёт
выставлять именно по нему. Теперь есть три вкладки — /shipper/done,
/carrier/done, /admin/billing — и один отчётный слой в базе под ними.
Роль решает состав колонок внутри функции, а не в интерфейсе.
completed_orders отдаёт заказчику его заказы без комиссии и без
компании-перевозчика (его контрагент — Aivomaa, ТЗ §1), перевозчику —
ставку, комиссию и выплату, оператору — обе стороны. Колонка, которую
роли знать не положено, приходит пустой, а не отсутствует: тип ответа
один на всех, и разойтись трём похожим функциям негде. Компонент списка
поэтому не знает о ролях вовсе — он показывает то, что пришло.
Неделя — понедельник по Хельсинки, а не по UTC. Рейс, закрытый в понедельник в 01:00 местного времени, по UTC приходится на воскресенье и попал бы в отчёт, уже отправленный клиенту.
Суммируются округлённые по рейсу числа. Строка отчёта обязана равняться строке счёта: округли итог отдельно — и сумма перестанет сходиться с составом, а спорить об этом будут живые люди с выпиской в руках.
Развёрнутая карточка не ходит в базу. Точки, документы и линия приезжают вместе со списком: рейс закрыт, меняться ему нечем, и второй запрос дал бы задержку без единого нового факта.
Две подстановки по умолчанию, обе намеренные. У рейсов, закрытых до
появления closed_at, неделя берётся по updated_at — выполненный рейс,
выпавший из сводки, это рейс, за который не выставили счёт. Без
записанной commission_bps берётся действующая ставка: другой на
платформе никогда не было, а как только их станет несколько, старые рейсы
уже будут закрыты с записанной.
После прохождения всех точек перевозчик прикладывает CMR, фото загрузки и
выгрузки, фото повреждений, и рейс переходит в DONE. Документы уходят
заказчику вместе со статусом (ТЗ §9).
CMR обязателен. Это не бюрократия: накладная — основание, по которому оператор платит перевозчику и выставляет счёт заказчику. Закрытый рейс без неё означал бы обязательство заплатить, не подтверждённое ничем. Об этом сказано в интерфейсе до нажатия, а не после отказа.
Все точки должны быть пройдены. Незакрытая точка — это либо неотмеченная работа, либо работа невыполненная, и различить их задним числом уже нельзя.
Владелец папки. У company-docs первый сегмент пути — компания, и
политика сравнивает его с компанией вызывающего. У trip-docs первый
сегмент — заказ, а доступ есть у обеих сторон: перевозчик грузит, заказчик
читает. Сравнением с одной компанией здесь не обойтись, поэтому появился
предикат app.party_to_order — security definer, потому что он читает
assigned_company_id, которого у authenticated нет вовсе.
Удаление. У документов компании его нет ни у кого, кроме оператора, потому что они основание выданных допусков. Здесь то же самое и по более острой причине: CMR — основание для выплаты и для счёта. Стереть его после закрытия не может ни одна из сторон.
Писать в папку рейса может только назначенный перевозчик: документы составляет тот, кто рейс выполнял, а заказчику они приходят готовыми. Ссылки выдаются подписанными на пять минут по нажатию — подписанный URL в разметке страницы утёк бы вместе с ней, в историю браузера и в кэш.
Выполненный рейс остаётся в списке перевозчика: по нему считается выплата, и до неё документы нужны.
Перецеп (irtoperä) — это не жёсткая тройка «забор, выгрузка, отцепка», а последовательность:
забрали прицеп → сколько угодно загрузок и выгрузок → отцепили
Между концами может быть одна выгрузка, одна загрузка, выгрузка и следом загрузка, несколько тех и других — как сложится рейс. Кругорейс по форме тот же самый (забрать прицеп на терминале, загрузиться, выгрузиться, отцепить), поэтому отдельного типа для него нет: разница выражается набором действий, а не пунктом в списке. Груз в один конец придёт позже — тогда селектор типа вернётся.
Отсюда три решения.
Середина — список, а не фиксированные секции. Заказчик добавляет
действия кнопками «+ Выгрузка» и «+ Загрузка»; порядок в списке и есть
порядок рейса. create_order требует забор, хотя бы одно действие и —
для TRAILER_SWAP — отцепку. Требовать именно выгрузку значило бы
запретить рейс «забрали пустой прицеп → загрузились → отвезли в порт»,
который ничем не реже обратного.
Прицеп забирают и отцепляют как с грузом, так и пустым. Раньше
состояние прицепа хранилось только у отцепки (returns_loaded); теперь
это общая колонка trailer_loaded, осмысленная на обоих концах и
запрещённая между ними — там прицеп уже прицеплен, и вопрос не стоит.
Тип места из формы убран. Подсказка адреса и так возвращает и порты, и терминалы, и обычные склады: заказчик начинает писать, и выходит адрес или место. Заставлять его сначала выбрать категорию из четырёх, а потом писать адрес — значит спрашивать то, что уже есть в адресе. Номер брони по той же причине спрашивается везде, а не только у портов: где его требуют на воротах, знает заказчик, а не схема.
Итог маршрута строкой «откуда → куда» больше не опирается на выгрузку — её может не быть вовсе или быть несколько. Концы рейса это первая и последняя точки: где взяли прицеп и где оставили.
В заказе есть trailer — тип и размер, «Тент 13.6, 3 оси». По нему прицеп
не найти: такое описание подходит к половине площадки. Поэтому у заказа
отдельная графа trailer_plate — регистрационный номер, и при
публикации она обязательна.
Это не предпочтение, а то, как устроена работа: сотня прицепов в порту выглядит одинаково, и водитель ищет конкретное железо по номеру. Заказ без номера означает водителя, который приехал и не знает, что цеплять.
Отдельной графы под номер брони нет. Референс нужен не всегда и выглядит каждый раз по-своему — у одного порта это номер брони, у другого номер визита, у третьего пропуск, — и поле под каждый вариант оставалось бы пустым в большинстве заказов. Всё это пишется в инструкции к точке, там, где водитель и читает, что сказать на воротах. Записанные ранее брони при переходе не выброшены, а перенесены в те же инструкции.
Реальные рейсы устроены так:
порт (забор гружёного прицепа) → выгрузка → загрузка → возврат в порт
терминал (забор пустого) → загрузка → выгрузка → возврат на паркинг
Оба разбираются на уже существующие роли: PICKUP — «забрать прицеп», а
собственно загрузка груза — это EXTRA_LOAD. Структура маршрута из-за
грузовой специфики не менялась, добавились только поля.
| поле | PICKUP | EXTRA_LOAD | EXTRA_UNLOAD | DELIVERY | CONT | RETURN |
|---|:—:|:—:|:—:|:—:|:—:|:—:|
| note инструкции | ○ | ○ | ○ | ○ | ○ | ○ |
| booking_ref бронь | ○* | ○* | ○* | ○* | ○* | ○* |
| cargo_weight_kg вес | ○ | ○ | — | — | ○ | — |
| consignee получатель | — | ○ | — | — | ○ | — |
| seal_required пломба | ○ | ○ | — | — | ○ | — |
| external_ref номер заказа | — | ○ | — | — | ○ | — |
| place_kind тип места | ● | ○ | ○ | ○ | ○ | ● |
* только когда place_kind — PORT или TERMINAL.
Вес и пломба разрешены и у забора. В первом сценарии прицеп забирают из порта уже гружёным: точки загрузки в маршруте нет, она была за морем. Получателя у забора при этом нет — его называют там, где груз принимают на борт.
Бронь привязана к типу места, а не к списку портов. Иначе Vuosaari и
Turku пришлось бы дописывать в код вслед за Kotka, Rauma и Hanko. Ворота
и оператор терминала есть у порта и терминала — там бронь и спрашивают.
По той же причине place_kind стал обязательным у возврата прицепа:
возврат в порт брони требует, возврат на паркинг — нет, и без типа места
это правило не выразить.
booking_ref и external_ref — разные вещи. Бронь предъявляют на
проходной, она про место; external_ref — номер заказа в чужой системе,
он про груз. У точки «продолжение рейса в порту» нужны оба сразу.
Вес хранится в килограммах целым числом, как деньги в центах: оно попадает в накладную и в расчёт нагрузки на ось. Верхняя граница финская — 76 тонн для сцепок HCT, тогда как по ЕС предел 40.
Получатель на стол не выходит. Это персональные данные третьего лица того же рода, что контакт на выгрузке. Бронь, вес и пломба выходят: по ним перевозчик решает, пройдёт ли машина по массе и успеет ли к окну на терминале.
Матрица записана дважды и намеренно: ограничениями в
20260817140100_stop_details.sql и предикатами в
src/lib/orders/stopFields.ts. Граница — в базе; модуль существует
затем, чтобы форма не показывала поле, которое база всё равно отвергнет.
Роль хранится только в profiles. В user_metadata её быть не может —
это поле пользователь меняет сам через supabase.auth.updateUser().
Вызовы хелперов в политиках обёрнуты в подзапрос — (select app.is_admin()),
а не app.is_admin(). Это позволяет планировщику вычислить функцию один раз
на запрос вместо одного раза на строку.
Заявки одобряет администратор, а завести администратора некому, пока его нет.
Круг разрывает supabase/bootstrap/first_admin.sql — разовый скрипт, а не
миграция: адрес первого администратора у каждого окружения свой, и в миграции
он завёл бы в тестовой базе пользователя с боевой почтой. Инструкция — внутри
файла.
Километраж считается по дорогам для грузовика. Поставщик — TomTom, выбран замером, а не по описанию.
| Маршрут | Фура 44 т | Легковая | Разница |
|---|---|---|---|
| Hanko → Kotka | 271,7 км | 261,4 км | +10,3 км (3,9 %) |
| Turku → Rauma | 102,6 км | 91,1 км | +11,5 км (12,6 %) |
| Vuosaari → Tampere | 186,7 км | 186,7 км | магистраль, совпадает |
Это и есть довод за грузовой профиль: на магистрали разницы нет, на второстепенных направлениях она доходит до 12,6 %. Легковой профиль занижал бы километраж систематически, а с ним и ставку.
Из грузовых параметров действует высота. Маршрут меняется при 4,8 м и 5,5 м. Масса, нагрузка на ось, ширина, длина и класс ADR не меняют маршрут ни на одном из проверенных направлений, включая глухие (Kuhmo → Suomussalmi, Ranua → Posio). Весовые лимиты финских мостов роутер, судя по всему, не знает. На километраж это почти не влияет — магистральная сеть Финляндии рассчитана на 76 тонн, — но при разборе жалобы на маршрут это первое, что стоит вспомнить.
distance_km — то же поле, что и раньше: от него считаются €/км, ставка и
комиссия. Расчёт его предлагает, а не присваивает.
distance_auto_km;distance_source (AUTO / MANUAL) говорит, откуда взялось
действующее значение;MANUAL, и пересчёт туда
больше не пишет.Иначе следующий пересчёт молча отменил бы решение заказчика, а вместе с ним изменил цену уже опубликованного заказа.
Это не про удобство. Геокодер несуществующий адрес не отвергает: запрос «Teollisuuskatu 12, 20100 Turku» (настоящая улица имеет индекс 20520) вернул похожую улицу в Kaskinen — за двести километров. По такой точке считается километраж, а километраж это деньги.
Поэтому координаты запоминаются в момент выбора из подсказки: они приходят от той же строки, которую человек увидел и выбрал. Набранный руками адрес координат не имеет и в расчёт не попадает — форма честно говорит, что пробег нужно указать самому.
«Не попадает в расчёт» означает, что расчёта не будет вовсе. Форма
считает маршрут, только когда координаты есть у ВСЕХ точек. Раньше точка
без координат просто выпадала из набора, и маршрут считался по
оставшимся: заказ RS-2026-0038 уехал с километражом 700 вместо 925 —
линия прошла мимо выгрузки в Рауме, а бейдж сообщал «рассчитано для
грузовика». Ошибка в 32% попадает прямо в €/км, потому что ставка
считается от distance_km.
Маршрут по неполному набору точек не короче настоящего — он просто
другой, и выглядит при этом совершенно правдоподобно. То же правило
действует при живой корректировке: refreshRoute молчит, если хотя бы у
одной точки нет координат.
Замер: «Hanko port» отдаёт водонапорную башню, «Hangon satama» — макаронную фабрику, а адреса точек в реальных заказах получали оценку 3,8–6,1 при норме 7,9–10,3. То есть по названиям порты не ищутся вовсе, а по адресам находятся приблизительно — ровно там, где ошибка дороже всего.
Поэтому площадки, на которые платформа возит, лежат в
lib/routing/places.ts и отдаются первыми, без обращения к поставщику.
Адреса названы оператором, координаты взяты из ответа геокодера на эти
адреса, а не придуманы. Где геокодер не знает номера дома (Ханко),
координата приходится на улицу: в пределах порта это сотни метров, на
километраж не влияет, а водитель едет по адресу, который написан точно.
Ищутся по началу слова на обоих языках сразу: «порт ханко», «ханко», «hanko», «викинг», «силья», «хиетанен». Список короткий и таким задуман — всё остальное ищется у поставщика, как и раньше.
Справочник продублирован в place_guides — таблицу, из которой читают оба
агента. Дублирование намеренное: подсказка адреса живёт в браузере, а
воркфлоу n8n ходит в базу и до кода сайта не достаёт. Строки собраны из
places.ts, а не набраны заново, и координаты в них те же.
Часов работы и порядка въезда в справочнике нет. Их никто не собирал, и каждая строка говорит об этом прямо — иначе модель заполнит пробел сама, а водитель приедет к закрытым воротам по нашему слову.
Агент кабинетов ищет площадку словом, как человек её называет, включая русское. Рейсу водителя площадка находится иначе — координатой точки: названием нельзя, заказчик пишет его как хочет, а ключ площадки до точки не доходит (подсказка отдаёт форме адрес и координату, свой идентификатор теряя). Выбирая площадку из справочника, человек кладёт в точку ту самую пару чисел, что записана у площадки, — не похожую, а ту же.
Порог совпадения — 150 метров, и это не «примерно рядом»: две ближайшие площадки справочника, терминалы Viking и Silja в Турку, стоят в 361 метре друг от друга, и таким порогом соседа не достать. Точка, набранная адресом вручную где-то на портовой территории, инструкции не получит: назвать наугад один из двух терминалов в трёхстах метрах хуже, чем не назвать никакого.
Язык площадка отдаёт один: у водителя — из языков в карточке машины, у агента кабинетов — из вызова. Пока их не выбирали, тот же порт приходил дважды, финской строкой и английской. Нужного языка у площадки нет — отдаётся тот, что есть: адрес и координату человек у ворот разберёт.
Интерфейс русский, места финские: «Порт Ханко» поставщик не найдёт
никогда. Перед вызовом запрос переводится — сначала пословно по словарю
городов, потом побуквенно тем, что в словарь не попало. Словарь важнее и
стоит первым: «Ювяскюля» побуквенно даёт yuvyaskyulya, чего не узнает
ни один геокодер. «Порт» переводится финским satama, а не английским
port: названия точек в финских данных местные.
Проверено по живому API: «порт Раума» → Ts Rauma Satama, «Котка порт» →
Kotkan satama. К поиску добавлены названные места (POI) — покрытие у
поставщика неровное, но там, где точки есть, они находятся.
Оценка совпадения порогом не служит. Шкала зависит от вызова: на одном и том же адресе геокодер даёт 7,87, а подсказка 0,99. Абсолютный порог, снятый с одного вызова, забраковал бы все результаты другого. Доверие определяет тип результата — точный адрес против центроида индекса.
Роутинг и геокодинг на наших объёмах укладываются в бесплатный уровень с запасом: около двух вызовов роутера и пяти геокодера на заказ. Подсказка же вызывается на нажатие клавиши: четыре адреса по двадцать нажатий дают восемьдесят вызовов на заказ вместо четырёх, и это пробивает любой лимит.
Поэтому три ограничителя стоят с самого начала, а не после первого счёта: задержка 300 мс после последней клавиши, запрос не раньше третьего символа, кэш по строке запроса на всю форму. Порог в три символа продублирован на сервере — клиентскую задержку обойти легко, а платит платформа.
src/lib/routing/
├─ types.ts интерфейс RoutingProvider, метры и секунды наружу
├─ tomtom.ts реализация, 'server-only'
├─ profiles.ts профили грузовика по стране забора
├─ cities.ts центры городов — смещение для подсказки
├─ polyline.ts кодирование линии маршрута
└─ actions.ts 'use server', единственный вход снаружи
Ключ живёт в TOMTOM_KEY без префикса NEXT_PUBLIC_ и читается только
внутри tomtom.ts, помеченного server-only. Из браузера к поставщику не
уходит ни один запрос — проверено поиском ключа и адреса api.tomtom.com
в собранных клиентских чанках.
Единицы фиксированы интерфейсом: метры и секунды. У TomTom габариты в метрах, у HERE в сантиметрах — расхождение, на котором ошибиться легко, а заметить трудно. Пересчёт делает реализация, наружу выходит одно представление.
Профиль выбирается по стране точки забора. Финляндия — исключение в Европе: до 76 тонн и 34,5 метра для сцепок HCT против 40 тонн и 16,5 метра по ЕС. Одна константа на всю платформу неизбежно врала бы в одну из сторон.
Геометрия кэшируется в заказе. Линия хранится закодированной
полилинией: Hanko → Vuosaari это 2758 точек, 118 КБ в JSON против 9,4 КБ
строкой — в двенадцать раз меньше, а едет она в браузер на каждый показ
карточки. Пересчёт нужен только при изменении координат или порядка
точек, и решает это route_fingerprint.
MapLibre GL с тайлами MapTiler. Расчёт и фон разделены намеренно: маршруты считает платный TomTom, а картинку рисует то, что дешевле, и менять одно, не трогая другое, можно свободно.
RouteMap один на все три места — карточка заказчика, стол и рейсы
перевозчика. На столе карта показывается до взятия заказа: именно по
ней видно, куда на самом деле ехать. Контактов и получателя она не
раскрывает — линия маршрута их не содержит.
Карта дополняет список точек, а не заменяет его. Нет ключа, нет геометрии, не загрузилась библиотека — блок просто не появляется, а маршрут читается списком, как и раньше. В кабине список часто удобнее.
Библиотека грузится по требованию. maplibre-gl весит 951 КБ и лежит
отдельным чанком: в общий бандл он не попадает, загружается только когда
карта действительно рисуется.
Цвета маркеров совпадают с глифами точек в списке — одна легенда на два представления. Номер на маркере текстом, а не картинкой.
Пройденные точки (MapStop.passed) — отметки по данным этапов рейса,
а не по координатам машины. Поле заполнится на Этапе 6. Живого GPS здесь
нет и не предполагается: когда он появится, это будет ещё один слой
поверх той же карты, а не замена этому.
Библиотека поставляется самодостаточным набором из трёх модулей —
главный, общий и воркер, который грузит тайлы и шрифты. Все три связаны
относительными импортами и путём, который воркер вычисляет через
import.meta.url.
Сборщик режет этот набор по своим чанкам с хешированными именами, и связи
рвутся. Воркер он копирует побайтово в /_next/static/media/ вместе с
импортом ./maplibre-gl-shared.mjs внутри, а общий модуль кладёт туда же
под именем с другим хешем — импорт упирается в 404. Если указать воркеру
путь на отдельную копию через setWorkerUrl, он поднимется, но загрузит
свой экземпляр общего модуля, пока главный поток работает с бандловым:
два разных экземпляра, и протокол между ними не сходится.
Ломается это молча и обманчиво: карта создаётся, стиль и спрайты
загружаются, контроллы и атрибуция на месте — а холст пустой. Ни одного
запроса тайла, события load и idle не наступают, в консоли чисто.
Единственный внешний признак — источники навсегда остаются
isSourceLoaded === false.
Поэтому RouteMap подгружает главный модуль прямо из public/maplibre/,
а тот сам находит рядом общий модуль и воркер — ровно так, как
рассчитывали авторы библиотеки. Путь вынесен в переменную: с ним импорт
перестаёт быть статически разрешимым и сборщик его не трогает.
scripts/sync-maplibre.mjs кладёт все три файла рядом и висит на predev
и prebuild, чтобы копия не отстала при обновлении. Папка в git не
хранится.
Побочная выгода: из клиентского бандла ушёл чанк на 951 КБ. Самый крупный теперь 223 КБ, а maplibre грузится отдельным файлом только тогда, когда карта действительно рисуется.
Тайлы грузит браузер, поэтому ключ обязан быть в бандле — отсюда префикс
NEXT_PUBLIC_. Это принципиально другая история, чем TOMTOM_KEY,
который живёт только на сервере.
Защита здесь не в секретности, а в ограничении по источнику: в панели MapTiler ключу задаются разрешённые домены. Без этого чужой сайт израсходует наш лимит. Это единственная настройка, которую нужно не забыть при выпуске.
Переменные NEXT_PUBLIC_* Next подставляет на этапе сборки. Ключ,
добавленный после сборки, не подхватится — приложение нужно собрать
заново.
Их стоит знать до того, как что-то писать.
Деньги — целые центы. Никаких float. Комиссия хранится в базисных
пунктах.
Сейчас комиссия 0% — это временно, на время проверки. По ТЗ §1 ставка 3%, вернуть нужно до первых настоящих расчётов: тело
app.current_commission_bps()иCOMMISSION_BPSвlib/config.ts. Уже закрытые рейсы возврат не затронет — у каждого своя ставка записана при закрытии. Действующая ставка комиссии фиксируется вorders.commission_bpsпри закрытии: иначе изменение комиссии перепишет задним числом уже выставленные счета. Сама арифметика живёт в базе одним определением —app.commission_centsиapp.payout_cents: отчёт перевозчика, счёт заказчику и сводка оператора обязаны сходиться до цента, а три похожие формулы в трёх запросах рано или поздно разойдутся на округлении.
Расчёты без налога, деньги с налогом. Ставка, комиссия, выплата и недельные итоги — везде нетто. ALV 25,5% добавляется к счёту заказчику и к выплате перевозчику: lisätään ALV 25,5%. В интерфейсе это сказано у каждой суммы, а не спрятано в договоре — 754 € и 754 € с налогом разные деньги, и человек, увидевший первое, не должен ждать второго на счёте.
Ставка живёт в VAT_BPS рядом с комиссией и в расчёты платформы не
входит: её меняет государство (25,5% с 1 сентября 2024 года), а закрытые
рейсы пересчитывать задним числом нельзя — по той же причине, по которой
замораживается комиссия. Единственное место, где налог показан числом, —
пульт оператора: он приходит туда выставлять счета и платить, и обе
операции идут с ALV.
Точки маршрута — один упорядоченный список. Не отдельные поля
pickup / delivery / extra[] / cont / ret, как в прототипе, а таблица
order_stops со sequence и kind. Причина: заказчик вправе вставить новую
точку в середину уже идущего рейса (ТЗ §8), а фильтр стола по региону забора
должен быть индексируемым.
Таймауты — только на сервере. В прототипе откат делал setInterval
в браузере. В проде дедлайн лежит в deadline_at, а двигает статусы
планировщик. Клиент рисует обратный отсчёт и ничего не решает.
Статус рейса — одно поле. Все три роли читают один источник; этап прохождения не хранится отдельно от статуса заказа.
Акцент отделён от семантики. Бирюза означает «сюда можно нажать» и больше
ничего. Состояния — свои цвета (ok / warn / danger / live). В прототипе
акцент и «успех» были одним цветом, и статус «на столе» сливался с кнопками.
Тема светлая, и это про эргономику, а не про вкус. Тёмный вариант оправдывался работой в кабине и ночью, но довод подменял аудиторию: по ТЗ §7 водитель общается с платформой через WhatsApp-агента, веб-кабинета у него нет вовсе. В интерфейсе сидят заказчик, диспетчер перевозчика и оператор — за столом, весь день, рядом с почтой и бухгалтерией, которые все светлые. Переключение между почти-чёрным окном и светлыми десятки раз в час утомляет сильнее, чем сам светлый фон.
Фон не белый, а холодный светло-серый: белая страница во весь монитор
бликует, а карточки на ней не отделяются. Все цвета текста пересчитаны
под белую карточку и подписаны контрастом прямо в globals.css. Акцент
пришлось затемнить: светлая бирюза #54d6c6 даёт на белом 1.6:1 и как
текст просто исчезает.
Движение есть ровно у двух вещей, и это правило, а не совпадение.
Дышащий контур означает «открыто окно решения, время идёт» (ТЗ §6):
карточка заказчика с откликами на выборе, карточка перевозчика в
AWAIT_DRIVER. Мерцание означает «блок ещё грузится». Больше не движется
ничего — анимация работает ровно до тех пор, пока движется что-то одно.
Цвет контура спокойный синий (--color-attention) и намеренно не совпадает
ни с акцентом, ни с live: «пора решать» не должно путаться ни с «сюда
можно нажать», ни с «рейс идёт». Мигать warn или danger нельзя —
тревожный цвет в движении заставляет торопиться, а решение здесь
взвешенное. Период 2,8 секунды, медленнее пульса: быстрое мерцание в окне,
открытом весь день, читается как неисправность.
Спокойная фаза задана базовым стилем, а не только кадром анимации. При
prefers-reduced-motion общее правило обрывает анимацию и элемент
возвращается к базовому стилю — без этого контур исчез бы вместе с
сигналом. Анимация лишь усиливает то, что нарисовано и без неё.
Моноширинный — не «для данных вообще», а для двух случаев. Символ
надо не спутать (I и 1, O и 0 в номерах, бронях, IBAN, телефонах — их
диктуют и сверяют глазами) или значения стоят колонкой и должны совпасть
по ширине. Числа внутри обычной фразы сюда не относятся: «580 км · 754 € ·
1,30 €/км» моноширинным превращает строку в четыре переключения гарнитуры,
и карточка выглядит собранной из кусков. Цифры и так не пляшут —
tabular-nums включён для всего текста, а не только для таблиц.
Госномер чёрным, а не акцентом. Акцент означает «сюда можно нажать», а номер нажать нельзя. Заодно ближе к жизни: на финских табличках чёрные знаки по белому.
Города отдельным полем в форме нет. Заказчик пишет адрес целиком —
улица, дом, индекс, город, — а название города приходит из ответа
геокодера вместе с координатами. Список из шести регионов ограничивал
публикацию произвольным набором городов и расходился с точкой на карте.
Если подсказка не выбрана, город разбирается из самого адреса по
почтовому индексу: город в базе NOT NULL, по нему работает фильтр стола,
и ронять публикацию из-за этого нельзя.
Пробег считается сам, кнопки расчёта нет. Как только известны две
точки с координатами, маршрут считается с задержкой в полсекунды.
Заказчик заполняет адреса и вводит ставку; километраж — следствие
маршрута, а не отдельное решение. Кнопка означала бы, что заказ можно
опубликовать с непосчитанным пробегом. Ручная правка поля по-прежнему
переводит источник в MANUAL и расчёт туда больше не пишет.
Мультиязычность заложена сразу. Все строки — в словарях
src/lib/i18n/dictionaries/, маршруты под префиксом локали. Сейчас наполнен
русский; следующий язык — финский как стартовый для запуска.
Тред один на компанию и роль, и он копится: пробные вопросы, разговор за прошлый месяц, чужая смена. Убрать его было нельзя ничем — политики на удаление у переписки нет вовсе, и это верно: стирать чужую строку не должен никто. Но свою переписку человек стирать вправе.
Функцией, а не политикой DELETE. Политика пустила бы и точечное
удаление отдельных сообщений, а переписка, из которой выброшено одно
сообщение, хуже отсутствующей: по ней делают выводы. Здесь либо весь
тред, либо ничего. Сообщения уходят каскадом — внешний ключ объявлен
on delete cascade с первого дня.
Тред в работе не удаляется. Пока висит dispatch_token, вопрос ушёл
в воркфлоу и ответ ещё придёт; удалив тред под ним, мы получили бы
«тред не найден» в чужом журнале и потерянный ответ. Кнопка в этот момент
не показывается, а база всё равно откажет — правило живёт в одном месте,
интерфейс лишь не предлагает нажатие, которое не сработает.
Оператор стирает только операторские треды. Переписка сторон принадлежит их компаниям, и вмешиваться в неё ему незачем.
Лента очищается после ответа сервера, а не до: очистив сразу, мы показали бы пустой чат там, где база отказала по делу.
Очередь модерации отвечает на вопрос «одобрить или нет» и показывает ровно то, что нужно для этого решения. Но решение принимается не только в момент заявки: страховка кончается, лицензию подделывают, реквизиты меняются молча. Посмотреть компанию целиком было негде.
Карточка открывается по имени компании — из очереди и из списка разобранных. В ней: кто она по реестру, лицензия и страховка с кнопкой «открыть», реквизиты, парк с допусками, пользователи, рейтинг и число заказов.
Документ открывается настоящим файлом. Настоящая страховка отличается
от ненастоящей только тем, что её можно увидеть; даты и статусы — это
подсказки, а не доказательство. Ссылка подписанная и живёт пять минут,
политики Storage разрешают оператору читать всю папку company-docs.
Ничего не редактируется. Это смотровое окно: решения принимаются кнопками там, где они и были. Загрузки документов здесь нет намеренно — оператор документы проверяет, а не подаёт, и кнопка «загрузить» на его экране означала бы, что он может подложить компании её лицензию, а отвечать за неё будет компания.
Пустой реквизит не показывается вовсе: строка с прочерком занимает экран и создаёт вид проверенного.
delete_company возвращает пользователей компании запросом
select p.user_id from profiles p, а в profiles такой колонки нет —
идентификатор там id и совпадает с auth.users. Тело plpgsql
проверяется при создании только синтаксически, план строится при первом
выполнении: функция принялась без жалоб и падала у оператора на каждой
компании.
Видел он при этом не «колонки нет», а общий отказ: разбор ошибок знает
код 55002 «у компании есть заказы» и показывает его человеческим
текстом, а на незнакомый код отвечает общей фразой. Ошибка выглядела как
правило продукта.
Рядом лежала вторая, ещё не проявившаяся: комментарий функции обещает,
что профили уйдут каскадом, а составной ключ profiles_company_matches_role
создан без ON DELETE, то есть NO ACTION. Починив первую, мы получили бы
вторую. Ровно это уже находили у машин — миграция vehicles_cascade от 16
августа переписывает такой же ключ с тем же диагнозом; тогда поправили
одну таблицу из двух.
Помощник ответил оператору: «платформа открывает мне только данные вашей компании». База была права, файл воркфлоу был прав — а работающий n8n жил по старому: наставление собиралось там, на JavaScript внутри поля JSON, и попадало в работу только ручным переимпортом. Правда была в двух местах, и одно из них обновляется руками.
Теперь наставление и список инструментов собирает платформа
(lib/agent/brief.ts) и присылает готовыми в теле запроса. Воркфлоу про
роли не знает вовсе: его дело — подпись, вызов модели и цикл
инструментов. Правка роли попадает в работу выкладкой.
Модуль намеренно без server-only: в нём нет ни ключей, ни обращений к
базе, зато его можно прочитать глазами —
npx tsx scripts/print-agent-brief.mts печатает наставление и набор
инструментов для всех четырёх ролей. Наставление, которое нельзя
прочитать, тем и опасно.
| Роль | Инструментов | Что особенного |
|---|---|---|
| заказчик | 7 | только своя компания, срок счёта |
| перевозчик | 7 | только своя компания, день выплаты |
| водитель | 5 | денежных инструментов нет вовсе |
| оператор | 8 | вся платформа плюс свободный sql |
Водителю денежные инструменты не показываются, а не отказывают при вызове: показать инструмент, который откажет, значит пообещать ответ и выдать отказ — модель всё равно попробует.
Набор инструментов агента закрыт намеренно, и для заказчика с перевозчиком это правило остаётся дословным. Для оператора оно снято: он не сторона сделки, а владелец платформы — в админке ему и так доступно всё, что лежит в базе, только руками и по одной странице. Помощник, который на любой вопрос отвечает «такого инструмента нет», помощником не является.
agent_sql принимает свободный запрос и ограничен так:
| Граница | Как проверяется |
|---|---|
| только оператор | по audience треда, внутри функции |
| только чтение | запрос начинается с select или with |
| одна инструкция | точка с запятой внутри запрещена |
| ничего не меняется | список слов проверяется по всему тексту, не только в начале |
| чужие схемы закрыты | auth, storage, vault, net, cron |
| время и объём | 30 секунд, до 1000 строк |
Слова запрещены везде в тексте, а не только в начале: общие табличные
выражения умеют писать — with x as (delete … returning *) select * from x
начинается с with и при проверке одного начала прошло бы.
Проверка по списку слов, а не разбором грамматики. Разбирать SQL
регулярками нельзя, но здесь и не нужно: достаточно не пропустить то,
чем пишут. Восемь попыток обойти — вторая инструкция, CTE с удалением,
чужая схема, pg_sleep — отвергаются.
Чего эта защита не делает. Она не мешает прочитать персональные данные: телефоны водителей, адреса, контакты получателей. Оператору они и так доступны в интерфейсе, и запрещать их здесь значило бы сделать помощника бесполезным. Но это стоит помнить, когда ответ агента копируют куда-то ещё.
Замер по боевой базе: любой запрос стоит около 90 мс — это дорога туда-обратно, а не работа базы. Самый тяжёлый из живых запросов (заказы со всеми полями) уложился в 144 мс, самый лёгкий в 87. При нынешних объёмах узкое место не в индексах, а в числе последовательных походов.
Одиннадцать действий звали revalidatePath('/fi', 'layout') — сброс
всего поддерева локали. Заказ трогает две страницы, а сбрасывались все:
витрина, условия, оба кабинета, админка. Отметил водитель точку — и
страница условий, которая читается из кэша и меняется раз в месяц,
собиралась заново вместе с обращением к базе за текстом. Аккуратно
заведённый кэш отменялся чужой строкой в другом файле.
Теперь список страниц, которые трогает заказ, лежит в одном месте —
lib/orders/revalidate.ts, — и сбрасываются только они. Сплошных сбросов
не осталось ни одного.
У заказчика точки, отклики и правки зависят от списка заказов, но не друг от друга, — и шли по очереди. Три похода по 90 мс это почти треть секунды ожидания на ровном месте. Запросов столько же, ожидание одно.
Кабинеты заказчика, перевозчика и оператора ходят к одному маршруту и
одному сценарию n8n. Общее у них только это — доставка вопроса. Кто что
увидит, решают функции в базе по полю audience треда, а не воркфлоу и
не наставление модели: наставление можно переписать, у инструмента же нет
поля, куда вписать чужую компанию, — она выводится из треда по пропуску.
Матрица прогоняется вызовом каждого инструмента от каждой аудитории:
| заказ | контрагент | этапы | документы | деньги | график | |
|---|---|---|---|---|---|---|
| заказчик рейса | да | нет | да | да | свои | срок счёта |
| заказчик посторонний | пусто | нет | пусто | пусто | свои | свой срок |
| перевозчик рейса | да | нет | да | да | свои | день выплаты |
| водитель | да, без ставки | нет | да | да | отказ | отказ |
| оператор | да | виден | да | да | вся платформа | обе даты |
С 21.09.2026 водитель видит в приложении свой заработок — экран «Ansiot»:
сумму за рейс или за день по своей ставке и нарастающий итог месяца.
Считает тот же lib/driverPay, что и отчёт перевозчика. Ставку рейса и
выплату перевозчику он не видит: для модели «процент от выплаты» функция
driver_earnings отдаёт только его долю. Правило ниже — о деньгах
компании, а не о его собственных.
Собственный канал водителя — driver_active_trips — ставку не отдаёт
вовсе: поля rate в нём нет. А инструменты кабинетов считали аудиторию
DRIVER «стороной перевозчика» наравне с CARRIER и отдавали ей ставку
рейса, дату выплаты, недельные суммы компании и весь график оплат. Один и
тот же человек получал разный ответ в зависимости от того, каким путём
спросил.
Деньги — дело компании, а не того, кто сидит за рулём: водитель наёмный, и заработок работодателя ему не показывают.
Сегодня это не стреляло только потому, что тредов с аудиторией DRIVER
никто не создаёт — /api/agent/ask ставит роль вошедшего, а таких
пользователей не бывает. Но комментарий к колонке channel прямо
предусматривает приход водителя из WhatsApp: дыра открылась бы в тот
день, когда её заведут.
Отказ, а не пустая выдача. Пустой ответ на вопрос про деньги выглядит как «ничего не заработали», и его не с чем сравнить; отказ агент перескажет человеку словами.
Инструменты агента аудиторию ADMIN знали с самого начала: оператору они
открывают любой заказ и показывают контрагента, скрытого от сторон. Не
было того, чем этой аудиторией воспользоваться — оператор не мог даже
завести тред.
Причина структурная: conversations.company_id объявлен not null, а у
оператора компании нет вовсе — ограничение companies_kind_not_admin
запрещает заводить компанию с ролью ADMIN, и правильно: Aivomaa
присутствует в системе посредником, а не стороной сделки.
Теперь у треда оператора компании нет, и связь жёсткая в обе стороны: тред без компании обязан быть операторским, операторский — без компании. Иначе однажды появится тред заказчика с пустой компанией, и инструменты откроют ему всю базу.
agent_company_money и agent_payout_schedule сверяют компанию треда с
компанией заказа. У оператора компании нет, и обе функции молча вернули
бы пусто — то есть ответили бы «за эту неделю ничего», когда рейсы были.
Пустая выдача вместо отказа хуже отказа: её не с чем сравнить.
Поэтому для ADMIN обе считают по всей платформе — это и есть его
вопрос: сколько прошло через нас и сколько мы на этом заработали.
Комиссия ему показывается: она и есть выручка оператора.
В n8n сторона определялась как «перевозчик или все остальные», и оператор попадал в ветку заказчика: получал «Компания пользователя: не указана» и запрет «ты видишь ТОЛЬКО данные этой компании» — то есть запрет ровно на то, что он и должен видеть. Теперь ветки три, и описания инструментов у оператора свои.
Водитель работал бы с платформой через агента в WhatsApp: n8n принимал
сообщения, модель понимала «purettu», а подписанные эндпоинты
/api/driver/* отмечали точки по номеру телефона. Агент так и не был
запущен, и 21.09.2026 решено, что водитель работает в собственном
приложении (раздел «Приложение водителя», ТЗ — RAHTIS-driver-app.md).
Удалены воркфлоу rahtis-driver-agent.json, маршруты /api/driver/*,
модуль src/lib/driver и функции базы, которые узнавали водителя по
номеру (20260922170000_whatsapp_agent_removed): definer, отдающий рейс
по одному номеру телефона, опасен и неиспользуемым. Переменная
DRIVER_WEBHOOK_SECRET больше не нужна — её можно убрать из Vercel.
const { t, m, f } = useI18n(); // клиентский компонент
const { t, m, f } = await getI18n(locale); // серверный
| Для чего | Пример | |
|---|---|---|
t | Готовые подписи без подстановок | t.action.take → «Беру» |
m | Сообщения с переменными и множественным числом (ICU) | m('order.tripsCount', { count: 21 }) → «21 рейс» |
f | Intl-форматтеры: суммы, даты, числа, проценты | f.eur(48_000) → «480 €» |
Граница простая: появилась переменная или число, влияющее на окончание, —
это m, а не t. Появились дата, сумма или процент — это f, а не строка
в словаре.
1. Множественное число — только через ICU plural. Окончания в коде не
хардкодятся никогда. У русского четыре формы, у финского две, у польского
четыре с другими границами; правила берёт на себя Intl.PluralRules по CLDR.
'order.tripsCount':
'{count, plural, one {# рейс} few {# рейса} many {# рейсов} other {# рейса}}'
Проверка на витрине /ru/ui включает ловушки русского: 0 рейсов,
11 рейсов, 21 рейс, 22 рейса.
2. Подстановки — только именованные. {company}, а не {0}. В финском и
немецком порядок слов другой, и позиционные подстановки не дают переводчику
его поменять.
3. Никаких склеек в коде. 'Осталось ' + n + ' мин' — это три разных
предложения в трёх языках. Целая фраза живёт в словаре одной строкой.
4. Даты, суммы и числа не пишутся в словарь готовыми строками. Либо
f.*, либо ICU-скелет прямо в сообщении: {rate, number, ::percent}.
Время всегда показывается в Europe/Helsinki независимо от языка — диспетчер
и водитель должны видеть одно время загрузки.
5. Ноль пользовательского текста вне словарей. Это проверяет ESLint:
кириллица в строковом литерале или JSX внутри src/app/** и
src/components/** — ошибка сборки. Комментарии под правило не подпадают.
Исключение одно и оно прописано в eslint.config.mjs: витрина /ui — это
инструмент разработки, она не переводится.
fi — язык стартового рынка, и он полон: 582 ключа, столько же, сколько
в русском, без пропусков и лишних. Русский остаётся, пока платформа
строится — на нём её пишут.
Термины взяты отраслевые, а не словарные. Куljettaja узнаёт
rahtikirja, irtoperä и tyhjäajo, а не их объяснения. Перевозчик —
kuljetusliike, заказчик — rahdinantaja, рейс — keikka, выплата —
tilitys, допуск — hyväksyntä.
Тип словаря пришлось расширить. Dictionary = typeof ru с as const
делал каждое значение литеральным типом, и финское «Suomi» не подходило
под тип «Русский» — второй язык был невозможен в принципе. Теперь строки
расширяются до string, а структура сохраняется: пропущенный или лишний
ключ по-прежнему ошибка компиляции.
Множественное число переписано, а не скопировано. У финского две
формы вместо четырёх русских: {count, plural, one {# keikka} other {# keikkaa}}.
cp src/lib/i18n/dictionaries/ru.ts …/fi.ts,
переименовать экспорт: export const fi = { … } as const;meta. label — название языка на нём самом, intl — тег
для форматтеров (fi-FI), htmlLang — значение атрибута lang.msg.msg, переписав формы plural под язык. Формы не копируются
из русского: у финского их две (one, other), у английского две,
у арабского шесть. Какие нужны — покажет
new Intl.PluralRules('fi').resolvedOptions().pluralCategories.
Лишняя форма безвредна, недостающая даёт неверный текст.# и имена переменных нетронутыми. {count}, {company},
{rate, number, ::percent} переводу не подлежат — меняется только текст
вокруг них.src/lib/i18n/dictionaries/index.ts.locales в src/lib/i18n/config.ts.npm run check. Любой непереведённый или лишний ключ — ошибка
компиляции: форма словаря выводится из ru.ts, а реестр загрузчика типизирован
как Record<Locale, …>./fi/ui и сверить: суммы, даты, проценты, множественное число.Шаг 8 — главный: пропустить ключ невозможно, TypeScript не даст собрать проект.
Водитель был двумя полями машины — driver_name и whatsapp. Модель
«одна машина — один водитель» не держала ни пересменку, ни водителя,
который сегодня на одном тягаче, а завтра на другом, а один номер на
двух машинах делал вопрос «какой рейс у этого водителя» неразрешимым.
Теперь водитель — строка drivers, привязка к машине — интервал в
vehicle_drivers с двумя исключающими ограничениями: у машины не бывает
двух водителей одновременно, водитель не сидит в двух кабинах. Телефон
уникален среди действующих водителей всей платформы: он станет
входом в приложение водителя.
Поля машины остались, но стали зеркалом. driver_name, whatsapp и
languages у машины читают отклики у заказчика, закреплённые рейсы,
агенты и карточки оператора. Переписывать их все ради переименования —
риск для матчинга, поэтому колонки пишет только app.sync_vehicle_driver
из текущей привязки, а у перевозчика права на них отозваны. Заодно
вставка машин стала поколоночной: прежний табличный грант пускал
access и даты решения оператора.
Смена водителя не снимает допуск. Раньше оператор допускал связку
«машина + водитель», и пересменка останавливала машину до его решения.
История пересадок при этом не теряется — она и есть vehicle_drivers.
Машина без водителя на рейсы не выходит: vehicle_is_dispatchable
требует текущую привязку.
Водитель рейса — снимок. orders.assigned_driver_id ставится при
назначении машины и обновляется при старте: по нему считаются часы и
километры, а привязки потом меняются.
Второй поток матчинга рядом с общим столом, а не вместо него. Заказчик
направляет заказ машине, которая уже возила его рейсы, если её
перевозчик разрешил ему прямые заказы (carrier_shipper_links).
Тот же AWAIT_DRIVER, но без срока. Прямое назначение пишет отклик
с origin = DIRECT и сразу его выбирает — поэтому подтверждение, откат,
закреплённые рейсы и панель откликов у заказчика работают без
изменений. Признак прямого назначения в строке — пустой deadline_at у
AWAIT_DRIVER: выбор со стола срок ставит всегда. Без срока
release_expired_order такой заказ не трогает, и стол его не
показывает.
На стол — только после отмены. Откатить может заказчик, перевозчик
или водитель назначенной машины. Это первый выход заказа на стол, и
published_at у него пуст — поэтому триггер публикации теперь
срабатывает на первый переход в OPEN откуда угодно, а не только из
DRAFT. Почтовый дубль рассылки шлёт серверное действие отката,
сверив published_at до вызова.
Анонимность — в обе стороны (с 22.09.2026). Знакомые машины
заказчик видит через known_vehicles_for_shipper: номер, класс,
рейтинг и для связи имя, телефон и почту текущего водителя — без
названия и идентификатора перевозчика. Строк согласия и истории прямых
назначений у заказчика нет вовсе: в них идентификатор перевозчика.
Перевозчик не видит, чей заказ, — иначе договорится с заказчиком мимо
платформы. В «Asiakkaat» и письме о прямом заказе заказчик — код
«Asiakas XXXX» (первые 4 знака md5(shipper_company_id)), число рейсов
и последний маршрут. Название заказчика из стола, назначенных рейсов,
заданий водителя, отчётов, претензий и помощника убрано — его видит
только оператор. На столе заказов точки показываются городом: без
места, адреса, брони и заметки, координаты округлены до 0,1°, линии
маршрута нет. Полные данные точек — после назначения рейса.
| Событие | Кому | Код |
|---|---|---|
| Прямое назначение | перевозчик (кабинет + письмо), водитель (входящие приложения) | order.direct |
| Подтверждено | заказчик | direct.accepted |
| Отменено до подтверждения | заказчик; перевозчики страны забора — как публикация | direct.released, order.published |
| Первый закрытый рейс пары | перевозчик | link.offer |
История попыток — order_direct_requests с исходом ACCEPTED,
DECLINED (отказалась машина) или WITHDRAWN (забрал заказчик).
PWA по адресу /{locale}/driver, ТЗ — RAHTIS-driver-app.md. Своя
оболочка без шапки кабинета: крупные цели, навигация внизу.
Водитель — пользователь без профиля. Роль водителя — строка drivers
с его auth_user_id; app.current_driver_id() и функции driver_*
отдают ему только его рейсы и без денег. getViewer считает такого
пользователя «сиротой», поэтому приложение проверяет его своим
getDriver, а в кабинеты он не попадает.
Вход — по приглашению, без SMS и писем. Перевозчик выпускает ссылку
(create_driver_invite, сутки, один раз; в базе только sha256 токена) и
отправляет её сам — кнопка «Lähetä tekstiviestinä» открывает SMS на его
телефоне. Приём ссылки на сервере под служебным ключом: новый
пользователь со служебной почтой, привязка к водителю
(claim_driver_invite), сессия через generateLink → verifyOtp.
Каждое приглашение заводит нового пользователя и удаляет прежнего — так
новый вход выводит потерянный телефон.
Вход по коду — для iPhone. Приложение на экране «Домой» хранит вход
отдельно от Safari, и вошедший по ссылке мог открыть установленное
приложение невошедшим — без адресной строки, куда вставить новую ссылку.
Поэтому у приглашения есть шестизначный код: экран «не привязано»
спрашивает телефон и код. Код без телефона не принимается (миллион
вариантов на всю платформу), с телефоном — пять попыток на номер за
15 минут (app.throttle_hit). Оба пути ищут приглашение одной функцией
driver_invite_lookup.
Подпись и CMR — на погрузке и выгрузке. На погрузке расписывается тот, кто отдаёт груз, на выгрузке — тот, кто принимает. На зацепе и отцепке полуприцепа или контейнера подписи нет: они проходят на площадке без людей. У экспресса забор — это погрузка у отправителя, и подпись там берётся.
Что умеет первая версия: принять или отклонить рейс (в том числе
прямой заказ), отметить точку с местом в момент нажатия, начать и
закончить день и перерыв (driver_shift_action, смены с
source = APP), сообщить перевозчику о проблеме, прочитать входящие.
Точка — шагами, как у DFDS. «Saapui» пишет order_stops.arrived_at:
от прибытия считается простой, а completed_at говорит только, когда
водитель уехал. Дальше осмотр по сторонам (order_documents.angle) с
переключателем «Uusi vaurio» до съёмки — снимок ложится видом
DAMAGE_PHOTO; на сдаче рядом со стороной её снимок при взятии. На
выгрузке — подпись получателя на экране с его именем (subject = SIGNATURE) или снимок подписанной накладной, который ложится видом
CMR: перевозчик закрывает рейс без повторной загрузки скана.
Снимки сжимаются на телефоне до 1600 px. Файл кладёт сервер служебным
ключом — у водителя нет компании, и политики хранилища его не знают, —
но только после того, как сессия водителя подтвердила, что рейс его;
строку регистрирует driver_register_photo под той же сессией, повтор
отправки по external_id второй строки не создаёт.
Без связи работа не встаёт. Каждое действие — день и перерыв,
«Saapui», «Tehty», снимок, подпись — сначала ложится в очередь на
телефоне (IndexedDB, lib/driverApp/outbox.ts) и сразу пробует уйти.
Нет сети — ждёт: попытки при появлении сети, при возврате в приложение и
раз в 20 секунд. Отправка строго по порядку нажатия одним входом
(applyDriverEventAction), чтобы отметка точки не обогнала прибытие.
Экран не ждёт сервера: какая точка текущая, решает клиент по серверным
отметкам плюс ждущим в очереди (TaskProgress), так что следующая точка
открывается сразу после «Tehty», а не когда найдётся сеть. Ждущее
помечено «jonossa», снимки видны миниатюрой из самого файла.
База принимает время нажатия, а не прихода: «Saapui», дошедшее
вечером, ложится временем прибытия, иначе простой на погрузке — выдумка.
Время с телефона проверяется (app.client_time: не из будущего, не
старше суток), отметка точки не раньше прибытия и предыдущей точки.
Повтор не удваивает: у каждого действия идентификатор с телефона, и
driver_app_events помнит принятые. Ответ сервера различает «принято»,
«отклонено по существу» (удаляется из очереди, водитель видит
предупреждение) и «не дошло» (остаётся).
Push-уведомления. Входящие водителя (driver_notifications) пишут
триггеры рейса — прямой заказ, выбор отклика, возврат на стол, отмена.
Каждая новая строка сразу уходит на телефон: триггер
driver_notifications_push через pg_net стучится в
/api/push/driver, а сайт шифрует и подписывает сообщение библиотекой
web-push (VAPID) и собирает текст на языке, на котором водитель
подписался. Маршрут закрыт тем же секретом, что выпуск отчётов
(REPORTS_CRON_SECRET), адрес и секрет миграция берёт из
app.runtime_config — новой настройки базе не нужно. Мёртвые подписки
(404/410) удаляются при первой неудачной отправке.
Включает водитель сам — кнопкой на главной и в профиле: разрешение браузер спрашивает только по нажатию, и отказ по привычке потом не отменить из приложения. На iPhone push есть только у приложения, установленного на экран «Домой»; вместо нерабочей кнопки там это и сказано.
| Переменная | Где | Зачем |
|---|---|---|
NEXT_PUBLIC_VAPID_PUBLIC_KEY | Vercel, .env.local | подписка в браузере |
VAPID_PRIVATE_KEY | Vercel, .env.local | подпись отправки, только сервер |
VAPID_SUBJECT | Vercel, .env.local | контакт для служб push, mailto:[email protected] |
Ключи — npx web-push generate-vapid-keys. Сменить пару — значит
отписать все телефоны: подписки привязаны к публичному ключу.
Service worker отдаёт страницы приложения из сети с копией в кэш, а без сети — последнюю загруженную, с баннером «Ei yhteyttä». Статика Next — из кэша. Выход из приложения стирает очередь и кэш и предупреждает, если в очереди что-то не ушло.
Смены пишет перевозчик руками (MANUAL) или водитель в приложении
(APP) — в одну таблицу, отчёты источник не различают. Смены одного
водителя не пересекаются (исключающее ограничение), смена не длиннее
суток, перерыв внутри своей смены. Каждая правка и удаление — в
driver_shift_log со старым и новым значением.
Калькулятор — справка, а не расчёт зарплаты. Поэтому в базе только
исходные данные и ставки, а арифметика — в src/lib/driverPay.ts: одна
функция на экран, PDF и таблицы. Правило «деньги считает база одним
определением» оно не нарушает: то правило про деньги между сторонами
сделки, а здесь справка внутри перевозчика. Модуль без импортов, чтобы
тесты шли голым node --test (npm test).
app.payout_cents с
замороженной комиссией), чтобы справка не расходилась с его отчётом.driver_pay_profiles, а не правка старой.Ставки TES — данные, а не код. tes_rule_sets — наборы с датой
начала: свои у перевозчика и шаблоны оператора (company_id is null).
tes_wage_rates — таблица ставок набора по категории водителя и стажу
(grade) с датами повышений. В модели оплаты два поля: категория
водителя (driver_pay_profiles.tes_grade, например TRUCK) и начало
стажа (tes_experience_since). Ступень стажа — меньше 4 лет, 4–8, 8–12,
12 и больше — калькулятор выводит на каждый день сам, и ставка
поднимается в день перехода. Без категории берётся базовая ставка набора.
В формулах чисел нет.
С 22.09.2026 есть шаблон Kuorma-autoalan TES 2025–2028 (AKT – Autoliikenteen Työnantajaliitto): 22 категории × 3 таблицы (1.3.2025, 1.6.2026, 1.6.2027), вечер +15 %, ночь +20 %, 80 ч / 2 недели, воскресенье и праздники +100 %, минимум 4 ч 45 мин. Ставки сверены с текстом договора программно. Модель оплаты может ссылаться на шаблон напрямую — тогда повышения подхватываются сами — или на его копию. Надбавки от keskituntiansio (§ 15) считаются от табличной ставки, как велит § 15.3. Не входят: ADR и спецперевозки, стаж в международных рейсах, ожидание, суточные, суточный отдых.
Вкладка «Kartta» в приложении водителя (/driver/map): 504 точки по
Финляндии, Швеции, Норвегии и Дании — заправки для фур, стоянки, душ и
сауна, сервис и мойки. Фильтры по виду, «бесплатно» и «охраняемая»,
ближайшие к водителю по геолокации, карточка точки с кнопками «Navigoi»
(Google Maps) и «Soita». Тексты на финском и английском.
Справочник живёт в data/driver-places/ (см. README там): places.json
— источник правды, npm run places:import загружает его в
driver_places. Видят только водитель и оператор. Карта на MapLibre и
MapTiler, точки одним слоем с кластерами.
| Этап | Статус | |
|---|---|---|
| 0 | Каркас: тема, UI-кит, i18n, клиенты Supabase | готово |
| 1 | Авторизация и роли: схема, RLS, кабинеты | готово |
| 2 | Онбординг и модерация | готово |
| 3 | Автопарк и допуск машин | готово |
| 4 | Заказы и стол | готово |
| 5 | Матчинг: отклики, выбор, таймауты | готово |
| 6 | Рейс и закрытие с документами | готово |
| 7 | Рейтинги и отчёты | готово |
| 8 | Автоматизации n8n (WhatsApp-агент отменён 21.09.2026, см. этап 10) | готово |
| 9 | Водители, смены, справочная оплата; знакомые машины и прямое назначение | готово, миграции ждут db push |
| 10 | Приложение водителя (PWA) — RAHTIS-driver-app.md | готово |