Doc Booking - PlutoGr/TravelAgency GitHub Wiki

Booking

src/Services/Booking/. Заявки на туры, предложения менеджера и избранное. База travel_booking.

Простой пример

Клиент на странице тура нажимает «Оставить заявку»:

POST /api/v1/bookings
{ "tourId": "3f2a…", "comment": "Двое взрослых, июль" }
→ 201 { "id": "b7c1…", "status": 0, … }

Booking сначала спрашивает у Catalog по gRPC, опубликован ли тур. Если нет, ответ 422 tour-unavailable, заявка не создаётся. Дальше заявку ведёт менеджер: берёт в работу, отправляет предложение, клиент его подтверждает. Весь сценарий: Заявки и чат.

Модель

Сущность Поля
Booking Id, ClientId, TourId, Comment, Status, CreatedAt, UpdatedAt, AssignedManagerId, AssignedAt
BookingStatusHistory BookingId, Status, ChangedByUserId, ChangedAt
BookingAssignmentHistory BookingId, PreviousManagerId, NewManagerId, ChangedByUserId, ChangedAt, Comment (до 500 символов): одна строка на каждую смену ответственного
Proposal BookingId, ManagerId, TourSnapshot, Notes, IsConfirmed, CreatedAt
TourSnapshot TourId, Title, Description, Price, Currency, DurationDays, SnapshotTakenAt: копия тура на момент предложения
Favorite UserId, TourId, AddedAt; пара уникальна
OutboxMessage EventType, Payload, Status (Pending, Processed, Failed), CreatedAt, ProcessedAt

Статус заявки в JSON передаётся числом:

Число Статус Смысл
0 New создана клиентом
1 InProgress менеджер взял в работу
2 ProposalSent клиенту отправлено предложение
3 Confirmed клиент подтвердил предложение
4 Closed заявка завершена
5 Cancelled отменена

Перевод чисел в названия на фронтенде: src/api/bookings.ts.

Переходы статусов

Из Можно в
New InProgress, Cancelled
InProgress ProposalSent (только если есть хотя бы одно предложение), Cancelled
ProposalSent Confirmed, InProgress, Cancelled
Confirmed Closed, Cancelled
Closed, Cancelled никуда

Недопустимый переход: BookingDomainException, ответ 422.

Кто что может

Действие Клиент Manager, Admin
создать заявку да да
свои заявки GET /bookings/my да да
все заявки GET /bookings нет (403 на Gateway) да
заявка по id только своя, иначе 403 любая; плюс имя и email клиента из Identity
смена статуса PATCH …/status только своя и только в Cancelled любой допустимый переход
предложение POST …/proposal нет да, кроме Closed и Cancelled
подтверждение POST …/confirm своя заявка да
взять заявку POST …/take нет (403) да, кроме Closed и Cancelled
назначить менеджера PUT …/assigned-manager нет (403) да, кроме Closed и Cancelled
список GET /bookings?assigned=me|queue|all нет (403) да

PATCH с Confirmed отвечает 400: подтверждение идёт только через confirm с proposalId. Неверный proposalId: 422. Поле clientPhone в ответе всегда null: телефон хранится в Identity, но gRPC GetUserSummary его не передаёт.

Ответственный менеджер

Простой пример. Заявка без ответственного лежит в общей очереди. Менеджер её берёт:

POST /api/v1/bookings/b7c1…/take
{ "expectedManagerId": null }
→ 200 { "id": "b7c1…", "assignedManagerId": "<id менеджера>", "assignedAt": "2026-10-19T14:00:00Z", … }

