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
@@ -4,7 +4,7 @@ import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.time.LocalDateTime
@Entity
@Table(name = "drop_modes")
@@ -12,7 +12,7 @@ import java.time.OffsetDateTime
class DropModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
timeStart: LocalDateTime? = null,
revolution: Long = 0,
@Column(name = "station")
@@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.time.OffsetDateTime
import java.time.LocalDateTime
@Entity
@Table(name = "modes")
@@ -29,7 +29,7 @@ abstract class ModeEntity(
open var plan: PlanEntity? = null,
@Column(name = "time_start")
open var timeStart: OffsetDateTime? = null,
open var timeStart: LocalDateTime? = null,
@Column(name = "revolution", nullable = false)
open var revolution: Long = 0
@@ -7,7 +7,7 @@ import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.OffsetDateTime
import java.time.LocalDateTime
@Entity
@Table(name = "survey_modes")
@@ -15,7 +15,7 @@ import java.time.OffsetDateTime
class SurveyModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
timeStart: LocalDateTime? = null,
revolution: Long = 0,
@Column(name = "lat", nullable = false)
@@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
interface ModeRepository : JpaRepository<ModeEntity, Long> {
@@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<ModeEntity>
fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId: Long,
timeStop: OffsetDateTime
timeStop: LocalDateTime
): List<ModeEntity>
fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long
@@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
@@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
@Param("satelliteNumber") satelliteNumber: String,
@Param("revolution") revolution: Long,
@Param("timeStartFrom") timeStartFrom: OffsetDateTime,
@Param("timeStartTo") timeStartTo: OffsetDateTime
@Param("timeStartFrom") timeStartFrom: LocalDateTime,
@Param("timeStartTo") timeStartTo: LocalDateTime
): List<SurveyModeEntity>
}
@@ -62,7 +62,6 @@ 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
@@ -305,7 +304,7 @@ class MissionPlaningService(
val modes = modeRepository
.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId,
timeStop.atOffset(ZoneOffset.UTC)
timeStop
)
.filter { it.intersects(timeStart, timeStop) }
@@ -346,27 +345,18 @@ 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()
/**
* Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт).
* Применяется при fork-forward-клоне: включения строго вне окна не клонируются.
*/
private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean {
val modeStart = timeStart?.toUtcLocalDateTime() ?: return false
val modeStart = timeStart ?: return false
val modeStop = modeStop() ?: return false
return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop)
}
private fun ModeEntity.modeStop(): LocalDateTime? {
val modeStart = timeStart?.toUtcLocalDateTime() ?: return null
val modeStart = timeStart ?: return null
val durationSeconds = when (this) {
is SurveyModeEntity -> duration
is DropModeEntity -> duration
@@ -731,7 +721,7 @@ class MissionPlaningService(
val saved = surveyModeRepository.save(
SurveyModeEntity(
plan = plan,
timeStart = built.timeStart.atOffset(ZoneOffset.UTC),
timeStart = built.timeStart,
revolution = built.revolution,
lat = built.lat,
long = built.longitude,
@@ -805,7 +795,7 @@ class MissionPlaningService(
}
val start = survey.timeStart
?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
val surveyEnd = start.toUtcLocalDateTime()
val surveyEnd = start
.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(req.zoneStart)) {
throw CustomErrorException(
@@ -826,7 +816,7 @@ class MissionPlaningService(
val dropMode = dropModeRepository.save(
DropModeEntity(
plan = plan,
timeStart = req.zoneStart.atOffset(ZoneOffset.UTC),
timeStart = req.zoneStart,
revolution = req.revolution,
station = req.station,
duration = usedDuration
@@ -867,7 +857,7 @@ class MissionPlaningService(
}
val built = buildContour(req)
survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC)
survey.timeStart = built.timeStart
survey.revolution = built.revolution
survey.lat = built.lat
survey.long = built.longitude
@@ -1164,12 +1154,11 @@ class MissionPlaningService(
val iterator = unassigned.iterator()
while (iterator.hasNext()) {
val survey = iterator.next()
val surveyStartOffset = survey.timeStart
if (surveyStartOffset == null) {
val surveyStart = survey.timeStart
if (surveyStart == null) {
iterator.remove()
continue
}
val surveyStart = surveyStartOffset.toUtcLocalDateTime()
val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(zoneStart)) {
@@ -1193,7 +1182,7 @@ class MissionPlaningService(
val dropMode = dropModeRepository.save(
DropModeEntity(
plan = plan,
timeStart = zoneStart.atOffset(ZoneOffset.UTC),
timeStart = zoneStart,
revolution = zone.revolution,
station = zone.stationId.toString(),
duration = usedDuration
@@ -1294,8 +1283,8 @@ class MissionPlaningService(
@Transactional(readOnly = true)
fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? {
val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1)
val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1)
val searchStart = routePassport.intervalBegin.minusSeconds(1)
val searchEnd = routePassport.intervalBegin.plusSeconds(1)
val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
satelliteNumber = routePassport.kaShort.trim(),
revolution = routePassport.orbitNumber,
@@ -1305,7 +1294,7 @@ class MissionPlaningService(
return candidates.minByOrNull { candidate ->
val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE
abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis())
abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis())
}
}
@@ -6,7 +6,6 @@ import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.ZoneOffset
abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
override fun prepareSurvey(
@@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
plan: PlanEntity
): SurveyModeEntity = SurveyModeEntity(
plan = plan,
timeStart = surv.startTime.atOffset(ZoneOffset.UTC),
timeStart = surv.startTime,
revolution = surv.revolution,
lat = surv.lat,
long = surv.longitude,
@@ -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))