pcp: откат UTC-слоя — сквозной naive LocalDateTime без зоны на проводе

Возврат к модели «настенных часов»: одна ISO-строка без зоны
("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к
часовому поясу, сдвигаться нечему.

Бэкенд — снять зону с границы API:
- DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация
  atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах:
  tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos,
  complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO.
- Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader,
  imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их
  регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310
  (JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен.
- Kafka-конверт KafkaMessage.time сериализуется ISO без офсета.
- mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис
  переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate
  мапит на naive без миграции — прецедент MissionEntity/PlanEntity).
- RequestServiceClient (dynamic) синхронизирован по типу с request-service.

Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов
оставлен (выбор настенных часов UTC, не зона на проводе).

Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z
(после удаления толерантного десериализатора штатный парсер падал бы на Z).
Дисплейные хелперы (показ «как пришло», без сдвига) сохранены.

Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация
офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md.

Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с
ранее опубликованным офсетом +00:00 дренировать/проиграть.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:03:52 +03:00
parent 0e534c5bd5
commit 3638afdb85
57 changed files with 230 additions and 1063 deletions
+2 -2
View File
@@ -21,8 +21,8 @@
- существующие публичные контракты не сломаны без явного запроса; - существующие публичные контракты не сломаны без явного запроса;
- добавлены или обновлены релевантные тесты; - добавлены или обновлены релевантные тесты;
- не внесено несвязанных правок; - не внесено несвязанных правок;
- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); - время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now(ZoneOffset.UTC)`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`;
- чтение времени на бэке толерантно к обоим форматам (naive=UTC и `Z`) — модуль наследуется из `pcp-types-lib` (`PcpTimeModule`) авто-конфигурацией; ручной `ObjectMapper`/не-pcp-types-lib сервис регистрирует модуль явно; - egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z);
- validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены.
### Дополнительно для BPM / Kafka / Zeebe / external integration изменений ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений
-182
View File
@@ -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"`);
- `<input type="datetime-local">` — значение через `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`).
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).
@@ -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()
}
@@ -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.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import java.time.OffsetDateTime import java.time.LocalDateTime
@Schema( @Schema(
description = "Базовая модель режима", description = "Базовая модель режима",
@@ -28,7 +28,7 @@ sealed interface ModeResponseDTO {
val planId: Long val planId: Long
@get:Schema(description = "Время начала режима") @get:Schema(description = "Время начала режима")
val timeStart: OffsetDateTime? val timeStart: LocalDateTime?
@get:Schema(description = "Номер витка") @get:Schema(description = "Номер витка")
val revolution: Long val revolution: Long
@@ -41,7 +41,7 @@ sealed interface ModeResponseDTO {
data class SurveyModeResponseDTO( data class SurveyModeResponseDTO(
override val id: Long = 0, override val id: Long = 0,
override val planId: Long = 0, override val planId: Long = 0,
override val timeStart: OffsetDateTime? = null, override val timeStart: LocalDateTime? = null,
override val revolution: Long = 0, override val revolution: Long = 0,
override val type: String = "SURVEY", override val type: String = "SURVEY",
val status: String = "", val status: String = "",
@@ -60,7 +60,7 @@ data class SurveyModeResponseDTO(
data class DropModeResponseDTO( data class DropModeResponseDTO(
override val id: Long = 0, override val id: Long = 0,
override val planId: Long = 0, override val planId: Long = 0,
override val timeStart: OffsetDateTime? = null, override val timeStart: LocalDateTime? = null,
override val revolution: Long = 0, override val revolution: Long = 0,
override val type: String = "DROP", override val type: String = "DROP",
val station: String? = "", val station: String? = "",
@@ -2,8 +2,6 @@ package space.nstart.pcp.pcp_types_lib.message
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonAlias 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.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.* import java.util.*
@@ -21,11 +19,9 @@ class KafkaMessage<out T> (
val id = UUID.randomUUID().toString() val id = UUID.randomUUID().toString()
var source = "Example" var source = "Example"
// Время конверта в UTC: naive LocalDateTime трактуется как UTC (см. docs/standards/DATETIME_UTC.md), // Время конверта naive LocalDateTime, штампуется по настенным часам UTC (now(ZoneOffset.UTC)),
// сериализатор помечает его офсетом +00:00. Раньше now() + systemDefault давали локальные часы и // сериализуется как ISO без зоны.
// несовпадение момента у потребителей, дропающих зону. @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]")
@JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class)
val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
val dataContentType = "application/json" val dataContentType = "application/json"
@@ -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<LocalDateTime?>() {
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))
}
}
@@ -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<LocalDateTime>() {
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<OffsetDateTime>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer<OffsetDateTime>
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<Instant>() {
@Suppress("UNCHECKED_CAST")
private val default = InstantDeserializer.INSTANT as JsonDeserializer<Instant>
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)
}
}
@@ -1 +0,0 @@
space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@@ -1 +0,0 @@
space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration
@@ -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)
}
}
@@ -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<LdtHolder>("""{"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)
}
}
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunResponseDTO( data class ComplexPlanRunResponseDTO(
val runId: Long, val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO(
val intervalEnd: LocalDateTime, val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>, val satelliteIds: List<Long>,
val requestedBy: String?, val requestedBy: String?,
val createdAt: OffsetDateTime, val createdAt: LocalDateTime,
val startedAt: OffsetDateTime?, val startedAt: LocalDateTime?,
val finishedAt: OffsetDateTime?, val finishedAt: LocalDateTime?,
val durationMs: Long?, val durationMs: Long?,
val errorMessage: String?, val errorMessage: String?,
val calculatedModesCount: Int, val calculatedModesCount: Int,
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunSummaryDTO( data class ComplexPlanRunSummaryDTO(
val runId: Long, val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO(
val intervalEnd: LocalDateTime, val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>, val satelliteIds: List<Long>,
val requestedBy: String?, val requestedBy: String?,
val createdAt: OffsetDateTime, val createdAt: LocalDateTime,
val startedAt: OffsetDateTime?, val startedAt: LocalDateTime?,
val finishedAt: OffsetDateTime?, val finishedAt: LocalDateTime?,
val durationMs: Long?, val durationMs: Long?,
val errorMessage: String?, val errorMessage: String?,
val calculatedModesCount: Int, val calculatedModesCount: Int,
@@ -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.ComplexPlanRunResponseDTO
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@Component @Component
class ComplexPlanRunMapper { class ComplexPlanRunMapper {
@@ -19,9 +16,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd, intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity), satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy, requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(), createdAt = entity.createdAt,
startedAt = entity.startedAt?.toUtcOffset(), startedAt = entity.startedAt,
finishedAt = entity.finishedAt?.toUtcOffset(), finishedAt = entity.finishedAt,
durationMs = entity.durationMs, durationMs = entity.durationMs,
errorMessage = entity.errorMessage, errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount, calculatedModesCount = entity.calculatedModesCount,
@@ -46,9 +43,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd, intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity), satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy, requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(), createdAt = entity.createdAt,
startedAt = entity.startedAt?.toUtcOffset(), startedAt = entity.startedAt,
finishedAt = entity.finishedAt?.toUtcOffset(), finishedAt = entity.finishedAt,
durationMs = entity.durationMs, durationMs = entity.durationMs,
errorMessage = entity.errorMessage, errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount, calculatedModesCount = entity.calculatedModesCount,
@@ -75,8 +72,4 @@ class ComplexPlanRunMapper {
.mapNotNull { it.id } .mapNotNull { it.id }
.distinct() .distinct()
.sorted() .sorted()
private fun LocalDateTime.toUtcOffset(): OffsetDateTime =
atOffset(ZoneOffset.UTC)
} }
@@ -50,9 +50,9 @@ class ComplexPlanControllerTest {
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
satelliteIds = listOf(22L), satelliteIds = listOf(22L),
requestedBy = "tester", requestedBy = "tester",
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC), createdAt = LocalDateTime.of(2026, 3, 26, 9, 59),
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC), startedAt = LocalDateTime.of(2026, 3, 26, 10, 0),
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC), finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1),
durationMs = 60000, durationMs = 60000,
errorMessage = null, errorMessage = null,
calculatedModesCount = 1, calculatedModesCount = 1,
@@ -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_satellites_service.service.SatelliteModePersistenceService
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ComplexPlanRunApiTest { class ComplexPlanRunApiTest {
@@ -81,7 +81,7 @@ class ComplexPlanRunApiTest {
assertEquals(10.0, response.totalRequestsArea) assertEquals(10.0, response.totalRequestsArea)
assertEquals(4.0, response.coveredArea) assertEquals(4.0, response.coveredArea)
assertEquals(40.0, response.coveredAreaPercent) assertEquals(40.0, response.coveredAreaPercent)
assertEquals(ZoneOffset.UTC, response.createdAt.offset) assertNotNull(response.createdAt)
} }
@Test @Test
@@ -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.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd, intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L), satelliteIds = listOf(22L),
requestedBy = "tester", requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000, durationMs = 60_000,
errorMessage = null, errorMessage = null,
calculatedModesCount = 1, calculatedModesCount = 1,
@@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd, intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L), satelliteIds = listOf(22L),
requestedBy = "tester", requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000, durationMs = 60_000,
errorMessage = null, errorMessage = null,
calculatedModesCount = 0, calculatedModesCount = 0,
@@ -6,34 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@Configuration @Configuration
class JacksonConfig { class JacksonConfig {
@Bean @Bean
fun objectMapper(): ObjectMapper { fun objectMapper(): ObjectMapper {
val mapper = ObjectMapper() val mapper = ObjectMapper()
// Настраиваем формат для LocalDateTime
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz")
// module.addSerializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeSerializer(formatter)
// )
// module.addDeserializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeDeserializer(formatter)
// )
mapper.registerModule(JavaTimeModule()) mapper.registerModule(JavaTimeModule())
.registerKotlinModule() .registerKotlinModule()
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
// Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI.
.registerModule(PcpTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
return mapper return mapper
} }
} }
@@ -48,6 +48,7 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -841,7 +842,7 @@ class BasicPlanCalculator(
val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) {
observationParametersDebugFileWriter.write( observationParametersDebugFileWriter.write(
ObservationParametersDebugFileDTO( ObservationParametersDebugFileDTO(
createdAt = Instant.now(), createdAt = LocalDateTime.now(ZoneOffset.UTC),
requestId = request.requestId, requestId = request.requestId,
satelliteIds = selectedSatelliteIds, satelliteIds = selectedSatelliteIds,
missingSatelliteIds = missingSatelliteIds, missingSatelliteIds = missingSatelliteIds,
@@ -7,8 +7,7 @@ import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.complan.types.RequestItemDTO import space.nstart.pcp.complan.types.RequestItemDTO
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** Получение заявок из pcp-request-service /v1 API. */ /** Получение заявок из pcp-request-service /v1 API. */
@@ -44,10 +43,9 @@ class RequestServiceClient {
id = id, id = id,
name = name, name = name,
geometry = geometry, geometry = geometry,
// Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы // pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC).
// при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md). intervalBegin = beginDateTime,
intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), intervalEnd = endDateTime,
intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(),
resolution = resolution(), resolution = resolution(),
importance = importance, importance = importance,
) )
@@ -66,16 +64,16 @@ data class RequestResponseDto(
val surveyType: String = "", val surveyType: String = "",
val geometry: String, val geometry: String,
val importance: Double, val importance: Double,
val beginDateTime: OffsetDateTime, val beginDateTime: LocalDateTime,
val endDateTime: OffsetDateTime, val endDateTime: LocalDateTime,
val kpp: List<Int> = emptyList(), val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false, val highPriorityTransmit: Boolean = false,
val optics: SurveyParamsDto? = null, val optics: SurveyParamsDto? = null,
val rsa: SurveyParamsDto? = null, val rsa: SurveyParamsDto? = null,
val coverage: CoverageStateDto = CoverageStateDto(), val coverage: CoverageStateDto = CoverageStateDto(),
val createdAt: OffsetDateTime? = null, val createdAt: LocalDateTime? = null,
val updatedAt: OffsetDateTime? = null, val updatedAt: LocalDateTime? = null,
val deletedAt: OffsetDateTime? = null, val deletedAt: LocalDateTime? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO(
@get:JsonProperty("createdAt") @get:JsonProperty("createdAt")
@param:JsonProperty("createdAt") @param:JsonProperty("createdAt")
val createdAt: Instant, val createdAt: LocalDateTime,
@get:JsonProperty("requestId") @get:JsonProperty("requestId")
@param:JsonProperty("requestId") @param:JsonProperty("requestId")
@@ -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 space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import kotlin.test.Test import kotlin.test.Test
@@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest {
outputDir = tempDir.resolve("observation-parameters").toString() outputDir = tempDir.resolve("observation-parameters").toString()
) )
val artifact = ObservationParametersDebugFileDTO( val artifact = ObservationParametersDebugFileDTO(
createdAt = Instant.parse("2026-05-05T10:15:30Z"), createdAt = LocalDateTime.parse("2026-05-05T10:15:30"),
requestId = requestId, requestId = requestId,
satelliteIds = listOf(1001L), satelliteIds = listOf(1001L),
missingSatelliteIds = emptyList(), missingSatelliteIds = emptyList(),
@@ -62,29 +62,11 @@ class RequestServiceClientTest {
assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) 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( private fun serverWithRequestResponse(
requestedUris: MutableList<URI>, requestedUris: MutableList<URI>,
requestId: UUID, requestId: UUID,
beginDateTime: String = "2026-01-02T03:04:05Z", beginDateTime: String = "2026-01-02T03:04:05",
endDateTime: String = "2026-01-03T04:05:06Z", endDateTime: String = "2026-01-03T04:05:06",
): HttpServer = ): HttpServer =
HttpServer.create(InetSocketAddress(0), 0).apply { HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange -> createContext("/v1/requests") { exchange ->
@@ -110,8 +92,8 @@ class RequestServiceClientTest {
}, },
"rsa": null, "rsa": null,
"coverage": {"currentPercent": 0.0}, "coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z", "createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00",
"deletedAt": null "deletedAt": null
} }
""".trimIndent() """.trimIndent()
@@ -4,7 +4,7 @@ import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.OffsetDateTime import java.time.LocalDateTime
@Entity @Entity
@Table(name = "drop_modes") @Table(name = "drop_modes")
@@ -12,7 +12,7 @@ import java.time.OffsetDateTime
class DropModeEntity( class DropModeEntity(
id: Long? = null, id: Long? = null,
plan: PlanEntity? = null, plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null, timeStart: LocalDateTime? = null,
revolution: Long = 0, revolution: Long = 0,
@Column(name = "station") @Column(name = "station")
@@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType
import jakarta.persistence.JoinColumn import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne import jakarta.persistence.ManyToOne
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.OffsetDateTime import java.time.LocalDateTime
@Entity @Entity
@Table(name = "modes") @Table(name = "modes")
@@ -29,7 +29,7 @@ abstract class ModeEntity(
open var plan: PlanEntity? = null, open var plan: PlanEntity? = null,
@Column(name = "time_start") @Column(name = "time_start")
open var timeStart: OffsetDateTime? = null, open var timeStart: LocalDateTime? = null,
@Column(name = "revolution", nullable = false) @Column(name = "revolution", nullable = false)
open var revolution: Long = 0 open var revolution: Long = 0
@@ -7,7 +7,7 @@ import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.Table import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.OffsetDateTime import java.time.LocalDateTime
@Entity @Entity
@Table(name = "survey_modes") @Table(name = "survey_modes")
@@ -15,7 +15,7 @@ import java.time.OffsetDateTime
class SurveyModeEntity( class SurveyModeEntity(
id: Long? = null, id: Long? = null,
plan: PlanEntity? = null, plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null, timeStart: LocalDateTime? = null,
revolution: Long = 0, revolution: Long = 0,
@Column(name = "lat", nullable = false) @Column(name = "lat", nullable = false)
@@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
interface ModeRepository : JpaRepository<ModeEntity, Long> { interface ModeRepository : JpaRepository<ModeEntity, Long> {
@@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<ModeEntity> fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<ModeEntity>
fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId: Long, satelliteId: Long,
timeStop: OffsetDateTime timeStop: LocalDateTime
): List<ModeEntity> ): List<ModeEntity>
fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long
@@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> { interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
@@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
@Param("satelliteNumber") satelliteNumber: String, @Param("satelliteNumber") satelliteNumber: String,
@Param("revolution") revolution: Long, @Param("revolution") revolution: Long,
@Param("timeStartFrom") timeStartFrom: OffsetDateTime, @Param("timeStartFrom") timeStartFrom: LocalDateTime,
@Param("timeStartTo") timeStartTo: OffsetDateTime @Param("timeStartTo") timeStartTo: LocalDateTime
): List<SurveyModeEntity> ): List<SurveyModeEntity>
} }
@@ -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 space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import kotlin.math.PI import kotlin.math.PI
@@ -305,7 +304,7 @@ class MissionPlaningService(
val modes = modeRepository val modes = modeRepository
.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId, satelliteId,
timeStop.atOffset(ZoneOffset.UTC) timeStop
) )
.filter { it.intersects(timeStart, timeStop) } .filter { it.intersects(timeStart, timeStop) }
@@ -346,27 +345,18 @@ class MissionPlaningService(
.mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } .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)` (правый конец открыт). * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт).
* Применяется при fork-forward-клоне: включения строго вне окна не клонируются. * Применяется при fork-forward-клоне: включения строго вне окна не клонируются.
*/ */
private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { 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 val modeStop = modeStop() ?: return false
return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop)
} }
private fun ModeEntity.modeStop(): LocalDateTime? { private fun ModeEntity.modeStop(): LocalDateTime? {
val modeStart = timeStart?.toUtcLocalDateTime() ?: return null val modeStart = timeStart ?: return null
val durationSeconds = when (this) { val durationSeconds = when (this) {
is SurveyModeEntity -> duration is SurveyModeEntity -> duration
is DropModeEntity -> duration is DropModeEntity -> duration
@@ -731,7 +721,7 @@ class MissionPlaningService(
val saved = surveyModeRepository.save( val saved = surveyModeRepository.save(
SurveyModeEntity( SurveyModeEntity(
plan = plan, plan = plan,
timeStart = built.timeStart.atOffset(ZoneOffset.UTC), timeStart = built.timeStart,
revolution = built.revolution, revolution = built.revolution,
lat = built.lat, lat = built.lat,
long = built.longitude, long = built.longitude,
@@ -805,7 +795,7 @@ class MissionPlaningService(
} }
val start = survey.timeStart val start = survey.timeStart
?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
val surveyEnd = start.toUtcLocalDateTime() val surveyEnd = start
.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(req.zoneStart)) { if (!surveyEnd.isBefore(req.zoneStart)) {
throw CustomErrorException( throw CustomErrorException(
@@ -826,7 +816,7 @@ class MissionPlaningService(
val dropMode = dropModeRepository.save( val dropMode = dropModeRepository.save(
DropModeEntity( DropModeEntity(
plan = plan, plan = plan,
timeStart = req.zoneStart.atOffset(ZoneOffset.UTC), timeStart = req.zoneStart,
revolution = req.revolution, revolution = req.revolution,
station = req.station, station = req.station,
duration = usedDuration duration = usedDuration
@@ -867,7 +857,7 @@ class MissionPlaningService(
} }
val built = buildContour(req) val built = buildContour(req)
survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC) survey.timeStart = built.timeStart
survey.revolution = built.revolution survey.revolution = built.revolution
survey.lat = built.lat survey.lat = built.lat
survey.long = built.longitude survey.long = built.longitude
@@ -1164,12 +1154,11 @@ class MissionPlaningService(
val iterator = unassigned.iterator() val iterator = unassigned.iterator()
while (iterator.hasNext()) { while (iterator.hasNext()) {
val survey = iterator.next() val survey = iterator.next()
val surveyStartOffset = survey.timeStart val surveyStart = survey.timeStart
if (surveyStartOffset == null) { if (surveyStart == null) {
iterator.remove() iterator.remove()
continue continue
} }
val surveyStart = surveyStartOffset.toUtcLocalDateTime()
val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(zoneStart)) { if (!surveyEnd.isBefore(zoneStart)) {
@@ -1193,7 +1182,7 @@ class MissionPlaningService(
val dropMode = dropModeRepository.save( val dropMode = dropModeRepository.save(
DropModeEntity( DropModeEntity(
plan = plan, plan = plan,
timeStart = zoneStart.atOffset(ZoneOffset.UTC), timeStart = zoneStart,
revolution = zone.revolution, revolution = zone.revolution,
station = zone.stationId.toString(), station = zone.stationId.toString(),
duration = usedDuration duration = usedDuration
@@ -1294,8 +1283,8 @@ class MissionPlaningService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? { fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? {
val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1) val searchStart = routePassport.intervalBegin.minusSeconds(1)
val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1) val searchEnd = routePassport.intervalBegin.plusSeconds(1)
val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
satelliteNumber = routePassport.kaShort.trim(), satelliteNumber = routePassport.kaShort.trim(),
revolution = routePassport.orbitNumber, revolution = routePassport.orbitNumber,
@@ -1305,7 +1294,7 @@ class MissionPlaningService(
return candidates.minByOrNull { candidate -> return candidates.minByOrNull { candidate ->
val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE 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())
} }
} }
@@ -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.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.ZoneOffset
abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
override fun prepareSurvey( override fun prepareSurvey(
@@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
plan: PlanEntity plan: PlanEntity
): SurveyModeEntity = SurveyModeEntity( ): SurveyModeEntity = SurveyModeEntity(
plan = plan, plan = plan,
timeStart = surv.startTime.atOffset(ZoneOffset.UTC), timeStart = surv.startTime,
revolution = surv.revolution, revolution = surv.revolution,
lat = surv.lat, lat = surv.lat,
long = surv.longitude, long = surv.longitude,
@@ -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_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -263,27 +261,24 @@ class MissionCloneTest {
val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1)
planStore[plan.id!!] = plan planStore[plan.id!!] = plan
val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0
val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration) val s1 = survey(plan, surveyStart, duration)
val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration) val s2 = survey(plan, surveyStart.plusMinutes(1), duration)
val d = drop(plan, surveyStart.plusMinutes(5)) val d = drop(plan, surveyStart.plusMinutes(5))
return SourceFixture(mission.missionId, plan, s1, s2, d) 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 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, lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL,
status = SurveyModeStatus.PLANNED) status = SurveyModeStatus.PLANNED)
surveyStore[id] = s surveyStore[id] = s
return 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 { private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity {
val id = ++modeAutoId 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) revolution = id, station = "102", duration = 60.0)
dropStore[id] = d dropStore[id] = d
return d return d
@@ -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.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
class MissionEditModesTest { class MissionEditModesTest {
@@ -262,7 +261,7 @@ class MissionEditModesTest {
private fun survey(): SurveyModeEntity { private fun survey(): SurveyModeEntity {
val id = ++autoId val id = ++autoId
val s = SurveyModeEntity( 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, revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0,
status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL
) )
@@ -273,7 +272,7 @@ class MissionEditModesTest {
private fun drop(): DropModeEntity { private fun drop(): DropModeEntity {
val id = ++autoId val id = ++autoId
val d = DropModeEntity( 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 revolution = id, station = "102", duration = 60.0
) )
dropStore[id] = d dropStore[id] = d
@@ -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_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import java.math.BigDecimal import java.math.BigDecimal
@@ -93,7 +91,7 @@ class MissionPlaningServiceTest {
val overlappingSurvey = SurveyModeEntity( val overlappingSurvey = SurveyModeEntity(
id = 1L, id = 1L,
plan = plan, plan = plan,
timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC), timeStart = intervalStart.minusMinutes(5),
revolution = 10, revolution = 10,
lat = 55.0, lat = 55.0,
long = 37.0, long = 37.0,
@@ -105,7 +103,7 @@ class MissionPlaningServiceTest {
val inWindowSurvey = SurveyModeEntity( val inWindowSurvey = SurveyModeEntity(
id = 2L, id = 2L,
plan = plan, plan = plan,
timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC), timeStart = intervalStart.plusMinutes(10),
revolution = 11, revolution = 11,
lat = 56.0, lat = 56.0,
long = 38.0, long = 38.0,
@@ -117,7 +115,7 @@ class MissionPlaningServiceTest {
val inWindowDrop = DropModeEntity( val inWindowDrop = DropModeEntity(
id = 3L, id = 3L,
plan = plan, plan = plan,
timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC), timeStart = intervalStart.plusMinutes(20),
revolution = 12, revolution = 12,
station = "station-1", station = "station-1",
duration = 120.0 duration = 120.0
@@ -126,7 +124,7 @@ class MissionPlaningServiceTest {
`when`( `when`(
modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
77, 77,
intervalStop.atOffset(ZoneOffset.UTC) intervalStop
) )
).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop)) ).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop))
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn( `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn(
@@ -153,41 +151,6 @@ class MissionPlaningServiceTest {
assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) 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 @Test
fun `modesBySatelliteAndInterval rejects inverted interval`() { fun `modesBySatelliteAndInterval rejects inverted interval`() {
val exception = assertThrows(ResponseStatusException::class.java) { val exception = assertThrows(ResponseStatusException::class.java) {
@@ -223,12 +186,12 @@ class MissionPlaningServiceTest {
) )
val exactMode = SurveyModeEntity( val exactMode = SurveyModeEntity(
id = 10L, id = 10L,
timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC), timeStart = routePassport.intervalBegin,
revolution = 321 revolution = 321
) )
val nearMode = SurveyModeEntity( val nearMode = SurveyModeEntity(
id = 11L, id = 11L,
timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC), timeStart = routePassport.intervalBegin.plusSeconds(1),
revolution = 321 revolution = 321
) )
@@ -236,8 +199,8 @@ class MissionPlaningServiceTest {
surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
"22", "22",
321, 321,
OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC), routePassport.intervalBegin.minusSeconds(1),
OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC) routePassport.intervalBegin.plusSeconds(1)
) )
).thenReturn(listOf(nearMode, exactMode)) ).thenReturn(listOf(nearMode, exactMode))
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -41,10 +41,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?, @RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?, @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) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int, @RequestParam(defaultValue = "50") size: Int,
@@ -73,10 +73,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?, @RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?, @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) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int, @RequestParam(defaultValue = "50") size: Int,
@@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
import java.time.OffsetDateTime import java.time.LocalDateTime
/** /**
* Текущие настройки сетки. * Текущие настройки сетки.
@@ -38,5 +38,5 @@ data class GridRebuildResponseDto(
/** Дата-время завершения перестроения в UTC. */ /** Дата-время завершения перестроения в UTC. */
@field:JsonFormat(shape = JsonFormat.Shape.STRING) @field:JsonFormat(shape = JsonFormat.Shape.STRING)
val rebuiltAt: OffsetDateTime, val rebuiltAt: LocalDateTime,
) )
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size import jakarta.validation.constraints.Size
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
/** /**
@@ -48,11 +48,11 @@ data class CreateRequestDto(
/** Начало временного окна выполнения заявки. */ /** Начало временного окна выполнения заявки. */
@field:NotNull @field:NotNull
val beginDateTime: OffsetDateTime, val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */ /** Окончание временного окна выполнения заявки. */
@field:NotNull @field:NotNull
val endDateTime: OffsetDateTime, val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */ /** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(), val kpp: List<Int> = emptyList(),
@@ -120,10 +120,10 @@ data class RequestResponseDto(
val importance: Double, val importance: Double,
/** Начало временного окна выполнения заявки. */ /** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime, val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */ /** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime, val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */ /** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(), val kpp: List<Int> = emptyList(),
@@ -141,13 +141,13 @@ data class RequestResponseDto(
val coverage: CoverageStateDto, val coverage: CoverageStateDto,
/** Время создания заявки. */ /** Время создания заявки. */
val createdAt: OffsetDateTime, val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */ /** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime, val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */ /** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null, val deletedAt: LocalDateTime? = null,
) )
/** /**
@@ -170,10 +170,10 @@ data class RequestSummaryResponseDto(
val importance: Double, val importance: Double,
/** Начало временного окна выполнения заявки. */ /** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime, val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */ /** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime, val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */ /** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(), val kpp: List<Int> = emptyList(),
@@ -185,13 +185,13 @@ data class RequestSummaryResponseDto(
val coveragePercent: Double, val coveragePercent: Double,
/** Время создания заявки. */ /** Время создания заявки. */
val createdAt: OffsetDateTime, val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */ /** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime, val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */ /** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null, val deletedAt: LocalDateTime? = null,
) )
/** /**
@@ -237,10 +237,10 @@ data class RequestMapItemDto(
val importance: Double, val importance: Double,
/** Начало временного окна выполнения заявки. */ /** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime, val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */ /** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime, val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */ /** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(), val kpp: List<Int> = emptyList(),
@@ -252,13 +252,13 @@ data class RequestMapItemDto(
val coveragePercent: Double, val coveragePercent: Double,
/** Время создания заявки. */ /** Время создания заявки. */
val createdAt: OffsetDateTime, val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */ /** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime, val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */ /** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null, val deletedAt: LocalDateTime? = null,
) )
/** /**
@@ -317,7 +317,7 @@ data class DeleteRequestResponseDto(
val id: UUID, val id: UUID,
/** Время soft delete. */ /** Время soft delete. */
val deletedAt: OffsetDateTime, val deletedAt: LocalDateTime,
) )
/** /**
@@ -421,16 +421,16 @@ data class RequestListFilter(
val highPriorityTransmit: Boolean? = null, val highPriorityTransmit: Boolean? = null,
/** Нижняя граница beginDateTime, включительно. */ /** Нижняя граница beginDateTime, включительно. */
val beginFrom: OffsetDateTime? = null, val beginFrom: LocalDateTime? = null,
/** Верхняя граница beginDateTime, включительно. */ /** Верхняя граница beginDateTime, включительно. */
val beginTo: OffsetDateTime? = null, val beginTo: LocalDateTime? = null,
/** Нижняя граница endDateTime, включительно. */ /** Нижняя граница endDateTime, включительно. */
val endFrom: OffsetDateTime? = null, val endFrom: LocalDateTime? = null,
/** Верхняя граница endDateTime, включительно. */ /** Верхняя граница endDateTime, включительно. */
val endTo: OffsetDateTime? = null, val endTo: LocalDateTime? = null,
/** Включать soft-deleted заявки в результат. */ /** Включать soft-deleted заявки в результат. */
val includeDeleted: Boolean = false, val includeDeleted: Boolean = false,
@@ -7,7 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@@ -41,7 +41,7 @@ class GridRebuildService(
return GridRebuildResponseDto( return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(), cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(), rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC), rebuiltAt = LocalDateTime.now(ZoneOffset.UTC),
) )
} }
@@ -39,7 +39,6 @@ import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@@ -82,8 +81,8 @@ class RequestService(
remainingArea = geometryArea, remainingArea = geometryArea,
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT,
importance = importance, importance = importance,
beginDateTime = request.beginDateTime.toUtcLocalDateTime(), beginDateTime = request.beginDateTime,
endDateTime = request.endDateTime.toUtcLocalDateTime(), endDateTime = request.endDateTime,
kpp = request.kpp.toMutableList(), kpp = request.kpp.toMutableList(),
highPriorityTransmit = request.highPriorityTransmit, highPriorityTransmit = request.highPriorityTransmit,
surveyType = surveyType, surveyType = surveyType,
@@ -162,7 +161,7 @@ class RequestService(
requestCellRepository.deleteByRequestId(request.id) requestCellRepository.deleteByRequestId(request.id)
return DeleteRequestResponseDto( return DeleteRequestResponseDto(
id = request.id, id = request.id,
deletedAt = deletedAt.toOffsetDateTime(), deletedAt = deletedAt,
) )
} }
@@ -206,10 +205,10 @@ class RequestService(
surveyType = query.surveyType?.toEntity(), surveyType = query.surveyType?.toEntity(),
kpp = query.kpp, kpp = query.kpp,
highPriorityTransmit = query.highPriorityTransmit, highPriorityTransmit = query.highPriorityTransmit,
beginFrom = query.beginFrom?.toUtcLocalDateTime(), beginFrom = query.beginFrom,
beginTo = query.beginTo?.toUtcLocalDateTime(), beginTo = query.beginTo,
endFrom = query.endFrom?.toUtcLocalDateTime(), endFrom = query.endFrom,
endTo = query.endTo?.toUtcLocalDateTime(), endTo = query.endTo,
pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()), pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()),
) )
@@ -223,16 +222,16 @@ class RequestService(
surveyType = surveyType.toDto(), surveyType = surveyType.toDto(),
geometry = geometry, geometry = geometry,
importance = importance, importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(), beginDateTime = beginDateTime,
endDateTime = endDateTime.toOffsetDateTime(), endDateTime = endDateTime,
kpp = kpp, kpp = kpp,
highPriorityTransmit = highPriorityTransmit, highPriorityTransmit = highPriorityTransmit,
optics = optics, optics = optics,
rsa = rsa, rsa = rsa,
coverage = coverage(), coverage = coverage(),
createdAt = createdAt.toOffsetDateTime(), createdAt = createdAt,
updatedAt = updatedAt.toOffsetDateTime(), updatedAt = updatedAt,
deletedAt = deletedAt?.toOffsetDateTime(), deletedAt = deletedAt,
) )
} }
@@ -252,14 +251,14 @@ class RequestService(
status = status.toDto(), status = status.toDto(),
surveyType = surveyType.toDto(), surveyType = surveyType.toDto(),
importance = importance, importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(), beginDateTime = beginDateTime,
endDateTime = endDateTime.toOffsetDateTime(), endDateTime = endDateTime,
kpp = kpp, kpp = kpp,
highPriorityTransmit = highPriorityTransmit, highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent, coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(), createdAt = createdAt,
updatedAt = updatedAt.toOffsetDateTime(), updatedAt = updatedAt,
deletedAt = deletedAt?.toOffsetDateTime(), deletedAt = deletedAt,
) )
} }
@@ -271,14 +270,14 @@ class RequestService(
surveyType = surveyType.toDto(), surveyType = surveyType.toDto(),
geometry = geometry, geometry = geometry,
importance = importance, importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(), beginDateTime = beginDateTime,
endDateTime = endDateTime.toOffsetDateTime(), endDateTime = endDateTime,
kpp = kpp, kpp = kpp,
highPriorityTransmit = highPriorityTransmit, highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent, coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(), createdAt = createdAt,
updatedAt = updatedAt.toOffsetDateTime(), updatedAt = updatedAt,
deletedAt = deletedAt?.toOffsetDateTime(), deletedAt = deletedAt,
) )
} }
@@ -361,10 +360,6 @@ class RequestService(
return Sort.by(direction, property) 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 { private fun Throwable.isDuplicateRequestId(): Boolean {
return generateSequence(this) { throwable -> throwable.cause } return generateSequence(this) { throwable -> throwable.cause }
.filterIsInstance<ConstraintViolationException>() .filterIsInstance<ConstraintViolationException>()
@@ -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.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
class GridManagementControllerTest { class GridManagementControllerTest {
@@ -97,7 +97,7 @@ class GridManagementControllerTest {
GridRebuildResponseDto( GridRebuildResponseDto(
cellsCount = 2, cellsCount = 2,
rebuiltRequestProjectionsCount = 2, rebuiltRequestProjectionsCount = 2,
rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"), rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30),
) )
).`when`(gridRebuildService).rebuildGrid() ).`when`(gridRebuildService).rebuildGrid()
@@ -105,7 +105,7 @@ class GridManagementControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.cellsCount").value(2)) .andExpect(jsonPath("$.cellsCount").value(2))
.andExpect(jsonPath("$.rebuiltRequestProjectionsCount").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 @Test
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -25,8 +25,8 @@ class RequestApiDtoContractTest {
"name": "Тест1", "name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "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, "importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z", "beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59Z", "endDateTime": "2026-01-31T23:59:59",
"kpp": [1, 2, 3], "kpp": [1, 2, 3],
"highPriorityTransmit": false, "highPriorityTransmit": false,
"optics": { "optics": {
@@ -43,7 +43,7 @@ class RequestApiDtoContractTest {
assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id) assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id)
assertEquals("Тест1", request.name) assertEquals("Тест1", request.name)
assertEquals(10.0, request.importance) 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) assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
} }
@@ -55,8 +55,8 @@ class RequestApiDtoContractTest {
"name": "Тест1", "name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "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, "importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z", "beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59Z", "endDateTime": "2026-01-31T23:59:59",
"surveyType": "OPTICS", "surveyType": "OPTICS",
"optics": { "optics": {
"resultType": "PANCHROMATIC", "resultType": "PANCHROMATIC",
@@ -78,8 +78,8 @@ class RequestApiDtoContractTest {
name = "Тест1", name = "Тест1",
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", 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, importance = 10.0,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto( optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC, resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0, resolution = 1.0,
@@ -87,40 +87,7 @@ class RequestApiDtoContractTest {
), ),
) )
assertFalse(json.contains("surveyType")) val node = objectMapper.readTree(json)
assertFalse(json.contains("app_type")) assertFalse(node.has("surveyType"), "surveyType should not be serialized")
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(),
)
} }
} }
@@ -3,18 +3,14 @@ package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals 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 org.junit.jupiter.api.Test
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
* Контракт формата времени на проводе для [RequestResponseDto] эталон группы: `OffsetDateTime` * Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` ISO без зоны
* сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
* (смену типа поля на naive `LocalDateTime` или Jackson-конфига).
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/ */
class RequestResponseDtoContractTest { class RequestResponseDtoContractTest {
@@ -22,7 +18,7 @@ class RequestResponseDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test @Test
fun `serializes request times as ISO with explicit Z zone`() { fun `serializes request times as naive ISO without zone`() {
val dto = RequestResponseDto( val dto = RequestResponseDto(
id = UUID.fromString("00000000-0000-0000-0000-000000000001"), id = UUID.fromString("00000000-0000-0000-0000-000000000001"),
name = "request", name = "request",
@@ -30,18 +26,18 @@ class RequestResponseDtoContractTest {
surveyType = SurveyTypeDto.OPTICS, surveyType = SurveyTypeDto.OPTICS,
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
importance = 1.0, importance = 1.0,
beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
highPriorityTransmit = false, highPriorityTransmit = false,
coverage = CoverageStateDto(currentPercent = 0.0), coverage = CoverageStateDto(currentPercent = 0.0),
createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC), createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0),
updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC) updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0)
) )
val node = mapper.readTree(mapper.writeValueAsString(dto)) val node = mapper.readTree(mapper.writeValueAsString(dto))
assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText()) assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText()) assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText())
assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z") assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z")
} }
} }
@@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests( val response = service.listRequests(
RequestListFilter( RequestListFilter(
beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
) )
) )
@@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests( val response = service.listRequests(
RequestListFilter( RequestListFilter(
endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
) )
) )
@@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -286,8 +285,8 @@ class RequestServiceTest {
name = REQUEST_NAME, 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))", 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, importance = REQUEST_IMPORTANCE,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto( optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC, resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0, resolution = 1.0,
@@ -86,8 +86,8 @@ export function Tgu2DMapTab({
try { try {
const result = await fetchRequestsForMap( const result = await fetchRequestsForMap(
{ {
endFrom: new Date(fromMs).toISOString(), endFrom: new Date(fromMs).toISOString().slice(0, 19),
beginTo: new Date(toMs).toISOString() beginTo: new Date(toMs).toISOString().slice(0, 19)
}, },
(loaded, total) => { (loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })); setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
@@ -82,7 +82,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try { try {
const result = await fetchRequestsForMap( 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 })) (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
); );
setRequestsState((current) => ({ setRequestsState((current) => ({
+3 -4
View File
@@ -42,10 +42,9 @@ export function toUtcInputValue(ms: number): string {
} }
/** /**
* Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`. * Naive-строку (из `datetime-local`) возвращает как naive ISO без зоны (`YYYY-MM-DDTHH:mm:ss`).
* Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service): * Все бэкенды ожидают `LocalDateTime` без зоны.
* пользователь вводит время по UTC, отправляем его же как UTC-смещение.
*/ */
export function toUtcIso(value: string): string { export function toUtcIso(value: string): string {
return new Date(parseUtc(value)).toISOString(); return new Date(parseUtc(value)).toISOString().slice(0, 19);
} }
@@ -10,7 +10,6 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper
import io.camunda.client.impl.CamundaObjectMapper import io.camunda.client.impl.CamundaObjectMapper
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@Configuration @Configuration
class CamundaJacksonConfig { class CamundaJacksonConfig {
@@ -25,10 +24,6 @@ class CamundaJacksonConfig {
return JsonMapper.builder() return JsonMapper.builder()
.addModule(KotlinModule.Builder().build()) .addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule()) .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) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build() .build()
} }
@@ -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<LocalDateTime>() {
private val default: ValueDeserializer<LocalDateTime> = 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<OffsetDateTime>() {
private val default: ValueDeserializer<OffsetDateTime> = 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<Instant>() {
private val default: ValueDeserializer<Instant> = 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)
}
}
@@ -1,21 +1,4 @@
package space.nstart.pcp_tgu_service.config package space.nstart.pcp_tgu_service.config
import org.springframework.context.annotation.Bean // Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг.
import org.springframework.context.annotation.Configuration // LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS).
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()
}
@@ -2,24 +2,19 @@ package space.nstart.pcp_tgu_service.dto
import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */
* «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена UTC ISO с `Z`
* (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC).
*/
data class ZrvWindowResponse( data class ZrvWindowResponse(
val startTime: OffsetDateTime, val startTime: LocalDateTime,
val endTime: OffsetDateTime, val endTime: LocalDateTime,
val kppId: String val kppId: String
) { ) {
companion object { companion object {
fun from(window: CalculatedPlan): ZrvWindowResponse = fun from(window: CalculatedPlan): ZrvWindowResponse =
ZrvWindowResponse( ZrvWindowResponse(
startTime = window.startTime.atOffset(ZoneOffset.UTC), startTime = window.startTime,
endTime = window.endTime.atOffset(ZoneOffset.UTC), endTime = window.endTime,
kppId = window.kppId kppId = window.kppId
) )
} }
@@ -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.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
* DTO ответа REST API для плана. * DTO ответа REST API для плана.
* *
* Времена ([startTime]/[endTime]) `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной * Времена ([startTime]/[endTime]) naive `LocalDateTime`, сериализуются как ISO-строка без зоны
* зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее * (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах.
* хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC);
* конвертация в `Z` происходит только на границе API.
*/ */
data class PlanResponse( data class PlanResponse(
@@ -24,11 +21,11 @@ data class PlanResponse(
/** Идентификатор космического аппарата */ /** Идентификатор космического аппарата */
val spacecraftId: String, val spacecraftId: String,
/** Время начала плана (UTC, ISO с `Z`). */ /** Время начала плана (ISO без зоны). */
val startTime: OffsetDateTime, val startTime: LocalDateTime,
/** Время окончания плана (UTC, ISO с `Z`). */ /** Время окончания плана (ISO без зоны). */
val endTime: OffsetDateTime, val endTime: LocalDateTime,
/** Идентификатор пункта закладки */ /** Идентификатор пункта закладки */
val kppId: String, val kppId: String,
@@ -46,9 +43,8 @@ data class PlanResponse(
PlanResponse( PlanResponse(
planId = plan.planId, planId = plan.planId,
spacecraftId = plan.spacecraftId, spacecraftId = plan.spacecraftId,
// naive LocalDateTime в БД = UTC → отдаём с явной зоной Z. startTime = plan.startTime,
startTime = plan.startTime.atOffset(ZoneOffset.UTC), endTime = plan.endTime,
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
kppId = plan.kppId, kppId = plan.kppId,
status = plan.status status = plan.status
) )
@@ -63,9 +59,8 @@ data class PlanResponse(
PlanResponse( PlanResponse(
planId = deterministicPlanId(plan), planId = deterministicPlanId(plan),
spacecraftId = plan.spacecraftId, spacecraftId = plan.spacecraftId,
// naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z. startTime = plan.startTime,
startTime = plan.startTime.atOffset(ZoneOffset.UTC), endTime = plan.endTime,
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
kppId = plan.kppId, kppId = plan.kppId,
status = PlannedPlanStatus.PLANNED status = PlannedPlanStatus.PLANNED
) )
@@ -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)
}
}
@@ -3,19 +3,15 @@ package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals 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 org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
* Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC ISO с явной зоной `Z` * Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` ISO без зоны
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта смену типа поля * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
* (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/ */
class PlanResponseContractTest { class PlanResponseContractTest {
@@ -23,20 +19,20 @@ class PlanResponseContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test @Test
fun `serializes plan times as ISO with explicit Z zone`() { fun `serializes plan times as naive ISO without zone`() {
val response = PlanResponse( val response = PlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544", spacecraftId = "25544",
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
kppId = "KPP-1", kppId = "KPP-1",
status = PlannedPlanStatus.PLANNED status = PlannedPlanStatus.PLANNED
) )
val node = mapper.readTree(mapper.writeValueAsString(response)) val node = mapper.readTree(mapper.writeValueAsString(response))
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
} }
} }
@@ -39,10 +39,9 @@ class PlanQueryServiceTest {
val result = service.getAllPlans(historyDays = 7) val result = service.getAllPlans(historyDays = 7)
assertEquals(2, result.size) assertEquals(2, result.size)
// PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC). val persistedResponse = result.first { it.startTime == persisted.startTime }
val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) }
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) 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) assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status)
// Отсортировано по startTime. // Отсортировано по startTime.
assertTrue(result[0].startTime <= result[1].startTime) assertTrue(result[0].startTime <= result[1].startTime)
@@ -2,13 +2,11 @@ package space.nstart.pcp.slots_service.dto.tgu
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
/* /*
* Времена в этом файле UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) * Времена плана ([TguPlanResponse]) naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service).
* `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` тоже с `Z`. `LocalDateTime` * `Instant` ([TguPlatformResponse.validFrom/validTo]) с `Z` (как отдаёт НСИ-прокси).
* (напр. [TguPlanDecisionMessage.decisionTime]) naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime.
*/ */
data class TguPlatformResponse( data class TguPlatformResponse(
@@ -25,8 +23,8 @@ data class TguPlatformResponse(
data class TguPlanResponse( data class TguPlanResponse(
val planId: UUID, val planId: UUID,
val spacecraftId: String, val spacecraftId: String,
val startTime: OffsetDateTime, val startTime: LocalDateTime,
val endTime: OffsetDateTime, val endTime: LocalDateTime,
val kppId: String, val kppId: String,
val status: String val status: String
) )
@@ -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.StationService
import space.nstart.pcp.slots_service.service.TguPlanningService import space.nstart.pcp.slots_service.service.TguPlanningService
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -212,8 +210,8 @@ class CatalogControllerTest {
TguPlanResponse( TguPlanResponse(
planId = planId, planId = planId,
spacecraftId = "56756", spacecraftId = "56756",
startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC), startTime = LocalDateTime.of(2026, 5, 29, 10, 0, 0),
endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC), endTime = LocalDateTime.of(2026, 5, 29, 10, 15, 0),
kppId = "KPP-1", kppId = "KPP-1",
status = "WAITING_DECISION" status = "WAITING_DECISION"
) )
@@ -3,20 +3,17 @@ package space.nstart.pcp.slots_service.dto.tgu
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals 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.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
* Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service:
* времена плана ([TguPlanResponse]) `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service), * времена плана ([TguPlanResponse]) naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service),
* `Instant` ([TguPlatformResponse.validFrom]) тоже с `Z` (см. docs/standards/DATETIME_UTC.md). * `Instant` ([TguPlatformResponse.validFrom]) с `Z` (как отдаёт НСИ-прокси).
* Тест ловит молчаливый дрейф контракта.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/ */
class TguPlanningDtoContractTest { class TguPlanningDtoContractTest {
@@ -24,21 +21,21 @@ class TguPlanningDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test @Test
fun `serializes plan times as ISO with explicit Z zone`() { fun `serializes plan times as naive ISO without zone`() {
val plan = TguPlanResponse( val plan = TguPlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544", spacecraftId = "25544",
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
kppId = "KPP-1", kppId = "KPP-1",
status = "PLANNED" status = "PLANNED"
) )
val node = mapper.readTree(mapper.writeValueAsString(plan)) val node = mapper.readTree(mapper.writeValueAsString(plan))
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
} }
@Test @Test