diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 88e0b19..4dff708 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -21,8 +21,8 @@ - существующие публичные контракты не сломаны без явного запроса; - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; -- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); -- чтение времени на бэке толерантно к обоим форматам (naive=UTC и `Z`) — модуль наследуется из `pcp-types-lib` (`PcpTimeModule`) авто-конфигурацией; ручной `ObjectMapper`/не-pcp-types-lib сервис регистрирует модуль явно; +- время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now(ZoneOffset.UTC)`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`; +- egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z); - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md deleted file mode 100644 index de1aaac..0000000 --- a/docs/standards/DATETIME_UTC.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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.** Сервис мигрирован с WebFlux на **servlet/MVC** (см. ниже -«Миграция tgu на MVC») и теперь зависит от pcp-types-lib, как остальной флот. Но из-за Boot 4 на -HTTP-границе всё равно работает **Jackson 3**, поэтому время покрывается двумя путями: -- Jackson-2 модуль **из либы** (`pcp_types_lib.utils.json.PcpTimeModule`) — явно зарегистрирован в - ручном маппере `CamundaJacksonConfig` (Camunda/внутренний парсинг); своего зеркала больше нет. -- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST HTTP message converters (MVC), - которые Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином - `tools.jackson.databind.JacksonModule` (`RestJacksonConfig`); `JacksonAutoConfiguration` собирает - все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Лежит локально, т.к. либа даёт - только Jackson-2 модуль; поведение обязано совпадать с библиотечным, правки синхронизировать. - -Контракт слоя закреплён `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 — модулем из либы (Camunda, - Jackson 2) плюс локальное Jackson-3 зеркало для REST-границы MVC. Это включатель: дальнейший флип - продюсеров на `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-граница на Jackson 3** — теперь толерантна (см. 2bis). Добавлен Jackson-3 - вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule` - (`config/RestJacksonConfig.kt`); входящий REST-JSON читается в обоих форматах (naive=UTC и `Z`). - Закреплено `PcpTimeModuleV3Test`. - -### Миграция tgu на MVC - -- **pcp-tgu-service: WebFlux → servlet/MVC.** Сервис не имел реального реактивного кода — JPA - блокирующая, контроллеры оборачивали блокирующие вызовы в `Mono.fromCallable{}.subscribeOn( - boundedElastic())`, WebClient-вызовы `.block()`-ались сразу. Это был единственный WebFlux-only - сервис флота. Переведён на MVC: подключён pcp-types-lib (тянет `starter-web` → тип приложения - servlet), `springdoc` → `webmvc-ui`, контроллеры возвращают значения напрямую (без `Mono`). - `starter-webflux` оставлен только ради WebClient. Побочный эффект для времени: убрано локальное - Jackson-2 зеркало (`config/PcpTimeModule.kt`) в пользу модуля из либы; Jackson-3 зеркало остаётся - (Boot 4 держит Jackson 3 на HTTP-границе и для MVC). - -### Остаток (под-задачи) - -- Обе Jackson-среды tgu толерантны; отдельных остатков по чтению времени нет. При миграции - оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. - -## 5. Правило для нового кода - -- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю). -- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`). -- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча). diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt deleted file mode 100644 index 6d1ae8f..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt +++ /dev/null @@ -1,23 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.configuration - -import com.fasterxml.jackson.databind.Module -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.context.annotation.Bean -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule - -/** - * Регистрирует [PcpTimeModule] в Boot-managed `ObjectMapper` каждого сервиса, который подключает - * pcp-types-lib. Spring Boot собирает все бины типа [Module] из контекста в авто-конфигурируемый - * маппер, поэтому отдельный `@Bean` доезжает до всех сервисов без явного component-scan пакета либы. - * - * Ручные мапперы (вызывающие `findAndRegisterModules()`) подхватывают модуль через Jackson SPI — - * см. META-INF/services/com.fasterxml.jackson.databind.Module. - * - * Слой толерантности — см. [PcpTimeModule] и docs/standards/DATETIME_UTC.md. - */ -@AutoConfiguration -class PcpTimeJacksonAutoConfiguration { - - @Bean - fun pcpTimeModule(): Module = PcpTimeModule() -} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt index a14befc..9ca3e7f 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt @@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema -import java.time.OffsetDateTime +import java.time.LocalDateTime @Schema( description = "Базовая модель режима", @@ -28,7 +28,7 @@ sealed interface ModeResponseDTO { val planId: Long @get:Schema(description = "Время начала режима") - val timeStart: OffsetDateTime? + val timeStart: LocalDateTime? @get:Schema(description = "Номер витка") val revolution: Long @@ -41,7 +41,7 @@ sealed interface ModeResponseDTO { data class SurveyModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "SURVEY", val status: String = "", @@ -60,7 +60,7 @@ data class SurveyModeResponseDTO( data class DropModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "DROP", val station: String? = "", diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt index 55a7589..d2349a2 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt @@ -2,8 +2,6 @@ package space.nstart.pcp.pcp_types_lib.message import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonAlias -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* @@ -21,11 +19,9 @@ class KafkaMessage ( val id = UUID.randomUUID().toString() var source = "Example" - // Время конверта в UTC: naive LocalDateTime трактуется как UTC (см. docs/standards/DATETIME_UTC.md), - // сериализатор помечает его офсетом +00:00. Раньше now() + systemDefault давали локальные часы и - // несовпадение момента у потребителей, дропающих зону. - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") - @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) + // Время конверта — naive LocalDateTime, штампуется по настенным часам UTC (now(ZoneOffset.UTC)), + // сериализуется как ISO без зоны. + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val dataContentType = "application/json" diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt deleted file mode 100755 index 65b5373..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import java.time.LocalDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter - -/** - * Сериализует naive [LocalDateTime] как момент в **UTC** (офсет `+00:00`). - * По соглашению группы `pcp` naive LocalDateTime = UTC (см. docs/standards/DATETIME_UTC.md), - * поэтому зона навешивается через [ZoneOffset.UTC], а не через пояс машины. - * Прежняя реализация (`ZoneId.systemDefault()`) помечала те же часы локальным офсетом, из-за чего - * потребитель, дропающий зону, получал сдвиг на пояс. - */ -class LocalDateTimeTimeZoneSerializer: JsonSerializer() { - - companion object { - val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx") - } - - override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { - p1.writeString(p0?.atOffset(ZoneOffset.UTC)?.format(fmt)) - } -} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt deleted file mode 100644 index 3ad19eb..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt +++ /dev/null @@ -1,124 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeParseException - -/** - * Слой толерантности времени для всей группы `pcp` (бэковый аналог фронтового `parseUtc`). - * - * Любой потребитель с этим модулем читает время в **обоих** форматах на проводе: - * naive ISO без зоны (исторический формат, трактуется как UTC по соглашению - * docs/standards/DATETIME_UTC.md) и ISO с явной зоной/`Z` (формат после миграции). - * Все значения нормализуются к UTC. - * - * Зачем: микросервисы деплоятся независимо. Если включить толерантность во всём флоте, - * продюсер можно перевести `LocalDateTime` → `OffsetDateTime` (на проводе появляется `Z`) - * в любом порядке — потребитель не упадёт на парсинге офсета. Без этого слоя миграция - * формата требовала бы синхронного деплоя продюсера и всех потребителей. - * - * Модуль трогает **только десериализацию** (чтение). Формат записи определяется типом поля - * (`LocalDateTime` → naive, `OffsetDateTime`/`Instant` → `Z`) и здесь не меняется, чтобы - * подключение слоя само по себе не сдвигало ни один контракт. - * - * Подключается через Spring Boot auto-configuration ([PcpTimeJacksonAutoConfiguration]) для - * Boot-managed `ObjectMapper` и через Jackson SPI - * (`META-INF/services/com.fasterxml.jackson.databind.Module`) для ручных мапперов, - * вызывающих `findAndRegisterModules()`. - */ -class PcpTimeModule : SimpleModule(NAME) { - - init { - addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) - addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) - addDeserializer(Instant::class.java, TolerantInstantDeserializer()) - } - - companion object { - const val NAME = "PcpTimeModule" - } -} - -/** - * Читает [LocalDateTime] из обоих форматов: строка с зоной/`Z` нормализуется к UTC и теряет зону, - * naive-строка парсится как есть (= UTC). Нестроковые токены (массивы/таймстемпы) делегируются - * штатному [LocalDateTimeDeserializer], чтобы не сломать прочие представления. - */ -class TolerantLocalDateTimeDeserializer : JsonDeserializer() { - - private val default = LocalDateTimeDeserializer.INSTANCE - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - tryParseToUtc(text)?.let { return it.toLocalDateTime() } - } - return default.deserialize(p, ctxt) - } - - /** Возвращает момент в UTC, если строка несёт зону/офсет; иначе null (строка naive). */ - private fun tryParseToUtc(text: String): OffsetDateTime? = - try { - OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - null - } -} - -/** - * Читает [OffsetDateTime] из обоих форматов: naive-строка получает зону UTC, - * строка с зоной приводится к UTC. - */ -class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} - -/** - * Читает [Instant] из обоих форматов: строка с `Z`/офсетом — штатно, naive-строка трактуется - * как момент в UTC. - */ -class TolerantInstantDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.INSTANT as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).toInstant() - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module deleted file mode 100644 index 6e96a8a..0000000 --- a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module +++ /dev/null @@ -1 +0,0 @@ -space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 32f1b41..0000000 --- a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt deleted file mode 100644 index 69de0d6..0000000 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.annotation.JsonFormat -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -/** - * Контракт сериализатора времени Kafka-конверта ([LocalDateTimeTimeZoneSerializer]): - * naive LocalDateTime помечается офсетом UTC (`+00:00`), часы не сдвигаются. - * Закрывает баг прежней реализации через `ZoneId.systemDefault()` - * (см. docs/standards/DATETIME_UTC.md). - */ -class LocalDateTimeTimeZoneSerializerTest { - - private val mapper = jacksonObjectMapper().findAndRegisterModules() - - // Зеркалит аннотации реального поля KafkaMessage.time (паттерн с опциональным офсетом). - private data class Holder( - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") - @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) - val t: LocalDateTime - ) - - @Test - fun `serializes naive LocalDateTime with UTC offset, no wall-clock shift`() { - val json = mapper.writeValueAsString(Holder(LocalDateTime.of(2026, 5, 29, 1, 0, 0))) - - assertTrue( - json.contains("2026-05-29T01:00:00.000+00:00"), - "ожидался UTC-офсет без сдвига часов, получено: $json" - ) - } - - @Test - fun `round-trips through the tolerance layer to the same UTC moment`() { - val original = LocalDateTime.of(2026, 5, 29, 1, 0, 0) - - val json = mapper.writeValueAsString(Holder(original)) - val back: Holder = mapper.readValue(json) - - assertEquals(original, back.t) - } -} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt deleted file mode 100644 index c14c4fe..0000000 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset - -/** - * Контракт слоя толерантности времени ([PcpTimeModule]): любой потребитель читает время - * в обоих форматах на проводе (naive=UTC и `Z`/офсет), всё нормализуется к UTC. - * Это и есть то, что делает безопасным пер-сервисный флип продюсера на `Z` - * без синхронного деплоя (см. docs/standards/DATETIME_UTC.md). - */ -class PcpTimeModuleTest { - - private val mapper = jacksonObjectMapper().registerModule(PcpTimeModule()) - - private data class LdtHolder(val t: LocalDateTime) - private data class OdtHolder(val t: OffsetDateTime) - private data class InstantHolder(val t: Instant) - - @Test - fun `LocalDateTime field reads naive and Z as the same UTC moment`() { - val naive: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val zoned: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - val expected = LocalDateTime.of(2026, 5, 29, 1, 0, 0) - assertEquals(expected, naive.t) - assertEquals(expected, zoned.t, "Z-строка должна дать тот же UTC-момент, что naive") - } - - @Test - fun `LocalDateTime field converts explicit offset to UTC`() { - // +03:00 01:00 == 22:00 UTC предыдущего дня - val moscow: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00+03:00"}""") - - assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), moscow.t) - } - - @Test - fun `LocalDateTime field accepts Z without throwing (the regression the layer prevents)`() { - // Штатный LocalDateTimeDeserializer кинул бы исключение на офсете/Z. - assertDoesNotThrow { - mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - } - } - - @Test - fun `OffsetDateTime field reads naive as UTC and normalizes offset to UTC`() { - val naive: OdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val moscow: OdtHolder = mapper.readValue("""{"t":"2026-05-29T04:00:00+03:00"}""") - - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), naive.t) - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), moscow.t) - } - - @Test - fun `Instant field reads naive as UTC and Z as is`() { - val naive: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val zoned: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - assertEquals(Instant.parse("2026-05-29T01:00:00Z"), naive.t) - assertEquals(Instant.parse("2026-05-29T01:00:00Z"), zoned.t) - } - - @Test - fun `module is auto-discovered via Jackson SPI (findAndRegisterModules)`() { - // Подтверждает, что META-INF/services/com.fasterxml.jackson.databind.Module работает: - // ручной маппер с findAndRegisterModules() получает толерантность без явной регистрации. - val spiMapper = jacksonObjectMapper().findAndRegisterModules() - - val holder: LdtHolder = spiMapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), holder.t) - } -} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt index 56c5887..42ed9c9 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunResponseDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt index d401132..aa5d252 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunSummaryDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt index 7d6bfd1..37d2c55 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt @@ -4,9 +4,6 @@ import org.springframework.stereotype.Component import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset @Component class ComplexPlanRunMapper { @@ -19,9 +16,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -46,9 +43,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -75,8 +72,4 @@ class ComplexPlanRunMapper { .mapNotNull { it.id } .distinct() .sorted() - - private fun LocalDateTime.toUtcOffset(): OffsetDateTime = - atOffset(ZoneOffset.UTC) - } diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt index 669a3db..b1b9915 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt @@ -50,9 +50,9 @@ class ComplexPlanControllerTest { intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC), - startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC), - finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC), + createdAt = LocalDateTime.of(2026, 3, 26, 9, 59), + startedAt = LocalDateTime.of(2026, 3, 26, 10, 0), + finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1), durationMs = 60000, errorMessage = null, calculatedModesCount = 1, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt index 83a23c6..9fdbebc 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt @@ -22,9 +22,9 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistence import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ComplexPlanRunApiTest { @@ -81,7 +81,7 @@ class ComplexPlanRunApiTest { assertEquals(10.0, response.totalRequestsArea) assertEquals(4.0, response.coveredArea) assertEquals(40.0, response.coveredAreaPercent) - assertEquals(ZoneOffset.UTC, response.createdAt.offset) + assertNotNull(response.createdAt) } @Test diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt index 7ad27df..0956d92 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt @@ -16,7 +16,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 1, @@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt index b7e6727..6c33e87 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -6,34 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule - - @Configuration class JacksonConfig { @Bean fun objectMapper(): ObjectMapper { val mapper = ObjectMapper() - - // Настраиваем формат для LocalDateTime -// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz") - -// module.addSerializer( -// LocalDateTime::class.java, -// LocalDateTimeSerializer(formatter) -// ) -// module.addDeserializer( -// LocalDateTime::class.java, -// LocalDateTimeDeserializer(formatter) -// ) mapper.registerModule(JavaTimeModule()) .registerKotlinModule() - // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. - // Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI. - .registerModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - return mapper } } diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt index 2bdcfff..78f7bff 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt @@ -48,6 +48,7 @@ import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.Executors @@ -841,7 +842,7 @@ class BasicPlanCalculator( val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { observationParametersDebugFileWriter.write( ObservationParametersDebugFileDTO( - createdAt = Instant.now(), + createdAt = LocalDateTime.now(ZoneOffset.UTC), requestId = request.requestId, satelliteIds = selectedSatelliteIds, missingSatelliteIds = missingSatelliteIds, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt index da31900..98cccf2 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -7,8 +7,7 @@ import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestClient import org.springframework.web.util.UriComponentsBuilder import space.nstart.pcp.complan.types.RequestItemDTO -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** Получение заявок из pcp-request-service /v1 API. */ @@ -44,10 +43,9 @@ class RequestServiceClient { id = id, name = name, geometry = geometry, - // Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы - // при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md). - intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), - intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), + // pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC). + intervalBegin = beginDateTime, + intervalEnd = endDateTime, resolution = resolution(), importance = importance, ) @@ -66,16 +64,16 @@ data class RequestResponseDto( val surveyType: String = "", val geometry: String, val importance: Double, - val beginDateTime: OffsetDateTime, - val endDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, + val endDateTime: LocalDateTime, val kpp: List = emptyList(), val highPriorityTransmit: Boolean = false, val optics: SurveyParamsDto? = null, val rsa: SurveyParamsDto? = null, val coverage: CoverageStateDto = CoverageStateDto(), - val createdAt: OffsetDateTime? = null, - val updatedAt: OffsetDateTime? = null, - val deletedAt: OffsetDateTime? = null, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val deletedAt: LocalDateTime? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt index 6db84b0..76620ae 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt @@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import io.swagger.v3.oas.annotations.media.Schema -import java.time.Instant import java.time.LocalDateTime import java.util.UUID @@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO( @get:JsonProperty("createdAt") @param:JsonProperty("createdAt") - val createdAt: Instant, + val createdAt: LocalDateTime, @get:JsonProperty("requestId") @param:JsonProperty("requestId") diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt index 2f59ed7..b2217ee 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt @@ -9,7 +9,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import java.nio.file.Files import java.nio.file.Path -import java.time.Instant import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test @@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest { outputDir = tempDir.resolve("observation-parameters").toString() ) val artifact = ObservationParametersDebugFileDTO( - createdAt = Instant.parse("2026-05-05T10:15:30Z"), + createdAt = LocalDateTime.parse("2026-05-05T10:15:30"), requestId = requestId, satelliteIds = listOf(1001L), missingSatelliteIds = emptyList(), diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt index ad1fafd..193a85c 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -62,29 +62,11 @@ class RequestServiceClientTest { assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) } - @Test - fun `getRequest normalizes non-UTC offsets to UTC LocalDateTime`() { - val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002") - // Источник прислал то же мгновение, но со смещением +03:00. Голый toLocalDateTime() оставил бы - // локальную стенку (06:04:05); нормализация к UTC обязана дать 03:04:05. - server = serverWithRequestResponse( - mutableListOf(), - requestId, - beginDateTime = "2026-01-02T06:04:05+03:00", - endDateTime = "2026-01-03T07:05:06+03:00", - ) - - val request = requestServiceClient().getRequest(requestId)!! - - assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin) - assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd) - } - private fun serverWithRequestResponse( requestedUris: MutableList, requestId: UUID, - beginDateTime: String = "2026-01-02T03:04:05Z", - endDateTime: String = "2026-01-03T04:05:06Z", + beginDateTime: String = "2026-01-02T03:04:05", + endDateTime: String = "2026-01-03T04:05:06", ): HttpServer = HttpServer.create(InetSocketAddress(0), 0).apply { createContext("/v1/requests") { exchange -> @@ -110,8 +92,8 @@ class RequestServiceClientTest { }, "rsa": null, "coverage": {"currentPercent": 0.0}, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z", + "createdAt": "2026-01-01T00:00:00", + "updatedAt": "2026-01-01T00:00:00", "deletedAt": null } """.trimIndent() diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt index 8a614ea..0b065db 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt @@ -4,7 +4,7 @@ import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity import jakarta.persistence.Table -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "drop_modes") @@ -12,7 +12,7 @@ import java.time.OffsetDateTime class DropModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "station") diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt index 75daeec..ab7e87d 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt @@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "modes") @@ -29,7 +29,7 @@ abstract class ModeEntity( open var plan: PlanEntity? = null, @Column(name = "time_start") - open var timeStart: OffsetDateTime? = null, + open var timeStart: LocalDateTime? = null, @Column(name = "revolution", nullable = false) open var revolution: Long = 0 diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt index ee053fc..cdeaacc 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt @@ -7,7 +7,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "survey_modes") @@ -15,7 +15,7 @@ import java.time.OffsetDateTime class SurveyModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "lat", nullable = false) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt index e75ccfc..f199ba3 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface ModeRepository : JpaRepository { @@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository { fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId: Long, - timeStop: OffsetDateTime + timeStop: LocalDateTime ): List fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt index b264ef1..064a71b 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt @@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface SurveyModeRepository : JpaRepository { @@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository { fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( @Param("satelliteNumber") satelliteNumber: String, @Param("revolution") revolution: Long, - @Param("timeStartFrom") timeStartFrom: OffsetDateTime, - @Param("timeStartTo") timeStartTo: OffsetDateTime + @Param("timeStartFrom") timeStartFrom: LocalDateTime, + @Param("timeStartTo") timeStartTo: LocalDateTime ): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 18b29aa..d48476a 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -62,7 +62,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.Duration import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI @@ -305,7 +304,7 @@ class MissionPlaningService( val modes = modeRepository .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId, - timeStop.atOffset(ZoneOffset.UTC) + timeStop ) .filter { it.intersects(timeStart, timeStop) } @@ -346,27 +345,18 @@ class MissionPlaningService( .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } } - /** - * Нормализует `OffsetDateTime` к UTC и отбрасывает зону. Все naive `LocalDateTime` в сервисе — UTC - * (см. docs/standards/DATETIME_UTC.md), поэтому сравнивать их можно только с UTC-стенкой времени мода, - * а не с локальной стенкой произвольного офсета. Голый `toLocalDateTime()` был корректен лишь пока - * источник слал офсет 0 (`Z`). - */ - private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = - withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - /** * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт). * Применяется при fork-forward-клоне: включения строго вне окна не клонируются. */ private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { - val modeStart = timeStart?.toUtcLocalDateTime() ?: return false + val modeStart = timeStart ?: return false val modeStop = modeStop() ?: return false return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { - val modeStart = timeStart?.toUtcLocalDateTime() ?: return null + val modeStart = timeStart ?: return null val durationSeconds = when (this) { is SurveyModeEntity -> duration is DropModeEntity -> duration @@ -731,7 +721,7 @@ class MissionPlaningService( val saved = surveyModeRepository.save( SurveyModeEntity( plan = plan, - timeStart = built.timeStart.atOffset(ZoneOffset.UTC), + timeStart = built.timeStart, revolution = built.revolution, lat = built.lat, long = built.longitude, @@ -805,7 +795,7 @@ class MissionPlaningService( } val start = survey.timeStart ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") - val surveyEnd = start.toUtcLocalDateTime() + val surveyEnd = start .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(req.zoneStart)) { throw CustomErrorException( @@ -826,7 +816,7 @@ class MissionPlaningService( val dropMode = dropModeRepository.save( DropModeEntity( plan = plan, - timeStart = req.zoneStart.atOffset(ZoneOffset.UTC), + timeStart = req.zoneStart, revolution = req.revolution, station = req.station, duration = usedDuration @@ -867,7 +857,7 @@ class MissionPlaningService( } val built = buildContour(req) - survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC) + survey.timeStart = built.timeStart survey.revolution = built.revolution survey.lat = built.lat survey.long = built.longitude @@ -1164,12 +1154,11 @@ class MissionPlaningService( val iterator = unassigned.iterator() while (iterator.hasNext()) { val survey = iterator.next() - val surveyStartOffset = survey.timeStart - if (surveyStartOffset == null) { + val surveyStart = survey.timeStart + if (surveyStart == null) { iterator.remove() continue } - val surveyStart = surveyStartOffset.toUtcLocalDateTime() val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(zoneStart)) { @@ -1193,7 +1182,7 @@ class MissionPlaningService( val dropMode = dropModeRepository.save( DropModeEntity( plan = plan, - timeStart = zoneStart.atOffset(ZoneOffset.UTC), + timeStart = zoneStart, revolution = zone.revolution, station = zone.stationId.toString(), duration = usedDuration @@ -1294,8 +1283,8 @@ class MissionPlaningService( @Transactional(readOnly = true) fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? { - val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1) - val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1) + val searchStart = routePassport.intervalBegin.minusSeconds(1) + val searchEnd = routePassport.intervalBegin.plusSeconds(1) val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( satelliteNumber = routePassport.kaShort.trim(), revolution = routePassport.orbitNumber, @@ -1305,7 +1294,7 @@ class MissionPlaningService( return candidates.minByOrNull { candidate -> val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE - abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis()) + abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis()) } } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt index 4b02ce8..9f1868c 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt @@ -6,7 +6,6 @@ import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO -import java.time.ZoneOffset abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { override fun prepareSurvey( @@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { plan: PlanEntity ): SurveyModeEntity = SurveyModeEntity( plan = plan, - timeStart = surv.startTime.atOffset(ZoneOffset.UTC), + timeStart = surv.startTime, revolution = surv.revolution, lat = surv.lat, long = surv.longitude, diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt index e0fcb57..3500d55 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -27,8 +27,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -263,27 +261,24 @@ class MissionCloneTest { val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) planStore[plan.id!!] = plan val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 - val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration) - val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration) + val s1 = survey(plan, surveyStart, duration) + val s2 = survey(plan, surveyStart.plusMinutes(1), duration) val d = drop(plan, surveyStart.plusMinutes(5)) return SourceFixture(mission.missionId, plan, s1, s2, d) } - private fun survey(plan: PlanEntity, startOffset: OffsetDateTime, duration: Double = 300.0): SurveyModeEntity { + private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0): SurveyModeEntity { val id = ++modeAutoId - val s = SurveyModeEntity(id = id, plan = plan, timeStart = startOffset, revolution = id, + val s = SurveyModeEntity(id = id, plan = plan, timeStart = start, revolution = id, lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL, status = SurveyModeStatus.PLANNED) surveyStore[id] = s return s } - private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0) = - survey(plan, start.atOffset(ZoneOffset.UTC), duration) - private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity { val id = ++modeAutoId - val d = DropModeEntity(id = id, plan = plan, timeStart = start.atOffset(ZoneOffset.UTC), + val d = DropModeEntity(id = id, plan = plan, timeStart = start, revolution = id, station = "102", duration = 60.0) dropStore[id] = d return d diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt index 05c9ab0..639e5e7 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -32,7 +32,6 @@ import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import java.time.LocalDateTime -import java.time.ZoneOffset import java.util.UUID class MissionEditModesTest { @@ -262,7 +261,7 @@ class MissionEditModesTest { private fun survey(): SurveyModeEntity { val id = ++autoId val s = SurveyModeEntity( - id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC), + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0), revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0, status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL ) @@ -273,7 +272,7 @@ class MissionEditModesTest { private fun drop(): DropModeEntity { val id = ++autoId val d = DropModeEntity( - id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC), + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0), revolution = id, station = "102", duration = 60.0 ) dropStore[id] = d diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt index 2991f1a..54f43f8 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -41,8 +41,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID import java.math.BigDecimal @@ -93,7 +91,7 @@ class MissionPlaningServiceTest { val overlappingSurvey = SurveyModeEntity( id = 1L, plan = plan, - timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.minusMinutes(5), revolution = 10, lat = 55.0, long = 37.0, @@ -105,7 +103,7 @@ class MissionPlaningServiceTest { val inWindowSurvey = SurveyModeEntity( id = 2L, plan = plan, - timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(10), revolution = 11, lat = 56.0, long = 38.0, @@ -117,7 +115,7 @@ class MissionPlaningServiceTest { val inWindowDrop = DropModeEntity( id = 3L, plan = plan, - timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(20), revolution = 12, station = "station-1", duration = 120.0 @@ -126,7 +124,7 @@ class MissionPlaningServiceTest { `when`( modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( 77, - intervalStop.atOffset(ZoneOffset.UTC) + intervalStop ) ).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop)) `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn( @@ -153,41 +151,6 @@ class MissionPlaningServiceTest { assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) } - @Test - fun `modesBySatelliteAndInterval normalizes non-UTC mode offsets to UTC before matching`() { - val mission = MissionEntity(missionId = UUID.randomUUID(), satelliteId = 77) - val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) - val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0) - val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0) - - // То же мгновение, что 10:10 UTC (внутри окна), но выражено со смещением +03:00 → 13:10. - // Голый toLocalDateTime() дал бы 13:10 и выкинул мод из окна; нормализация к UTC оставляет его. - val offsetSurvey = SurveyModeEntity( - id = 1L, - plan = plan, - timeStart = OffsetDateTime.of(2026, 3, 16, 13, 10, 0, 0, ZoneOffset.ofHours(3)), - revolution = 11, - lat = 56.0, - long = 38.0, - duration = 300.0, - contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", - roll = 1.0, - source = SurveyModeSource.SLOT - ) - - `when`( - modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( - 77, - intervalStop.atOffset(ZoneOffset.UTC) - ) - ).thenReturn(listOf(offsetSurvey)) - `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L))).thenReturn(emptyList()) - - val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop) - - assertEquals(listOf(1L), result.map { it.id }) - } - @Test fun `modesBySatelliteAndInterval rejects inverted interval`() { val exception = assertThrows(ResponseStatusException::class.java) { @@ -223,12 +186,12 @@ class MissionPlaningServiceTest { ) val exactMode = SurveyModeEntity( id = 10L, - timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin, revolution = 321 ) val nearMode = SurveyModeEntity( id = 11L, - timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin.plusSeconds(1), revolution = 321 ) @@ -236,8 +199,8 @@ class MissionPlaningServiceTest { surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( "22", 321, - OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC), - OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC) + routePassport.intervalBegin.minusSeconds(1), + routePassport.intervalBegin.plusSeconds(1) ) ).thenReturn(listOf(nearMode, exactMode)) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt index c1d5367..dd56310 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID @RestController @@ -41,10 +41,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, @@ -73,10 +73,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt index 669b84d..7c4cabb 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt @@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto import com.fasterxml.jackson.annotation.JsonFormat import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.NotNull -import java.time.OffsetDateTime +import java.time.LocalDateTime /** * Текущие настройки сетки. @@ -38,5 +38,5 @@ data class GridRebuildResponseDto( /** Дата-время завершения перестроения в UTC. */ @field:JsonFormat(shape = JsonFormat.Shape.STRING) - val rebuiltAt: OffsetDateTime, + val rebuiltAt: LocalDateTime, ) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt index 2131f87..a67df9a 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID /** @@ -48,11 +48,11 @@ data class CreateRequestDto( /** Начало временного окна выполнения заявки. */ @field:NotNull - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ @field:NotNull - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -120,10 +120,10 @@ data class RequestResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -141,13 +141,13 @@ data class RequestResponseDto( val coverage: CoverageStateDto, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -170,10 +170,10 @@ data class RequestSummaryResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -185,13 +185,13 @@ data class RequestSummaryResponseDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -237,10 +237,10 @@ data class RequestMapItemDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -252,13 +252,13 @@ data class RequestMapItemDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -317,7 +317,7 @@ data class DeleteRequestResponseDto( val id: UUID, /** Время soft delete. */ - val deletedAt: OffsetDateTime, + val deletedAt: LocalDateTime, ) /** @@ -421,16 +421,16 @@ data class RequestListFilter( val highPriorityTransmit: Boolean? = null, /** Нижняя граница beginDateTime, включительно. */ - val beginFrom: OffsetDateTime? = null, + val beginFrom: LocalDateTime? = null, /** Верхняя граница beginDateTime, включительно. */ - val beginTo: OffsetDateTime? = null, + val beginTo: LocalDateTime? = null, /** Нижняя граница endDateTime, включительно. */ - val endFrom: OffsetDateTime? = null, + val endFrom: LocalDateTime? = null, /** Верхняя граница endDateTime, включительно. */ - val endTo: OffsetDateTime? = null, + val endTo: LocalDateTime? = null, /** Включать soft-deleted заявки в результат. */ val includeDeleted: Boolean = false, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt index 7cf002b..eee1af5 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt @@ -7,7 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.time.ZoneOffset class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") @@ -41,7 +41,7 @@ class GridRebuildService( return GridRebuildResponseDto( cellsCount = earthCellRepository.count(), rebuiltRequestProjectionsCount = activeRequests.size.toLong(), - rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC), + rebuiltAt = LocalDateTime.now(ZoneOffset.UTC), ) } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index c4f3d73..a59ead0 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -39,7 +39,6 @@ import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID @@ -82,8 +81,8 @@ class RequestService( remainingArea = geometryArea, coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, importance = importance, - beginDateTime = request.beginDateTime.toUtcLocalDateTime(), - endDateTime = request.endDateTime.toUtcLocalDateTime(), + beginDateTime = request.beginDateTime, + endDateTime = request.endDateTime, kpp = request.kpp.toMutableList(), highPriorityTransmit = request.highPriorityTransmit, surveyType = surveyType, @@ -162,7 +161,7 @@ class RequestService( requestCellRepository.deleteByRequestId(request.id) return DeleteRequestResponseDto( id = request.id, - deletedAt = deletedAt.toOffsetDateTime(), + deletedAt = deletedAt, ) } @@ -206,10 +205,10 @@ class RequestService( surveyType = query.surveyType?.toEntity(), kpp = query.kpp, highPriorityTransmit = query.highPriorityTransmit, - beginFrom = query.beginFrom?.toUtcLocalDateTime(), - beginTo = query.beginTo?.toUtcLocalDateTime(), - endFrom = query.endFrom?.toUtcLocalDateTime(), - endTo = query.endTo?.toUtcLocalDateTime(), + beginFrom = query.beginFrom, + beginTo = query.beginTo, + endFrom = query.endFrom, + endTo = query.endTo, pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()), ) @@ -223,16 +222,16 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, optics = optics, rsa = rsa, coverage = coverage(), - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -252,14 +251,14 @@ class RequestService( status = status.toDto(), surveyType = surveyType.toDto(), importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -271,14 +270,14 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -361,10 +360,6 @@ class RequestService( return Sort.by(direction, property) } - private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - - private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC) - private fun Throwable.isDuplicateRequestId(): Boolean { return generateSequence(this) { throwable -> throwable.cause } .filterIsInstance() diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt index 71ef3cd..9a094e1 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt @@ -21,7 +21,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.Optional class GridManagementControllerTest { @@ -97,7 +97,7 @@ class GridManagementControllerTest { GridRebuildResponseDto( cellsCount = 2, rebuiltRequestProjectionsCount = 2, - rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"), + rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30), ) ).`when`(gridRebuildService).rebuildGrid() @@ -105,7 +105,7 @@ class GridManagementControllerTest { .andExpect(status().isOk) .andExpect(jsonPath("$.cellsCount").value(2)) .andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2)) - .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z")) + .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30")) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt index fcec078..3996a45 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -25,8 +25,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "kpp": [1, 2, 3], "highPriorityTransmit": false, "optics": { @@ -43,7 +43,7 @@ class RequestApiDtoContractTest { assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id) assertEquals("Тест1", request.name) assertEquals(10.0, request.importance) - assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime) + assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime) assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) } @@ -55,8 +55,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "surveyType": "OPTICS", "optics": { "resultType": "PANCHROMATIC", @@ -78,8 +78,8 @@ class RequestApiDtoContractTest { name = "Тест1", geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = 10.0, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, @@ -87,40 +87,7 @@ class RequestApiDtoContractTest { ), ) - assertFalse(json.contains("surveyType")) - assertFalse(json.contains("app_type")) - assertFalse(json.contains("appType")) - assertFalse(json.contains("AppType")) - assertFalse(json.contains("requestType")) - } - - @Test - fun `cells list response dto matches openapi fields`() { - val json = objectMapper.writeValueAsString( - CellsListResponseDto( - items = listOf( - CellSummaryResponseDto( - cellNum = 42, - latitude = 55.0, - longitude = 37.0, - importance = 10.0, - contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", - ) - ), - page = 0, - size = 50, - totalItems = 1, - totalPages = 1, - ) - ) - - assertEquals( - setOf("items", "page", "size", "totalItems", "totalPages"), - objectMapper.readTree(json).fieldNames().asSequence().toSet(), - ) - assertEquals( - setOf("cellNum", "latitude", "longitude", "importance", "contour"), - objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(), - ) + val node = objectMapper.readTree(json) + assertFalse(node.has("surveyType"), "surveyType should not be serialized") } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt index 86bb075..e65ddb2 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt @@ -3,18 +3,14 @@ package org.nstart.dep265.requestservice.dto import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** - * Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime` - * сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф - * (смену типа поля на naive `LocalDateTime` или Jackson-конфига). - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. */ class RequestResponseDtoContractTest { @@ -22,7 +18,7 @@ class RequestResponseDtoContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes request times as ISO with explicit Z zone`() { + fun `serializes request times as naive ISO without zone`() { val dto = RequestResponseDto( id = UUID.fromString("00000000-0000-0000-0000-000000000001"), name = "request", @@ -30,18 +26,18 @@ class RequestResponseDtoContractTest { surveyType = SurveyTypeDto.OPTICS, geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", importance = 1.0, - beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), highPriorityTransmit = false, coverage = CoverageStateDto(currentPercent = 0.0), - createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC), - updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC) + createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0), + updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0) ) val node = mapper.readTree(mapper.writeValueAsString(dto)) - assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText()) - assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText()) + assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z") } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt index 50d8f8b..5603f62 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.UUID import kotlin.test.assertEquals @@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) @@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt index d6e071e..3f02619 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.Optional import java.util.UUID import kotlin.test.assertEquals @@ -286,8 +285,8 @@ class RequestServiceTest { name = REQUEST_NAME, geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = REQUEST_IMPORTANCE, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index b9b5637..e897cae 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -86,8 +86,8 @@ export function Tgu2DMapTab({ try { const result = await fetchRequestsForMap( { - endFrom: new Date(fromMs).toISOString(), - beginTo: new Date(toMs).toISOString() + endFrom: new Date(fromMs).toISOString().slice(0, 19), + beginTo: new Date(toMs).toISOString().slice(0, 19) }, (loaded, total) => { setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx index c111700..5372fc3 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -82,7 +82,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); try { const result = await fetchRequestsForMap( - { endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() }, + { endFrom: new Date(fromMs).toISOString().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) }, (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) ); setRequestsState((current) => ({ diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts index 117c03e..351f904 100644 --- a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts +++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts @@ -42,10 +42,9 @@ export function toUtcInputValue(ms: number): string { } /** - * Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`. - * Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service): - * пользователь вводит время по UTC, отправляем его же как UTC-смещение. + * Naive-строку (из `datetime-local`) возвращает как naive ISO без зоны (`YYYY-MM-DDTHH:mm:ss`). + * Все бэкенды ожидают `LocalDateTime` без зоны. */ export function toUtcIso(value: string): string { - return new Date(parseUtc(value)).toISOString(); + return new Date(parseUtc(value)).toISOString().slice(0, 19); } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index 0c04f21..ecc1537 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -10,7 +10,6 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper import io.camunda.client.impl.CamundaObjectMapper import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule @Configuration class CamundaJacksonConfig { @@ -25,10 +24,6 @@ class CamundaJacksonConfig { return JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .addModule(JavaTimeModule()) - // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. - // Модуль из pcp-types-lib; покрывает Jackson-2 путь (этот ручной маппер для Camunda). - // REST-границу (Jackson 3) покрывает PcpTimeModuleV3 (см. RestJacksonConfig). - .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt deleted file mode 100644 index bed6d43..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt +++ /dev/null @@ -1,94 +0,0 @@ -package space.nstart.pcp_tgu_service.config - -import tools.jackson.core.JsonParser -import tools.jackson.core.JsonToken -import tools.jackson.databind.DeserializationContext -import tools.jackson.databind.ValueDeserializer -import tools.jackson.databind.ext.javatime.deser.InstantDeserializer -import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer -import tools.jackson.databind.module.SimpleModule -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeParseException - -/** - * Слой толерантности времени для **Jackson 3** (`tools.jackson.*`). - * - * Близнец библиотечного `PcpTimeModule` (Jackson 2): то же поведение — читать время в обоих - * форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. - * Нужен отдельно, потому что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST HTTP message - * converters (MVC), а он не видит Jackson-2 модули. Регистрируется как бин - * `tools.jackson.databind.JacksonModule` ([RestJacksonConfig]) — `JacksonAutoConfiguration` собирает - * такие бины в общий `JsonMapper`. - * - * Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`; - * правки синхронизировать. См. docs/standards/DATETIME_UTC.md. - */ -class PcpTimeModuleV3 : SimpleModule(NAME) { - - init { - addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3()) - addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3()) - addDeserializer(Instant::class.java, TolerantInstantDeserializerV3()) - } - - companion object { - const val NAME = "PcpTimeModuleV3" - } -} - -private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = LocalDateTimeDeserializer.INSTANCE - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = InstantDeserializer.OFFSET_DATE_TIME - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantInstantDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = InstantDeserializer.INSTANT - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).toInstant() - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt index 4dcb2e9..2bb3eaa 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt @@ -1,21 +1,4 @@ package space.nstart.pcp_tgu_service.config -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import tools.jackson.databind.JacksonModule - -/** - * Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3 - * `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC). - * - * `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста - * и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин - * этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`), - * как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md. - */ -@Configuration -class RestJacksonConfig { - - @Bean - fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3() -} +// Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг. +// LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt index f313778..2fdbdcd 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt @@ -2,24 +2,19 @@ package space.nstart.pcp_tgu_service.dto import space.nstart.pcp_tgu_service.domain.CalculatedPlan import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID -/** - * «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — UTC ISO с `Z` - * (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC). - */ +/** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */ data class ZrvWindowResponse( - val startTime: OffsetDateTime, - val endTime: OffsetDateTime, + val startTime: LocalDateTime, + val endTime: LocalDateTime, val kppId: String ) { companion object { fun from(window: CalculatedPlan): ZrvWindowResponse = ZrvWindowResponse( - startTime = window.startTime.atOffset(ZoneOffset.UTC), - endTime = window.endTime.atOffset(ZoneOffset.UTC), + startTime = window.startTime, + endTime = window.endTime, kppId = window.kppId ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 6d746f0..71b21a1 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -4,17 +4,14 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.nio.charset.StandardCharsets -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** * DTO ответа REST API для плана. * - * Времена ([startTime]/[endTime]) — `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной - * зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее - * хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC); - * конвертация в `Z` происходит только на границе API. + * Времена ([startTime]/[endTime]) — naive `LocalDateTime`, сериализуются как ISO-строка без зоны + * (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах. */ data class PlanResponse( @@ -24,11 +21,11 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана (UTC, ISO с `Z`). */ - val startTime: OffsetDateTime, + /** Время начала плана (ISO без зоны). */ + val startTime: LocalDateTime, - /** Время окончания плана (UTC, ISO с `Z`). */ - val endTime: OffsetDateTime, + /** Время окончания плана (ISO без зоны). */ + val endTime: LocalDateTime, /** Идентификатор пункта закладки */ val kppId: String, @@ -46,9 +43,8 @@ data class PlanResponse( PlanResponse( planId = plan.planId, spacecraftId = plan.spacecraftId, - // naive LocalDateTime в БД = UTC → отдаём с явной зоной Z. - startTime = plan.startTime.atOffset(ZoneOffset.UTC), - endTime = plan.endTime.atOffset(ZoneOffset.UTC), + startTime = plan.startTime, + endTime = plan.endTime, kppId = plan.kppId, status = plan.status ) @@ -63,9 +59,8 @@ data class PlanResponse( PlanResponse( planId = deterministicPlanId(plan), spacecraftId = plan.spacecraftId, - // naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z. - startTime = plan.startTime.atOffset(ZoneOffset.UTC), - endTime = plan.endTime.atOffset(ZoneOffset.UTC), + startTime = plan.startTime, + endTime = plan.endTime, kppId = plan.kppId, status = PlannedPlanStatus.PLANNED ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt deleted file mode 100644 index 4679474..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt +++ /dev/null @@ -1,55 +0,0 @@ -package space.nstart.pcp_tgu_service.config - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tools.jackson.databind.json.JsonMapper -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset - -/** - * Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает - * время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец - * библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md. - */ -class PcpTimeModuleV3Test { - - private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build() - - @Test - fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java) - val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java) - - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive) - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ) - } - - @Test - fun `LocalDateTime normalizes an explicit offset to UTC`() { - // 01:00+03:00 == 28-го 22:00 UTC - val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java) - - assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset) - } - - @Test - fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java) - val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java) - - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive) - assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset) - } - - @Test - fun `Instant reads naive(=UTC) and Z to the same moment`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java) - val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java) - - val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant() - assertEquals(expected, fromNaive) - assertEquals(expected, fromZ) - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt index cf2a44b..18e0dd8 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -3,19 +3,15 @@ package space.nstart.pcp_tgu_service.dto import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** - * Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC → ISO с явной зоной `Z` - * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля - * (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей. - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. */ class PlanResponseContractTest { @@ -23,20 +19,20 @@ class PlanResponseContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as ISO with explicit Z zone`() { + fun `serializes plan times as naive ISO without zone`() { val response = PlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), kppId = "KPP-1", status = PlannedPlanStatus.PLANNED ) val node = mapper.readTree(mapper.writeValueAsString(response)) - assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) - assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 9adb4e7..345f0be 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -39,10 +39,9 @@ class PlanQueryServiceTest { val result = service.getAllPlans(historyDays = 7) assertEquals(2, result.size) - // PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC). - val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) } + val persistedResponse = result.first { it.startTime == persisted.startTime } assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) - val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) } + val projectedResponse = result.first { it.startTime == freshProjected.startTime } assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) // Отсортировано по startTime. assertTrue(result[0].startTime <= result[1].startTime) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index a0be157..ee32dcf 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -2,13 +2,11 @@ package space.nstart.pcp.slots_service.dto.tgu import java.time.Instant import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.UUID /* - * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) — - * `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` — тоже с `Z`. `LocalDateTime` - * (напр. [TguPlanDecisionMessage.decisionTime]) — naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime. + * Времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service). + * `Instant` ([TguPlatformResponse.validFrom/validTo]) — с `Z` (как отдаёт НСИ-прокси). */ data class TguPlatformResponse( @@ -25,8 +23,8 @@ data class TguPlatformResponse( data class TguPlanResponse( val planId: UUID, val spacecraftId: String, - val startTime: OffsetDateTime, - val endTime: OffsetDateTime, + val startTime: LocalDateTime, + val endTime: LocalDateTime, val kppId: String, val status: String ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index 13d99ca..d219125 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -45,8 +45,6 @@ import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.StationService import space.nstart.pcp.slots_service.service.TguPlanningService import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -212,8 +210,8 @@ class CatalogControllerTest { TguPlanResponse( planId = planId, spacecraftId = "56756", - startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 10, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 10, 15, 0), kppId = "KPP-1", status = "WAITING_DECISION" ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt index 63225ec..d166025 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -3,20 +3,17 @@ package space.nstart.pcp.slots_service.dto.tgu import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: - * времена плана ([TguPlanResponse]) — `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service), - * `Instant` ([TguPlatformResponse.validFrom]) — тоже с `Z` (см. docs/standards/DATETIME_UTC.md). - * Тест ловит молчаливый дрейф контракта. - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service), + * `Instant` ([TguPlatformResponse.validFrom]) — с `Z` (как отдаёт НСИ-прокси). */ class TguPlanningDtoContractTest { @@ -24,21 +21,21 @@ class TguPlanningDtoContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as ISO with explicit Z zone`() { + fun `serializes plan times as naive ISO without zone`() { val plan = TguPlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), kppId = "KPP-1", status = "PLANNED" ) val node = mapper.readTree(mapper.writeValueAsString(plan)) - assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) - assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") } @Test