Doc Catalog - PlutoGr/TravelAgency GitHub Wiki

Catalog

src/Services/Catalog/. Каталог туров и направлений: публичная выдача опубликованных туров и конструктор, в котором менеджер собирает и публикует свой тур. Источник правды о турах для Booking. База travel_catalog.

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

Гость открывает /tours/3f2a…. Фронтенд запрашивает GET /api/v1/catalog/tours/3f2a…:

  • тур опубликован: 200 с описанием, программой по дням, предложениями (даты и цены), фото по id;
  • тур черновик или снят с публикации: 404, как будто его нет.

Модель

Tour (агрегат):

Поле Что это
Title, ShortDescription (до 300 символов), Description тексты
TourType Beach, Mountain, City, Cultural, Adventure, Cruise, Safari
Country, DirectionId, DepartureCity, DurationDays основное
MealPlan RO, BB, HB, FB, AI, UAI
AccommodationText проживание текстом (отель как сущность планируется на этапе 2)
Status Draft, Published, Unpublished; PendingReview зарезервирован под премодерацию и не используется
Source Manager или Automation (сейчас всегда Manager)
OwnerId менеджер-владелец
PublishedAt, CreatedAt, UpdatedAt даты
Version версия для ETag и If-Match, растёт при любом изменении тура
ImageUrl устаревшая колонка, в ответы не попадает; удаление в задаче #81

Дочерние таблицы:

Сущность Что хранит
TourOffer предложение тура: DepartureDate (местный день вылета, столбец date), ValidFrom, ValidTo, PricePerPerson, Currency, AvailableSeats
TourDay программа по дням: DayNumber, Title, Description
TourInclusion пункт Included или NotIncluded, текст, порядок
TourImage MediaFileId, порядок, IsCover (обложка одна, уникальный индекс), Alt, WidthPx
TourComponent задел под составные туры этапа 2; в ответах components: []

Direction: Name, Country, Description, IsActive.

Свойство Tour.IsActive вычисляется как Status == Published и оставлено для совместимости ответов.

Публичное API

Только Published. Таблица с параметрами: Публичный API.

  • GET /catalog/tours: фильтры по стране (подстрока), направлению, типу, цене и датам; сортировка по дате создания, названию, цене, длительности; страницы. Цена «от», фильтр цены и ближайшая дата (nearestDate, строка yyyy-MM-dd) считаются по предложениям с DepartureDate не раньше сегодня. Сегодня берёт TimeProvider в поясе Catalog:DefaultTimeZone (по умолчанию Europe/Moscow): вылет в этот день ещё показывается. До 5 превью на тур.

Фильтр дат вылета:

  • дата задаётся календарным днём (DateOnly): в адресе yyyy-MM-dd, на чипах «с ДД.ММ.ГГГГ» и «по ДД.ММ.ГГГГ»;
  • обе границы включительны: DepartureDate >= dateFrom и DepartureDate <= dateTo. Сравнивается местный день города вылета, без перевода в UTC;
  • допустимы даты с 2000-01-01 по 2100-12-31, вне диапазона 400 с code=date_range и текстом «Выберите дату в пределах 2000–2100 года»;
  • если дата «с» позже даты «по», тот же код и текст «Дата «по» должна быть не раньше даты «с».»;
  • у полей формы стоят min и max, дата из адреса вне диапазона или перепутанный порядок показывают подсказку, и запрос не отправляется;
  • ошибка базы (DbException или тип из пространства Npgsql) всегда даёт 500 с code=database_error и фиксированным текстом;
  • ответ валидации сервисов на общем middleware содержит errors, в code попадает только свой код из белого списка (date_range), иначе validation.
  • GET /catalog/tours/{id}: страница тура (PublicTourDto).
  • GET /catalog/tours/cards?ids=: до 50 карточек для избранного; снятый тур приходит с available: false.
  • GET /catalog/directions.

Пояс города вылета задан списком DepartureCityTimeZones (src/Services/Catalog/TravelAgency.Catalog.Application/Time/DepartureCityTimeZones.cs), это не таблица базы. Сопоставление без регистра, без пробелов по краям, «ё» равно «е». Пример: «Москва» это Europe/Moscow, «Новосибирск» это Asia/Novosibirsk. Города нет в списке: момент с ненулевым временем переводится в Catalog:DefaultTimeZone. Миграция 20261019125458_AddTourOfferDepartureDate для такого города подставляет константу Europe/Moscow и пишет отдельный NOTICE. PostgresNoticeLoggingInterceptor переносит этот NOTICE в лог приложения.

Управление турами

/catalog/manage/tours, роли Manager и Admin (TourManageController). Менеджер видит и меняет только свои туры (иначе 403), Admin все. Сценарий целиком: Конструктор и публикация тура.

Действие Запрос
список GET /catalog/manage/tours
один тур с чек-листом и ETag GET /catalog/manage/tours/{id}
черновик POST /catalog/manage/tours
шаги мастера PUT {id}/basics, description, program, conditions, prices, images
публикация POST {id}/publish, POST {id}/unpublish
удаление черновика DELETE {id}

Ошибки

CatalogExceptionMapper переводит исключения в ProblemDetails:

Код Когда
400 валидация
403 чужой тур
404 тура нет
409 устаревший If-Match; удаление не черновика; снятие с публикации черновика
422 type: https://travelagency/errors/tour-not-publishable, список кодов в missing; чужое, неизвестное или не фото тура, один код images.notFound
428 нет If-Match
503 Media недоступен

gRPC

  • Сервер CatalogService (порт 8081): GetTourSnapshot, GetTourSnapshotForExistingBooking. Вызывает Booking.
  • Клиент Media: GetMediaFiles, MarkMediaFilesPublic.

Подробно: Межсервисные контракты.

Демо-данные

CatalogDataSeeder создаёт 5 демо-туров через доменный Publish() (владелец: демо-аккаунт менеджера, см. README) и один черновик второго менеджера. Туры ищутся по названию, существующие сохраняют id и предложения. Направления: Мальдивы, Пхукет, Санторини, Бали, Дубай.

Настройки

ConnectionStrings__CatalogDb, JwtSettings__*, GrpcSettings__InternalServiceToken, GrpcClients__MediaServiceUrl, Seeding__DemoCatalog, Catalog__DefaultTimeZone (по умолчанию Europe/Moscow).

Решения

ADR-001. Модель тура, этап 1.

ADR-003. Часовой пояс фильтра дат вылета.