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:
@@ -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 изменений
|
||||||
|
|||||||
@@ -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`).
|
|
||||||
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).
|
|
||||||
-23
@@ -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()
|
|
||||||
}
|
|
||||||
+4
-4
@@ -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? = "",
|
||||||
|
|||||||
+3
-7
@@ -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"
|
||||||
|
|||||||
-26
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-124
@@ -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
@@ -1 +0,0 @@
|
|||||||
space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration
|
|
||||||
-48
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-81
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-4
@@ -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,
|
||||||
|
|||||||
+3
-4
@@ -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,
|
||||||
|
|||||||
+6
-13
@@ -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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+6
-7
@@ -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,
|
||||||
|
|||||||
-19
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+9
-11
@@ -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)
|
||||||
|
|||||||
+1
-2
@@ -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")
|
||||||
|
|||||||
+1
-2
@@ -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(),
|
||||||
|
|||||||
+4
-22
@@ -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()
|
||||||
|
|||||||
+2
-2
@@ -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")
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-24
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -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,
|
||||||
|
|||||||
+5
-10
@@ -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
|
||||||
|
|||||||
+2
-3
@@ -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
|
||||||
|
|||||||
+8
-45
@@ -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))
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-23
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-27
@@ -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>()
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+10
-43
@@ -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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-16
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -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) => ({
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
-5
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
-94
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-19
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-10
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-16
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
-55
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-14
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -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)
|
||||||
|
|||||||
+4
-6
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-4
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-13
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user