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
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunResponseDTO(
val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO(
val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>,
val requestedBy: String?,
val createdAt: OffsetDateTime,
val startedAt: OffsetDateTime?,
val finishedAt: OffsetDateTime?,
val createdAt: LocalDateTime,
val startedAt: LocalDateTime?,
val finishedAt: LocalDateTime?,
val durationMs: Long?,
val errorMessage: String?,
val calculatedModesCount: Int,
@@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class ComplexPlanRunSummaryDTO(
val runId: Long,
@@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO(
val intervalEnd: LocalDateTime,
val satelliteIds: List<Long>,
val requestedBy: String?,
val createdAt: OffsetDateTime,
val startedAt: OffsetDateTime?,
val finishedAt: OffsetDateTime?,
val createdAt: LocalDateTime,
val startedAt: LocalDateTime?,
val finishedAt: LocalDateTime?,
val durationMs: Long?,
val errorMessage: String?,
val calculatedModesCount: Int,
@@ -4,9 +4,6 @@ import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
@Component
class ComplexPlanRunMapper {
@@ -19,9 +16,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(),
startedAt = entity.startedAt?.toUtcOffset(),
finishedAt = entity.finishedAt?.toUtcOffset(),
createdAt = entity.createdAt,
startedAt = entity.startedAt,
finishedAt = entity.finishedAt,
durationMs = entity.durationMs,
errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount,
@@ -46,9 +43,9 @@ class ComplexPlanRunMapper {
intervalEnd = entity.intervalEnd,
satelliteIds = satelliteIds(entity),
requestedBy = entity.requestedBy,
createdAt = entity.createdAt.toUtcOffset(),
startedAt = entity.startedAt?.toUtcOffset(),
finishedAt = entity.finishedAt?.toUtcOffset(),
createdAt = entity.createdAt,
startedAt = entity.startedAt,
finishedAt = entity.finishedAt,
durationMs = entity.durationMs,
errorMessage = entity.errorMessage,
calculatedModesCount = entity.calculatedModesCount,
@@ -75,8 +72,4 @@ class ComplexPlanRunMapper {
.mapNotNull { it.id }
.distinct()
.sorted()
private fun LocalDateTime.toUtcOffset(): OffsetDateTime =
atOffset(ZoneOffset.UTC)
}
@@ -50,9 +50,9 @@ class ComplexPlanControllerTest {
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC),
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC),
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC),
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59),
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0),
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1),
durationMs = 60000,
errorMessage = null,
calculatedModesCount = 1,
@@ -22,9 +22,9 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistence
import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ComplexPlanRunApiTest {
@@ -81,7 +81,7 @@ class ComplexPlanRunApiTest {
assertEquals(10.0, response.totalRequestsArea)
assertEquals(4.0, response.coveredArea)
assertEquals(40.0, response.coveredAreaPercent)
assertEquals(ZoneOffset.UTC, response.createdAt.offset)
assertNotNull(response.createdAt)
}
@Test
@@ -16,7 +16,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000,
errorMessage = null,
calculatedModesCount = 1,
@@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest {
intervalEnd = request.intervalEnd,
satelliteIds = listOf(22L),
requestedBy = "tester",
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
createdAt = request.intervalStart.minusMinutes(1),
startedAt = request.intervalStart,
finishedAt = request.intervalStart.plusMinutes(1),
durationMs = 60_000,
errorMessage = null,
calculatedModesCount = 0,
@@ -6,34 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper {
val mapper = ObjectMapper()
// Настраиваем формат для LocalDateTime
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz")
// module.addSerializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeSerializer(formatter)
// )
// module.addDeserializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeDeserializer(formatter)
// )
mapper.registerModule(JavaTimeModule())
.registerKotlinModule()
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
// Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI.
.registerModule(PcpTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
return mapper
}
}
@@ -48,6 +48,7 @@ import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
@@ -841,7 +842,7 @@ class BasicPlanCalculator(
val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) {
observationParametersDebugFileWriter.write(
ObservationParametersDebugFileDTO(
createdAt = Instant.now(),
createdAt = LocalDateTime.now(ZoneOffset.UTC),
requestId = request.requestId,
satelliteIds = selectedSatelliteIds,
missingSatelliteIds = missingSatelliteIds,
@@ -7,8 +7,7 @@ import org.springframework.web.client.HttpClientErrorException
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.time.LocalDateTime
import java.util.UUID
/** Получение заявок из pcp-request-service /v1 API. */
@@ -44,10 +43,9 @@ class RequestServiceClient {
id = id,
name = name,
geometry = geometry,
// Нормализуем к 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(),
// pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC).
intervalBegin = beginDateTime,
intervalEnd = endDateTime,
resolution = resolution(),
importance = importance,
)
@@ -66,16 +64,16 @@ data class RequestResponseDto(
val surveyType: String = "",
val geometry: String,
val importance: Double,
val beginDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false,
val optics: SurveyParamsDto? = null,
val rsa: SurveyParamsDto? = null,
val coverage: CoverageStateDto = CoverageStateDto(),
val createdAt: OffsetDateTime? = null,
val updatedAt: OffsetDateTime? = null,
val deletedAt: OffsetDateTime? = null,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO(
@get:JsonProperty("createdAt")
@param:JsonProperty("createdAt")
val createdAt: Instant,
val createdAt: LocalDateTime,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
@@ -9,7 +9,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.Test
@@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest {
outputDir = tempDir.resolve("observation-parameters").toString()
)
val artifact = ObservationParametersDebugFileDTO(
createdAt = Instant.parse("2026-05-05T10:15:30Z"),
createdAt = LocalDateTime.parse("2026-05-05T10:15:30"),
requestId = requestId,
satelliteIds = listOf(1001L),
missingSatelliteIds = emptyList(),
@@ -62,29 +62,11 @@ 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",
beginDateTime: String = "2026-01-02T03:04:05",
endDateTime: String = "2026-01-03T04:05:06",
): HttpServer =
HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange ->
@@ -110,8 +92,8 @@ class RequestServiceClientTest {
},
"rsa": null,
"coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-01T00:00:00",
"deletedAt": null
}
""".trimIndent()
@@ -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))
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
@RestController
@@ -41,10 +41,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@@ -73,10 +73,10 @@ class RequestController(
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotNull
import java.time.OffsetDateTime
import java.time.LocalDateTime
/**
* Текущие настройки сетки.
@@ -38,5 +38,5 @@ data class GridRebuildResponseDto(
/** Дата-время завершения перестроения в UTC. */
@field:JsonFormat(shape = JsonFormat.Shape.STRING)
val rebuiltAt: OffsetDateTime,
val rebuiltAt: LocalDateTime,
)
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
/**
@@ -48,11 +48,11 @@ data class CreateRequestDto(
/** Начало временного окна выполнения заявки. */
@field:NotNull
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
@field:NotNull
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -120,10 +120,10 @@ data class RequestResponseDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -141,13 +141,13 @@ data class RequestResponseDto(
val coverage: CoverageStateDto,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -170,10 +170,10 @@ data class RequestSummaryResponseDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -185,13 +185,13 @@ data class RequestSummaryResponseDto(
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -237,10 +237,10 @@ data class RequestMapItemDto(
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
val beginDateTime: LocalDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
val endDateTime: LocalDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
@@ -252,13 +252,13 @@ data class RequestMapItemDto(
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
val createdAt: LocalDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
val updatedAt: LocalDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
val deletedAt: LocalDateTime? = null,
)
/**
@@ -317,7 +317,7 @@ data class DeleteRequestResponseDto(
val id: UUID,
/** Время soft delete. */
val deletedAt: OffsetDateTime,
val deletedAt: LocalDateTime,
)
/**
@@ -421,16 +421,16 @@ data class RequestListFilter(
val highPriorityTransmit: Boolean? = null,
/** Нижняя граница beginDateTime, включительно. */
val beginFrom: OffsetDateTime? = null,
val beginFrom: LocalDateTime? = null,
/** Верхняя граница beginDateTime, включительно. */
val beginTo: OffsetDateTime? = null,
val beginTo: LocalDateTime? = null,
/** Нижняя граница endDateTime, включительно. */
val endFrom: OffsetDateTime? = null,
val endFrom: LocalDateTime? = null,
/** Верхняя граница endDateTime, включительно. */
val endTo: OffsetDateTime? = null,
val endTo: LocalDateTime? = null,
/** Включать soft-deleted заявки в результат. */
val includeDeleted: Boolean = false,
@@ -7,7 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.time.ZoneOffset
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@@ -41,7 +41,7 @@ class GridRebuildService(
return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC),
rebuiltAt = LocalDateTime.now(ZoneOffset.UTC),
)
}
@@ -39,7 +39,6 @@ import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
@@ -82,8 +81,8 @@ class RequestService(
remainingArea = geometryArea,
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT,
importance = importance,
beginDateTime = request.beginDateTime.toUtcLocalDateTime(),
endDateTime = request.endDateTime.toUtcLocalDateTime(),
beginDateTime = request.beginDateTime,
endDateTime = request.endDateTime,
kpp = request.kpp.toMutableList(),
highPriorityTransmit = request.highPriorityTransmit,
surveyType = surveyType,
@@ -162,7 +161,7 @@ class RequestService(
requestCellRepository.deleteByRequestId(request.id)
return DeleteRequestResponseDto(
id = request.id,
deletedAt = deletedAt.toOffsetDateTime(),
deletedAt = deletedAt,
)
}
@@ -206,10 +205,10 @@ class RequestService(
surveyType = query.surveyType?.toEntity(),
kpp = query.kpp,
highPriorityTransmit = query.highPriorityTransmit,
beginFrom = query.beginFrom?.toUtcLocalDateTime(),
beginTo = query.beginTo?.toUtcLocalDateTime(),
endFrom = query.endFrom?.toUtcLocalDateTime(),
endTo = query.endTo?.toUtcLocalDateTime(),
beginFrom = query.beginFrom,
beginTo = query.beginTo,
endFrom = query.endFrom,
endTo = query.endTo,
pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()),
)
@@ -223,16 +222,16 @@ class RequestService(
surveyType = surveyType.toDto(),
geometry = geometry,
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
beginDateTime = beginDateTime,
endDateTime = endDateTime,
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
optics = optics,
rsa = rsa,
coverage = coverage(),
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}
@@ -252,14 +251,14 @@ class RequestService(
status = status.toDto(),
surveyType = surveyType.toDto(),
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
beginDateTime = beginDateTime,
endDateTime = endDateTime,
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}
@@ -271,14 +270,14 @@ class RequestService(
surveyType = surveyType.toDto(),
geometry = geometry,
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
beginDateTime = beginDateTime,
endDateTime = endDateTime,
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}
@@ -361,10 +360,6 @@ class RequestService(
return Sort.by(direction, property)
}
private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC)
private fun Throwable.isDuplicateRequestId(): Boolean {
return generateSequence(this) { throwable -> throwable.cause }
.filterIsInstance<ConstraintViolationException>()
@@ -21,7 +21,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.Optional
class GridManagementControllerTest {
@@ -97,7 +97,7 @@ class GridManagementControllerTest {
GridRebuildResponseDto(
cellsCount = 2,
rebuiltRequestProjectionsCount = 2,
rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"),
rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30),
)
).`when`(gridRebuildService).rebuildGrid()
@@ -105,7 +105,7 @@ class GridManagementControllerTest {
.andExpect(status().isOk)
.andExpect(jsonPath("$.cellsCount").value(2))
.andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z"))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30"))
}
@Test
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -25,8 +25,8 @@ class RequestApiDtoContractTest {
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
@@ -43,7 +43,7 @@ class RequestApiDtoContractTest {
assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id)
assertEquals("Тест1", request.name)
assertEquals(10.0, request.importance)
assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime)
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime)
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
}
@@ -55,8 +55,8 @@ class RequestApiDtoContractTest {
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59",
"surveyType": "OPTICS",
"optics": {
"resultType": "PANCHROMATIC",
@@ -78,8 +78,8 @@ class RequestApiDtoContractTest {
name = "Тест1",
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
importance = 10.0,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
@@ -87,40 +87,7 @@ class RequestApiDtoContractTest {
),
)
assertFalse(json.contains("surveyType"))
assertFalse(json.contains("app_type"))
assertFalse(json.contains("appType"))
assertFalse(json.contains("AppType"))
assertFalse(json.contains("requestType"))
}
@Test
fun `cells list response dto matches openapi fields`() {
val json = objectMapper.writeValueAsString(
CellsListResponseDto(
items = listOf(
CellSummaryResponseDto(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
),
page = 0,
size = 50,
totalItems = 1,
totalPages = 1,
)
)
assertEquals(
setOf("items", "page", "size", "totalItems", "totalPages"),
objectMapper.readTree(json).fieldNames().asSequence().toSet(),
)
assertEquals(
setOf("cellNum", "latitude", "longitude", "importance", "contour"),
objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(),
)
val node = objectMapper.readTree(json)
assertFalse(node.has("surveyType"), "surveyType should not be serialized")
}
}
@@ -3,18 +3,14 @@ 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.Assertions.assertFalse
import org.junit.jupiter.api.Test
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime`
* сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф
* (смену типа поля на naive `LocalDateTime` или Jackson-конфига).
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
* Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` → ISO без зоны
* (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
*/
class RequestResponseDtoContractTest {
@@ -22,7 +18,7 @@ class RequestResponseDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes request times as ISO with explicit Z zone`() {
fun `serializes request times as naive ISO without zone`() {
val dto = RequestResponseDto(
id = UUID.fromString("00000000-0000-0000-0000-000000000001"),
name = "request",
@@ -30,18 +26,18 @@ class RequestResponseDtoContractTest {
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),
beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
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)
createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0),
updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0)
)
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")
assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText())
assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText())
assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z")
}
}
@@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.test.assertEquals
@@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests(
RequestListFilter(
beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
)
)
@@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests(
RequestListFilter(
endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
)
)
@@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
@@ -286,8 +285,8 @@ class RequestServiceTest {
name = REQUEST_NAME,
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
importance = REQUEST_IMPORTANCE,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
@@ -86,8 +86,8 @@ export function Tgu2DMapTab({
try {
const result = await fetchRequestsForMap(
{
endFrom: new Date(fromMs).toISOString(),
beginTo: new Date(toMs).toISOString()
endFrom: new Date(fromMs).toISOString().slice(0, 19),
beginTo: new Date(toMs).toISOString().slice(0, 19)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
@@ -82,7 +82,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{ endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() },
{ endFrom: new Date(fromMs).toISOString().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) },
(loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
);
setRequestsState((current) => ({
+3 -4
View File
@@ -42,10 +42,9 @@ export function toUtcInputValue(ms: number): string {
}
/**
* Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`.
* Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service):
* пользователь вводит время по UTC, отправляем его же как UTC-смещение.
* Naive-строку (из `datetime-local`) возвращает как naive ISO без зоны (`YYYY-MM-DDTHH:mm:ss`).
* Все бэкенды ожидают `LocalDateTime` без зоны.
*/
export function toUtcIso(value: string): string {
return new Date(parseUtc(value)).toISOString();
return new Date(parseUtc(value)).toISOString().slice(0, 19);
}
@@ -10,7 +10,6 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper
import io.camunda.client.impl.CamundaObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule
@Configuration
class CamundaJacksonConfig {
@@ -25,10 +24,6 @@ class CamundaJacksonConfig {
return JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule())
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
// Модуль из pcp-types-lib; покрывает Jackson-2 путь (этот ручной маппер для Camunda).
// REST-границу (Jackson 3) покрывает PcpTimeModuleV3 (см. RestJacksonConfig).
.addModule(PcpTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()
}
@@ -1,94 +0,0 @@
package space.nstart.pcp_tgu_service.config
import tools.jackson.core.JsonParser
import tools.jackson.core.JsonToken
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
import tools.jackson.databind.ext.javatime.deser.InstantDeserializer
import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer
import tools.jackson.databind.module.SimpleModule
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
/**
* Слой толерантности времени для **Jackson 3** (`tools.jackson.*`).
*
* Близнец библиотечного `PcpTimeModule` (Jackson 2): то же поведение читать время в обоих
* форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию.
* Нужен отдельно, потому что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST HTTP message
* converters (MVC), а он не видит Jackson-2 модули. Регистрируется как бин
* `tools.jackson.databind.JacksonModule` ([RestJacksonConfig]) `JacksonAutoConfiguration` собирает
* такие бины в общий `JsonMapper`.
*
* Канонический источник поведения `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`;
* правки синхронизировать. См. docs/standards/DATETIME_UTC.md.
*/
class PcpTimeModuleV3 : SimpleModule(NAME) {
init {
addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3())
addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3())
addDeserializer(Instant::class.java, TolerantInstantDeserializerV3())
}
companion object {
const val NAME = "PcpTimeModuleV3"
}
}
private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer<LocalDateTime>() {
private val default: ValueDeserializer<LocalDateTime> = LocalDateTimeDeserializer.INSTANCE
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.string.trim()
if (text.isEmpty()) return null
try {
return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
} catch (_: DateTimeParseException) {
// naive-строка без зоны — трактуем как UTC
}
}
return default.deserialize(p, ctxt)
}
}
private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer<OffsetDateTime>() {
private val default: ValueDeserializer<OffsetDateTime> = InstantDeserializer.OFFSET_DATE_TIME
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.string.trim()
if (text.isEmpty()) return null
try {
return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC)
} catch (_: DateTimeParseException) {
return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC)
}
}
return default.deserialize(p, ctxt)
}
}
private class TolerantInstantDeserializerV3 : ValueDeserializer<Instant>() {
private val default: ValueDeserializer<Instant> = InstantDeserializer.INSTANT
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? {
if (p.currentToken() == JsonToken.VALUE_STRING) {
val text = p.string.trim()
if (text.isEmpty()) return null
try {
return OffsetDateTime.parse(text).toInstant()
} catch (_: DateTimeParseException) {
return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC)
}
}
return default.deserialize(p, ctxt)
}
}
@@ -1,21 +1,4 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import tools.jackson.databind.JacksonModule
/**
* Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3
* `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC).
*
* `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста
* и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин
* этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`),
* как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md.
*/
@Configuration
class RestJacksonConfig {
@Bean
fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3()
}
// Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг.
// LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS).
@@ -2,24 +2,19 @@ package space.nstart.pcp_tgu_service.dto
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
* «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена UTC ISO с `Z`
* (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC).
*/
/** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */
data class ZrvWindowResponse(
val startTime: OffsetDateTime,
val endTime: OffsetDateTime,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val kppId: String
) {
companion object {
fun from(window: CalculatedPlan): ZrvWindowResponse =
ZrvWindowResponse(
startTime = window.startTime.atOffset(ZoneOffset.UTC),
endTime = window.endTime.atOffset(ZoneOffset.UTC),
startTime = window.startTime,
endTime = window.endTime,
kppId = window.kppId
)
}
@@ -4,17 +4,14 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.nio.charset.StandardCharsets
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
import java.util.UUID
/**
* DTO ответа REST API для плана.
*
* Времена ([startTime]/[endTime]) `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной
* зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее
* хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC);
* конвертация в `Z` происходит только на границе API.
* Времена ([startTime]/[endTime]) naive `LocalDateTime`, сериализуются как ISO-строка без зоны
* (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах.
*/
data class PlanResponse(
@@ -24,11 +21,11 @@ data class PlanResponse(
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Время начала плана (UTC, ISO с `Z`). */
val startTime: OffsetDateTime,
/** Время начала плана (ISO без зоны). */
val startTime: LocalDateTime,
/** Время окончания плана (UTC, ISO с `Z`). */
val endTime: OffsetDateTime,
/** Время окончания плана (ISO без зоны). */
val endTime: LocalDateTime,
/** Идентификатор пункта закладки */
val kppId: String,
@@ -46,9 +43,8 @@ data class PlanResponse(
PlanResponse(
planId = plan.planId,
spacecraftId = plan.spacecraftId,
// naive LocalDateTime в БД = UTC → отдаём с явной зоной Z.
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
startTime = plan.startTime,
endTime = plan.endTime,
kppId = plan.kppId,
status = plan.status
)
@@ -63,9 +59,8 @@ data class PlanResponse(
PlanResponse(
planId = deterministicPlanId(plan),
spacecraftId = plan.spacecraftId,
// naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z.
startTime = plan.startTime.atOffset(ZoneOffset.UTC),
endTime = plan.endTime.atOffset(ZoneOffset.UTC),
startTime = plan.startTime,
endTime = plan.endTime,
kppId = plan.kppId,
status = PlannedPlanStatus.PLANNED
)
@@ -1,55 +0,0 @@
package space.nstart.pcp_tgu_service.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import tools.jackson.databind.json.JsonMapper
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
/**
* Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает
* время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец
* библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md.
*/
class PcpTimeModuleV3Test {
private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build()
@Test
fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() {
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java)
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java)
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive)
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ)
}
@Test
fun `LocalDateTime normalizes an explicit offset to UTC`() {
// 01:00+03:00 == 28-го 22:00 UTC
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java)
assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset)
}
@Test
fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() {
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java)
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java)
assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive)
assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset)
}
@Test
fun `Instant reads naive(=UTC) and Z to the same moment`() {
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java)
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java)
val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant()
assertEquals(expected, fromNaive)
assertEquals(expected, fromZ)
}
}
@@ -3,19 +3,15 @@ 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.Assertions.assertFalse
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC ISO с явной зоной `Z`
* (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта смену типа поля
* (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
* Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` ISO без зоны
* (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
*/
class PlanResponseContractTest {
@@ -23,20 +19,20 @@ class PlanResponseContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as ISO with explicit Z zone`() {
fun `serializes plan times as naive ISO without zone`() {
val response = PlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544",
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
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:00Z", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z")
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
}
}
@@ -39,10 +39,9 @@ class PlanQueryServiceTest {
val result = service.getAllPlans(historyDays = 7)
assertEquals(2, result.size)
// PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC).
val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) }
val persistedResponse = result.first { it.startTime == persisted.startTime }
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status)
val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) }
val projectedResponse = result.first { it.startTime == freshProjected.startTime }
assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status)
// Отсортировано по startTime.
assertTrue(result[0].startTime <= result[1].startTime)
@@ -2,13 +2,11 @@ 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). Времена плана ([TguPlanResponse])
* `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` тоже с `Z`. `LocalDateTime`
* (напр. [TguPlanDecisionMessage.decisionTime]) naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime.
* Времена плана ([TguPlanResponse]) naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service).
* `Instant` ([TguPlatformResponse.validFrom/validTo]) с `Z` (как отдаёт НСИ-прокси).
*/
data class TguPlatformResponse(
@@ -25,8 +23,8 @@ data class TguPlatformResponse(
data class TguPlanResponse(
val planId: UUID,
val spacecraftId: String,
val startTime: OffsetDateTime,
val endTime: OffsetDateTime,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val kppId: String,
val status: String
)
@@ -45,8 +45,6 @@ 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
@@ -212,8 +210,8 @@ class CatalogControllerTest {
TguPlanResponse(
planId = planId,
spacecraftId = "56756",
startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC),
endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC),
startTime = LocalDateTime.of(2026, 5, 29, 10, 0, 0),
endTime = LocalDateTime.of(2026, 5, 29, 10, 15, 0),
kppId = "KPP-1",
status = "WAITING_DECISION"
)
@@ -3,20 +3,17 @@ 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.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service:
* времена плана ([TguPlanResponse]) `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service),
* `Instant` ([TguPlatformResponse.validFrom]) тоже с `Z` (см. docs/standards/DATETIME_UTC.md).
* Тест ловит молчаливый дрейф контракта.
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
* времена плана ([TguPlanResponse]) naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service),
* `Instant` ([TguPlatformResponse.validFrom]) с `Z` (как отдаёт НСИ-прокси).
*/
class TguPlanningDtoContractTest {
@@ -24,21 +21,21 @@ class TguPlanningDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes plan times as ISO with explicit Z zone`() {
fun `serializes plan times as naive ISO without zone`() {
val plan = TguPlanResponse(
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
spacecraftId = "25544",
startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
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:00Z", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText())
assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z")
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z")
}
@Test