pcp: миграция времени плана ТГУ на явный Z (OffsetDateTime)

PlanResponse (pcp-tgu-service) и зависящий от него TguPlanResponse (pcp-ui-service)
переведены с naive LocalDateTime на OffsetDateTime в UTC → на проводе явная зона Z.
Домен/БД (PlannedPlanEntity/CalculatedPlan) остаются naive LocalDateTime (= UTC);
конвертация только на границе API (atOffset(ZoneOffset.UTC)). Потребитель один
(ui-service.TguPlanningService), обновлён синхронно; фронт parseUtc совместим с Z.

- контрактные тесты обновлены на Z (PlanResponseContractTest, TguPlanningDtoContractTest);
- тестовые фикстуры/ассерты приведены к OffsetDateTime/Z (PlanQueryServiceTest,
  CatalogControllerTest, TguPlanningServiceTest);
- docs/standards/DATETIME_UTC.md: отражена миграция и обновлена таблица сервисов.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 22:02:13 +03:00
parent 260583b134
commit c2e2b2922f
8 changed files with 68 additions and 52 deletions
@@ -2,11 +2,13 @@ package space.nstart.pcp.slots_service.dto.tgu
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID
/*
* Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как
* naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC.
* Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) —
* `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` — тоже с `Z`. `LocalDateTime`
* (напр. [TguPlanDecisionMessage.decisionTime]) — naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime.
*/
data class TguPlatformResponse(
@@ -23,8 +25,8 @@ data class TguPlatformResponse(
data class TguPlanResponse(
val planId: UUID,
val spacecraftId: String,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val startTime: OffsetDateTime,
val endTime: OffsetDateTime,
val kppId: String,
val status: String
)
@@ -45,6 +45,8 @@ import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import space.nstart.pcp.slots_service.service.TguPlanningService
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -210,8 +212,8 @@ class CatalogControllerTest {
TguPlanResponse(
planId = planId,
spacecraftId = "56756",
startTime = LocalDateTime.of(2026, 5, 29, 10, 0),
endTime = LocalDateTime.of(2026, 5, 29, 10, 15),
startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC),
endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC),
kppId = "KPP-1",
status = "WAITING_DECISION"
)
@@ -6,13 +6,15 @@ 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.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
* Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service:
* naive `LocalDateTime` → ISO без зоны (= UTC), `Instant` → ISO с `Z`
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта.
* времена плана ([TguPlanResponse]) — `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service),
* `Instant` ([TguPlatformResponse.validFrom]) — тоже с `Z` (см. docs/standards/DATETIME_UTC.md).
* Тест ловит молчаливый дрейф контракта.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/
@@ -22,23 +24,21 @@ class TguPlanningDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as naive ISO without zone`() {
fun `serializes plan times as ISO with explicit Z 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),
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
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 без зоны"
)
assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z")
}
@Test
@@ -116,7 +116,7 @@ class TguPlanningServiceTest {
exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond(
exchange,
200,
"""[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]"""
"""[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION"}]"""
)
exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond(
exchange,