pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
+1
View File
@@ -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 изменений
+92
View File
@@ -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"`);
- `<input type="datetime-local">` — значение через `toUtcInputValue`, чтение через `parseUtc`,
отправка на offset-бэк — через `toUtcIso`.
- Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах).
5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка.
## 2. Эталоны переиспользования
- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования.
- Backend: `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`).
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).
@@ -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,
@@ -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")
@@ -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,
@@ -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)
@@ -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,
@@ -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
}
@@ -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(
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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)
}
@@ -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,
)
@@ -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<FlightLineDTO> = 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 фабрика геометрии
@@ -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<URI>,
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": {
@@ -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,
@@ -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)) {
@@ -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) {
@@ -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)
@@ -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
@@ -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(
@@ -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")
}
}
@@ -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),
)
)
@@ -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),
@@ -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
@@ -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
@@ -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 {
@@ -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"
}
@@ -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
);
@@ -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")
});
});
@@ -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([]);
});
}
});
+3 -1
View File
@@ -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"]
}
@@ -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,
/** Идентификатор пункта закладки */
@@ -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())
@@ -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)
@@ -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(
@@ -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())
@@ -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 = "")
@@ -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)
@@ -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)
@@ -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 = "")
@@ -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<SpacecraftPoints> {
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()
@@ -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)
@@ -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))
}
}
@@ -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<AcceptedPlanSlotsUpdateJobEntity> =
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
@@ -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)
@@ -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
}
@@ -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
@@ -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"
@@ -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(
@@ -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<PlatformCatalogItem>) {
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")
@@ -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)
@@ -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))
}
}
@@ -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<VisibilityRefreshJobEntity> =
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)
}
}
@@ -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 без зоны"
)
}
}
@@ -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)) хранит микросекунды. Парсер обязан
@@ -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"
)
@@ -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)
@@ -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)
@@ -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 <T> anyNonNull(): T {
@@ -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())
}
@@ -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<Consumer<TransactionStatus>>(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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
private fun <T> anyNullable(): T? = org.mockito.ArgumentMatchers.any()
}
@@ -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
)
@@ -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
)
}
@@ -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)
@@ -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,
@@ -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
@@ -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
@@ -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?,
@@ -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,
@@ -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 = ""
)
@@ -426,7 +426,7 @@ private val docReqCovName ="req_cov_"
timeStop: LocalDateTime? = null
): Iterable<CZMLEntity> {
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 }
@@ -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<WebClient.Builder>) {
@@ -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 = "",
@@ -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<SatellitePdcmSummaryDTO> = satelliteSummaries(LocalDateTime.now())
fun satelliteSummaries(): List<SatellitePdcmSummaryDTO> = satelliteSummaries(LocalDateTime.now(ZoneOffset.UTC))
fun satelliteSummaries(referenceTime: LocalDateTime): List<SatellitePdcmSummaryDTO> {
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()
@@ -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")
}
}
@@ -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)
@@ -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 = "",
@@ -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,
@@ -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"),
@@ -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")