Второй менеджер, который отправил тот же запрос, получает 409 «Заявку уже взял другой менеджер».

  • AssignedManagerId = null: заявка в общей очереди. GET /bookings?assigned=queue отдаёт такие заявки, assigned=me заявки текущего менеджера, assigned=all (по умолчанию) все. Другое значение: 400.
  • expectedManagerId: какой ответственный был на экране у того, кто нажал (null для очереди). Если в базе уже другой, ответ 409, назначение не меняется.
  • Одновременное «Взять» защищено системным столбцом PostgreSQL xmin (concurrency token): второй SaveChanges получает DbUpdateConcurrencyException, ответ тот же 409.
  • Повтор тем же менеджером (expectedManagerId равен его id) возвращает 200 и не добавляет строку истории.
  • PUT /bookings/{id}/assigned-manager назначает другого менеджера. Booking спрашивает роль цели у Identity (GetUserSummary): если Identity пользователя не нашёл (NotFound) или роль не Manager, ответ 400 «Указанный пользователь не менеджер.». Любой другой сбой вызова Identity даёт 503, назначение не меняется.
  • Закрытая (Closed) или отменённая (Cancelled) заявка: 422.
  • Каждая смена пишет строку BookingAssignmentHistory и сообщение outbox BookingManagerAssigned.

Закреплённый менеджер клиента. При создании заявки Booking берёт ответственного последней заявки этого клиента, у которой менеджер назначен (учитываются и отменённые, и завершённые). Значение копируется в новую заявку, только если Identity подтверждает роль Manager; тогда же пишется первая строка истории. Если Identity пользователя не нашёл или роль другая, заявка попадает в общую очередь. Любой другой сбой вызова Identity даёт 503, заявка не сохраняется. Снятие уже назначенного менеджера при смене роли (#170) не делается.

Что видно в ответе. BookingDto содержит assignedManagerId, assignedAt и assignedManagerName (имя из Identity; при сбое Identity поле пустое, ответ не падает). assignmentHistory заполнен только для Manager и Admin, у клиента поле null.

Чат. Писать в чат заявки могут клиент-владелец, ответственный менеджер и Admin. Остальные менеджеры только читают. Booking отдаёт это Chat через gRPC: Межсервисные контракты.

Миграция 20261019135257_AddBookingAssignedManager добавляет в Bookings пустые столбцы AssignedManagerId и AssignedAt, таблицу BookingAssignmentHistories и индексы. Существующие заявки не заполняются: после миграции все они в общей очереди, и менеджер пишет в их чат только после take или назначения. Задача #220, PR #245.

Связи с другими сервисами (gRPC)

Вызов Когда
Catalog GetTourSnapshot создание заявки и добавление в избранное; тур должен быть опубликован
Catalog GetTourSnapshotForExistingBooking создание предложения; подходит и снятый с публикации тур
Identity GetUserSummary GET /bookings/{id} для менеджера и админа (имя и email клиента); имя ответственного менеджера; роль пользователя в ValidateBookingAccess; проверка роли Manager при создании заявки и в assigned-manager
сервер ValidateBookingAccess Chat проверяет, может ли пользователь читать (has_access) и писать (can_write)
сервер GetBookingAssignment клиент, ответственный и прежние менеджеры заявки

Цена в снимке: самое дешёвое предложение тура, действующее сегодня; если такого нет, самое дешёвое из всех; если предложений нет, 0 USD. Подробно: Межсервисные контракты.

Избранное

GET /favorites, POST /favorites/{tourId} (409, если уже есть), DELETE /favorites/{tourId}. Карточки избранного фронтенд берёт из Catalog: GET /catalog/tours/cards?ids=.

Outbox

При создании заявки, смене статуса и отправке предложения в той же транзакции пишутся сообщения BookingCreated, BookingStatusChanged, ProposalSent. При каждой смене ответственного, включая закрепление при создании, пишется BookingManagerAssigned (BookingId, PreviousManagerId, NewManagerId, ChangedBy). Обработчика, который бы их отправлял, в коде нет: сообщения остаются в статусе Pending. Задача на этапе 1: MVP-1.

Настройки

ConnectionStrings__BookingDb, JwtSettings__*, GrpcSettings__InternalServiceToken, GrpcClients__CatalogServiceUrl, GrpcClients__IdentityServiceUrl.