pcp: время — сквозной naive LocalDateTime как локальное настенное время (без UTC)

Отказ от остаточной UTC-семантики: naive LocalDateTime трактуется как
локальное время единой зоны всех сервисов.

Backend (границы внешних систем):
- VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase: ZoneOffset.UTC →
  ZoneId.systemDefault() для проекции внешних Instant/ISO-с-Z в naive.
- тесты границ перевёрнуты на systemDefault (под Europe/Moscow ждут +3).
- чистка UTC-формулировок в DTO-комментариях, мёртвых ссылок на
  docs/standards/DATETIME_UTC.md.

Frontend (pcp-tgu-ops-ui):
- utils/utcTime.ts → utils/localTime.ts: parseLocal/formatLocal*/toLocalIso/
  toLocalInputValue с локальной семантикой (без дописывания Z и timeZone:UTC).
- замена getUTC*/Date.UTC/toISOString-сериализации на локальные аналоги.
- убраны пользовательские подписи «UTC» в редакторе/таймлайне.
- удалён utcTime.guard.test.ts (форсил обратный UTC-инвариант); тест-фикстуры
  сделаны зоно-независимыми. tsc + vitest зелёные под TZ=UTC и TZ=Europe/Moscow.

Включает также прочие незакоммиченные правки рабочего дерева.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 09:50:39 +03:00
parent 5049395911
commit 294b17757d
123 changed files with 556 additions and 535 deletions
@@ -12,9 +12,9 @@ object DraftMode {
data class CreateDraftRequest(
/** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */
val mode: String,
/** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */
/** Начало окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */
val windowStart: LocalDateTime? = null,
/** Конец окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */
/** Конец окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */
val windowStop: LocalDateTime? = null,
/** КПП целевой ЗРВ. Обязательно для fork-forward. */
val windowKppId: String? = null
@@ -33,9 +33,9 @@ data class CreateChainPlanRequest(
val spacecraftId: String,
/** Прошлый план-предыстория или null для цепочки без истории. */
val basePlanId: UUID? = null,
/** Начало окна (UTC naive) — старт выбранной стартовой ЗРВ. */
/** Начало окна (naive, локальное время) — старт выбранной стартовой ЗРВ. */
val windowStart: LocalDateTime,
/** Конец окна (UTC naive) — конец выбранной конечной ЗРВ. */
/** Конец окна (naive, локальное время) — конец выбранной конечной ЗРВ. */
val windowStop: LocalDateTime,
/** КПП стартовой ЗРВ. */
val windowKppId: String
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -40,10 +39,10 @@ class AcceptedPlanSlotsUpdateJobEntity(
var lastError: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(planId = UUID.randomUUID())
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -46,10 +45,10 @@ class PlanIssueAttemptEntity(
var finishedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0)
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -49,10 +48,10 @@ class PlannedPlanEntity(
var acceptedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
@@ -19,7 +18,7 @@ class ProcessedPlanDecisionEventEntity(
var eventId: UUID,
@Column(name = "received_at", nullable = false)
var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var receivedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(eventId = UUID.randomUUID())
@@ -6,7 +6,6 @@ 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
@@ -43,7 +42,7 @@ class SpacecraftPlanningStateEntity(
var pausedAfterReject: Boolean = false,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Persisted snapshot КА из НСИ, используемый как вход для refresh jobs.
@@ -45,10 +44,10 @@ class SpacecraftSnapshotEntity(
var active: Boolean = true,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0)
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Полный raw snapshot окон видимости КА из баллистики.
@@ -41,10 +40,10 @@ class SpacecraftVisibilitySnapshotEntity(
var windowsCount: Int? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN)
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Job на перечитывание окон видимости одного КА у баллистики.
@@ -42,10 +41,10 @@ class VisibilityRefreshJobEntity(
var rerunRequested: Boolean = false,
@Column(name = "requested_at", nullable = false)
var requestedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC),
var requestedAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
@@ -12,7 +12,6 @@ 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
/**
@@ -37,7 +36,7 @@ class ExternalPointsClient(
* которые разрешены для работы TGU.
*/
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES)
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadAllowedPlatforms()
@@ -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.ZoneOffset
import java.time.ZoneId
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
@@ -43,10 +43,11 @@ class VisibilityPayloadParser(
}
/**
* Разбирает UTC/ISO строку времени баллистики в naive `LocalDateTime` (по соглашению системы — UTC).
* Разбирает ISO-строку времени баллистики в naive `LocalDateTime`.
*
* Момент из `Instant.parse` берём в UTC (а не в поясе JVM): naive-поля `planned_plan.start_time/end_time`
* по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет.
* Если время приходит с зоной (`...Z`/офсет), момент проецируем в локальный пояс JVM
* (`ZoneId.systemDefault()`): naive-время в системе — настенные часы локальной зоны, в которой
* работают все сервисы, без UTC-семантики. Naive-строку без зоны берём как есть.
*
* Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time`
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
@@ -55,7 +56,7 @@ class VisibilityPayloadParser(
*/
private fun toLocalDateTime(value: String): LocalDateTime =
try {
LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC)
LocalDateTime.ofInstant(Instant.parse(value), ZoneId.systemDefault())
} catch (_: DateTimeParseException) {
LocalDateTime.parse(value)
}.truncatedTo(ChronoUnit.MICROS)
@@ -4,7 +4,6 @@ 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
/**
@@ -17,6 +16,6 @@ class AcceptedPlanSlotsUpdateJobService(
/** Idempotent enqueue: один planId получает максимум одну job. */
@Transactional
fun enqueue(planId: UUID) {
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now(ZoneOffset.UTC))
acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now())
}
}
@@ -10,7 +10,6 @@ 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
/**
@@ -36,7 +35,7 @@ class AcceptedPlanSlotsUpdateWorker(
/** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */
fun claimJobs(limit: Int): List<AcceptedPlanSlotsUpdateJobEntity> =
transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit)
jobs.forEach { job ->
job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING
@@ -70,7 +69,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markDone(planId: UUID, processInstanceKey: Long) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.DONE
job.processInstanceKey = processInstanceKey
@@ -84,7 +83,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS
job.processInstanceKey = processInstanceKey ?: job.processInstanceKey
@@ -17,7 +17,6 @@ import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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
/**
@@ -47,7 +46,7 @@ class CreateChainPlanUseCase(
private val log = LoggerFactory.getLogger(javaClass)
fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
if (!req.windowStop.isAfter(req.windowStart)) {
throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже windowStart")
@@ -15,7 +15,6 @@ import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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
/**
@@ -46,7 +45,7 @@ class CreateDraftUseCase(
val base = plannedPlanRepository.findById(basePlanId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") }
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
// Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик»
// не плодит вторую миссию-клон и вторую DRAFT-строку.
@@ -19,7 +19,6 @@ 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.
@@ -119,7 +118,7 @@ class HandlePlanDecisionUseCase(
return
}
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
plan.status = terminalStatus
plan.updatedAt = now
plannedPlanRepository.save(plan)
@@ -131,7 +130,7 @@ class HandlePlanDecisionUseCase(
message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity
) {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
attempt.status = when (message.decision) {
PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
@@ -148,7 +147,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
) {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.AWAITING_LAYIN
@@ -175,7 +174,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
) {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.REJECTED
@@ -217,7 +216,7 @@ class HandlePlanDecisionUseCase(
}
private fun claimEvent(eventId: java.util.UUID): Boolean =
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
@@ -7,7 +7,6 @@ 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
/**
* На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА.
@@ -26,7 +25,7 @@ class HandleStationParamsChangedUseCase(
/** Идемпотентно по eventId; ставит refresh job по всем активным КА. */
@Transactional
fun handle(message: StationParamsChangedMessage) {
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0
val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0
if (!claimed) {
return
}
@@ -19,7 +19,6 @@ 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
/**
@@ -84,7 +83,7 @@ class IssueDuePlanWorker(
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
return null
@@ -163,7 +162,7 @@ class IssueDuePlanWorker(
}
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId)
assertReissuableInSlot(plan, state, now)
@@ -185,7 +184,7 @@ class IssueDuePlanWorker(
}
private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null
if (plan.status != PlannedPlanStatus.DRAFT) {
throw ResponseStatusException(
@@ -342,7 +341,7 @@ class IssueDuePlanWorker(
private fun markStarted(response: ProcessStartResponse) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult
@@ -366,7 +365,7 @@ class IssueDuePlanWorker(
private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
attempt.status = PlanIssueAttemptStatus.AMBIGUOUS
@@ -11,7 +11,6 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
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
/**
@@ -81,7 +80,7 @@ class PlanQueryService(
.sortedBy { response -> response.startTime }
private fun historySince(historyDays: Long): LocalDateTime =
LocalDateTime.now(ZoneOffset.UTC).minusDays(historyDays.coerceAtLeast(0))
LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
private fun PlannedPlanEntity.businessKey(): String =
"$spacecraftId|$startTime|$endTime|$kppId"
@@ -7,7 +7,6 @@ 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 из снапшота
@@ -26,7 +25,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(ZoneOffset.UTC)
val now = LocalDateTime.now()
if (existing == null) {
spacecraftVisibilitySnapshotRepository.save(
@@ -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.ZoneOffset
import java.time.ZoneId
/**
* Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs.
@@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase(
}
private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() }
allowedPlatforms.forEach { platform ->
@@ -40,10 +40,10 @@ class SyncSpacecraftFromNsiUseCase(
name = platform.name,
status = platform.status,
mission = platform.mission,
// 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) },
// validFrom/validTo приходят как Instant; в naive-поля БД пишем настенные часы локальной
// зоны JVM (ZoneId.systemDefault()) — naive-время в системе локальное, без UTC-семантики.
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
now = now
)
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC")
@@ -11,7 +11,6 @@ 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
/**
@@ -67,7 +66,7 @@ class TestPlanDecisionService(
planId = plan.planId,
attemptId = attempt.attemptId,
decision = decision,
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
decisionTime = LocalDateTime.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handle(message)
@@ -4,7 +4,6 @@ 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.
@@ -16,6 +15,6 @@ class VisibilityRefreshJobService(
/** Ставит или переоткрывает refresh job без дублей. */
@Transactional
fun enqueue(spacecraftId: String, noradId: Long?, reason: String) {
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now(ZoneOffset.UTC))
visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now())
}
}
@@ -12,7 +12,6 @@ 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
/**
@@ -39,7 +38,7 @@ class VisibilityRefreshWorker(
private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> =
transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val now = LocalDateTime.now()
val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit)
jobs.forEach { job ->
job.status = JobStatus.RUNNING
@@ -62,10 +61,10 @@ class VisibilityRefreshWorker(
}
try {
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES)
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now(ZoneOffset.UTC))
spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now())
markDone(spacecraftId)
} catch (ex: Exception) {
log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex)
@@ -80,7 +79,7 @@ class VisibilityRefreshWorker(
job.rerunRequested = false
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -91,7 +90,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(ZoneOffset.UTC)
job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -103,7 +102,7 @@ class VisibilityRefreshWorker(
job.reason = reason
job.lockedBy = null
job.lockedUntil = null
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC)
job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job)
}
}
@@ -19,9 +19,9 @@ class VisibilityPayloadParserTest {
}
@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) время уезжало на офсет.
fun `projects zoned instant into JVM default zone as naive local LocalDateTime`() {
// naive-время в системе — настенные часы локальной зоны JVM (без UTC-семантики). Парсер проецирует
// зонированный инстант через ZoneId.systemDefault(), поэтому на МСК (+3) 14:14Z даёт локальные 17:14.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val payload = """
[{
@@ -35,9 +35,9 @@ class VisibilityPayloadParserTest {
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)
// Инстант 14:14Z спроецирован в локальную зону JVM (МСК, +3) — настенные 17:14.
assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), window.startTime)
assertEquals(LocalDateTime.parse("2026-06-02T17:20:00"), window.endTime)
}
@Test
@@ -24,7 +24,6 @@ import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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
@@ -227,7 +226,7 @@ class CreateChainPlanUseCaseTest {
// Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в
// моке сырых ЗРВ совпадает байт-в-байт (без дрейфа now() между вызовами).
private val t0: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
private val t0: LocalDateTime = LocalDateTime.now()
private fun time(hour: Int): LocalDateTime = t0.plusHours(hour.toLong())
@Suppress("UNCHECKED_CAST")
@@ -23,7 +23,6 @@ import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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
@@ -267,7 +266,7 @@ class CreateDraftUseCaseTest {
private fun props(freeze: Long): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze)
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
// Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах.
@Suppress("UNCHECKED_CAST")
@@ -19,7 +19,6 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -79,8 +78,8 @@ class DeleteDraftUseCaseTest {
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = "25544",
startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(3),
endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(4),
startTime = LocalDateTime.now().plusHours(3),
endTime = LocalDateTime.now().plusHours(4),
kppId = "KPP-1",
status = status
)
@@ -10,7 +10,6 @@ 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 {
@@ -41,7 +40,7 @@ class HandleStationParamsChangedUseCaseTest {
StationParamsChangedMessage(
eventId = UUID.randomUUID(),
stationId = "ST-7",
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
occurredAt = LocalDateTime.now(),
reason = "OUT_OF_SERVICE"
)
@@ -32,7 +32,6 @@ 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
@@ -551,9 +550,9 @@ class IssueDuePlanWorkerOnDemandTest {
private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")
// Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now(ZoneOffset.UTC)),
// Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now()),
// иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет.
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
private fun now(): LocalDateTime = LocalDateTime.now()
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, 10)
@@ -31,7 +31,6 @@ 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
@@ -85,7 +84,7 @@ class PlanIssueAndDecisionFlowTest {
@Test
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().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)
@@ -295,7 +294,7 @@ class PlanIssueAndDecisionFlowTest {
endTime = end,
kppId = "KPP-1",
status = status,
issueNotBefore = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)
issueNotBefore = LocalDateTime.now().minusMinutes(1)
)
private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
@@ -318,7 +317,7 @@ class PlanIssueAndDecisionFlowTest {
planId = planId,
attemptId = attemptId,
decision = decision,
decisionTime = LocalDateTime.now(ZoneOffset.UTC),
decisionTime = LocalDateTime.now(),
reason = null
)
@@ -327,11 +326,11 @@ class PlanIssueAndDecisionFlowTest {
eventId = UUID.randomUUID(),
spacecraftId = spacecraftId,
planId = planId,
occurredAt = LocalDateTime.now(ZoneOffset.UTC),
occurredAt = LocalDateTime.now(),
reason = null
)
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10)
@@ -18,7 +18,6 @@ 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
@@ -192,13 +191,13 @@ class PlanProjectionServiceTest {
noradId = 25544L,
payloadJson = "[]",
payloadHash = "hash",
fetchedAt = LocalDateTime.now(ZoneOffset.UTC)
fetchedAt = LocalDateTime.now()
)
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(ZoneOffset.UTC).plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T {
@@ -12,7 +12,6 @@ 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 {
@@ -124,5 +123,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(ZoneOffset.UTC).plusHours(hour.toLong())
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
}
@@ -27,9 +27,9 @@ class SyncSpacecraftFromNsiUseCaseTest {
}
@Test
fun `converts NSI validity instants to UTC LocalDateTime regardless of JVM default zone`() {
// validFrom/validTo пишутся в naive-поля БД, которые по контракту хранят UTC. Раньше конвертация
// шла через ZoneId.systemDefault(), и на не-UTC JVM (МСК, +3) значение уезжало на офсет.
fun `projects NSI validity instants into JVM default zone as naive local LocalDateTime`() {
// validFrom/validTo пишутся в naive-поля БД как настенные часы локальной зоны JVM (без UTC-семантики).
// Конвертация идёт через ZoneId.systemDefault(), поэтому на МСК (+3) инстант смещается на офсет.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val platformService = mock(PlatformService::class.java)
@@ -76,8 +76,9 @@ class SyncSpacecraftFromNsiUseCaseTest {
now = anyNonNull()
)
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), validFrom.value)
assertEquals(LocalDateTime.parse("2026-12-31T23:00:00"), validTo.value)
// Инстанты спроецированы в локальную зону JVM (МСК, +3): 14:14Z → 17:14, 23:00Z → 02:00 следующих суток.
assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), validFrom.value)
assertEquals(LocalDateTime.parse("2027-01-01T02:00:00"), validTo.value)
}
@Suppress("UNCHECKED_CAST")
@@ -21,7 +21,6 @@ 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
@@ -92,8 +91,8 @@ class TestPlanDecisionServiceTest {
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = "25544",
startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(1),
endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(2),
startTime = LocalDateTime.now().plusHours(1),
endTime = LocalDateTime.now().plusHours(2),
kppId = "KPP-1",
status = PlannedPlanStatus.WAITING_DECISION
)
@@ -5,12 +5,11 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import java.time.LocalDateTime
import java.time.ZoneOffset
class ZrvWindowMatcherTest {
private val sc = "25544"
private val t0 = LocalDateTime.now(ZoneOffset.UTC).withNano(0)
private val t0 = LocalDateTime.now().withNano(0)
private fun at(min: Long) = t0.plusMinutes(min)
private val zrvA = CalculatedPlan(sc, at(0), at(10), "KPP-1")