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:
@@ -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 изменений
|
||||
|
||||
@@ -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`).
|
||||
- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча).
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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")
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+4
-3
@@ -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
|
||||
}
|
||||
|
||||
+7
-6
@@ -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(
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+4
-3
@@ -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
-2
@@ -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,
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+6
-5
@@ -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)
|
||||
}
|
||||
|
||||
+5
-2
@@ -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,
|
||||
)
|
||||
|
||||
+3
-2
@@ -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 фабрика геометрии
|
||||
|
||||
+22
-2
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+17
-7
@@ -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)) {
|
||||
|
||||
+35
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+47
@@ -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")
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+7
-6
@@ -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),
|
||||
|
||||
+2
-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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+4
-3
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
/** Идентификатор пункта закладки */
|
||||
|
||||
+3
-2
@@ -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())
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+2
-1
@@ -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())
|
||||
|
||||
+2
-1
@@ -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 = "")
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+3
-2
@@ -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 = "")
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+6
-4
@@ -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)
|
||||
|
||||
+2
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+6
-5
@@ -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)
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+6
-4
@@ -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")
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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 без зоны"
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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)) хранит микросекунды. Парсер обязан
|
||||
|
||||
+2
-1
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+6
-5
@@ -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)
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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())
|
||||
}
|
||||
|
||||
+90
@@ -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()
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
)
|
||||
|
||||
+2
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+5
@@ -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?,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+4
@@ -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 = ""
|
||||
)
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
+3
-2
@@ -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 = "",
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+62
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+5
-2
@@ -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"),
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user