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")