Skip to content

Doc ADR 003 Tour Date Timezone

TravelAgency Team Bot edited this page Oct 19, 2026 · 3 revisions

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

Статус: принято и реализовано (#212, PR #243). Исходная задача фильтра: #204, PR #208. Номер ADR-002 занят агрегацией туров (#124).

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

Рейс вылетает из Москвы 2 ноября в 01:00 по московскому времени. В UTC это 1 ноября, 22:00.

  • Клиент ставит фильтр «с 02.11 по 02.11» и видит этот тур.
  • Клиент ставит фильтр «по 01.11» и этот тур не видит.
  • На карточке и в чипе фильтра написано «2 ноя», в URL dateFrom=2026-11-02.

День вылета считается так, как он написан в билете и путёвке: по местному времени города вылета.

Контекст

До реализации (код main на 8f2a524) день вылета считался по UTC:

  • Дата предложения хранилась в TourOffer.ValidFrom типа DateTime, столбец timestamp with time zone.
  • Параметры dateFrom и dateTo приходили как yyyy-MM-dd и разбирались в DateOnly (DepartureCalendarDate.TryParse, хвост со временем отбрасывался).
  • TourListQuery переводил день в полуинтервал UTC: [день 00:00 UTC, следующий день 00:00 UTC) (DepartureCalendarDate.UtcStart и UtcEndExclusive).
  • «Будущее» предложение: ValidFrom > DateTime.UtcNow. TimeProvider и настроенного часового пояса в Catalog не было.
  • Конструктор тура отправлял день как ${день}T00:00:00Z, поэтому даты лежали как полночь UTC и ошибка не проявлялась.
  • Карточка показывала nearestDate через parseISO и format в поясе браузера: полночь UTC у клиента западнее UTC превращалась в предыдущий день.
  • Tour.DepartureCity хранился свободным текстом, часового пояса у города не было. В демо-данных город вылета: «Москва».

Проблема была в том, что реальное время вылета (ручной ввод, агрегация туров) сдвигало день: вылет 2 ноября в 01:00 МСК попадал в 1 ноября, потому что граница суток считалась по UTC.

Решение

  1. У предложения хранится TourOffer.DepartureDate типа DateOnly, столбец date без часового пояса. Это местный день вылета в городе вылета.
  2. Фильтр сравнивает дни напрямую: DepartureDate >= dateFrom и DepartureDate <= dateTo, обе границы включительно. Никакого пересчёта в UTC и полуинтервалов.
  3. Параметры dateFrom и dateTo остаются строками yyyy-MM-dd. Время и пояс клиента день не сдвигают.
  4. «Сегодня» считается на сервере: TimeProvider (через DI, в тестах подменяется) и пояс из настройки Catalog:DefaultTimeZone (по умолчанию Europe/Moscow, TimeZoneInfo.FindSystemTimeZoneById). Будущее предложение: DepartureDate >= сегодня, то есть вылет сегодня ещё показывается. Цена «от», фильтр цены и ближайшая дата используют то же правило.
  5. Часовой пояс города вылета нужен только для перевода старых ValidFrom в день вылета (миграция) и для данных, которые приходят с моментом времени (агрегация). Источник: таблица соответствия «город: IANA-пояс» в Catalog (например, «Москва»: Europe/Moscow, «Новосибирск»: Asia/Novosibirsk). Если у города пояс не задан, используется Catalog:DefaultTimeZone, и такая запись пишется в лог отдельно.
  6. API отдаёт departureDate строкой yyyy-MM-dd, nearestDate тоже становится днём yyyy-MM-dd. Менеджер в конструкторе вводит день вылета без времени и пояса, фронтенд отправляет его как есть.
  7. Фронтенд никогда не переводит календарный день через toISOString, new Date('yyyy-MM-ddT00:00:00Z') или Date с поясом. День вылета хранится и передаётся строкой yyyy-MM-dd, для показа строка разбирается как местная дата без сдвига.
  8. Npgsql: DateOnly сопоставляется со столбцом date штатно, режим Npgsql.EnableLegacyTimestampBehavior не включается.

Рассмотренные варианты

А. Сутки по UTC (как было до #212).

  • Плюсы: код уже есть, один пояс для всех, никакой настройки.
  • Минусы: ночной вылет с 00:00 до 03:00 МСК уходит в предыдущий день; клиент в 00:30 МСК видит «сегодня» как вчерашний день по UTC; дата на карточке расходится с билетом.

Б. Сутки по одному поясу каталога (Europe/Moscow) поверх timestamptz.

  • Плюсы: проще, чем пояс по городу; для Москвы даёт верный день.
  • Минусы: вылет из Новосибирска 02.11 в 00:30 местного времени (01.11 17:30 UTC, 20:30 МСК) попадает в 01.11; в запросе остаётся пересчёт границ, индекс по дню не используется напрямую.

В. DateOnly с местным днём вылета (выбрано).

  • Плюсы: день совпадает с билетом и путёвкой; фильтр простой и не зависит от пояса сервера, базы и браузера; ввод менеджера без времени; индекс по date.
  • Минусы: нужна миграция с заполнением из ValidFrom; нужна таблица поясов городов; время вылета (если понадобится) хранится отдельным полем.

Г. Хранить timestamptz и пояс города, считать день в запросе.

  • Плюсы: сохраняется точное время вылета.
  • Минусы: AT TIME ZONE по каждой строке, сложный и медленный фильтр, легко ошибиться на границах; день вылета всё равно нужен для показа.

Последствия

  • Миграция 20261019125458_AddTourOfferDepartureDate добавила DepartureDate, заполняет её из ValidFrom, переводя момент в местный день по поясу города вылета. Пример для Europe/Moscow: 2026-11-01 22:30 UTC даёт 2026-11-02. Полночь UTC в Москве это 03:00 того же дня, поэтому даты 5 демо-туров не меняются.
  • Миграция идемпотентна (DepartureDate IS NULL), у неё есть Down, она пишет в лог число изменённых записей и отдельно записи без пояса города. Перед деплоем по #212: временный дамп, после проверки он удаляется.
  • DepartureCalendarDate.UtcStart и UtcEndExclusive удалены вместе с полуинтервалом в TourListQuery. TryParse остаётся.
  • DateTime.UtcNow в TourListQuery и маппере заменён на TimeProvider (ICatalogClock).
  • «Сегодня» берётся по поясу каталога, а не по поясу города. Пример: предложение из Новосибирска на 01.11 ещё видно в выдаче 02.11 с 00:00 до 04:00 по Новосибирску, пока в Москве 01.11. Расхождение равно разнице поясов и бывает только в день вылета, это принято.
  • Контракты для Booking (GetTourSnapshot) и поле ValidTo этим решением не меняются. Описание выдачи: Catalog и Публичный API.

Отклонения реализации

Код 52934e9 следует решению выше. Ниже то, что в коде устроено иначе.

  1. Тело PUT …/prices по-прежнему { offers: [{ validFrom, validTo, pricePerPerson, currency, availableSeats }] }. Отдельного поля дня в запросе нет. Конструктор кладёт в validFrom и validTo строку yyyy-MM-dd как есть. Если время 00:00:00, DepartureDateConversion.ForInput берёт этот календарный день и пояс города не применяет. Пояс города применяется только к моменту с ненулевым временем.
  2. Запасной пояс миграции зашит константой Europe/Moscow (CatalogOptions.DefaultTimeZoneId). Настройка Catalog:DefaultTimeZone на текст миграции не влияет. Она влияет на «сегодня» и на перевод момента с ненулевым временем при сохранении предложения.
  3. Город без пояса пишется отдельной строкой при миграции PostgreSQL (RAISE NOTICE, затем PostgresNoticeLoggingInterceptor). При сохранении предложения ForInput передаёт пустой журнал, отдельной строки нет.
  4. Чек-лист публикации по-прежнему требует ValidFrom > DateTime.UtcNow (Tour.GetMissingPublishRequirements). На фронтенде код offers.future сравнивает день через new Date('yyyy-MM-ddT00:00:00Z') (parseInstant). Правило DepartureDate >= сегодня действует для выдачи, цены «от», фильтра цены и nearestDate.
  5. CatalogDataSeeder создаёт предложение без явного DepartureDate. День берётся из календарной даты ValidFrom (DateOnly.FromDateTime), без пояса города.
  6. Заполнение для SQLite берёт date("ValidFrom") без пояса. Рабочая база PostgreSQL переводит момент через AT TIME ZONE.

Как проверить

  1. Unit-тест фильтра: предложение с DepartureDate = 2026-11-02 находится по dateFrom=2026-11-02 и по dateTo=2026-11-02, не находится по dateTo=2026-11-01.
  2. Unit-тест фильтра: DepartureDate = 2026-11-01 (вылет 23:30 МСК) находится по dateTo=2026-11-01 и не находится по dateFrom=2026-11-02.
  3. Тест «сегодня» в 00:30 МСК: FakeTimeProvider на 2026-11-01T21:30:00Z (это 02.11, 00:30 МСК). «Сегодня» равно 2026-11-02: предложение на 02.11 в выдаче и в цене «от», предложение на 01.11 нет. При счёте по UTC «сегодня» было бы 01.11, этот тест должен падать на старом коде.
  4. Тест миграции для двух городов: вылет 02.11 в 00:30 по Москве (2026-11-01T21:30:00Z) и 02.11 в 00:30 по Новосибирску (2026-11-01T17:30:00Z) оба дают DepartureDate = 2026-11-02. Город без пояса даёт день по Europe/Moscow и строку в логе.
  5. Тест vitest: фильтр и конструктор отправляют 2026-11-02 без времени; карточка с nearestDate = 2026-11-02 показывает «2 ноя» при поясе теста America/New_York и Asia/Novosibirsk (TZ в окружении теста).
  6. На dev: тур с вылетом 02.11, фильтр «с 02.11 по 02.11» показывает его, «по 01.11» не показывает; в URL dateFrom=2026-11-02; даты 5 демо-туров на карточках до и после миграции совпадают.

Clone this wiki locally