Javis OS
Javis OS is an Agentic AI that assists entrepreneurs in business tasks, helping them grow their work.
AI translation
Freight exchange and transport management for carriers: fleet, drivers, CMR notes and direct orders. Trailer swaps, containers and express transport.
BusinessTypeScript · PLpgSQLGitHub @mikhailnefentyevOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full 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 | готово |