diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 27445e9..3be28ae 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -21,6 +21,7 @@ - существующие публичные контракты не сломаны без явного запроса; - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; +- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md new file mode 100644 index 0000000..c8f6c15 --- /dev/null +++ b/docs/standards/DATETIME_UTC.md @@ -0,0 +1,92 @@ +# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp` + +## Назначение + +Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp` +и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде. +Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе, +которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч). + +## 1. Соглашение (canonical) + +1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC. +2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически + отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо + **только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`. +3. **Backend:** + - «сейчас» — только UTC: `Instant.now()` или `LocalDateTime.now(ZoneOffset.UTC)`; **не** `LocalDateTime.now()`; + - конвертация `Instant`/`OffsetDateTime` → `LocalDateTime` для naive-полей — только через UTC + (`LocalDateTime.ofInstant(instant, ZoneOffset.UTC)`, `withOffsetSameInstant(ZoneOffset.UTC)`), + **никогда** через `ZoneId.systemDefault()` / `atZone(systemDefault())`; + - Jackson во всех сервисах настроен с `JavaTimeModule` + `disable(WRITE_DATES_AS_TIMESTAMPS)`, + поэтому `LocalDateTime` уходит как ISO без зоны, а `OffsetDateTime`/`Instant` — с `Z`/офсетом. +4. **Frontend (`pcp-tgu-ops-ui`):** + - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких «голых» + `new Date(string)` / `Date.parse(string)`; + - отображение — только UTC (`formatUtcDateTime/Date/Time` либо `toLocale*` с `timeZone: "UTC"`); + - `` — значение через `toUtcInputValue`, чтение через `parseUtc`, + отправка на offset-бэк — через `toUtcIso`. + - Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах). +5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. + +## 2. Эталоны переиспользования + +- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. +- Backend: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход + через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец. + +## 3. Состояние сервисов (срез аудита) + +Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC), +`OffsetDateTime`/`Instant` → ISO с `Z`. «Потребители» — кто читает время этого сервиса. + +| Сервис | Преобладает на границе API | Naive? | Вердикт | +|---|---|---|---| +| pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | +| pcp-tgu-service | `LocalDateTime` (planId/plan times) | да | задокументирован «=UTC» (`PlanResponse`); баги конвертаций исправлены | +| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries) | да | задокументировать «=UTC»; кандидат на `Z` | +| 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) — naive `LocalDateTime` без зоны. +- `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). +- `TguPlanningDtoContractTest` (pcp-ui-service) — оба формата: naive `LocalDateTime` + `Instant` с `Z`. + +Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, +mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании +пер-сервисный страж копируется по образцу `PlanResponseContractTest`. + +## 5. Правило для нового кода + +- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю). +- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`). +- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча). diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt index a7673b2..74b021a 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @@ -26,7 +27,7 @@ class AscNodeEntity( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, val longitude : Double = 0.0, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt index 0e84c33..a2efa41 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt @@ -10,6 +10,7 @@ import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @Entity @@ -26,13 +27,13 @@ class EarthCellViewsEntity( @Column(name = "rev_begin", nullable = false) val revolutionBegin : Long = 0, @Column(nullable = false) - val timeBegin : LocalDateTime = LocalDateTime.now(), + val timeBegin : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revSignBegin : Int = 0, @Column(name = "rev_end", nullable = false) val revolutionEnd: Long = 0, @Column(nullable = false) - val timeEnd : LocalDateTime = LocalDateTime.now(), + val timeEnd : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revSignEnd : Int = 0, @Column(name = "roll_min") diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt index 2a924d5..e7d9cf7 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt @@ -9,6 +9,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "earth_coverage") @@ -22,9 +23,9 @@ class EarthCoverageEntity( @Column(nullable = false) val cellNumber : Long = 0, @Column(nullable = false) - val tStart : LocalDateTime = LocalDateTime.now(), + val tStart : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) - val tStop : LocalDateTime = LocalDateTime.now(), + val tStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), ) { constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this( null, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt index 7d72c1e..d9fbd15 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt @@ -6,6 +6,7 @@ import jakarta.persistence.* import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @@ -19,7 +20,7 @@ class FlightLineEntity( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, @Column(nullable = false) diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt index afe03da..0da1e4c 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt @@ -7,6 +7,7 @@ import ballistics.utils.toDateTime import jakarta.persistence.* import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "pdcm") @@ -18,7 +19,7 @@ class PDCMEntity ( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, val x: Double = 0.0, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt index 044f7de..916e2b8 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt @@ -13,6 +13,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "satellite_initial_conditions") @@ -27,7 +28,7 @@ class SatelliteInitialConditionEntity( @Column(name = "movement_model", nullable = false) val movementModel: MovementModel = MovementModel.FOTO, @Column(name = "orb_time", nullable = false) - val orbTime: LocalDateTime = LocalDateTime.now(), + val orbTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "orb_revolution", nullable = false) val orbRevolution: Long = 0, @Column(nullable = false) @@ -47,7 +48,7 @@ class SatelliteInitialConditionEntity( @Column(nullable = false) val f81: Double = 147.8, @Column(name = "received_at", nullable = false) - val receivedAt: LocalDateTime = LocalDateTime.now(), + val receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "calculated_at") var calculatedAt: LocalDateTime? = null, @Column(name = "last_calculated", nullable = false) @@ -68,7 +69,7 @@ class SatelliteInitialConditionEntity( f81 = request.ic.f81 ) - fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now()) { + fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)) { this.calculatedAt = calculatedAt this.lastCalculated = true } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt index 74d027b..8e40d7e 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -63,6 +63,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.collections.forEach import kotlin.math.PI import kotlin.math.abs @@ -761,8 +762,8 @@ class SatelliteService { fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) = pdcmRepository.findBySatelliteIdAndTimeBetween( id, - tn?: LocalDateTime.now(), - tk?: LocalDateTime.now().plusDays(1)) + tn?: LocalDateTime.now(ZoneOffset.UTC), + tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1)) .map { it.toDTO() } @@ -775,8 +776,8 @@ class SatelliteService { fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) = flightLineRepository.findBySatelliteIdAndTimeBetween(id, - tn?: LocalDateTime.now(), - tk?: LocalDateTime.now().plusDays(1) + tn?: LocalDateTime.now(ZoneOffset.UTC), + tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) ) .map { it.toDTO() } @@ -897,8 +898,8 @@ class SatelliteService { return ascNodeRepository.findBySatelliteIdAndTimeBetween( id, - tn ?: LocalDateTime.now(), - tk ?: LocalDateTime.now().plusDays(1) + tn ?: LocalDateTime.now(ZoneOffset.UTC), + tk ?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) ) .map { val keps = calculateKeplerParams( diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt index 874fb10..a7b599b 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.Index import jakarta.persistence.OneToMany import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -34,7 +35,7 @@ class ComplexPlanRunEntity( var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "started_at") var startedAt: LocalDateTime? = null, @@ -46,10 +47,10 @@ class ComplexPlanRunEntity( var durationMs: Long? = null, @Column(name = "interval_start", nullable = false) - var intervalStart: LocalDateTime = LocalDateTime.now(), + var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "interval_end", nullable = false) - var intervalEnd: LocalDateTime = LocalDateTime.now(), + var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "requested_by") var requestedBy: String? = null, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt index 1c519d5..b090920 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt @@ -15,6 +15,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.Table import jakarta.persistence.Index import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -43,10 +44,10 @@ class SatelliteModeEntity( var type: SatelliteModeType = SatelliteModeType.SURVEY, @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime = LocalDateTime.now(), + var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime = LocalDateTime.now(), + var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "revolution", nullable = false) var revolution: Long = 0, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt index 4d44d0e..1b3e037 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -34,13 +35,13 @@ class SatelliteModeSnapshotEntity( var satelliteId: Long = 0, @Column(name = "interval_start", nullable = false) - var intervalStart: LocalDateTime = LocalDateTime.now(), + var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "interval_end", nullable = false) - var intervalEnd: LocalDateTime = LocalDateTime.now(), + var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "is_active", nullable = false) var isActive: Boolean = false, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt index 05fc0bf..984f120 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt @@ -3,11 +3,12 @@ package space.nstart.pcp.pcp_satellites_service.model.mode import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO import java.time.LocalDateTime +import java.time.ZoneOffset class SurveyMode( val revolution : Long = 1, - val time : LocalDateTime = LocalDateTime.now(), - val timeStop : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, val latitude : Double = 0.0, val longitude : Double = 0.0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt index 259202f..4f496ed 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt @@ -10,6 +10,7 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Table(name = "dynamic_plan_route") @@ -31,10 +32,10 @@ class DynamicPlanRouteEntity( var satelliteId: Long = 0, @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime = LocalDateTime.now(), + var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime = LocalDateTime.now(), + var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "duration", nullable = false) var duration: Double = 0.0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt index a307bdb..6c1f32b 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt @@ -10,6 +10,7 @@ import space.nstart.pcp.complan.types.DynamicPlanCalculationMode import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode import space.nstart.pcp.complan.types.DynamicPlanRunStatus import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Table(name = "dynamic_plan_run") @@ -47,7 +48,7 @@ class DynamicPlanRunEntity( var filterCoveredRoutes: Boolean = false, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "started_at") var startedAt: LocalDateTime? = null, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt index 3afa0da..d528a51 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt @@ -25,6 +25,7 @@ import space.nstart.pcp.complan.types.DynamicPlanRunStatus import space.nstart.pcp.complan.types.IntervalItemDTO import space.nstart.pcp.complan.types.SurveyContour import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import java.util.concurrent.ExecutorService import kotlin.math.roundToLong @@ -59,7 +60,7 @@ class DynamicPlanRunService( calculationMode = request.calculationMode, revolutionMode = request.revolutionMode, filterCoveredRoutes = request.filterCoveredRoutes, - createdAt = LocalDateTime.now() + createdAt = LocalDateTime.now(ZoneOffset.UTC) ) ) } ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run") @@ -135,7 +136,7 @@ class DynamicPlanRunService( return@execute 0 } - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) runs.forEach { run -> run.status = DynamicPlanRunStatus.FAILED run.finishedAt = finishedAt @@ -171,7 +172,7 @@ class DynamicPlanRunService( transactionTemplate.execute { val run = runRepository.findById(runId).orElse(null) ?: return@execute null run.status = DynamicPlanRunStatus.RUNNING - run.startedAt = LocalDateTime.now() + run.startedAt = LocalDateTime.now(ZoneOffset.UTC) run.errorMessage = null runRepository.save(run) run.toRequest() @@ -182,7 +183,7 @@ class DynamicPlanRunService( val run = runRepository.findById(runId).orElseThrow() val result = output.result run.status = DynamicPlanRunStatus.COMPLETED - run.finishedAt = LocalDateTime.now() + run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.durationMs = result.durationMs run.resultStatus = result.status.name run.routesCount = result.routesCount @@ -200,7 +201,7 @@ class DynamicPlanRunService( transactionTemplate.execute { val run = runRepository.findById(runId).orElseThrow() run.status = DynamicPlanRunStatus.FAILED - run.finishedAt = LocalDateTime.now() + run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.errorMessage = error.message ?: error::class.java.simpleName runRepository.save(run) } diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt index 020de0c..da31900 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -8,6 +8,7 @@ import org.springframework.web.client.RestClient import org.springframework.web.util.UriComponentsBuilder import space.nstart.pcp.complan.types.RequestItemDTO import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** Получение заявок из pcp-request-service /v1 API. */ @@ -43,8 +44,10 @@ class RequestServiceClient { id = id, name = name, geometry = geometry, - intervalBegin = beginDateTime.toLocalDateTime(), - intervalEnd = endDateTime.toLocalDateTime(), + // Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы + // при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md). + intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), + intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), resolution = resolution(), importance = importance, ) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt index 853aeec..72334fe 100755 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt @@ -6,6 +6,7 @@ import org.locationtech.jts.geom.LineString import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset /** * Класс космического аппарата** @@ -17,9 +18,9 @@ class SpaceCraft ( val flightLine : MutableList = mutableListOf() /**Время начала планирования аппарата*/ - var timeStart: LocalDateTime = LocalDateTime.now() + var timeStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) /**Время конца планирования аппарата*/ - var timeStop : LocalDateTime = LocalDateTime.now() + var timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) /** * Создание линий полета восходящего и нисходящего витка * @param geomFactory фабрика геометрии diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt index 03c19b7..ad1fafd 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -62,9 +62,29 @@ class RequestServiceClientTest { assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) } + @Test + fun `getRequest normalizes non-UTC offsets to UTC LocalDateTime`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002") + // Источник прислал то же мгновение, но со смещением +03:00. Голый toLocalDateTime() оставил бы + // локальную стенку (06:04:05); нормализация к UTC обязана дать 03:04:05. + server = serverWithRequestResponse( + mutableListOf(), + requestId, + beginDateTime = "2026-01-02T06:04:05+03:00", + endDateTime = "2026-01-03T07:05:06+03:00", + ) + + val request = requestServiceClient().getRequest(requestId)!! + + assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin) + assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd) + } + private fun serverWithRequestResponse( requestedUris: MutableList, requestId: UUID, + beginDateTime: String = "2026-01-02T03:04:05Z", + endDateTime: String = "2026-01-03T04:05:06Z", ): HttpServer = HttpServer.create(InetSocketAddress(0), 0).apply { createContext("/v1/requests") { exchange -> @@ -77,8 +97,8 @@ class RequestServiceClientTest { "surveyType": "OPTICS", "geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "importance": 7.5, - "beginDateTime": "2026-01-02T03:04:05Z", - "endDateTime": "2026-01-03T04:05:06Z", + "beginDateTime": "$beginDateTime", + "endDateTime": "$endDateTime", "kpp": [1], "highPriorityTransmit": false, "optics": { diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt index 4587cc3..f144289 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt @@ -15,6 +15,7 @@ import space.nstart.pcp.pcp_types_lib.message.KafkaMessage import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent import java.nio.charset.StandardCharsets import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Component @@ -59,7 +60,7 @@ class BookedSlotsEventPublisher( val event = BookedSlotsStatusChangedEvent( bookedSlotIds = normalizedIds, status = status, - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), sourceService = applicationName, missionId = missionId, modeId = modeId, diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 051c96e..cfd8185 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -57,6 +57,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.Duration import java.time.LocalDateTime +import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI @@ -189,14 +190,23 @@ class MissionPlaningService( .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } } + /** + * Нормализует `OffsetDateTime` к UTC и отбрасывает зону. Все naive `LocalDateTime` в сервисе — UTC + * (см. docs/standards/DATETIME_UTC.md), поэтому сравнивать их можно только с UTC-стенкой времени мода, + * а не с локальной стенкой произвольного офсета. Голый `toLocalDateTime()` был корректен лишь пока + * источник слал офсет 0 (`Z`). + */ + private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = + withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { - val modeStart = timeStart?.toLocalDateTime() ?: return false + val modeStart = timeStart?.toUtcLocalDateTime() ?: return false val modeStop = modeStop() ?: return false return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { - val modeStart = timeStart?.toLocalDateTime() ?: return null + val modeStart = timeStart?.toUtcLocalDateTime() ?: return null val durationSeconds = when (this) { is SurveyModeEntity -> duration is DropModeEntity -> duration @@ -273,7 +283,7 @@ class MissionPlaningService( overrideIntervalEnd: LocalDateTime? = null, comPlanSnapshotId: Long? = null ): MissionSurveyCalculationResponseDTO { - val startedAt = LocalDateTime.now() + val startedAt = LocalDateTime.now(ZoneOffset.UTC) logger.info( "Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}", missionId, @@ -361,7 +371,7 @@ class MissionPlaningService( ) if (survs.isEmpty()) { - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) return MissionSurveyCalculationResponseDTO( missionId = missionId, completed = true, @@ -406,7 +416,7 @@ class MissionPlaningService( modeBookings.size ) - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) return MissionSurveyCalculationResponseDTO( missionId = missionId, completed = true, @@ -677,7 +687,7 @@ class MissionPlaningService( } val start = survey.timeStart ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") - val surveyEnd = start.toLocalDateTime() + val surveyEnd = start.toUtcLocalDateTime() .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(req.zoneStart)) { throw CustomErrorException( @@ -930,7 +940,7 @@ class MissionPlaningService( iterator.remove() continue } - val surveyStart = surveyStartOffset.toLocalDateTime() + val surveyStart = surveyStartOffset.toUtcLocalDateTime() val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(zoneStart)) { diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt index b4e7943..2991f1a 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -153,6 +153,41 @@ class MissionPlaningServiceTest { assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) } + @Test + fun `modesBySatelliteAndInterval normalizes non-UTC mode offsets to UTC before matching`() { + val mission = MissionEntity(missionId = UUID.randomUUID(), satelliteId = 77) + val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) + val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0) + val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0) + + // То же мгновение, что 10:10 UTC (внутри окна), но выражено со смещением +03:00 → 13:10. + // Голый toLocalDateTime() дал бы 13:10 и выкинул мод из окна; нормализация к UTC оставляет его. + val offsetSurvey = SurveyModeEntity( + id = 1L, + plan = plan, + timeStart = OffsetDateTime.of(2026, 3, 16, 13, 10, 0, 0, ZoneOffset.ofHours(3)), + revolution = 11, + lat = 56.0, + long = 38.0, + duration = 300.0, + contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", + roll = 1.0, + source = SurveyModeSource.SLOT + ) + + `when`( + modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( + 77, + intervalStop.atOffset(ZoneOffset.UTC) + ) + ).thenReturn(listOf(offsetSurvey)) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L))).thenReturn(emptyList()) + + val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop) + + assertEquals(listOf(1L), result.map { it.id }) + } + @Test fun `modesBySatelliteAndInterval rejects inverted interval`() { val exception = assertThrows(ResponseStatusException::class.java) { diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt index 31627eb..55672f2 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.concurrent.ExecutionException @Service @@ -69,7 +70,7 @@ class OutboxPublisherService( .get() event.status = OutboxEventStatus.PUBLISHED - event.publishedAt = LocalDateTime.now() + event.publishedAt = LocalDateTime.now(ZoneOffset.UTC) event.errorMessage = null outboxEventRepository.save(event) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index 13df5cb..c4f3d73 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -66,7 +66,7 @@ class RequestService( "beginDateTime должен быть раньше endDateTime" } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry) val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry) val geometryArea = geometryService.area(normalizedGeometry) @@ -154,7 +154,7 @@ class RequestService( @Transactional fun deleteRequest(id: UUID): DeleteRequestResponseDto? { val request = requestRepository.findById(id).orElse(null) ?: return null - val deletedAt = request.deletedAt ?: LocalDateTime.now() + val deletedAt = request.deletedAt ?: LocalDateTime.now(ZoneOffset.UTC) request.status = RequestStatus.DELETED request.deletedAt = deletedAt request.updatedAt = deletedAt diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt index 0fb6ed7..fbe5676 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -43,7 +44,7 @@ class RouteMatchingService( val routeId = route.routeId val routeRollRange = route.rollAngle - val matchedAt: LocalDateTime = LocalDateTime.now() + val matchedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry) val candidateRequestIds = requestRepository.findRequestIdsForRoute( diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt new file mode 100644 index 0000000..86bb075 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt @@ -0,0 +1,47 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime` + * сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф + * (смену типа поля на naive `LocalDateTime` или Jackson-конфига). + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class RequestResponseDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes request times as ISO with explicit Z zone`() { + val dto = RequestResponseDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000001"), + name = "request", + status = RequestStatusDto.ACTIVE, + surveyType = SurveyTypeDto.OPTICS, + geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + importance = 1.0, + beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), + endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + highPriorityTransmit = false, + coverage = CoverageStateDto(currentPercent = 0.0), + createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC), + updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC) + ) + + val node = mapper.readTree(mapper.writeValueAsString(dto)) + + assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText()) + assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText()) + assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z") + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt index 1a9f7cc..f4c68fe 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt @@ -13,6 +13,7 @@ import org.nstart.dep265.requestservice.repository.EarthCellRepository import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -112,14 +113,14 @@ class RequestGridProjectionServiceTest { geometryArea = 1.0, remainingArea = 1.0, importance = importance, - beginDateTime = LocalDateTime.now(), - endDateTime = LocalDateTime.now().plusDays(1), + beginDateTime = LocalDateTime.now(ZoneOffset.UTC), + endDateTime = LocalDateTime.now(ZoneOffset.UTC).plusDays(1), highPriorityTransmit = false, surveyType = RequestSurveyType.OPTICS, status = RequestStatus.ACTIVE, matchCount = 0, - createdAt = LocalDateTime.now(), - updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(ZoneOffset.UTC), + updatedAt = LocalDateTime.now(ZoneOffset.UTC), ) ) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt index 6af3400..5aff657 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt @@ -29,6 +29,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -47,7 +48,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalEnd = intervalBegin.plusHours(1) val request = request( beginDateTime = intervalBegin, @@ -96,7 +97,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalEnd = intervalBegin.plusHours(1) val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))" val request = request( @@ -146,7 +147,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -182,7 +183,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -230,7 +231,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -268,7 +269,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt index fe4d09e..6a0c768 100644 --- a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper as JpaJsonObjectMapper import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -38,7 +39,7 @@ class RoutePassportIngestionService( rawPayload: String, sourceRecord: RoutePassportSourceRecordMetadata, ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val routePassportDto = parsedMessage.routePassportDto val existingRoutePassport = routePassportRepository.findById(routePassportDto.routeId) val routeCreated = existingRoutePassport.isEmpty diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt index 052c820..ae0a3e9 100644 --- a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt @@ -9,6 +9,7 @@ import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -32,7 +33,7 @@ class RoutePassportOutboxService( return emptyList() } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val lockedUntil = now.plus(Duration.ofMillis(outboxProperties.lockTimeoutMs)) val claimedRows = routePassportOutboxRepository.findDueForUpdateSkipLocked(outboxProperties.batchSize) @@ -70,7 +71,7 @@ class RoutePassportOutboxService( */ @Transactional fun markPublished(outboxId: UUID) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val outbox = findOutbox(outboxId) outbox.status = RoutePassportOutboxStatus.PUBLISHED @@ -99,7 +100,7 @@ class RoutePassportOutboxService( */ @Transactional fun markRetryOrFailed(outboxId: UUID, exception: Exception) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val outbox = findOutbox(outboxId) outbox.attempts += 1 diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt index 2841ebc..e9db81c 100644 --- a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt @@ -27,6 +27,7 @@ import tools.jackson.databind.json.JsonMapper import tools.jackson.module.kotlin.KotlinModule import java.math.BigDecimal import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -219,9 +220,9 @@ class RoutePassportIngestionServiceTest { status = RoutePassportOutboxStatus.PENDING, attempts = 0, maxAttempts = 10, - nextAttemptAt = LocalDateTime.now(), - createdAt = LocalDateTime.now(), - updatedAt = LocalDateTime.now(), + nextAttemptAt = LocalDateTime.now(ZoneOffset.UTC), + createdAt = LocalDateTime.now(ZoneOffset.UTC), + updatedAt = LocalDateTime.now(ZoneOffset.UTC), ) private fun fixture(): IngestionFixture { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index 56401a8..eedaae2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -14,6 +14,7 @@ import { } from "./editorDraft"; import type { PlanMode } from "./editorApi"; import type { DraftHistory } from "./model/editorTypes"; +import { parseUtc } from "../../utils/utcTime"; const plan: TguPlanUi = { planId: "plan-1", @@ -22,8 +23,8 @@ const plan: TguPlanUi = { endTime: "2026-05-29T02:00:00Z", kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse("2026-05-29T01:00:00Z"), - endMs: Date.parse("2026-05-29T02:00:00Z") + startMs: parseUtc("2026-05-29T01:00:00Z"), + endMs: parseUtc("2026-05-29T02:00:00Z") }; describe("editorDraft", () => { @@ -84,8 +85,8 @@ describe("editorDraft", () => { id: "survey-11", kind: "shoot", label: "Съёмка · виток 4321", - startMs: Date.parse("2026-05-29T01:10:00Z"), - endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000, + startMs: parseUtc("2026-05-29T01:10:00Z"), + endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000, origin: "plan", targetLat: 55.7, targetLon: 37.6 @@ -94,8 +95,8 @@ describe("editorDraft", () => { id: "drop-22", kind: "downlink", label: "Сброс · 102", - startMs: Date.parse("2026-05-29T01:40:00Z"), - endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000, + startMs: parseUtc("2026-05-29T01:40:00Z"), + endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000, origin: "plan", stationId: "102" } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index ec5cec8..fbddec9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi } from "../../model/timelineTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes"; import { getTgu2DMapIntervalState } from "./tgu2DMapInterval"; +import { parseUtc } from "../../utils/utcTime"; function plan(startTime: string, endTime: string): TguPlanUi { return { @@ -11,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi { endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseUtc(startTime), + endMs: parseUtc(endTime) }; } describe("tgu2DMapInterval", () => { it("marks common map interval longer than 7 days as too large", () => { const state = getTgu2DMapIntervalState({ - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-05T00:00:01") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-06-05T00:00:01") }); expect(state.tooLarge).toBe(true); @@ -30,8 +31,8 @@ describe("tgu2DMapInterval", () => { const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00"); const state = getTgu2DMapIntervalState( { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-10T00:00:00") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-06-10T00:00:00") }, selectedPlan ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts index ede46ee..4e22532 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout"; +import { parseUtc } from "../../utils/utcTime"; const range = { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-05-30T00:00:00") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-05-30T00:00:00") }; function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi { @@ -15,14 +16,14 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56 endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseUtc(startTime), + endMs: parseUtc(endTime) }; } describe("tguTimelineLayout", () => { it("maps time to x inside the selected interval", () => { - expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600); + expect(timeToX(parseUtc("2026-05-29T12:00:00"), range, 1200)).toBe(600); }); it("clips partially visible plans to the selected interval", () => { @@ -30,7 +31,7 @@ describe("tguTimelineLayout", () => { expect(clipped).toEqual({ startMs: range.fromMs, - endMs: Date.parse("2026-05-29T02:00:00") + endMs: parseUtc("2026-05-29T02:00:00") }); }); diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts new file mode 100644 index 0000000..d617fac --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts @@ -0,0 +1,62 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Страж инварианта «всё время — UTC» (см. docs/standards/DATETIME_UTC.md). + * + * Сканирует исходники и падает на паттернах, которые трактуют время в поясе + * браузера: «голый» разбор строки (`new Date("...")`, `Date.parse`) и показ через + * `toLocale*` без `timeZone`. Парсинг и форматирование времени бэка должны идти + * только через `utils/utcTime` (`parseUtc`, `formatUtc*`). Этот файл и сам + * `utcTime.ts` — единственные исключения: в них паттерны присутствуют легитимно + * (как реализация утилиты и как строки-описания запретов). + */ + +const SRC_DIR = join(process.cwd(), "src"); +// Исключения: сам страж (содержит паттерны как строки-описания) и каноническая +// утилита utcTime.ts (легитимная реализация parseUtc/formatUtc*). +const EXCLUDED = ["utcTime.ts", "utcTime.guard.test.ts"]; + +interface Rule { + readonly name: string; + readonly pattern: RegExp; +} + +const RULES: Rule[] = [ + { name: "Date.parse(...) — парсит строку в поясе браузера; используйте parseUtc", pattern: /\bDate\.parse\s*\(/ }, + { name: "new Date(\"...\") со строкой — трактует naive-строку как локальное время; используйте parseUtc", pattern: /new\s+Date\(\s*['"`]/ }, + { name: "formatDateTimeLocal — локальный хелпер пикеров; используйте toUtcInputValue/parseUtc", pattern: /\bformatDateTimeLocal\b/ }, + // toLocale* без опций или только с локалью → пояс браузера. + { name: "toLocale*() без timeZone — показывает в поясе браузера; используйте formatUtc* или { timeZone: \"UTC\" }", pattern: /\.toLocale(String|DateString|TimeString)\(\s*(['"`][^'"`]*['"`]\s*)?\)/ }, + // toLocale* с inline-объектом опций, в котором нет timeZone. + { name: "toLocale*({...}) без timeZone в опциях — показывает в поясе браузера; добавьте timeZone: \"UTC\"", pattern: /\.toLocale(String|DateString|TimeString)\(\s*['"`][^'"`]*['"`]\s*,\s*\{(?![\s\S]*?timeZone)[\s\S]*?\}/ } +]; + +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectSourceFiles(full)); + } else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.includes(entry.name)) { + out.push(full); + } + } + return out; +} + +describe("UTC time invariant", () => { + const files = collectSourceFiles(SRC_DIR); + + it("scans the source tree", () => { + expect(files.length).toBeGreaterThan(0); + }); + + for (const rule of RULES) { + it(`forbids: ${rule.name}`, () => { + const offenders = files.filter((file) => rule.pattern.test(readFileSync(file, "utf8"))); + expect(offenders.map((file) => relative(SRC_DIR, file))).toEqual([]); + }); + } +}); diff --git a/services/pcp-tgu-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json index 7a7a882..2526d92 100644 --- a/services/pcp-tgu-ops-ui/tsconfig.app.json +++ b/services/pcp-tgu-ops-ui/tsconfig.app.json @@ -17,5 +17,7 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"] + "include": ["src"], + "comment": "Страж utcTime.guard.test.ts использует node:fs/process и проверяется только в vitest (node-окружение), а не при типизации приложения — иначе потребовался бы @types/node.", + "exclude": ["src/**/*.guard.test.ts"] } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 9c4bacc..63740c0 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -9,6 +9,11 @@ import java.util.UUID /** * DTO ответа REST API для плана. + * + * Времена ([startTime]/[endTime]) — `LocalDateTime` и сериализуются как ISO-строка без зоны + * (напр. `"2026-05-29T01:00:00"`). По соглашению системы это **UTC** (см. `docs/standards/DATETIME_UTC.md`): + * потребители обязаны парсить эти строки как UTC (фронт — через `utils/utcTime.parseUtc`), а не как + * локальное время. Кандидат на миграцию к явному `Z` (`OffsetDateTime`/`Instant`) на границе API. */ data class PlanResponse( @@ -18,10 +23,10 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана */ + /** Время начала плана (UTC, naive ISO без зоны). */ val startTime: LocalDateTime, - /** Время окончания плана */ + /** Время окончания плана (UTC, naive ISO без зоны). */ val endTime: LocalDateTime, /** Идентификатор пункта закладки */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt index 66d1cf5..025aafe 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -39,10 +40,10 @@ class AcceptedPlanSlotsUpdateJobEntity( var lastError: String? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(planId = UUID.randomUUID()) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt index 26cea9b..5e9c5c6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -45,10 +46,10 @@ class PlanIssueAttemptEntity( var finishedAt: LocalDateTime? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index e96a5e6..f46acab 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -48,10 +49,10 @@ class PlannedPlanEntity( var acceptedAt: LocalDateTime? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt index b594c13..464290e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -18,7 +19,7 @@ class ProcessedPlanDecisionEventEntity( var eventId: UUID, @Column(name = "received_at", nullable = false) - var receivedAt: LocalDateTime = LocalDateTime.now() + var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(eventId = UUID.randomUUID()) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index b90bc82..b30385d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -6,6 +6,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Entity @@ -35,7 +36,7 @@ class SpacecraftPlanningStateEntity( var urgentReissueFrom: LocalDateTime? = null, @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt index bd8cf2a..ce70873 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Persisted snapshot КА из НСИ, используемый как вход для refresh jobs. @@ -44,10 +45,10 @@ class SpacecraftSnapshotEntity( var active: Boolean = true, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "", noradId = 0) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt index 9aaa543..1662b34 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Полный raw snapshot окон видимости КА из баллистики. @@ -40,10 +41,10 @@ class SpacecraftVisibilitySnapshotEntity( var windowsCount: Int? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt index 272b2c5..a4eb635 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Job на перечитывание окон видимости одного КА у баллистики. @@ -41,10 +42,10 @@ class VisibilityRefreshJobEntity( var rerunRequested: Boolean = false, @Column(name = "requested_at", nullable = false) - var requestedAt: LocalDateTime = LocalDateTime.now(), + var requestedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt index 2e89f28..a7e3419 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt @@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientException import org.springframework.web.reactive.function.client.WebClientResponseException import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.temporal.ChronoUnit /** @@ -36,7 +37,7 @@ class ExternalPointsClient( * которые разрешены для работы TGU. */ fun fetchAllSpacecraftPoints(): List { - val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) return platformService.loadAllowedPlatforms() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt index 5745153..e510dc3 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -8,7 +8,7 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit @@ -19,7 +19,6 @@ import java.time.temporal.ChronoUnit class VisibilityPayloadParser( private val objectMapper: ObjectMapper ) { - private val zoneId: ZoneId = ZoneId.systemDefault() /** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */ fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints { @@ -44,7 +43,10 @@ class VisibilityPayloadParser( } /** - * Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. + * Разбирает UTC/ISO строку времени баллистики в naive `LocalDateTime` (по соглашению системы — UTC). + * + * Момент из `Instant.parse` берём в UTC (а не в поясе JVM): naive-поля `planned_plan.start_time/end_time` + * по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет. * * Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time` * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. @@ -53,7 +55,7 @@ class VisibilityPayloadParser( */ private fun toLocalDateTime(value: String): LocalDateTime = try { - Instant.parse(value).atZone(zoneId).toLocalDateTime() + LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC) } catch (_: DateTimeParseException) { LocalDateTime.parse(value) }.truncatedTo(ChronoUnit.MICROS) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt index 63652f3..3c94c40 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -16,6 +17,6 @@ class AcceptedPlanSlotsUpdateJobService( /** Idempotent enqueue: один planId получает максимум одну job. */ @Transactional fun enqueue(planId: UUID) { - acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now()) + acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now(ZoneOffset.UTC)) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt index d5a8078..4e6b67e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository import java.net.InetAddress import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -35,7 +36,7 @@ class AcceptedPlanSlotsUpdateWorker( /** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */ fun claimJobs(limit: Int): List = transactionTemplate.execute { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit) jobs.forEach { job -> job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING @@ -69,7 +70,7 @@ class AcceptedPlanSlotsUpdateWorker( private fun markDone(planId: UUID, processInstanceKey: Long) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult job.status = AcceptedPlanSlotsUpdateJobStatus.DONE job.processInstanceKey = processInstanceKey @@ -83,7 +84,7 @@ class AcceptedPlanSlotsUpdateWorker( private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS job.processInstanceKey = processInstanceKey ?: job.processInstanceKey diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt index 18d5a6f..e8973ca 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. @@ -115,7 +116,7 @@ class HandlePlanDecisionUseCase( return } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) plan.status = terminalStatus plan.updatedAt = now plannedPlanRepository.save(plan) @@ -127,7 +128,7 @@ class HandlePlanDecisionUseCase( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) attempt.status = when (message.decision) { PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED @@ -144,7 +145,7 @@ class HandlePlanDecisionUseCase( plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) recordDecisionOnAttempt(message, attempt) plan.status = PlannedPlanStatus.AWAITING_LAYIN @@ -170,7 +171,7 @@ class HandlePlanDecisionUseCase( plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) recordDecisionOnAttempt(message, attempt) plan.status = PlannedPlanStatus.REJECTED @@ -203,7 +204,7 @@ class HandlePlanDecisionUseCase( } private fun claimEvent(eventId: java.util.UUID): Boolean = - processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0 + processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0 private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt index 7583db4..1fe2eab 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt @@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. @@ -25,7 +26,7 @@ class HandleStationParamsChangedUseCase( /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ @Transactional fun handle(message: StationParamsChangedMessage) { - val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0 + val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0 if (!claimed) { return } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index c361ee6..777d456 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -56,7 +57,7 @@ class IssueDuePlanWorker( /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val state = getOrCreateLockedState(spacecraftId) if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { return null @@ -132,7 +133,7 @@ class IssueDuePlanWorker( } private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val state = getOrCreateLockedState(plan.spacecraftId) if (state.activeAttemptId != null) { @@ -207,7 +208,7 @@ class IssueDuePlanWorker( private fun markStarted(response: ProcessStartResponse) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult @@ -231,7 +232,7 @@ class IssueDuePlanWorker( private fun markAmbiguous(preparedAttempt: PreparedAttempt) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult attempt.status = PlanIssueAttemptStatus.AMBIGUOUS diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index c3980d7..4a2da61 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -62,7 +63,7 @@ class PlanQueryService( .sortedBy { response -> response.startTime } private fun historySince(historyDays: Long): LocalDateTime = - LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + LocalDateTime.now(ZoneOffset.UTC).minusDays(historyDays.coerceAtLeast(0)) private fun PlannedPlanEntity.businessKey(): String = "$spacecraftId|$startTime|$endTime|$kppId" diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt index 7dde8c8..7d3b755 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.security.MessageDigest import java.time.LocalDateTime +import java.time.ZoneOffset /** * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота @@ -25,7 +26,7 @@ class SpacecraftVisibilitySnapshotService( val payloadHash = sha256(payloadJson) val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull() - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) if (existing == null) { spacecraftVisibilitySnapshotRepository.save( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt index 10f7d9a..58dc7d1 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt @@ -5,7 +5,7 @@ import org.springframework.transaction.support.TransactionTemplate import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset /** * Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs. @@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase( } private fun persistAllowedSpacecraft(allowedPlatforms: List) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() } allowedPlatforms.forEach { platform -> @@ -40,8 +40,10 @@ class SyncSpacecraftFromNsiUseCase( name = platform.name, status = platform.status, mission = platform.mission, - validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), - validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), + // validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-стенку, а не пояс JVM + // (по соглашению системы naive LocalDateTime = UTC) — иначе на не-UTC машине время уезжало бы. + validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, + validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, now = now ) visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt index fdd8b56..6b77ee9 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt @@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -66,7 +67,7 @@ class TestPlanDecisionService( planId = plan.planId, attemptId = attempt.attemptId, decision = decision, - decisionTime = LocalDateTime.now(), + decisionTime = LocalDateTime.now(ZoneOffset.UTC), reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON ) handlePlanDecisionUseCase.handle(message) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt index 79dd8fc..f0b3191 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * Coalesce-сервис для visibility_refresh_job по spacecraftId. @@ -15,6 +16,6 @@ class VisibilityRefreshJobService( /** Ставит или переоткрывает refresh job без дублей. */ @Transactional fun enqueue(spacecraftId: String, noradId: Long?, reason: String) { - visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now()) + visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now(ZoneOffset.UTC)) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt index 8c62d17..34fa8ea 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt @@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository import java.net.InetAddress import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.temporal.ChronoUnit /** @@ -38,7 +39,7 @@ class VisibilityRefreshWorker( private fun claimJobs(limit: Int): List = transactionTemplate.execute { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit) jobs.forEach { job -> job.status = JobStatus.RUNNING @@ -61,10 +62,10 @@ class VisibilityRefreshWorker( } try { - val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) - spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now()) + spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now(ZoneOffset.UTC)) markDone(spacecraftId) } catch (ex: Exception) { log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex) @@ -79,7 +80,7 @@ class VisibilityRefreshWorker( job.rerunRequested = false job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } @@ -90,7 +91,7 @@ class VisibilityRefreshWorker( job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } @@ -102,7 +103,7 @@ class VisibilityRefreshWorker( job.reason = reason job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt new file mode 100644 index 0000000..1a336fc --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp_tgu_service.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC, + * см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля + * (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей. + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class PlanResponseContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val response = PlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = PlannedPlanStatus.PLANNED + ) + + val node = mapper.readTree(mapper.writeValueAsString(response)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + // Нет суффикса зоны (Z/±офсет) — иначе потребитель, читающий naive как UTC, ошибётся. + assertTrue( + node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), + "ожидался naive ISO без зоны" + ) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt index 87be7b4..87fe63d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt @@ -1,14 +1,45 @@ package space.nstart.pcp_tgu_service.integration.api import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.time.LocalDateTime +import java.util.TimeZone class VisibilityPayloadParserTest { private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `parses UTC instant as UTC LocalDateTime regardless of JVM default zone`() { + // planned_plan.start_time/end_time по контракту хранят UTC. Раньше парсер конвертировал момент + // через ZoneId.systemDefault(), и на не-UTC JVM (напр. МСК, +3) время уезжало на офсет. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-02T14:20:00Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T14:17:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val window = parser.parse("20", payload).points.single().observationWindows.single() + + // Значение совпадает с UTC-стенкой инстанта (14:14), а не со сдвигом на пояс JVM (17:14). + assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime) + assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime) + } + @Test fun `truncates window times to microseconds to match persisted timestamp precision`() { // Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt index 6c4d131..9197f0a 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID class HandleStationParamsChangedUseCaseTest { @@ -40,7 +41,7 @@ class HandleStationParamsChangedUseCaseTest { StationParamsChangedMessage( eventId = UUID.randomUUID(), stationId = "ST-7", - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), reason = "OUT_OF_SERVICE" ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index 1604334..a14c597 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -27,6 +27,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -258,7 +259,9 @@ class IssueDuePlanWorkerOnDemandTest { private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun now(): LocalDateTime = LocalDateTime.now() + // Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now(ZoneOffset.UTC)), + // иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет. + private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) private fun planningProperties(): PlanningProperties = PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 87e1b88..93cd737 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -31,6 +31,7 @@ import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventReposit import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -86,7 +87,7 @@ class PlanIssueAndDecisionFlowTest { @Test fun `rejected decision marks plan REJECTED and arms urgent reissue`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) + val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) @@ -293,7 +294,7 @@ class PlanIssueAndDecisionFlowTest { endTime = end, kppId = "KPP-1", status = status, - issueNotBefore = LocalDateTime.now().minusMinutes(1) + issueNotBefore = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1) ) private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity = @@ -316,7 +317,7 @@ class PlanIssueAndDecisionFlowTest { planId = planId, attemptId = attemptId, decision = decision, - decisionTime = LocalDateTime.now(), + decisionTime = LocalDateTime.now(ZoneOffset.UTC), reason = null ) @@ -325,11 +326,11 @@ class PlanIssueAndDecisionFlowTest { eventId = UUID.randomUUID(), spacecraftId = spacecraftId, planId = planId, - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), reason = null ) - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt index c40d53e..0071b0e 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -176,13 +177,13 @@ class PlanProjectionServiceTest { noradId = 25544L, payloadJson = "[]", payloadHash = "hash", - fetchedAt = LocalDateTime.now() + fetchedAt = LocalDateTime.now(ZoneOffset.UTC) ) private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) @Suppress("UNCHECKED_CAST") private fun anyNonNull(): T { diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 602de33..e97a4f5 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID class PlanQueryServiceTest { @@ -74,5 +75,5 @@ class PlanQueryServiceTest { private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt new file mode 100644 index 0000000..caf456a --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt @@ -0,0 +1,90 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.Instant +import java.time.LocalDateTime +import java.util.TimeZone +import java.util.function.Consumer + +class SyncSpacecraftFromNsiUseCaseTest { + + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `converts NSI validity instants to UTC LocalDateTime regardless of JVM default zone`() { + // validFrom/validTo пишутся в naive-поля БД, которые по контракту хранят UTC. Раньше конвертация + // шла через ZoneId.systemDefault(), и на не-UTC JVM (МСК, +3) значение уезжало на офсет. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + + val platformService = mock(PlatformService::class.java) + val repository = mock(SpacecraftSnapshotRepository::class.java) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val transactionTemplate = mock(TransactionTemplate::class.java) + // executeWithoutResult просто прогоняет переданный блок без реальной транзакции. + doAnswer { invocation -> + invocation.getArgument>(0).accept(mock(TransactionStatus::class.java)) + null + }.`when`(transactionTemplate).executeWithoutResult(anyNonNull()) + + // loadAllowedPlatforms имеет дефолтный аргумент Instant.now(); матчер нужен, иначе stub не совпадёт. + `when`(platformService.loadAllowedPlatforms(anyNonNull())).thenReturn( + listOf( + PlatformCatalogItem( + nsiId = "nsi-1", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-06-02T14:14:08Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + ) + ) + + SyncSpacecraftFromNsiUseCase(platformService, repository, refreshJobService, transactionTemplate) + .syncAllowedSpacecraft() + + val validFrom = ArgumentCaptor.forClass(LocalDateTime::class.java) + val validTo = ArgumentCaptor.forClass(LocalDateTime::class.java) + verify(repository).upsertFromNsi( + spacecraftId = anyNonNull(), + noradId = org.mockito.Mockito.anyLong(), + nsiId = anyNullable(), + businessKey = anyNullable(), + name = anyNullable(), + status = anyNullable(), + mission = anyNullable(), + validFrom = validFrom.capture(), + validTo = validTo.capture(), + now = anyNonNull() + ) + + assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), validFrom.value) + assertEquals(LocalDateTime.parse("2026-12-31T23:00:00"), validTo.value) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + private fun anyNullable(): T? = org.mockito.ArgumentMatchers.any() +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt index 79ae4a6..317d30d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt @@ -21,6 +21,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -91,8 +92,8 @@ class TestPlanDecisionServiceTest { PlannedPlanEntity( planId = UUID.randomUUID(), spacecraftId = "25544", - startTime = LocalDateTime.now().plusHours(1), - endTime = LocalDateTime.now().plusHours(2), + startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(1), + endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(2), kppId = "KPP-1", status = PlannedPlanStatus.WAITING_DECISION ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt index 576c1b1..25ea45b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -13,9 +13,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.security.MessageDigest -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId import java.util.Optional class VisibilityFlowTest { @@ -28,8 +26,9 @@ class VisibilityFlowTest { assertEquals("25544", points.spacecraftId) assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId }) + // Парсер трактует UTC-инстант баллистики как UTC-стенку (naive LocalDateTime = UTC), без сдвига на пояс JVM. assertEquals( - Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(), + LocalDateTime.parse("2026-05-26T10:00:00"), points.points[1].observationWindows.single().startTime ) } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt index fba882f..8f7fc6a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt @@ -28,6 +28,7 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SlotService import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.format.ResolverStyle @@ -103,7 +104,7 @@ class CatalogRestController( private fun parseSendInitialConditionsTime(time: String?): LocalDateTime { val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() } - ?: return LocalDateTime.now() + ?: return LocalDateTime.now(ZoneOffset.UTC) return try { LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt index e7e3e96..0604cd6 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt @@ -6,6 +6,8 @@ import java.time.LocalDateTime /** * UI DTO for the bookings page. It intentionally lives in ui-service because the page combines * data from slots-service, request-service and satellite-catalog-service without changing their APIs. + * + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). */ data class BookedSlotViewDTO( val bookedSlotId: Long, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt index ee50115..dcc3a23 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.groupstate import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class GroupStateAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt index 41870ce..b779fa3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt @@ -3,6 +3,10 @@ package space.nstart.pcp.slots_service.dto.pdcm import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class SatellitePdcmAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index 42fb510..b426c55 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -4,6 +4,11 @@ import java.time.Instant import java.time.LocalDateTime import java.util.UUID +/* + * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как + * naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC. + */ + data class TguPlatformResponse( val id: String?, val businessKey: String?, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt index 1d55f43..49ad619 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tleanalysis import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], в т.ч. epoch) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class TleAnalysisSatelliteDTO( val id: Long, val catalogId: Long? = null, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt index 238b312..be8feab 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tlecomparison import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], epoch/time) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + class TleTextRequestDTO( var tle: String = "" ) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt index cd97520..c246d53 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt @@ -426,7 +426,7 @@ private val docReqCovName ="req_cov_" timeStop: LocalDateTime? = null ): Iterable { - val tstart = timeStart ?: LocalDateTime.now() + val tstart = timeStart ?: LocalDateTime.now(ZoneOffset.UTC) val tstop = timeStop ?: tstart.plusDays(1) var vuz = ballisticsService.ascNodes(id, tstart, tstop).collectList().block() vuz = vuz?.filter { it.time >= tstart && it.time <= tstop }?.sortedBy { it.time } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt index fb77839..2d56bed 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt @@ -20,6 +20,7 @@ import space.nstart.pcp.slots_service.czml.CZMLPolygon import space.nstart.pcp.slots_service.czml.CZMLPosition import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset @Service class CoverageSchemeService(webClientBuilderProvider: ObjectProvider) { @@ -185,8 +186,8 @@ class CoverageSchemeModeRowDTO( val entityId: String = "", val sequence: Int = 0, val satelliteId: Long = 0, - val tn: LocalDateTime = LocalDateTime.now(), - val tk: LocalDateTime = LocalDateTime.now(), + val tn: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val tk: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll: Double? = null, val revolutionSign: RevolutionSign? = null, val contour: String = "", diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt index acf13a5..36553a1 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt @@ -7,6 +7,7 @@ import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Service class SatellitePdcmService( @@ -14,7 +15,7 @@ class SatellitePdcmService( private val ballisticsService: BallisticsService ) { - fun satelliteSummaries(): List = satelliteSummaries(LocalDateTime.now()) + fun satelliteSummaries(): List = satelliteSummaries(LocalDateTime.now(ZoneOffset.UTC)) fun satelliteSummaries(referenceTime: LocalDateTime): List { val satellites = satelliteCatalogService.allSatellites() @@ -39,7 +40,7 @@ class SatellitePdcmService( } fun satelliteDetails(satelliteId: Long): SatellitePdcmDetailsDTO = - satelliteDetails(satelliteId, LocalDateTime.now()) + satelliteDetails(satelliteId, LocalDateTime.now(ZoneOffset.UTC)) fun satelliteDetails(satelliteId: Long, referenceTime: LocalDateTime): SatellitePdcmDetailsDTO { val satellites = satelliteCatalogService.allSatellites() diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt new file mode 100644 index 0000000..8e0a67d --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -0,0 +1,62 @@ +package space.nstart.pcp.slots_service.dto.tgu + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: + * naive `LocalDateTime` → ISO без зоны (= UTC), `Instant` → ISO с `Z` + * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта. + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class TguPlanningDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val plan = TguPlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = "PLANNED" + ) + + val node = mapper.readTree(mapper.writeValueAsString(plan)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertTrue( + node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), + "ожидался naive ISO без зоны" + ) + } + + @Test + fun `serializes platform validity instants with explicit Z zone`() { + val platform = TguPlatformResponse( + id = "25544", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-05-29T01:00:00Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + + val node = mapper.readTree(mapper.writeValueAsString(platform)) + + assertEquals("2026-05-29T01:00:00Z", node.get("validFrom").asText()) + assertTrue(node.get("validTo").asText().endsWith("Z"), "ожидалась явная зона Z") + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt index 0eab57e..9709366 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "satellite_ic") @@ -13,7 +14,7 @@ class SatelliteIcEntity( @Column(name = "satellite_id", nullable = false) val satelliteId: Long = 0, @Column(name = "orb_time", nullable = false) - val orbTime: LocalDateTime = LocalDateTime.now(), + val orbTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "orb_revolution", nullable = false) val orbRevolution: Long = 0, @Column(nullable = false) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt index 757b134..ecbfd8e 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt @@ -9,6 +9,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser import space.nstart.pcp.slots_service.model.Mar import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @@ -26,9 +27,9 @@ class SlotEntity( @Column(nullable = false) val coveringType : Int = 0, @Column(nullable = false) - var tn : LocalDateTime = LocalDateTime.now(), + var tn : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) - var tk : LocalDateTime = LocalDateTime.now(), + var tk : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, @Column(name = "contour_wkt", columnDefinition = "TEXT", nullable = false) val contour : String = "", diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt index 7ea770f..0a3933f 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt @@ -3,11 +3,12 @@ package space.nstart.pcp.slots_service.model import org.locationtech.jts.geom.Geometry import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset class Mar ( val sat : Long = 0, - val tn : LocalDateTime = LocalDateTime.now(), - val tk : LocalDateTime = LocalDateTime.now(), + val tn : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val tk : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, val contour : String = "", var poly : Geometry? = null, diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt index 7b02854..f790bf7 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt @@ -33,8 +33,11 @@ class SlotRepositoryImpl( cycle = rs.getLong("cycle"), satelliteId = rs.getLong("satellite_id"), coveringType = rs.getInt("covering_type"), - tn = rs.getTimestamp("tn").toLocalDateTime(), - tk = rs.getTimestamp("tk").toLocalDateTime(), + // getObject(LocalDateTime) читает `timestamp without time zone` без участия + // зоны JVM. getTimestamp().toLocalDateTime() зоно-нейтрален лишь для фикс-офсета; + // на зонах с DST граничные значения сдвигались бы. Времена в системе — UTC. + tn = rs.getObject("tn", java.time.LocalDateTime::class.java), + tk = rs.getObject("tk", java.time.LocalDateTime::class.java), roll = rs.getDouble("roll"), contour = rs.getString("contour_wkt"), revolution = rs.getLong("revolution"), diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt index 67997d5..f709391 100644 --- a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt @@ -24,6 +24,7 @@ import space.nstart.pcp.tle_monitoring_service.message.KafkaProducer import space.nstart.pcp.tle_monitoring_service.repository.TLERepository import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI import kotlin.math.abs @@ -303,7 +304,7 @@ class CelesTrakService( .sortedBy { tle -> tle.timeTle } var first = true var vit = 0 - var tVuz = LocalDateTime.now() + var tVuz = LocalDateTime.now(ZoneOffset.UTC) var lVuz = 0.0 for(t in tles){ var s = t.tle.split("\r\n")