Командировки
Модуль trips оформляет командировки сотрудников и связывает их с проектом, маршрутом, датами и финансовым контуром.
Роли
В карточке командировки есть три участника:
requester_id— заявитель, который оформил командировку в CRM;user_id— командируемый сотрудник;approver_id— согласующий, который должен подтвердить поездку.
Для совместимости user_id остается основным полем командируемого сотрудника. Если requester_id не передан при создании, API подставляет текущего пользователя из токена, а сервисный слой использует user_id как fallback.
Статусы
Базовая цепочка:
planned -> approved -> in_progress -> completedДополнительно из активных стадий можно перейти в cancelled.
Смысл статусов:
planned— заявка создана, поездка еще не подтверждена;approved— согласующий подтвердил командировку;in_progress— сотрудник находится в поездке;completed— поездка закрыта, заполнены фактические даты;cancelled— командировка отменена.
Переход planned -> in_progress закрыт: командировка должна быть согласована перед началом.
Матрица переходов (для UI)
Кнопки смены статуса в интерфейсе нужно показывать строго по этой матрице — PATCH с недопустимым переходом вернёт 403 (access_denied).
| Из \ В | planned | approved | in_progress | completed | cancelled |
|---|---|---|---|---|---|
| planned | ✓ | ✓ | — | — | ✓ |
| approved | — | ✓ | ✓ | — | ✓ |
| in_progress | — | — | ✓ | ✓ | ✓ |
| completed | — | — | — | ✓ | — |
| cancelled | — | — | — | — | ✓ |
completed и cancelled — терминальные: из них выйти нельзя. Переход в completed требует заполненных departure_actual и return_actual (иначе 400).
Основные поля
- проект:
project_id— необязателен (командировка может быть не привязана к проекту; в этом случае попутные задачи и синхронизация с бюджетом не выполняются); - маршрут:
from_location_id/to_location_id(выбор из справочника локаций) и/илиfrom_address/to_address(произвольный текст). Можно выбрать локацию из списка, вписать адрес вручную, либо указать и то и другое; произвольный адрес имеет приоритет в подписях попутных задач; - цель поездки:
purpose; - плановые даты:
departure_planned,return_planned; - фактические даты:
departure_actual,return_actual; - транспорт и проживание:
transport_type,transport_details,accommodation; - плановые и фактические расходы:
planned_cost,actual_cost; - служебные заметки:
notes.
При переходе в approved API фиксирует approved_by_id текущим пользователем и approved_at текущим временем. После завершения командировки фактические расходы синхронизируются с финансовым контуром как источник trip.
Факт может превышать план: перерасход по источнику trip записывается в статью затрат как есть (раньше такое обновление отклонялось planned_amount_below_actual, и факт терялся). В интерфейсе фактические расходы вводятся только при редактировании, при создании — только плановый бюджет (билеты, жильё, суточные).
При отмене командировки (cancelled) ранее зарегистрированные плановые и фактические расходы по источнику trip обнуляются в бюджете проекта, чтобы отменённая поездка не раздувала смету.
Попутные задачи
Командировка — это не только запись, но и работа для других сотрудников. Система автоматически ставит попутные задачи в проект командировки:
Вид (kind) | Когда создаётся | Исполнитель | Срок |
|---|---|---|---|
transport | при переходе в approved | организатор (requester_id) | дата выезда (departure_planned) |
accommodation | при переходе в approved | организатор | дата выезда |
advance | при переходе в approved | организатор | дата выезда |
expense_report | при переходе в completed | командируемый (user_id) | возвращение + 3 дня |
Подготовительные задачи (transport/accommodation/advance) создаются на организатора командировки; авансовый отчёт после возвращения — на самого командируемого. Если командировка создаётся сразу в статусе approved/in_progress/completed, соответствующие задачи ставятся при создании.
Срок подготовительных задач может быть раньше начала проекта — сотрудник выезжает к монтажу; проверка «не раньше начала проекта» для них не действует (tasks.CreateTaskInput.PreparationTask), срок в прошлом по-прежнему запрещён. Раньше такие задачи молча не создавались. То же у задач логиста по перевозке.
Названия и описания задач читаемые: «Купить билеты — командировка Павлов Иван Олегович, 15.09–18.09.2026», в описании — названия локаций и даты в формате ДД.ММ.ГГГГ по часовому поясу компании (APP_TIMEZONE; даты командировки хранятся как местная полночь, поэтому в UTC день сдвигался бы назад). Срок в прошлом (командировку завели задним числом) сдвигается на сегодня, иначе модуль задач отказал бы; сбой постановки задачи пишется в лог, а не проглатывается молча.
Генерация идемпотентна: на одну командировку приходится не более одной задачи каждого вида (уникальный индекс trip_tasks(trip_id, kind)), поэтому повторный PATCH тем же статусом не плодит дубликаты. Связи хранятся в таблице trip_tasks и возвращаются в карточке командировки в поле tasks (GET /api/v1/trips/:id).
Автогенерацию можно отключить переменной окружения APP_AUTO_CREATE_TRIP_TASKS=false (по умолчанию включена). Создание задач делается best-effort: сбой модуля задач не срывает смену статуса командировки.
API
POST /api/v1/trips— создать командировку;GET /api/v1/trips— получить список с фильтрамиproject_id,user_id,requester_id,approver_id,status;GET /api/v1/trips/:id— получить карточку;PATCH /api/v1/trips/:id— обновить статус, согласующего, цель, маршрут (from_location_id/to_location_id,from_address/to_address), фактические даты и фактическую стоимость;GET /api/v1/trips/export— выгрузить список в Excel.
Все эндпоинты требуют права trips.manage. Доступ к конкретной карточке есть администратору, командируемому, заявителю, согласующему и пользователям того же отдела (см. ниже).
Контракт ответа (карточка командировки)
Поля объекта Trip. Nullable-поля при отсутствии значения опускаются (omitempty), фронт должен трактовать их как необязательные.
| Поле | Тип | Nullable | Примечание |
|---|---|---|---|
id | integer | нет | |
user_id | integer | нет | командируемый сотрудник |
requester_id | integer | да | заявитель |
approver_id | integer | да | согласующий |
approved_by_id | integer | да | кто подтвердил (ставится автоматически) |
approved_at | string (RFC3339) | да | момент подтверждения |
project_id | integer | да | проект (необязателен) |
department_id | integer | да | отдел, оплачивающий поездку; не передан — основной отдел командируемого (не того, кто оформляет) |
from_location_id | integer | да | откуда — локация из справочника |
to_location_id | integer | да | куда — локация из справочника |
from_address | string | да | откуда — произвольный адрес (до 500 символов) |
to_address | string | да | куда — произвольный адрес (до 500 символов) |
status | string (enum) | нет | planned/approved/in_progress/completed/cancelled |
purpose | string | да | цель поездки |
transport_type | string (enum) | да | plane/train/car/bus/taxi/other |
transport_details | string | да | рейс/поезд и т.п. |
accommodation | string | да | гостиница/жильё |
departure_planned | string (RFC3339) | да | плановый выезд |
return_planned | string (RFC3339) | да | плановое возвращение |
departure_actual | string (RFC3339) | да | фактический выезд |
return_actual | string (RFC3339) | да | фактическое возвращение |
planned_cost | number | да | плановые расходы (деньги — число, 2 знака) |
actual_cost | number | да | фактические расходы |
notes | string | да | заметки |
tasks | array | да | попутные задачи; только в GET /api/v1/trips/:id |
created_at | string (RFC3339) | нет | |
updated_at | string (RFC3339) | нет |
Элемент массива tasks: { "task_id": integer, "kind": string }, где kind ∈ transport/accommodation/advance/expense_report. В списке (GET /api/v1/trips) и в ответах POST/PATCH поле tasks не возвращается — за списком попутных задач обращайтесь к карточке или к модулю задач по task_id.
Ошибки
| Код | Когда |
|---|---|
400 invalid_request | некорректные данные, нарушен порядок дат, нет фактических дат при completed |
403 access_denied | недопустимый переход статуса либо нет доступа к карточке |
404 not_found | командировка не найдена |
409 conflict | у сотрудника уже есть пересекающаяся по датам командировка |
400 trips_same_from_and_to | «откуда» и «куда» — одно и то же место (при изменении проверяется итоговый маршрут) |
400 trips_actual_date_in_future | фактическая дата выезда или возвращения позже завтрашнего дня: факт — то, что уже случилось |
Рекомендации для UI и дизайна
- Карточка vs список. Список (
GET /api/v1/trips) не содержитtasks— блок «Попутные задачи» рисуем только на детальной карточке (GET /api/v1/trips/:id). - Кнопки статусов строим по матрице переходов; для терминальных
completed/cancelledдействий смены статуса не показываем. - Завершение поездки (
-> completed) требует фактических дат: форму завершения делаем с обязательнымиdeparture_actualиreturn_actual, иначе бэкенд вернёт400. - Попутные задачи. После одобрения карточка обзаводится тремя подготовительными задачами (билеты, жильё, аванс), после завершения — авансовым отчётом. В UI стоит показывать их статус-чипами со ссылкой на задачу по
task_id. Иконки/цвета поkind:transport(билеты),accommodation(жильё),advance(деньги),expense_report(отчёт). - Проект необязателен. Поле выбора проекта в форме делаем опциональным — командировку можно создать без привязки к проекту (тогда не будет попутных задач и записи в бюджет).
- Маршрут. Для «откуда»/«куда» даём выбор локации из справочника (
*_location_id) и поле свободного ввода адреса (*_address). Допустимо заполнить любое из двух или оба; при наличии произвольного адреса он используется в подписях попутных задач. - Деньги (
planned_cost/actual_cost) приходят числом с двумя знаками — форматируем как валюту, не как строку. - Пересечение дат. На
409 conflictпоказываем понятное сообщение, что у сотрудника уже есть командировка на эти даты, а не общую ошибку.
Пересечение дат. Командировка сотрудника не должна пересекаться с другой его командировкой (trips_overlapping_trip); отменённые не считаются. Форма проверяет это заранее и не даёт сохранить. Карточку командировки без права правки (не организатор, не финансы и не согласующий) форма открывает только для чтения.