Repository navigation
Doc ADR 003 Tour Date Timezone
Статус: принято и реализовано (#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.
- У предложения хранится
TourOffer.DepartureDateтипаDateOnly, столбецdateбез часового пояса. Это местный день вылета в городе вылета. - Фильтр сравнивает дни напрямую:
DepartureDate >= dateFromиDepartureDate <= dateTo, обе границы включительно. Никакого пересчёта в UTC и полуинтервалов. - Параметры
dateFromиdateToостаются строкамиyyyy-MM-dd. Время и пояс клиента день не сдвигают. - «Сегодня» считается на сервере:
TimeProvider(через DI, в тестах подменяется) и пояс из настройкиCatalog:DefaultTimeZone(по умолчаниюEurope/Moscow,TimeZoneInfo.FindSystemTimeZoneById). Будущее предложение:DepartureDate >= сегодня, то есть вылет сегодня ещё показывается. Цена «от», фильтр цены и ближайшая дата используют то же правило. - Часовой пояс города вылета нужен только для перевода старых
ValidFromв день вылета (миграция) и для данных, которые приходят с моментом времени (агрегация). Источник: таблица соответствия «город: IANA-пояс» в Catalog (например, «Москва»:Europe/Moscow, «Новосибирск»:Asia/Novosibirsk). Если у города пояс не задан, используетсяCatalog:DefaultTimeZone, и такая запись пишется в лог отдельно. - API отдаёт
departureDateстрокойyyyy-MM-dd,nearestDateтоже становится днёмyyyy-MM-dd. Менеджер в конструкторе вводит день вылета без времени и пояса, фронтенд отправляет его как есть. - Фронтенд никогда не переводит календарный день через
toISOString,new Date('yyyy-MM-ddT00:00:00Z')илиDateс поясом. День вылета хранится и передаётся строкойyyyy-MM-dd, для показа строка разбирается как местная дата без сдвига. - 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 следует решению выше. Ниже то, что в коде устроено иначе.
- Тело
PUT …/pricesпо-прежнему{ offers: [{ validFrom, validTo, pricePerPerson, currency, availableSeats }] }. Отдельного поля дня в запросе нет. Конструктор кладёт вvalidFromиvalidToстрокуyyyy-MM-ddкак есть. Если время00:00:00,DepartureDateConversion.ForInputберёт этот календарный день и пояс города не применяет. Пояс города применяется только к моменту с ненулевым временем. - Запасной пояс миграции зашит константой
Europe/Moscow(CatalogOptions.DefaultTimeZoneId). НастройкаCatalog:DefaultTimeZoneна текст миграции не влияет. Она влияет на «сегодня» и на перевод момента с ненулевым временем при сохранении предложения. - Город без пояса пишется отдельной строкой при миграции PostgreSQL (
RAISE NOTICE, затемPostgresNoticeLoggingInterceptor). При сохранении предложенияForInputпередаёт пустой журнал, отдельной строки нет. - Чек-лист публикации по-прежнему требует
ValidFrom > DateTime.UtcNow(Tour.GetMissingPublishRequirements). На фронтенде кодoffers.futureсравнивает день черезnew Date('yyyy-MM-ddT00:00:00Z')(parseInstant). ПравилоDepartureDate >= сегоднядействует для выдачи, цены «от», фильтра цены иnearestDate. -
CatalogDataSeederсоздаёт предложение без явногоDepartureDate. День берётся из календарной датыValidFrom(DateOnly.FromDateTime), без пояса города. - Заполнение для SQLite берёт
date("ValidFrom")без пояса. Рабочая база PostgreSQL переводит момент черезAT TIME ZONE.
- Unit-тест фильтра: предложение с
DepartureDate = 2026-11-02находится поdateFrom=2026-11-02и поdateTo=2026-11-02, не находится поdateTo=2026-11-01. - Unit-тест фильтра:
DepartureDate = 2026-11-01(вылет 23:30 МСК) находится поdateTo=2026-11-01и не находится поdateFrom=2026-11-02. - Тест «сегодня» в 00:30 МСК:
FakeTimeProviderна2026-11-01T21:30:00Z(это 02.11, 00:30 МСК). «Сегодня» равно2026-11-02: предложение на 02.11 в выдаче и в цене «от», предложение на 01.11 нет. При счёте по UTC «сегодня» было бы 01.11, этот тест должен падать на старом коде. - Тест миграции для двух городов: вылет 02.11 в 00:30 по Москве (
2026-11-01T21:30:00Z) и 02.11 в 00:30 по Новосибирску (2026-11-01T17:30:00Z) оба даютDepartureDate = 2026-11-02. Город без пояса даёт день поEurope/Moscowи строку в логе. - Тест vitest: фильтр и конструктор отправляют
2026-11-02без времени; карточка сnearestDate = 2026-11-02показывает «2 ноя» при поясе тестаAmerica/New_YorkиAsia/Novosibirsk(TZв окружении теста). - На dev: тур с вылетом 02.11, фильтр «с 02.11 по 02.11» показывает его, «по 01.11» не показывает; в URL
dateFrom=2026-11-02; даты 5 демо-туров на карточках до и после миграции совпадают.
Система
API
Сервисы
Как реализовано
Справочное