pcp: единообразие времени (UTC) по группе сервисов
Фронт (pcp-tgu-ops-ui): - тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) / toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc. Бэкенд: - UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах. - Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе): - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime); - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime); - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC); - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()). - Контрактные сериализационные тесты формата на проводе (naive без зоны / Z): PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui). - KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ). Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z), docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
+7
-2
@@ -9,6 +9,11 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* DTO ответа REST API для плана.
|
||||
*
|
||||
* Времена ([startTime]/[endTime]) — `LocalDateTime` и сериализуются как ISO-строка без зоны
|
||||
* (напр. `"2026-05-29T01:00:00"`). По соглашению системы это **UTC** (см. `docs/standards/DATETIME_UTC.md`):
|
||||
* потребители обязаны парсить эти строки как UTC (фронт — через `utils/utcTime.parseUtc`), а не как
|
||||
* локальное время. Кандидат на миграцию к явному `Z` (`OffsetDateTime`/`Instant`) на границе API.
|
||||
*/
|
||||
data class PlanResponse(
|
||||
|
||||
@@ -18,10 +23,10 @@ data class PlanResponse(
|
||||
/** Идентификатор космического аппарата */
|
||||
val spacecraftId: String,
|
||||
|
||||
/** Время начала плана */
|
||||
/** Время начала плана (UTC, naive ISO без зоны). */
|
||||
val startTime: LocalDateTime,
|
||||
|
||||
/** Время окончания плана */
|
||||
/** Время окончания плана (UTC, naive ISO без зоны). */
|
||||
val endTime: LocalDateTime,
|
||||
|
||||
/** Идентификатор пункта закладки */
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -39,10 +40,10 @@ class AcceptedPlanSlotsUpdateJobEntity(
|
||||
var lastError: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(planId = UUID.randomUUID())
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -45,10 +46,10 @@ class PlanIssueAttemptEntity(
|
||||
var finishedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0)
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -48,10 +49,10 @@ class PlannedPlanEntity(
|
||||
var acceptedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,7 @@ class ProcessedPlanDecisionEventEntity(
|
||||
var eventId: UUID,
|
||||
|
||||
@Column(name = "received_at", nullable = false)
|
||||
var receivedAt: LocalDateTime = LocalDateTime.now()
|
||||
var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(eventId = UUID.randomUUID())
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -35,7 +36,7 @@ class SpacecraftPlanningStateEntity(
|
||||
var urgentReissueFrom: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(spacecraftId = "")
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Persisted snapshot КА из НСИ, используемый как вход для refresh jobs.
|
||||
@@ -44,10 +45,10 @@ class SpacecraftSnapshotEntity(
|
||||
var active: Boolean = true,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(spacecraftId = "", noradId = 0)
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Полный raw snapshot окон видимости КА из баллистики.
|
||||
@@ -40,10 +41,10 @@ class SpacecraftVisibilitySnapshotEntity(
|
||||
var windowsCount: Int? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN)
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Job на перечитывание окон видимости одного КА у баллистики.
|
||||
@@ -41,10 +42,10 @@ class VisibilityRefreshJobEntity(
|
||||
var rerunRequested: Boolean = false,
|
||||
|
||||
@Column(name = "requested_at", nullable = false)
|
||||
var requestedAt: LocalDateTime = LocalDateTime.now(),
|
||||
var requestedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
|
||||
constructor() : this(spacecraftId = "")
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.reactive.function.client.WebClientException
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@ class ExternalPointsClient(
|
||||
* которые разрешены для работы TGU.
|
||||
*/
|
||||
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
|
||||
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
|
||||
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES)
|
||||
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
|
||||
|
||||
return platformService.loadAllowedPlatforms()
|
||||
|
||||
+6
-4
@@ -8,7 +8,7 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
|
||||
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@@ -19,7 +19,6 @@ import java.time.temporal.ChronoUnit
|
||||
class VisibilityPayloadParser(
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
private val zoneId: ZoneId = ZoneId.systemDefault()
|
||||
|
||||
/** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */
|
||||
fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints {
|
||||
@@ -44,7 +43,10 @@ class VisibilityPayloadParser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client.
|
||||
* Разбирает UTC/ISO строку времени баллистики в naive `LocalDateTime` (по соглашению системы — UTC).
|
||||
*
|
||||
* Момент из `Instant.parse` берём в UTC (а не в поясе JVM): naive-поля `planned_plan.start_time/end_time`
|
||||
* по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет.
|
||||
*
|
||||
* Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time`
|
||||
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
|
||||
@@ -53,7 +55,7 @@ class VisibilityPayloadParser(
|
||||
*/
|
||||
private fun toLocalDateTime(value: String): LocalDateTime =
|
||||
try {
|
||||
Instant.parse(value).atZone(zoneId).toLocalDateTime()
|
||||
LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC)
|
||||
} catch (_: DateTimeParseException) {
|
||||
LocalDateTime.parse(value)
|
||||
}.truncatedTo(ChronoUnit.MICROS)
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,6 @@ class AcceptedPlanSlotsUpdateJobService(
|
||||
/** Idempotent enqueue: один planId получает максимум одну job. */
|
||||
@Transactional
|
||||
fun enqueue(planId: UUID) {
|
||||
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now())
|
||||
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now(ZoneOffset.UTC))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus
|
||||
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
|
||||
import java.net.InetAddress
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -35,7 +36,7 @@ class AcceptedPlanSlotsUpdateWorker(
|
||||
/** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */
|
||||
fun claimJobs(limit: Int): List<AcceptedPlanSlotsUpdateJobEntity> =
|
||||
transactionTemplate.execute {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit)
|
||||
jobs.forEach { job ->
|
||||
job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING
|
||||
@@ -69,7 +70,7 @@ class AcceptedPlanSlotsUpdateWorker(
|
||||
|
||||
private fun markDone(planId: UUID, processInstanceKey: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
|
||||
job.status = AcceptedPlanSlotsUpdateJobStatus.DONE
|
||||
job.processInstanceKey = processInstanceKey
|
||||
@@ -83,7 +84,7 @@ class AcceptedPlanSlotsUpdateWorker(
|
||||
|
||||
private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
|
||||
job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS
|
||||
job.processInstanceKey = processInstanceKey ?: job.processInstanceKey
|
||||
|
||||
+6
-5
@@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
|
||||
@@ -115,7 +116,7 @@ class HandlePlanDecisionUseCase(
|
||||
return
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
plan.status = terminalStatus
|
||||
plan.updatedAt = now
|
||||
plannedPlanRepository.save(plan)
|
||||
@@ -127,7 +128,7 @@ class HandlePlanDecisionUseCase(
|
||||
message: PlanDecisionMessage,
|
||||
attempt: PlanIssueAttemptEntity
|
||||
) {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
attempt.status = when (message.decision) {
|
||||
PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
|
||||
PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
|
||||
@@ -144,7 +145,7 @@ class HandlePlanDecisionUseCase(
|
||||
plan: PlannedPlanEntity,
|
||||
state: SpacecraftPlanningStateEntity
|
||||
) {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
|
||||
plan.status = PlannedPlanStatus.AWAITING_LAYIN
|
||||
@@ -170,7 +171,7 @@ class HandlePlanDecisionUseCase(
|
||||
plan: PlannedPlanEntity,
|
||||
state: SpacecraftPlanningStateEntity
|
||||
) {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
@@ -203,7 +204,7 @@ class HandlePlanDecisionUseCase(
|
||||
}
|
||||
|
||||
private fun claimEvent(eventId: java.util.UUID): Boolean =
|
||||
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
|
||||
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0
|
||||
|
||||
private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
|
||||
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage
|
||||
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА.
|
||||
@@ -25,7 +26,7 @@ class HandleStationParamsChangedUseCase(
|
||||
/** Идемпотентно по eventId; ставит refresh job по всем активным КА. */
|
||||
@Transactional
|
||||
fun handle(message: StationParamsChangedMessage) {
|
||||
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0
|
||||
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0
|
||||
if (!claimed) {
|
||||
return
|
||||
}
|
||||
|
||||
+5
-4
@@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,7 @@ class IssueDuePlanWorker(
|
||||
|
||||
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
|
||||
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val state = getOrCreateLockedState(spacecraftId)
|
||||
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
|
||||
return null
|
||||
@@ -132,7 +133,7 @@ class IssueDuePlanWorker(
|
||||
}
|
||||
|
||||
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
if (state.activeAttemptId != null) {
|
||||
@@ -207,7 +208,7 @@ class IssueDuePlanWorker(
|
||||
|
||||
private fun markStarted(response: ProcessStartResponse) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
|
||||
val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult
|
||||
|
||||
@@ -231,7 +232,7 @@ class IssueDuePlanWorker(
|
||||
|
||||
private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
|
||||
val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
|
||||
attempt.status = PlanIssueAttemptStatus.AMBIGUOUS
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ class PlanQueryService(
|
||||
.sortedBy { response -> response.startTime }
|
||||
|
||||
private fun historySince(historyDays: Long): LocalDateTime =
|
||||
LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
|
||||
LocalDateTime.now(ZoneOffset.UTC).minusDays(historyDays.coerceAtLeast(0))
|
||||
|
||||
private fun PlannedPlanEntity.businessKey(): String =
|
||||
"$spacecraftId|$startTime|$endTime|$kppId"
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Сохраняет raw visibility payload. Расписание считается on-demand из снапшота
|
||||
@@ -25,7 +26,7 @@ class SpacecraftVisibilitySnapshotService(
|
||||
val payloadHash = sha256(payloadJson)
|
||||
val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
|
||||
val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull()
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
if (existing == null) {
|
||||
spacecraftVisibilitySnapshotRepository.save(
|
||||
|
||||
+6
-4
@@ -5,7 +5,7 @@ import org.springframework.transaction.support.TransactionTemplate
|
||||
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs.
|
||||
@@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase(
|
||||
}
|
||||
|
||||
private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() }
|
||||
|
||||
allowedPlatforms.forEach { platform ->
|
||||
@@ -40,8 +40,10 @@ class SyncSpacecraftFromNsiUseCase(
|
||||
name = platform.name,
|
||||
status = platform.status,
|
||||
mission = platform.mission,
|
||||
validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(),
|
||||
validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(),
|
||||
// validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-стенку, а не пояс JVM
|
||||
// (по соглашению системы naive LocalDateTime = UTC) — иначе на не-UTC машине время уезжало бы.
|
||||
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) },
|
||||
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) },
|
||||
now = now
|
||||
)
|
||||
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC")
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -66,7 +67,7 @@ class TestPlanDecisionService(
|
||||
planId = plan.planId,
|
||||
attemptId = attempt.attemptId,
|
||||
decision = decision,
|
||||
decisionTime = LocalDateTime.now(),
|
||||
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
|
||||
)
|
||||
handlePlanDecisionUseCase.handle(message)
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Coalesce-сервис для visibility_refresh_job по spacecraftId.
|
||||
@@ -15,6 +16,6 @@ class VisibilityRefreshJobService(
|
||||
/** Ставит или переоткрывает refresh job без дублей. */
|
||||
@Transactional
|
||||
fun enqueue(spacecraftId: String, noradId: Long?, reason: String) {
|
||||
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now())
|
||||
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now(ZoneOffset.UTC))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
|
||||
import java.net.InetAddress
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
@@ -38,7 +39,7 @@ class VisibilityRefreshWorker(
|
||||
|
||||
private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> =
|
||||
transactionTemplate.execute {
|
||||
val now = LocalDateTime.now()
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit)
|
||||
jobs.forEach { job ->
|
||||
job.status = JobStatus.RUNNING
|
||||
@@ -61,10 +62,10 @@ class VisibilityRefreshWorker(
|
||||
}
|
||||
|
||||
try {
|
||||
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
|
||||
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES)
|
||||
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
|
||||
val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
|
||||
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now())
|
||||
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now(ZoneOffset.UTC))
|
||||
markDone(spacecraftId)
|
||||
} catch (ex: Exception) {
|
||||
log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex)
|
||||
@@ -79,7 +80,7 @@ class VisibilityRefreshWorker(
|
||||
job.rerunRequested = false
|
||||
job.lockedBy = null
|
||||
job.lockedUntil = null
|
||||
job.updatedAt = LocalDateTime.now()
|
||||
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
visibilityRefreshJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +91,7 @@ class VisibilityRefreshWorker(
|
||||
job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING
|
||||
job.lockedBy = null
|
||||
job.lockedUntil = null
|
||||
job.updatedAt = LocalDateTime.now()
|
||||
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
visibilityRefreshJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@ class VisibilityRefreshWorker(
|
||||
job.reason = reason
|
||||
job.lockedBy = null
|
||||
job.lockedUntil = null
|
||||
job.updatedAt = LocalDateTime.now()
|
||||
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
visibilityRefreshJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package space.nstart.pcp_tgu_service.dto
|
||||
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC,
|
||||
* см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля
|
||||
* (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей.
|
||||
*
|
||||
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
|
||||
*/
|
||||
class PlanResponseContractTest {
|
||||
|
||||
private val mapper = jacksonObjectMapper().findAndRegisterModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
|
||||
@Test
|
||||
fun `serializes plan times as naive ISO without zone`() {
|
||||
val response = PlanResponse(
|
||||
planId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
spacecraftId = "25544",
|
||||
startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
|
||||
endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
|
||||
kppId = "KPP-1",
|
||||
status = PlannedPlanStatus.PLANNED
|
||||
)
|
||||
|
||||
val node = mapper.readTree(mapper.writeValueAsString(response))
|
||||
|
||||
assertEquals("2026-05-29T01:00:00", node.get("startTime").asText())
|
||||
assertEquals("2026-05-29T02:00:00", node.get("endTime").asText())
|
||||
// Нет суффикса зоны (Z/±офсет) — иначе потребитель, читающий naive как UTC, ошибётся.
|
||||
assertTrue(
|
||||
node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")),
|
||||
"ожидался naive ISO без зоны"
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -1,14 +1,45 @@
|
||||
package space.nstart.pcp_tgu_service.integration.api
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
import java.util.TimeZone
|
||||
|
||||
class VisibilityPayloadParserTest {
|
||||
|
||||
private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules())
|
||||
|
||||
private val originalTimeZone: TimeZone = TimeZone.getDefault()
|
||||
|
||||
@AfterEach
|
||||
fun restoreTimeZone() {
|
||||
TimeZone.setDefault(originalTimeZone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses UTC instant as UTC LocalDateTime regardless of JVM default zone`() {
|
||||
// planned_plan.start_time/end_time по контракту хранят UTC. Раньше парсер конвертировал момент
|
||||
// через ZoneId.systemDefault(), и на не-UTC JVM (напр. МСК, +3) время уезжало на офсет.
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
|
||||
val payload = """
|
||||
[{
|
||||
"noradId": 20,
|
||||
"stationId": 102,
|
||||
"onStart": {"time": "2026-06-02T14:14:08Z", "elevation": 5.0},
|
||||
"onStop": {"time": "2026-06-02T14:20:00Z", "elevation": 5.0},
|
||||
"onMaximum": {"time": "2026-06-02T14:17:00Z", "elevation": 45.0}
|
||||
}]
|
||||
""".trimIndent()
|
||||
|
||||
val window = parser.parse("20", payload).points.single().observationWindows.single()
|
||||
|
||||
// Значение совпадает с UTC-стенкой инстанта (14:14), а не со сдвигом на пояс JVM (17:14).
|
||||
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime)
|
||||
assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncates window times to microseconds to match persisted timestamp precision`() {
|
||||
// Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity
|
||||
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class HandleStationParamsChangedUseCaseTest {
|
||||
@@ -40,7 +41,7 @@ class HandleStationParamsChangedUseCaseTest {
|
||||
StationParamsChangedMessage(
|
||||
eventId = UUID.randomUUID(),
|
||||
stationId = "ST-7",
|
||||
occurredAt = LocalDateTime.now(),
|
||||
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
|
||||
reason = "OUT_OF_SERVICE"
|
||||
)
|
||||
|
||||
|
||||
+4
-1
@@ -27,6 +27,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -258,7 +259,9 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
|
||||
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
|
||||
|
||||
private fun now(): LocalDateTime = LocalDateTime.now()
|
||||
// Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now(ZoneOffset.UTC)),
|
||||
// иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет.
|
||||
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
private fun planningProperties(): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
|
||||
|
||||
+6
-5
@@ -31,6 +31,7 @@ import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventReposit
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -86,7 +87,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
@Test
|
||||
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
||||
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
@@ -293,7 +294,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
endTime = end,
|
||||
kppId = "KPP-1",
|
||||
status = status,
|
||||
issueNotBefore = LocalDateTime.now().minusMinutes(1)
|
||||
issueNotBefore = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)
|
||||
)
|
||||
|
||||
private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
|
||||
@@ -316,7 +317,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
planId = planId,
|
||||
attemptId = attemptId,
|
||||
decision = decision,
|
||||
decisionTime = LocalDateTime.now(),
|
||||
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
|
||||
reason = null
|
||||
)
|
||||
|
||||
@@ -325,11 +326,11 @@ class PlanIssueAndDecisionFlowTest {
|
||||
eventId = UUID.randomUUID(),
|
||||
spacecraftId = spacecraftId,
|
||||
planId = planId,
|
||||
occurredAt = LocalDateTime.now(),
|
||||
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
|
||||
reason = null
|
||||
)
|
||||
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||
|
||||
private fun planningProperties(): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
|
||||
|
||||
+3
-2
@@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -176,13 +177,13 @@ class PlanProjectionServiceTest {
|
||||
noradId = 25544L,
|
||||
payloadJson = "[]",
|
||||
payloadHash = "hash",
|
||||
fetchedAt = LocalDateTime.now()
|
||||
fetchedAt = LocalDateTime.now(ZoneOffset.UTC)
|
||||
)
|
||||
|
||||
private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
|
||||
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
|
||||
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> anyNonNull(): T {
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class PlanQueryServiceTest {
|
||||
@@ -74,5 +75,5 @@ class PlanQueryServiceTest {
|
||||
private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
|
||||
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
|
||||
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito.doAnswer
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.transaction.TransactionStatus
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.util.TimeZone
|
||||
import java.util.function.Consumer
|
||||
|
||||
class SyncSpacecraftFromNsiUseCaseTest {
|
||||
|
||||
private val originalTimeZone: TimeZone = TimeZone.getDefault()
|
||||
|
||||
@AfterEach
|
||||
fun restoreTimeZone() {
|
||||
TimeZone.setDefault(originalTimeZone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `converts NSI validity instants to UTC LocalDateTime regardless of JVM default zone`() {
|
||||
// validFrom/validTo пишутся в naive-поля БД, которые по контракту хранят UTC. Раньше конвертация
|
||||
// шла через ZoneId.systemDefault(), и на не-UTC JVM (МСК, +3) значение уезжало на офсет.
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
|
||||
|
||||
val platformService = mock(PlatformService::class.java)
|
||||
val repository = mock(SpacecraftSnapshotRepository::class.java)
|
||||
val refreshJobService = mock(VisibilityRefreshJobService::class.java)
|
||||
val transactionTemplate = mock(TransactionTemplate::class.java)
|
||||
// executeWithoutResult просто прогоняет переданный блок без реальной транзакции.
|
||||
doAnswer { invocation ->
|
||||
invocation.getArgument<Consumer<TransactionStatus>>(0).accept(mock(TransactionStatus::class.java))
|
||||
null
|
||||
}.`when`(transactionTemplate).executeWithoutResult(anyNonNull())
|
||||
|
||||
// loadAllowedPlatforms имеет дефолтный аргумент Instant.now(); матчер нужен, иначе stub не совпадёт.
|
||||
`when`(platformService.loadAllowedPlatforms(anyNonNull())).thenReturn(
|
||||
listOf(
|
||||
PlatformCatalogItem(
|
||||
nsiId = "nsi-1",
|
||||
businessKey = "bk-1",
|
||||
name = "SAT",
|
||||
status = "OPERATIONAL",
|
||||
noradId = 25544L,
|
||||
mission = "obs",
|
||||
validFrom = Instant.parse("2026-06-02T14:14:08Z"),
|
||||
validTo = Instant.parse("2026-12-31T23:00:00Z")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
SyncSpacecraftFromNsiUseCase(platformService, repository, refreshJobService, transactionTemplate)
|
||||
.syncAllowedSpacecraft()
|
||||
|
||||
val validFrom = ArgumentCaptor.forClass(LocalDateTime::class.java)
|
||||
val validTo = ArgumentCaptor.forClass(LocalDateTime::class.java)
|
||||
verify(repository).upsertFromNsi(
|
||||
spacecraftId = anyNonNull(),
|
||||
noradId = org.mockito.Mockito.anyLong(),
|
||||
nsiId = anyNullable(),
|
||||
businessKey = anyNullable(),
|
||||
name = anyNullable(),
|
||||
status = anyNullable(),
|
||||
mission = anyNullable(),
|
||||
validFrom = validFrom.capture(),
|
||||
validTo = validTo.capture(),
|
||||
now = anyNonNull()
|
||||
)
|
||||
|
||||
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), validFrom.value)
|
||||
assertEquals(LocalDateTime.parse("2026-12-31T23:00:00"), validTo.value)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
|
||||
private fun <T> anyNullable(): T? = org.mockito.ArgumentMatchers.any()
|
||||
}
|
||||
+3
-2
@@ -21,6 +21,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -91,8 +92,8 @@ class TestPlanDecisionServiceTest {
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = "25544",
|
||||
startTime = LocalDateTime.now().plusHours(1),
|
||||
endTime = LocalDateTime.now().plusHours(2),
|
||||
startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(1),
|
||||
endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(2),
|
||||
kppId = "KPP-1",
|
||||
status = PlannedPlanStatus.WAITING_DECISION
|
||||
)
|
||||
|
||||
+2
-3
@@ -13,9 +13,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity
|
||||
import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Optional
|
||||
|
||||
class VisibilityFlowTest {
|
||||
@@ -28,8 +26,9 @@ class VisibilityFlowTest {
|
||||
|
||||
assertEquals("25544", points.spacecraftId)
|
||||
assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId })
|
||||
// Парсер трактует UTC-инстант баллистики как UTC-стенку (naive LocalDateTime = UTC), без сдвига на пояс JVM.
|
||||
assertEquals(
|
||||
Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(),
|
||||
LocalDateTime.parse("2026-05-26T10:00:00"),
|
||||
points.points[1].observationWindows.single().startTime
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user