pcp: откат UTC-слоя — сквозной naive LocalDateTime без зоны на проводе

Возврат к модели «настенных часов»: одна ISO-строка без зоны
("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к
часовому поясу, сдвигаться нечему.

Бэкенд — снять зону с границы API:
- DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация
  atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах:
  tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos,
  complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO.
- Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader,
  imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их
  регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310
  (JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен.
- Kafka-конверт KafkaMessage.time сериализуется ISO без офсета.
- mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис
  переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate
  мапит на naive без миграции — прецедент MissionEntity/PlanEntity).
- RequestServiceClient (dynamic) синхронизирован по типу с request-service.

Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов
оставлен (выбор настенных часов UTC, не зона на проводе).

Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z
(после удаления толерантного десериализатора штатный парсер падал бы на Z).
Дисплейные хелперы (показ «как пришло», без сдвига) сохранены.

Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация
офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md.

Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с
ранее опубликованным офсетом +00:00 дренировать/проиграть.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:03:52 +03:00
parent 0e534c5bd5
commit 3638afdb85
57 changed files with 230 additions and 1063 deletions
@@ -27,8 +27,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -263,27 +261,24 @@ class MissionCloneTest {
val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1)
planStore[plan.id!!] = plan
val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0
val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration)
val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration)
val s1 = survey(plan, surveyStart, duration)
val s2 = survey(plan, surveyStart.plusMinutes(1), duration)
val d = drop(plan, surveyStart.plusMinutes(5))
return SourceFixture(mission.missionId, plan, s1, s2, d)
}
private fun survey(plan: PlanEntity, startOffset: OffsetDateTime, duration: Double = 300.0): SurveyModeEntity {
private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0): SurveyModeEntity {
val id = ++modeAutoId
val s = SurveyModeEntity(id = id, plan = plan, timeStart = startOffset, revolution = id,
val s = SurveyModeEntity(id = id, plan = plan, timeStart = start, revolution = id,
lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL,
status = SurveyModeStatus.PLANNED)
surveyStore[id] = s
return s
}
private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0) =
survey(plan, start.atOffset(ZoneOffset.UTC), duration)
private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity {
val id = ++modeAutoId
val d = DropModeEntity(id = id, plan = plan, timeStart = start.atOffset(ZoneOffset.UTC),
val d = DropModeEntity(id = id, plan = plan, timeStart = start,
revolution = id, station = "102", duration = 60.0)
dropStore[id] = d
return d
@@ -32,7 +32,6 @@ import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
class MissionEditModesTest {
@@ -262,7 +261,7 @@ class MissionEditModesTest {
private fun survey(): SurveyModeEntity {
val id = ++autoId
val s = SurveyModeEntity(
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC),
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0),
revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0,
status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL
)
@@ -273,7 +272,7 @@ class MissionEditModesTest {
private fun drop(): DropModeEntity {
val id = ++autoId
val d = DropModeEntity(
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC),
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0),
revolution = id, station = "102", duration = 60.0
)
dropStore[id] = d
@@ -41,8 +41,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
import java.math.BigDecimal
@@ -93,7 +91,7 @@ class MissionPlaningServiceTest {
val overlappingSurvey = SurveyModeEntity(
id = 1L,
plan = plan,
timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.minusMinutes(5),
revolution = 10,
lat = 55.0,
long = 37.0,
@@ -105,7 +103,7 @@ class MissionPlaningServiceTest {
val inWindowSurvey = SurveyModeEntity(
id = 2L,
plan = plan,
timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.plusMinutes(10),
revolution = 11,
lat = 56.0,
long = 38.0,
@@ -117,7 +115,7 @@ class MissionPlaningServiceTest {
val inWindowDrop = DropModeEntity(
id = 3L,
plan = plan,
timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC),
timeStart = intervalStart.plusMinutes(20),
revolution = 12,
station = "station-1",
duration = 120.0
@@ -126,7 +124,7 @@ class MissionPlaningServiceTest {
`when`(
modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
77,
intervalStop.atOffset(ZoneOffset.UTC)
intervalStop
)
).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop))
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn(
@@ -153,41 +151,6 @@ 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) {
@@ -223,12 +186,12 @@ class MissionPlaningServiceTest {
)
val exactMode = SurveyModeEntity(
id = 10L,
timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC),
timeStart = routePassport.intervalBegin,
revolution = 321
)
val nearMode = SurveyModeEntity(
id = 11L,
timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC),
timeStart = routePassport.intervalBegin.plusSeconds(1),
revolution = 321
)
@@ -236,8 +199,8 @@ class MissionPlaningServiceTest {
surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
"22",
321,
OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC),
OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC)
routePassport.intervalBegin.minusSeconds(1),
routePassport.intervalBegin.plusSeconds(1)
)
).thenReturn(listOf(nearMode, exactMode))