# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp` ## Назначение Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp` и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде. Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе, которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч). ## 1. Соглашение (canonical) 1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC. 2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо **только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`. 3. **Backend:** - «сейчас» — только UTC: `Instant.now()` или `LocalDateTime.now(ZoneOffset.UTC)`; **не** `LocalDateTime.now()`; - конвертация `Instant`/`OffsetDateTime` → `LocalDateTime` для naive-полей — только через UTC (`LocalDateTime.ofInstant(instant, ZoneOffset.UTC)`, `withOffsetSameInstant(ZoneOffset.UTC)`), **никогда** через `ZoneId.systemDefault()` / `atZone(systemDefault())`; - Jackson во всех сервисах настроен с `JavaTimeModule` + `disable(WRITE_DATES_AS_TIMESTAMPS)`, поэтому `LocalDateTime` уходит как ISO без зоны, а `OffsetDateTime`/`Instant` — с `Z`/офсетом. 4. **Frontend (`pcp-tgu-ops-ui`):** - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких «голых» `new Date(string)` / `Date.parse(string)`; - отображение — только UTC (`formatUtcDateTime/Date/Time` либо `toLocale*` с `timeZone: "UTC"`); - `` — значение через `toUtcInputValue`, чтение через `parseUtc`, отправка на offset-бэк — через `toUtcIso`. - Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах). 5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. ## 2. Эталоны переиспользования - Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. - Backend (чтение): `libs/pcp-types-lib` `utils/json/PcpTimeModule` — слой толерантности (бэковый аналог `parseUtc`): любой потребитель читает и naive(=UTC), и `Z`/офсет (см. раздел ниже). - Backend (запись): `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец. ## 2bis. Слой толерантности времени (включатель безопасного флипа) `PcpTimeModule` (`libs/pcp-types-lib/.../utils/json/PcpTimeModule.kt`) — Jackson-модуль с толерантными **десериализаторами** для `LocalDateTime`, `OffsetDateTime`, `Instant`. Любой потребитель с ним читает время в **обоих** форматах на проводе и нормализует к UTC: | Тип поля | naive `…T01:00:00` | с зоной `…T01:00:00Z` / `+03:00` | |---|---|---| | `LocalDateTime` | как есть (= UTC) | приводится к UTC, зона отбрасывается | | `OffsetDateTime` | `atOffset(UTC)` | `withOffsetSameInstant(UTC)` | | `Instant` | трактуется как момент UTC | штатно | Модуль трогает **только чтение** — формат записи определяется типом поля и не меняется, поэтому подключение слоя само по себе не сдвигает ни один контракт. **Зачем.** Микросервисы деплоятся независимо. Раньше перевод продюсера `LocalDateTime` → `OffsetDateTime` (на проводе появляется `Z`) ломал ещё не обновлённого потребителя — штатный `LocalDateTimeDeserializer` кидает исключение на офсете. С толерантностью во всём флоте продюсера можно флипать **в любом порядке, без синхронного деплоя**. Это снимает требование «продюсер + все потребители одним коммитом». **Подключение (три механизма, чтобы покрыть все мапперы):** - Spring Boot auto-configuration `PcpTimeJacksonAutoConfiguration` (`@Bean Module`) + `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` — Boot-managed `ObjectMapper` каждого из 12 сервисов, зависящих от pcp-types-lib (правок в сервисах не требуется). - Jackson SPI `META-INF/services/com.fasterxml.jackson.databind.Module` — ручные мапперы с `findAndRegisterModules()` (напр. `pcp-request-service`). - Точечная регистрация в мапперах, что обходят оба: `pcp-dynamic-plan-service/JacksonConfig`. **Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux (подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на сервлетный). Поэтому в tgu лежат **два локальных зеркала** (поведение обязано совпадать с библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса: - `config/PcpTimeModule.kt` (Jackson 2) — зарегистрирован в `CamundaJacksonConfig`, покрывает Camunda/внутренний парсинг; - `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST-кодеки WebFlux, которые Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином `tools.jackson.databind.JacksonModule` (`WebFluxJacksonConfig`); `JacksonAutoConfiguration` собирает все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Тем самым входящий REST-JSON tgu тоже читается толерантно к обоим форматам. Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, плюс проверка авто-подхвата через SPI. ## 3. Состояние сервисов (срез аудита) Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC), `OffsetDateTime`/`Instant` → ISO с `Z`. «Потребители» — кто читает время этого сервиса. | Сервис | Преобладает на границе API | Naive? | Вердикт | |---|---|---|---| | pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | | pcp-tgu-service | REST `PlanResponse` — `OffsetDateTime` (`Z`); Kafka/прочие DTO — `LocalDateTime` | частично | `PlanResponse` **мигрирован на `Z`**; баги конвертаций исправлены; Kafka-DTO остаются naive (=UTC) | | pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries); план ТГУ — `OffsetDateTime` (`Z`) | частично | `TguPlanResponse` **мигрирован на `Z`** вслед за tgu; прочие naive DTO — задокументировать | | pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать | | pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» | | pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» | | pcp-mission-planing-service | `LocalDateTime` (вход — `OffsetDateTime`) | да | задокументировать «=UTC» | | slots-service | `LocalDateTime` | да | задокументировать «=UTC»; проверить `rs.getTimestamp().toLocalDateTime()` | | pcp-coverage-scheme-service | `LocalDateTime` | да | задокументировать «=UTC» | | pcp-route-processing-service | нет времени на границе API | — | — | | pcp-satellite-catalog-service | нет времени на границе API | — | — | | pcp-stations-service | нет времени на границе API | — | — | | tle-monitoring-service | нет времени на границе API | — | — | ## 4. Известные расхождения и под-задачи (миграция к `Z` — не «большим взрывом») Систематический класс: `LocalDateTime.now()` без UTC-зоны (~80 мест: entity-дефолты `createdAt/updatedAt`, воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок, но формально неверно. Рекомендованные под-задачи по сервисам: - **pcp-tgu-service** — заменить оставшиеся `LocalDateTime.now()` (воркеры выдачи/решений, `PlanQueryService` cutoff, entity-дефолты) на `LocalDateTime.now(ZoneOffset.UTC)`; тест на cutoff истории. - **slots-service** — проверить `SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime()` (зависит от зоны драйвера); зафиксировать чтение в UTC. - **pcp-mission-planing-service / pcp-dynamic-plan-service** — `OffsetDateTime.toLocalDateTime()` теряет офсет; убедиться, что вход всегда UTC, иначе конвертировать через `withOffsetSameInstant(UTC)`. - **Все сервисы с naive `LocalDateTime` на границе** — добавить сериализационный тест, фиксирующий формат на проводе (есть `Z` / нет зоны), затем по согласованию мигрировать DTO к `OffsetDateTime`/`Instant` (UTC). Фронтовый `parseUtc` уже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате. ### Контрактные тесты формата (зафиксировано) Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`): - `PlanResponseContractTest` (pcp-tgu-service) — `OffsetDateTime` с `Z` (после миграции, см. ниже). - `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). - `TguPlanningDtoContractTest` (pcp-ui-service) — план ТГУ `OffsetDateTime` с `Z` + `Instant` с `Z`. Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании пер-сервисный страж копируется по образцу `RequestResponseDtoContractTest`. ### Миграция к `Z` — выполнено - **План ТГУ (tgu → ui-service → фронт).** `PlanResponse` (pcp-tgu-service) и зависящий от него `TguPlanResponse` (pcp-ui-service) переведены с naive `LocalDateTime` на `OffsetDateTime` в UTC → на проводе `Z`. Домен/БД (`PlannedPlanEntity`/`CalculatedPlan`) остались naive `LocalDateTime` (=UTC); конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. - **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами (Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя. После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. ### Исправленные баги формата - **`KafkaMessage.time` (общий Kafka-конверт, pcp-types-lib) — исправлено.** Было `time = LocalDateTime.now()` (локальное!) + сериализация `LocalDateTimeTimeZoneSerializer` через `ZoneId.systemDefault()`: конверт нёс МСК-стенные часы с `+03:00`, а потребитель (формат `…SSS[xxx]` в `LocalDateTime`) отбрасывал зону → сдвиг на пояс. Стало `now(ZoneOffset.UTC)` + сериализация `atOffset(ZoneOffset.UTC)` → на проводе `…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`. ### Исправленные баги формата - **pcp-tgu-service REST-codec на Jackson 3** — теперь толерантен (см. 2bis). Добавлен Jackson-3 вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule` (`config/WebFluxJacksonConfig.kt`); входящий REST-JSON WebFlux читается в обоих форматах (naive=UTC и `Z`). Закреплено `PcpTimeModuleV3Test`. ### Остаток (под-задачи) - Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. ## 5. Правило для нового кода - Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю). - Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`). - Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).