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
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <old-ip> <new-ip>"
echo "Example: $0 192.168.60.201 192.168.60.201"
exit 1
fi
OLD_IP="$1"
NEW_IP="$2"
is_valid_ip() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r a b c d <<< "$ip"
for part in "$a" "$b" "$c" "$d"; do
(( part >= 0 && part <= 255 )) || return 1
done
}
if ! is_valid_ip "$OLD_IP"; then
echo "Invalid old IP: $OLD_IP"
exit 1
fi
if ! is_valid_ip "$NEW_IP"; then
echo "Invalid new IP: $NEW_IP"
exit 1
fi
export OLD_IP
export NEW_IP
changed_files=0
skipped_files=0
while IFS= read -r -d '' file; do
# Пропускаем бинарные файлы
if ! grep -Iq . "$file"; then
skipped_files=$((skipped_files + 1))
continue
fi
if perl -0ne '
BEGIN { $old = quotemeta($ENV{"OLD_IP"}); }
exit 0 if /(?<![0-9.])$old(?![0-9.])/;
exit 1;
' "$file"; then
perl -0pi -e '
BEGIN {
$old = quotemeta($ENV{"OLD_IP"});
$new = $ENV{"NEW_IP"};
}
s/(?<![0-9.])$old(?![0-9.])/$new/g;
' "$file"
echo "changed: $file"
changed_files=$((changed_files + 1))
fi
done < <(
find . \
-path './.git' -prune -o \
-type f \
-print0
)
echo ""
echo "Done."
echo "IP replaced: $OLD_IP -> $NEW_IP"
echo "Files updated: $changed_files"
echo "Files skipped: $skipped_files"
+4 -4
View File
@@ -1,16 +1,16 @@
# TODO before merge request: вернуть старые адреса окружения. # TODO before merge request: вернуть старые адреса окружения.
# pcp.infra.postgres.host: 192.168.1.8 # pcp.infra.postgres.host: 192.168.60.201
pcp: pcp:
infra: infra:
postgres: postgres:
host: 192.168.1.8 host: 192.168.60.201
port: 35400 port: 35400
kafka: kafka:
host: 192.168.1.8 host: 192.168.60.201
port: 19092 port: 19092
network: network:
host: 192.168.1.8 host: 192.168.60.201
ports: ports:
config: 8888 config: 8888
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# PostgreSQL connection settings. Override via env if needed: # PostgreSQL connection settings. Override via env if needed:
# PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... ./create_pcp_databases.sh # PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... ./create_pcp_databases.sh
PGHOST="${PGHOST:-192.168.1.8}" PGHOST="${PGHOST:-192.168.60.201}"
PGPORT="${PGPORT:-5432}" PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}" PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}" PGPASSWORD="${PGPASSWORD:-password}"
+1 -1
View File
@@ -21,7 +21,7 @@
- существующие публичные контракты не сломаны без явного запроса; - существующие публичные контракты не сломаны без явного запроса;
- добавлены или обновлены релевантные тесты; - добавлены или обновлены релевантные тесты;
- не внесено несвязанных правок; - не внесено несвязанных правок;
- время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now(ZoneOffset.UTC)`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`; - время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now()`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`;
- egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z); - egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z);
- validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены.
+30
View File
@@ -0,0 +1,30 @@
# Следующая задача: маршруты, сбросы и перенос включений между планами
> Статус: запланировано (дизайн ещё не начат). Это фиксация намерения, не финальный план.
Следующий блок работ — три связанные темы вокруг содержимого плана (включений) и
их съёмочно-сбросовой обвязки:
## 1. Построение маршрутов
Довести построение маршрутов наблюдения по плану. Сейчас маршруты порождаются в
потоке `createSatelliteMission` (BPMN), где `planId == missionId`; результат
ложится в БД mission-planing. Уточнить: когда и по какому триггеру строятся
маршруты для нового/перевыданного плана, и как они привязаны к включениям.
## 2. Формирование сбросов
Формирование сбросов (`DropMode`) поверх съёмочных включений (`SurveyMode`):
расчёт окон сброса, привязка к ЗРВ/КПП, согласование времени сброса со временем
съёмки. Внутренние времена — naive `LocalDateTime` (см. сквозную модель без зон).
## 3. Перенос включений из плана в план
При перевыдаче/версионировании плана переносить уже сформированные включения
(съёмки + сбросы) из предыдущего плана в новый — чтобы не терять набранное
содержимое цепочки. Определить, что переносится как есть, что пересчитывается,
и как это стыкуется с двумя осями версионирования (цепочка ТГУ + контент миссии).
## Контекст / связанные места
- Маршруты и их сохранение: `createSatelliteMission`, `planId == missionId`.
- Сбросы/съёмки: `pcp-mission-planing-service``SurveyMode`/`DropMode`,
`MissionPlaningService`.
- Версионирование/перевыдача плана — две оси (цепочка ТГУ + контент миссии).
- Время — сквозной naive `LocalDateTime` без зоны (после отката UTC-слоя).
@@ -0,0 +1,13 @@
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
2. Ошибка при попытке принять план во вкладке планы
3. Происходит накопление целей съемки, этого делать не нужно, пусть будет просто перекрестие на карте
А на панели справа пусть будет написано опорная точка прицеливания и ее координаты
4. Дата время в формате 24 часов а не AM PM, во всем интерфейсе.
5. Добавить маску важности, чтобы можно было легко ввести 0.1 или 0.3
6. При нажатии на включение нужно центрировать экран на него и подсвечивать выбор, будто кликнули по самому объекту на карте
7. Сбросы нужно отобрадать на картооснове как толстую яркую линию на трассе, время начала конца линии - время начала конца сброса
8. При нажатии на ЗРВ в окне Планы нужно справа отображать информацию по этим ЗРВ и если их несколько то по нескольких по примеру завок на вкладке Карта
10. Проблема того, что в одном и том же месте есть план Rejected и план WAIting_decision (скриншот сохранен)
@@ -2,8 +2,9 @@ package space.nstart.pcp.pcp_types_lib.message
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.* import java.util.*
@@ -19,10 +20,9 @@ class KafkaMessage<out T> (
val id = UUID.randomUUID().toString() val id = UUID.randomUUID().toString()
var source = "Example" var source = "Example"
// Время конверта — naive LocalDateTime, штампуется по настенным часам UTC (now(ZoneOffset.UTC)), @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]")
// сериализуется как ISO без зоны. @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") val time: LocalDateTime = LocalDateTime.now()
val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
val dataContentType = "application/json" val dataContentType = "application/json"
val specVersion = "1.0" val specVersion = "1.0"
@@ -0,0 +1,22 @@
package space.nstart.pcp.pcp_types_lib.utils.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class LocalDateTimeTimeZoneSerializer: JsonSerializer<LocalDateTime?>() {
companion object {
val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
}
override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) {
//todo: проверить корректность зоны по нормальным правилам
// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt))
p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt))
}
}
@@ -13,7 +13,6 @@ import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.PI import kotlin.math.PI
@@ -27,7 +26,7 @@ class AscNodeEntity(
@Column(nullable = false) @Column(nullable = false)
val satelliteId : Long = 0, val satelliteId : Long = 0,
@Column(name = "t", nullable = false) @Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val revolution : Long = 0, val revolution : Long = 0,
val longitude : Double = 0.0, val longitude : Double = 0.0,
@@ -10,7 +10,6 @@ import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.PI import kotlin.math.PI
@Entity @Entity
@@ -27,13 +26,13 @@ class EarthCellViewsEntity(
@Column(name = "rev_begin", nullable = false) @Column(name = "rev_begin", nullable = false)
val revolutionBegin : Long = 0, val revolutionBegin : Long = 0,
@Column(nullable = false) @Column(nullable = false)
val timeBegin : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val timeBegin : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val revSignBegin : Int = 0, val revSignBegin : Int = 0,
@Column(name = "rev_end", nullable = false) @Column(name = "rev_end", nullable = false)
val revolutionEnd: Long = 0, val revolutionEnd: Long = 0,
@Column(nullable = false) @Column(nullable = false)
val timeEnd : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val timeEnd : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val revSignEnd : Int = 0, val revSignEnd : Int = 0,
@Column(name = "roll_min") @Column(name = "roll_min")
@@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table(name = "earth_coverage") @Table(name = "earth_coverage")
@@ -23,9 +22,9 @@ class EarthCoverageEntity(
@Column(nullable = false) @Column(nullable = false)
val cellNumber : Long = 0, val cellNumber : Long = 0,
@Column(nullable = false) @Column(nullable = false)
val tStart : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val tStart : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val tStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val tStop : LocalDateTime = LocalDateTime.now(),
) { ) {
constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this( constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this(
null, null,
@@ -6,7 +6,6 @@ import jakarta.persistence.*
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.PI import kotlin.math.PI
@@ -20,7 +19,7 @@ class FlightLineEntity(
@Column(nullable = false) @Column(nullable = false)
val satelliteId : Long = 0, val satelliteId : Long = 0,
@Column(name = "t", nullable = false) @Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val revolution : Long = 0, val revolution : Long = 0,
@Column(nullable = false) @Column(nullable = false)
@@ -7,7 +7,6 @@ import ballistics.utils.toDateTime
import jakarta.persistence.* import jakarta.persistence.*
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table(name = "pdcm") @Table(name = "pdcm")
@@ -19,7 +18,7 @@ class PDCMEntity (
@Column(nullable = false) @Column(nullable = false)
val satelliteId : Long = 0, val satelliteId : Long = 0,
@Column(name = "t", nullable = false) @Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false) @Column(nullable = false)
val revolution : Long = 0, val revolution : Long = 0,
val x: Double = 0.0, val x: Double = 0.0,
@@ -13,7 +13,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table(name = "satellite_initial_conditions") @Table(name = "satellite_initial_conditions")
@@ -28,7 +27,7 @@ class SatelliteInitialConditionEntity(
@Column(name = "movement_model", nullable = false) @Column(name = "movement_model", nullable = false)
val movementModel: MovementModel = MovementModel.FOTO, val movementModel: MovementModel = MovementModel.FOTO,
@Column(name = "orb_time", nullable = false) @Column(name = "orb_time", nullable = false)
val orbTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val orbTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "orb_revolution", nullable = false) @Column(name = "orb_revolution", nullable = false)
val orbRevolution: Long = 0, val orbRevolution: Long = 0,
@Column(nullable = false) @Column(nullable = false)
@@ -48,7 +47,7 @@ class SatelliteInitialConditionEntity(
@Column(nullable = false) @Column(nullable = false)
val f81: Double = 147.8, val f81: Double = 147.8,
@Column(name = "received_at", nullable = false) @Column(name = "received_at", nullable = false)
val receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val receivedAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "calculated_at") @Column(name = "calculated_at")
var calculatedAt: LocalDateTime? = null, var calculatedAt: LocalDateTime? = null,
@Column(name = "last_calculated", nullable = false) @Column(name = "last_calculated", nullable = false)
@@ -69,7 +68,7 @@ class SatelliteInitialConditionEntity(
f81 = request.ic.f81 f81 = request.ic.f81
) )
fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)) { fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now()) {
this.calculatedAt = calculatedAt this.calculatedAt = calculatedAt
this.lastCalculated = true this.lastCalculated = true
} }
@@ -63,7 +63,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.collections.forEach import kotlin.collections.forEach
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
@@ -762,8 +761,8 @@ class SatelliteService {
fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) = fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) =
pdcmRepository.findBySatelliteIdAndTimeBetween( pdcmRepository.findBySatelliteIdAndTimeBetween(
id, id,
tn?: LocalDateTime.now(ZoneOffset.UTC), tn?: LocalDateTime.now(),
tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1)) tk?: LocalDateTime.now().plusDays(1))
.map { it.toDTO() .map { it.toDTO()
} }
@@ -776,8 +775,8 @@ class SatelliteService {
fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) = fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) =
flightLineRepository.findBySatelliteIdAndTimeBetween(id, flightLineRepository.findBySatelliteIdAndTimeBetween(id,
tn?: LocalDateTime.now(ZoneOffset.UTC), tn?: LocalDateTime.now(),
tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) tk?: LocalDateTime.now().plusDays(1)
) )
.map { it.toDTO() } .map { it.toDTO() }
@@ -898,8 +897,8 @@ class SatelliteService {
return ascNodeRepository.findBySatelliteIdAndTimeBetween( return ascNodeRepository.findBySatelliteIdAndTimeBetween(
id, id,
tn ?: LocalDateTime.now(ZoneOffset.UTC), tn ?: LocalDateTime.now(),
tk ?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) tk ?: LocalDateTime.now().plusDays(1)
) )
.map { .map {
val keps = calculateKeplerParams( val keps = calculateKeplerParams(
@@ -13,7 +13,6 @@ import jakarta.persistence.Index
import jakarta.persistence.OneToMany import jakarta.persistence.OneToMany
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table( @Table(
@@ -35,7 +34,7 @@ class ComplexPlanRunEntity(
var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED, var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "started_at") @Column(name = "started_at")
var startedAt: LocalDateTime? = null, var startedAt: LocalDateTime? = null,
@@ -47,10 +46,10 @@ class ComplexPlanRunEntity(
var durationMs: Long? = null, var durationMs: Long? = null,
@Column(name = "interval_start", nullable = false) @Column(name = "interval_start", nullable = false)
var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var intervalStart: LocalDateTime = LocalDateTime.now(),
@Column(name = "interval_end", nullable = false) @Column(name = "interval_end", nullable = false)
var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var intervalEnd: LocalDateTime = LocalDateTime.now(),
@Column(name = "requested_by") @Column(name = "requested_by")
var requestedBy: String? = null, var requestedBy: String? = null,
@@ -15,7 +15,6 @@ import jakarta.persistence.ManyToOne
import jakarta.persistence.Table import jakarta.persistence.Table
import jakarta.persistence.Index import jakarta.persistence.Index
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table( @Table(
@@ -44,10 +43,10 @@ class SatelliteModeEntity(
var type: SatelliteModeType = SatelliteModeType.SURVEY, var type: SatelliteModeType = SatelliteModeType.SURVEY,
@Column(name = "start_time", nullable = false) @Column(name = "start_time", nullable = false)
var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var startTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "end_time", nullable = false) @Column(name = "end_time", nullable = false)
var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var endTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "revolution", nullable = false) @Column(name = "revolution", nullable = false)
var revolution: Long = 0, var revolution: Long = 0,
@@ -13,7 +13,6 @@ import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany import jakarta.persistence.OneToMany
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Entity @Entity
@Table( @Table(
@@ -35,13 +34,13 @@ class SatelliteModeSnapshotEntity(
var satelliteId: Long = 0, var satelliteId: Long = 0,
@Column(name = "interval_start", nullable = false) @Column(name = "interval_start", nullable = false)
var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var intervalStart: LocalDateTime = LocalDateTime.now(),
@Column(name = "interval_end", nullable = false) @Column(name = "interval_end", nullable = false)
var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var intervalEnd: LocalDateTime = LocalDateTime.now(),
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
var isActive: Boolean = false, var isActive: Boolean = false,
@@ -3,12 +3,11 @@ package space.nstart.pcp.pcp_satellites_service.model.mode
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
class SurveyMode( class SurveyMode(
val revolution : Long = 1, val revolution : Long = 1,
val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val time : LocalDateTime = LocalDateTime.now(),
val timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val timeStop : LocalDateTime = LocalDateTime.now(),
val roll : Double = 0.0, val roll : Double = 0.0,
val latitude : Double = 0.0, val latitude : Double = 0.0,
val longitude : Double = 0.0, val longitude : Double = 0.0,
@@ -17,7 +17,6 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.test.assertContains import kotlin.test.assertContains
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -48,7 +48,6 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -842,7 +841,7 @@ class BasicPlanCalculator(
val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) {
observationParametersDebugFileWriter.write( observationParametersDebugFileWriter.write(
ObservationParametersDebugFileDTO( ObservationParametersDebugFileDTO(
createdAt = LocalDateTime.now(ZoneOffset.UTC), createdAt = LocalDateTime.now(),
requestId = request.requestId, requestId = request.requestId,
satelliteIds = selectedSatelliteIds, satelliteIds = selectedSatelliteIds,
missingSatelliteIds = missingSatelliteIds, missingSatelliteIds = missingSatelliteIds,
@@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne import jakarta.persistence.ManyToOne
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@Table(name = "dynamic_plan_route") @Table(name = "dynamic_plan_route")
@@ -32,10 +31,10 @@ class DynamicPlanRouteEntity(
var satelliteId: Long = 0, var satelliteId: Long = 0,
@Column(name = "start_time", nullable = false) @Column(name = "start_time", nullable = false)
var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var startTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "end_time", nullable = false) @Column(name = "end_time", nullable = false)
var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var endTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "duration", nullable = false) @Column(name = "duration", nullable = false)
var duration: Double = 0.0, var duration: Double = 0.0,
@@ -10,7 +10,6 @@ import space.nstart.pcp.complan.types.DynamicPlanCalculationMode
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.DynamicPlanRunStatus import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@Table(name = "dynamic_plan_run") @Table(name = "dynamic_plan_run")
@@ -48,7 +47,7 @@ class DynamicPlanRunEntity(
var filterCoveredRoutes: Boolean = false, var filterCoveredRoutes: Boolean = false,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "started_at") @Column(name = "started_at")
var startedAt: LocalDateTime? = null, var startedAt: LocalDateTime? = null,
@@ -25,7 +25,6 @@ import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import space.nstart.pcp.complan.types.IntervalItemDTO import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.SurveyContour import space.nstart.pcp.complan.types.SurveyContour
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -60,7 +59,7 @@ class DynamicPlanRunService(
calculationMode = request.calculationMode, calculationMode = request.calculationMode,
revolutionMode = request.revolutionMode, revolutionMode = request.revolutionMode,
filterCoveredRoutes = request.filterCoveredRoutes, filterCoveredRoutes = request.filterCoveredRoutes,
createdAt = LocalDateTime.now(ZoneOffset.UTC) createdAt = LocalDateTime.now()
) )
) )
} ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run") } ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run")
@@ -136,7 +135,7 @@ class DynamicPlanRunService(
return@execute 0 return@execute 0
} }
val finishedAt = LocalDateTime.now(ZoneOffset.UTC) val finishedAt = LocalDateTime.now()
runs.forEach { run -> runs.forEach { run ->
run.status = DynamicPlanRunStatus.FAILED run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = finishedAt run.finishedAt = finishedAt
@@ -172,7 +171,7 @@ class DynamicPlanRunService(
transactionTemplate.execute { transactionTemplate.execute {
val run = runRepository.findById(runId).orElse(null) ?: return@execute null val run = runRepository.findById(runId).orElse(null) ?: return@execute null
run.status = DynamicPlanRunStatus.RUNNING run.status = DynamicPlanRunStatus.RUNNING
run.startedAt = LocalDateTime.now(ZoneOffset.UTC) run.startedAt = LocalDateTime.now()
run.errorMessage = null run.errorMessage = null
runRepository.save(run) runRepository.save(run)
run.toRequest() run.toRequest()
@@ -183,7 +182,7 @@ class DynamicPlanRunService(
val run = runRepository.findById(runId).orElseThrow() val run = runRepository.findById(runId).orElseThrow()
val result = output.result val result = output.result
run.status = DynamicPlanRunStatus.COMPLETED run.status = DynamicPlanRunStatus.COMPLETED
run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.finishedAt = LocalDateTime.now()
run.durationMs = result.durationMs run.durationMs = result.durationMs
run.resultStatus = result.status.name run.resultStatus = result.status.name
run.routesCount = result.routesCount run.routesCount = result.routesCount
@@ -201,7 +200,7 @@ class DynamicPlanRunService(
transactionTemplate.execute { transactionTemplate.execute {
val run = runRepository.findById(runId).orElseThrow() val run = runRepository.findById(runId).orElseThrow()
run.status = DynamicPlanRunStatus.FAILED run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.finishedAt = LocalDateTime.now()
run.errorMessage = error.message ?: error::class.java.simpleName run.errorMessage = error.message ?: error::class.java.simpleName
runRepository.save(run) runRepository.save(run)
} }
@@ -6,7 +6,6 @@ import org.locationtech.jts.geom.LineString
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Класс космического аппарата** * Класс космического аппарата**
@@ -18,9 +17,9 @@ class SpaceCraft (
val flightLine : MutableList<FlightLineDTO> = mutableListOf() val flightLine : MutableList<FlightLineDTO> = mutableListOf()
/**Время начала планирования аппарата*/ /**Время начала планирования аппарата*/
var timeStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var timeStart: LocalDateTime = LocalDateTime.now()
/**Время конца планирования аппарата*/ /**Время конца планирования аппарата*/
var timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var timeStop : LocalDateTime = LocalDateTime.now()
/** /**
* Создание линий полета восходящего и нисходящего витка * Создание линий полета восходящего и нисходящего витка
* @param geomFactory фабрика геометрии * @param geomFactory фабрика геометрии
@@ -12,9 +12,9 @@ object CloneMode {
data class MissionCloneRequest( data class MissionCloneRequest(
/** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */ /** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */
val mode: String, val mode: String,
/** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */ /** Начало окна нового плана (naive, локальное время). Обязательно для fork-forward. */
val windowStart: LocalDateTime? = null, val windowStart: LocalDateTime? = null,
/** Конец окна нового плана (UTC naive). Обязательно для fork-forward. */ /** Конец окна нового плана (naive, локальное время). Обязательно для fork-forward. */
val windowStop: LocalDateTime? = null val windowStop: LocalDateTime? = null
) )
@@ -24,8 +24,8 @@ data class MissionCloneResponse(val newMissionId: UUID)
data class MissionBlankRequest( data class MissionBlankRequest(
/** КА (= noradId). */ /** КА (= noradId). */
val satelliteId: Long, val satelliteId: Long,
/** Начало окна миссии (UTC naive). */ /** Начало окна миссии (naive, локальное время). */
val missionStart: LocalDateTime, val missionStart: LocalDateTime,
/** Конец окна миссии (UTC naive). */ /** Конец окна миссии (naive, локальное время). */
val missionStop: LocalDateTime val missionStop: LocalDateTime
) )
@@ -15,7 +15,6 @@ import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@Component @Component
@@ -60,7 +59,7 @@ class BookedSlotsEventPublisher(
val event = BookedSlotsStatusChangedEvent( val event = BookedSlotsStatusChangedEvent(
bookedSlotIds = normalizedIds, bookedSlotIds = normalizedIds,
status = status, status = status,
occurredAt = LocalDateTime.now(ZoneOffset.UTC), occurredAt = LocalDateTime.now(),
sourceService = applicationName, sourceService = applicationName,
missionId = missionId, missionId = missionId,
modeId = modeId, modeId = modeId,
@@ -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 space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
@@ -433,7 +432,7 @@ class MissionPlaningService(
overrideIntervalEnd: LocalDateTime? = null, overrideIntervalEnd: LocalDateTime? = null,
comPlanSnapshotId: Long? = null comPlanSnapshotId: Long? = null
): MissionSurveyCalculationResponseDTO { ): MissionSurveyCalculationResponseDTO {
val startedAt = LocalDateTime.now(ZoneOffset.UTC) val startedAt = LocalDateTime.now()
logger.info( logger.info(
"Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}", "Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}",
missionId, missionId,
@@ -521,7 +520,7 @@ class MissionPlaningService(
) )
if (survs.isEmpty()) { if (survs.isEmpty()) {
val finishedAt = LocalDateTime.now(ZoneOffset.UTC) val finishedAt = LocalDateTime.now()
return MissionSurveyCalculationResponseDTO( return MissionSurveyCalculationResponseDTO(
missionId = missionId, missionId = missionId,
completed = true, completed = true,
@@ -566,7 +565,7 @@ class MissionPlaningService(
modeBookings.size modeBookings.size
) )
val finishedAt = LocalDateTime.now(ZoneOffset.UTC) val finishedAt = LocalDateTime.now()
return MissionSurveyCalculationResponseDTO( return MissionSurveyCalculationResponseDTO(
missionId = missionId, missionId = missionId,
completed = true, completed = true,
@@ -8,7 +8,6 @@ import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@@ -41,7 +40,7 @@ class GridRebuildService(
return GridRebuildResponseDto( return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(), cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(), rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = LocalDateTime.now(ZoneOffset.UTC), rebuiltAt = LocalDateTime.now(),
) )
} }
@@ -12,7 +12,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@Service @Service
@@ -70,7 +69,7 @@ class OutboxPublisherService(
.get() .get()
event.status = OutboxEventStatus.PUBLISHED event.status = OutboxEventStatus.PUBLISHED
event.publishedAt = LocalDateTime.now(ZoneOffset.UTC) event.publishedAt = LocalDateTime.now()
event.errorMessage = null event.errorMessage = null
outboxEventRepository.save(event) outboxEventRepository.save(event)
@@ -39,7 +39,6 @@ import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
class InvalidRequestListSortException(message: String) : IllegalArgumentException(message) class InvalidRequestListSortException(message: String) : IllegalArgumentException(message)
@@ -65,7 +64,7 @@ class RequestService(
"beginDateTime должен быть раньше endDateTime" "beginDateTime должен быть раньше endDateTime"
} }
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry) val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry)
val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry) val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry)
val geometryArea = geometryService.area(normalizedGeometry) val geometryArea = geometryService.area(normalizedGeometry)
@@ -153,7 +152,7 @@ class RequestService(
@Transactional @Transactional
fun deleteRequest(id: UUID): DeleteRequestResponseDto? { fun deleteRequest(id: UUID): DeleteRequestResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null val request = requestRepository.findById(id).orElse(null) ?: return null
val deletedAt = request.deletedAt ?: LocalDateTime.now(ZoneOffset.UTC) val deletedAt = request.deletedAt ?: LocalDateTime.now()
request.status = RequestStatus.DELETED request.status = RequestStatus.DELETED
request.deletedAt = deletedAt request.deletedAt = deletedAt
request.updatedAt = deletedAt request.updatedAt = deletedAt
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -44,7 +43,7 @@ class RouteMatchingService(
val routeId = route.routeId val routeId = route.routeId
val routeRollRange = route.rollAngle val routeRollRange = route.rollAngle
val matchedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val matchedAt: LocalDateTime = LocalDateTime.now()
val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry) val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry)
val candidateRequestIds = requestRepository.findRequestIdsForRoute( val candidateRequestIds = requestRepository.findRequestIdsForRoute(
@@ -13,7 +13,6 @@ import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -113,14 +112,14 @@ class RequestGridProjectionServiceTest {
geometryArea = 1.0, geometryArea = 1.0,
remainingArea = 1.0, remainingArea = 1.0,
importance = importance, importance = importance,
beginDateTime = LocalDateTime.now(ZoneOffset.UTC), beginDateTime = LocalDateTime.now(),
endDateTime = LocalDateTime.now(ZoneOffset.UTC).plusDays(1), endDateTime = LocalDateTime.now().plusDays(1),
highPriorityTransmit = false, highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS, surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE, status = RequestStatus.ACTIVE,
matchCount = 0, matchCount = 0,
createdAt = LocalDateTime.now(ZoneOffset.UTC), createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(ZoneOffset.UTC), updatedAt = LocalDateTime.now(),
) )
) )
@@ -29,7 +29,6 @@ import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -48,7 +47,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1) val intervalEnd = intervalBegin.plusHours(1)
val request = request( val request = request(
beginDateTime = intervalBegin, beginDateTime = intervalBegin,
@@ -97,7 +96,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1) val intervalEnd = intervalBegin.plusHours(1)
val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))" val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
val request = request( val request = request(
@@ -147,7 +146,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request( val request = request(
beginDateTime = intervalBegin, beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1), endDateTime = intervalBegin.plusHours(1),
@@ -183,7 +182,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request( val request = request(
beginDateTime = intervalBegin, beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1), endDateTime = intervalBegin.plusHours(1),
@@ -231,7 +230,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request( val request = request(
beginDateTime = intervalBegin, beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1), endDateTime = intervalBegin.plusHours(1),
@@ -269,7 +268,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService, geometryService = geometryService,
) )
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request( val request = request(
beginDateTime = intervalBegin, beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1), endDateTime = intervalBegin.plusHours(1),
@@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper as JpaJsonObjectMapper import com.fasterxml.jackson.databind.ObjectMapper as JpaJsonObjectMapper
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -39,7 +38,7 @@ class RoutePassportIngestionService(
rawPayload: String, rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata, sourceRecord: RoutePassportSourceRecordMetadata,
) { ) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val routePassportDto = parsedMessage.routePassportDto val routePassportDto = parsedMessage.routePassportDto
val existingRoutePassport = routePassportRepository.findById(routePassportDto.routeId) val existingRoutePassport = routePassportRepository.findById(routePassportDto.routeId)
val routeCreated = existingRoutePassport.isEmpty val routeCreated = existingRoutePassport.isEmpty
@@ -9,7 +9,6 @@ import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -33,7 +32,7 @@ class RoutePassportOutboxService(
return emptyList() return emptyList()
} }
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val lockedUntil = now.plus(Duration.ofMillis(outboxProperties.lockTimeoutMs)) val lockedUntil = now.plus(Duration.ofMillis(outboxProperties.lockTimeoutMs))
val claimedRows = routePassportOutboxRepository.findDueForUpdateSkipLocked(outboxProperties.batchSize) val claimedRows = routePassportOutboxRepository.findDueForUpdateSkipLocked(outboxProperties.batchSize)
@@ -71,7 +70,7 @@ class RoutePassportOutboxService(
*/ */
@Transactional @Transactional
fun markPublished(outboxId: UUID) { fun markPublished(outboxId: UUID) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val outbox = findOutbox(outboxId) val outbox = findOutbox(outboxId)
outbox.status = RoutePassportOutboxStatus.PUBLISHED outbox.status = RoutePassportOutboxStatus.PUBLISHED
@@ -100,7 +99,7 @@ class RoutePassportOutboxService(
*/ */
@Transactional @Transactional
fun markRetryOrFailed(outboxId: UUID, exception: Exception) { fun markRetryOrFailed(outboxId: UUID, exception: Exception) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val outbox = findOutbox(outboxId) val outbox = findOutbox(outboxId)
outbox.attempts += 1 outbox.attempts += 1
@@ -98,7 +98,7 @@
], ],
"assets": { "assets": {
"schema-calibration-vh": { "schema-calibration-vh": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -107,7 +107,7 @@
] ]
}, },
"schema-calibration-vv": { "schema-calibration-vv": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -116,7 +116,7 @@
] ]
}, },
"schema-noise-vh": { "schema-noise-vh": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -125,7 +125,7 @@
] ]
}, },
"schema-noise-vv": { "schema-noise-vv": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -134,7 +134,7 @@
] ]
}, },
"schema-product-vh": { "schema-product-vh": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -143,7 +143,7 @@
] ]
}, },
"schema-product-vv": { "schema-product-vv": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee",
"title": "xml", "title": "xml",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -152,7 +152,7 @@
] ]
}, },
"safe-manifest": { "safe-manifest": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53",
"title": "safe", "title": "safe",
"description": null, "description": null,
"type": "application/xml", "type": "application/xml",
@@ -161,7 +161,7 @@
] ]
}, },
"vh": { "vh": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648",
"title": "tif", "title": "tif",
"description": null, "description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized", "type": "image/tiff; application=geotiff; profile=cloud-optimized",
@@ -170,7 +170,7 @@
] ]
}, },
"vv": { "vv": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb",
"title": "tif", "title": "tif",
"description": null, "description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized", "type": "image/tiff; application=geotiff; profile=cloud-optimized",
@@ -179,7 +179,7 @@
] ]
}, },
"kml": { "kml": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54",
"title": "kml", "title": "kml",
"description": null, "description": null,
"type": "application/kml", "type": "application/kml",
@@ -188,7 +188,7 @@
] ]
}, },
"thumbnail": { "thumbnail": {
"href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0", "href": "http://192.168.60.201:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0",
"title": "png", "title": "png",
"description": null, "description": null,
"type": "image/png", "type": "image/png",
@@ -27,7 +27,6 @@ import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule import tools.jackson.module.kotlin.KotlinModule
import java.math.BigDecimal import java.math.BigDecimal
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -220,9 +219,9 @@ class RoutePassportIngestionServiceTest {
status = RoutePassportOutboxStatus.PENDING, status = RoutePassportOutboxStatus.PENDING,
attempts = 0, attempts = 0,
maxAttempts = 10, maxAttempts = 10,
nextAttemptAt = LocalDateTime.now(ZoneOffset.UTC), nextAttemptAt = LocalDateTime.now(),
createdAt = LocalDateTime.now(ZoneOffset.UTC), createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(ZoneOffset.UTC), updatedAt = LocalDateTime.now(),
) )
private fun fixture(): IngestionFixture { private fun fixture(): IngestionFixture {
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
import { TguApiError } from "../model/tguTypes"; import { TguApiError } from "../model/tguTypes";
import { toLocalIso } from "../utils/localTime";
const BASE = "/api/pcp-ballistics"; const BASE = "/api/pcp-ballistics";
@@ -152,9 +153,9 @@ export async function fetchSatelliteRva(
return JSON.parse(text) as RvaZone[]; return JSON.parse(text) as RvaZone[];
} }
/** LocalDateTime без зоны: YYYY-MM-DDTHH:mm:ss. */ /** LocalDateTime без зоны (локальные настенные часы): YYYY-MM-DDTHH:mm:ss. */
function toLocalDateTime(ms: number): string { function toLocalDateTime(ms: number): string {
return new Date(ms).toISOString().slice(0, 19); return toLocalIso(ms);
} }
function extractErrorText(body: string): string { function extractErrorText(body: string): string {
+3 -3
View File
@@ -5,6 +5,7 @@ import {
type TguPlanDecisionMessage, type TguPlanDecisionMessage,
type TguPlatform type TguPlatform
} from "../model/tguTypes"; } from "../model/tguTypes";
import { toLocalIso } from "../utils/localTime";
export type FlightLinePoint = { export type FlightLinePoint = {
time: string; time: string;
@@ -27,10 +28,9 @@ export async function fetchFlightLine(
fromMs: number, fromMs: number,
toMs: number toMs: number
): Promise<FlightLinePoint[]> { ): Promise<FlightLinePoint[]> {
const toLocalDT = (ms: number) => new Date(ms).toISOString().slice(0, 19);
const query = new URLSearchParams({ const query = new URLSearchParams({
time_start: toLocalDT(fromMs), time_start: toLocalIso(fromMs),
time_stop: toLocalDT(toMs) time_stop: toLocalIso(toMs)
}); });
return fetchJson<FlightLinePoint[]>( return fetchJson<FlightLinePoint[]>(
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}` `/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`
@@ -11,7 +11,7 @@ export type DraftMode = "replace" | "fork-forward";
export type CreateDraftRequest = { export type CreateDraftRequest = {
mode: DraftMode; mode: DraftMode;
/** Начало окна целевой ЗРВ (UTC naive, ISO без зоны). Обязательно для fork-forward. */ /** Начало окна целевой ЗРВ (naive, ISO без зоны, локальное). Обязательно для fork-forward. */
windowStart?: string; windowStart?: string;
/** Конец окна целевой ЗРВ. Обязательно для fork-forward. */ /** Конец окна целевой ЗРВ. Обязательно для fork-forward. */
windowStop?: string; windowStop?: string;
@@ -53,7 +53,7 @@ export type ZrvWindowResponse = {
/** /**
* Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ: * Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ:
* `windowStart`/`windowKppId` старт выбранной стартовой зоны, `windowStop` конец конечной. * `windowStart`/`windowKppId` старт выбранной стартовой зоны, `windowStop` конец конечной.
* Времена UTC naive (ISO без зоны), как ждёт `LocalDateTime` бэка. `basePlanId` предыстория * Времена naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка. `basePlanId` предыстория
* (история тянется) либо `null` (цепочка без истории, пустой план). * (история тянется) либо `null` (цепочка без истории, пустой план).
*/ */
export type CreateChainPlanRequest = { export type CreateChainPlanRequest = {
@@ -1,7 +1,7 @@
import { useEffect, useState, type CSSProperties } from "react"; import { useEffect, useState, type CSSProperties } from "react";
/** /**
* Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (UTC). * Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (локальное).
* *
* Нативные `<input type="time">`/`type="datetime-local"` рендерят время по локали * Нативные `<input type="time">`/`type="datetime-local"` рендерят время по локали
* браузера/ОС (часто AM/PM) и не подчиняются атрибуту `lang`, поэтому время вводим * браузера/ОС (часто AM/PM) и не подчиняются атрибуту `lang`, поэтому время вводим
@@ -101,7 +101,7 @@ export function Time24Field({ value, withSeconds = false, onChange, className, s
/** /**
* Замена нативного `<input type="datetime-local">`: нативный календарь для даты * Замена нативного `<input type="datetime-local">`: нативный календарь для даты
* (у даты нет проблемы AM/PM) + [Time24Field] для времени в 24-часовом формате. * (у даты нет проблемы AM/PM) + [Time24Field] для времени в 24-часовом формате.
* Контракт значения тот же строка `YYYY-MM-DDTHH:mm` (UTC), как у datetime-local. * Контракт значения тот же строка `YYYY-MM-DDTHH:mm` (локальное), как у datetime-local.
*/ */
type DateTime24FieldProps = { type DateTime24FieldProps = {
value: string; value: string;
@@ -1,6 +1,6 @@
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields"; import { Time24Field } from "../../components/timeFields";
import type { RvaZone } from "../../api/ballisticsApi"; import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute } from "../../api/missionPlanningApi"; import type { SurveyRoute } from "../../api/missionPlanningApi";
@@ -50,7 +50,7 @@ type ShootPanelProps = {
selectedPass?: EditorVisibilityWindow; selectedPass?: EditorVisibilityWindow;
modeId?: string; modeId?: string;
modes: EditorMode[]; modes: EditorMode[];
/** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */ /** Окно строящегося маршрута (локальное, мс). undefined пока не выбран проход. */
routeWindow?: { startMs: number; endMs: number }; routeWindow?: { startMs: number; endMs: number };
/** Текущий крен (gamma) маршрута, градусы. */ /** Текущий крен (gamma) маршрута, градусы. */
routeRoll?: number; routeRoll?: number;
@@ -105,7 +105,7 @@ type DropPanelProps = {
onSelectZone: (zone?: RvaZone) => void; onSelectZone: (zone?: RvaZone) => void;
/** Название станции по её номеру (из каталога станций). */ /** Название станции по её номеру (из каталога станций). */
stationName: (stationId: number) => string; stationName: (stationId: number) => string;
/** Время начала зоны в мс (UTC) — для подписи. */ /** Время начала зоны в мс (локальное) — для подписи. */
zoneStartMs: (zone: RvaZone) => number; zoneStartMs: (zone: RvaZone) => number;
zoneStopMs: (zone: RvaZone) => number; zoneStopMs: (zone: RvaZone) => number;
/** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */ /** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */
@@ -699,7 +699,7 @@ function surveyTypeLabel(surveyType: string): string {
} }
function formatIso(iso: string): string { function formatIso(iso: string): string {
return formatUtcDateTime(parseUtc(iso)); return formatLocalDateTime(parseLocal(iso));
} }
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
@@ -747,7 +747,7 @@ function TimeStepper({
/** /**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной * Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (UTC, без даты дата берётся из текущего значения). * ввод времени `чч:мм:сс` (локальное, без даты дата берётся из текущего значения).
* Значение зажимается в [min, max]. * Значение зажимается в [min, max].
*/ */
function RouteTimeStepper({ function RouteTimeStepper({
@@ -766,16 +766,16 @@ function RouteTimeStepper({
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next)); const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
const change = (deltaMs: number) => onChange(clamp(value + deltaMs)); const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
const onTimeChange = (raw: string) => { const onTimeChange = (raw: string) => {
// Ввод — только время суток (ЧЧ:ММ:СС, UTC); день оставляем прежним. // Ввод — только время суток (ЧЧ:ММ:СС, локальное); день оставляем прежним.
const datePart = new Date(value).toISOString().slice(0, 10); const datePart = toLocalIso(value).slice(0, 10);
const ms = parseUtc(`${datePart}T${raw}`); const ms = parseLocal(`${datePart}T${raw}`);
if (Number.isFinite(ms)) onChange(clamp(ms)); if (Number.isFinite(ms)) onChange(clamp(ms));
}; };
return ( return (
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds"> <label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
<span>{label}</span> <span>{label}</span>
<Time24Field withSeconds value={new Date(value).toISOString().slice(11, 19)} onChange={onTimeChange} /> <Time24Field withSeconds value={toLocalIso(value).slice(11, 19)} onChange={onTimeChange} />
<div className="tgu-editor-time-stepper__steps"> <div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => change(-30_000)}>30с</button> <button type="button" onClick={() => change(-30_000)}>30с</button>
<button type="button" onClick={() => change(-5_000)}>5с</button> <button type="button" onClick={() => change(-5_000)}>5с</button>
@@ -85,7 +85,7 @@ export function EditorTimeline({
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}> <div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}> <div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}> <div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время (UTC) Время
</div> </div>
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}> <div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{days.map((day) => ( {days.map((day) => (
@@ -206,7 +206,7 @@ export function EditorTimeline({
function buildDays(window: EditorTimelineWindow): number[] { function buildDays(window: EditorTimelineWindow): number[] {
const result: number[] = []; const result: number[] = [];
const start = new Date(window.fromMs); const start = new Date(window.fromMs);
let dayMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()); let dayMs = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
while (dayMs <= window.toMs) { while (dayMs <= window.toMs) {
result.push(dayMs); result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000; dayMs += 24 * 60 * 60 * 1000;
@@ -9,14 +9,14 @@ type MapPassSelectorProps = {
onToChange: (idx: number) => void; onToChange: (idx: number) => void;
}; };
function utcHM(ms: number): string { function localHM(ms: number): string {
const d = new Date(ms); const d = new Date(ms);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
} }
function utcDM(ms: number): string { function localDM(ms: number): string {
const d = new Date(ms); const d = new Date(ms);
return `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}`; return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
} }
export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) { export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) {
@@ -27,7 +27,7 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))]; const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))]; const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))];
const multiDay = utcDM(passes[0].startMs) !== utcDM(passes[n - 1].startMs); const multiDay = localDM(passes[0].startMs) !== localDM(passes[n - 1].startMs);
const isFullRange = fromIndex === 1 && toIndex === n; const isFullRange = fromIndex === 1 && toIndex === n;
const fromRev = fromPass.revolution ?? fromIndex; const fromRev = fromPass.revolution ?? fromIndex;
const toRev = toPass.revolution ?? toIndex; const toRev = toPass.revolution ?? toIndex;
@@ -76,10 +76,9 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
<span className="map-pass-selector__title">Витки</span> <span className="map-pass-selector__title">Витки</span>
<span className="map-pass-selector__nums">{fromRev}{toRev}</span> <span className="map-pass-selector__nums">{fromRev}{toRev}</span>
<span className="map-pass-selector__times"> <span className="map-pass-selector__times">
{multiDay ? `${utcDM(fromPass.startMs)} ` : ""}{utcHM(fromPass.startMs)} {multiDay ? `${localDM(fromPass.startMs)} ` : ""}{localHM(fromPass.startMs)}
{" "} {" "}
{multiDay ? `${utcDM(toPass.startMs)} ` : ""}{utcHM(toPass.startMs)} {multiDay ? `${localDM(toPass.startMs)} ` : ""}{localHM(toPass.startMs)}
{" UTC"}
</span> </span>
{!isFullRange && ( {!isFullRange && (
<button <button
@@ -14,7 +14,7 @@ import {
type SurveyRoute type SurveyRoute
} from "../../api/missionPlanningApi"; } from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi"; import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal, toLocalIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers"; import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
@@ -150,8 +150,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
.map(([revolution, pts], idx) => ({ .map(([revolution, pts], idx) => ({
index: idx + 1, index: idx + 1,
revolution, revolution,
startMs: parseUtc(pts[0].time), startMs: parseLocal(pts[0].time),
endMs: parseUtc(pts[pts.length - 1].time), endMs: parseLocal(pts[pts.length - 1].time),
})); }));
}, [flightLineData]); }, [flightLineData]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES; const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
@@ -177,7 +177,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]); const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>(); const [shootPassesError, setShootPassesError] = useState<string>();
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC). // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное).
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>(); const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную. // Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
const [routeRoll, setRouteRoll] = useState<number>(); const [routeRoll, setRouteRoll] = useState<number>();
@@ -275,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void fetchRequestsForMap({ void fetchRequestsForMap({
endFrom: new Date(window.fromMs).toISOString().slice(0, 19), endFrom: toLocalIso(window.fromMs),
beginTo: new Date(window.toMs).toISOString().slice(0, 19) beginTo: toLocalIso(window.toMs)
}).then((result) => { }).then((result) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setRequestItems(result.items); setRequestItems(result.items);
@@ -365,7 +365,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
void buildSurveyRoute({ void buildSurveyRoute({
satelliteId: numericNorad, satelliteId: numericNorad,
// Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность. // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
time: new Date(routeWindow.startMs).toISOString().slice(0, 19), time: toLocalIso(routeWindow.startMs),
durationSeconds, durationSeconds,
roll: routeRoll, roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
@@ -522,15 +522,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const draftId = await ensureDraft(); const draftId = await ensureDraft();
savedRoute = await saveSurveyRoute(draftId, { savedRoute = await saveSurveyRoute(draftId, {
satelliteId: numericNorad, satelliteId: numericNorad,
time: new Date(routeWindow.startMs).toISOString().slice(0, 19), time: toLocalIso(routeWindow.startMs),
durationSeconds: routeDurationSeconds, durationSeconds: routeDurationSeconds,
roll: routeRoll, roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0, revolution: selectedShootPass.revolution ?? 0,
anchor: "FORWARD", anchor: "FORWARD",
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planStart: toLocalIso(selectedPlan.startMs),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) planEnd: toLocalIso(selectedPlan.endMs)
}); });
setSurveyRoute(savedRoute); setSurveyRoute(savedRoute);
void refreshDraftModes(draftId); void refreshDraftModes(draftId);
@@ -614,8 +614,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
zoneStop: selectedZone.onStop.time.slice(0, 19), zoneStop: selectedZone.onStop.time.slice(0, 19),
surveyModeIds: [...selectedDropSurveyIds], surveyModeIds: [...selectedDropSurveyIds],
// Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом. // Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planStart: toLocalIso(selectedPlan.startMs),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) planEnd: toLocalIso(selectedPlan.endMs)
}); });
savedDropId = savedDrop.id; savedDropId = savedDrop.id;
// Перечитываем включения DRAFT (источник истины) и ревалидируем. // Перечитываем включения DRAFT (источник истины) и ревалидируем.
@@ -1051,7 +1051,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
} }
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = parseUtc(param.time); const timeMs = parseLocal(param.time);
const half = SHOOT_WINDOW_MS / 2; const half = SHOOT_WINDOW_MS / 2;
return { return {
startMs: timeMs - half, startMs: timeMs - half,
@@ -1088,11 +1088,12 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
} }
/** /**
* LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi): * LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time`
* зоны ЗРВ приходят без таймзоны, а времена съёмок с offset; приводим к одной шкале. * в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок с offset (его учитываем);
* приводим к одной шкале в локальной зоне браузера.
*/ */
function localDateTimeMs(s: string): number { function localDateTimeMs(s: string): number {
return parseUtc(s); return parseLocal(s);
} }
function buildVisibilityTracks( function buildVisibilityTracks(
@@ -1208,7 +1209,7 @@ function buildEditorMapScene(
const startMs = localDateTimeMs(mode.timeStart); const startMs = localDateTimeMs(mode.timeStart);
const endMs = startMs + mode.duration * 1000; const endMs = startMs + mode.duration * 1000;
const pts = flightLineData const pts = flightLineData
.map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long })) .map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long }))
.filter((p) => p.t >= startMs && p.t <= endMs) .filter((p) => p.t >= startMs && p.t <= endMs)
.sort((a, b) => a.t - b.t) .sort((a, b) => a.t - b.t)
.map((p) => ({ lat: p.lat, lon: p.lon })); .map((p) => ({ lat: p.lat, lon: p.lon }));
@@ -14,17 +14,17 @@ import {
} from "./editorDraft"; } from "./editorDraft";
import type { PlanMode } from "./editorApi"; import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes"; import type { DraftHistory } from "./model/editorTypes";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
const plan: TguPlanUi = { const plan: TguPlanUi = {
planId: "plan-1", planId: "plan-1",
spacecraftId: "56756", spacecraftId: "56756",
startTime: "2026-05-29T01:00:00Z", startTime: "2026-05-29T01:00:00",
endTime: "2026-05-29T02:00:00Z", endTime: "2026-05-29T02:00:00",
kppId: "KPP-1", kppId: "KPP-1",
status: "PLANNED", status: "PLANNED",
startMs: parseUtc("2026-05-29T01:00:00Z"), startMs: parseLocal("2026-05-29T01:00:00"),
endMs: parseUtc("2026-05-29T02:00:00Z") endMs: parseLocal("2026-05-29T02:00:00")
}; };
describe("editorDraft", () => { describe("editorDraft", () => {
@@ -85,8 +85,8 @@ describe("editorDraft", () => {
id: "survey-11", id: "survey-11",
kind: "shoot", kind: "shoot",
label: "Съёмка · виток 4321", label: "Съёмка · виток 4321",
startMs: parseUtc("2026-05-29T01:10:00Z"), startMs: parseLocal("2026-05-29T01:10:00"),
endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000, endMs: parseLocal("2026-05-29T01:10:00") + 180_000,
origin: "plan", origin: "plan",
planModeId: 11, planModeId: 11,
revolution: 4321, revolution: 4321,
@@ -100,8 +100,8 @@ describe("editorDraft", () => {
id: "drop-22", id: "drop-22",
kind: "downlink", kind: "downlink",
label: "Сброс · 102", label: "Сброс · 102",
startMs: parseUtc("2026-05-29T01:40:00Z"), startMs: parseLocal("2026-05-29T01:40:00"),
endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000, endMs: parseLocal("2026-05-29T01:40:00") + 120_000,
origin: "plan", origin: "plan",
planModeId: 22, planModeId: 22,
revolution: 4322, revolution: 4322,
@@ -1,5 +1,5 @@
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
import type { PlanMode } from "./editorApi"; import type { PlanMode } from "./editorApi";
import type { import type {
Draft, Draft,
@@ -64,7 +64,7 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
export function planModesToWorks(modes: PlanMode[]): EditorWork[] { export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
return modes.flatMap((mode): EditorWork[] => { return modes.flatMap((mode): EditorWork[] => {
if (mode.timeStart == null) return []; if (mode.timeStart == null) return [];
const startMs = parseUtc(mode.timeStart); const startMs = parseLocal(mode.timeStart);
if (!Number.isFinite(startMs)) return []; if (!Number.isFinite(startMs)) return [];
const endMs = startMs + Math.max(1, mode.duration) * 1000; const endMs = startMs + Math.max(1, mode.duration) * 1000;
@@ -272,7 +272,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
return value; return value;
} }
if (typeof value === "string") { if (typeof value === "string") {
const parsed = parseUtc(value); const parsed = parseLocal(value);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
return undefined; return undefined;
@@ -24,12 +24,12 @@ export function kindColor(kind: EditorWorkKind): string {
export function formatTime(ms: number): string { export function formatTime(ms: number): string {
const date = new Date(ms); const date = new Date(ms);
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
} }
export function formatDate(ms: number): string { export function formatDate(ms: number): string {
const date = new Date(ms); const date = new Date(ms);
return `${pad(date.getUTCDate())} ${EDITOR_MONTHS[date.getUTCMonth()]}`; return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
} }
export function formatDateTime(ms: number): string { export function formatDateTime(ms: number): string {
@@ -1,6 +1,6 @@
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes"; import type { MapPolygon } from "./model/mapTypes";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
@@ -96,18 +96,18 @@ export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, on
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value)); return formatLocalDateTime(parseLocal(value));
} }
function formatDateRange(begin: string, end: string): string { function formatDateRange(begin: string, end: string): string {
const b = new Date(parseUtc(begin)); const b = new Date(parseLocal(begin));
const e = new Date(parseUtc(end)); const e = new Date(parseLocal(end));
const sameDay = const sameDay =
b.getUTCFullYear() === e.getUTCFullYear() && b.getFullYear() === e.getFullYear() &&
b.getUTCMonth() === e.getUTCMonth() && b.getMonth() === e.getMonth() &&
b.getUTCDate() === e.getUTCDate(); b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", day: "2-digit", month: "2-digit" }; const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }; const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`; const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts) ? b.toLocaleTimeString("ru-RU", timeOpts)
@@ -3,7 +3,7 @@ import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMappe
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers"; import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers";
import type { RequestsState } from "./Tgu2DMapTab"; import type { RequestsState } from "./Tgu2DMapTab";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields"; import { DateTime24Field } from "../../components/timeFields";
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@@ -48,7 +48,7 @@ export function Tgu2DMapSidebar({
const quickToday = () => { const quickToday = () => {
const now = new Date(); const now = new Date();
const fromMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
onRequestsQuickRange(fromMs, fromMs + DAY_MS); onRequestsQuickRange(fromMs, fromMs + DAY_MS);
}; };
@@ -62,8 +62,8 @@ export function Tgu2DMapSidebar({
onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS); onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS);
}; };
const requestsFromMs = parseUtc(requestsState.fromValue); const requestsFromMs = parseLocal(requestsState.fromValue);
const requestsToMs = parseUtc(requestsState.toValue); const requestsToMs = parseLocal(requestsState.toValue);
const requestsRangeInvalid = const requestsRangeInvalid =
!Number.isFinite(requestsFromMs) || !Number.isFinite(requestsFromMs) ||
!Number.isFinite(requestsToMs) || !Number.isFinite(requestsToMs) ||
@@ -15,7 +15,7 @@ import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView"; import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseUtc, toUtcInputValue } from "../../utils/utcTime"; import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
type Tgu2DMapTabProps = { type Tgu2DMapTabProps = {
range: TimelineRange; range: TimelineRange;
@@ -38,8 +38,8 @@ export type RequestsState = {
function buildInitialRequestsState(range: TimelineRange): RequestsState { function buildInitialRequestsState(range: TimelineRange): RequestsState {
return { return {
fromValue: toUtcInputValue(range.fromMs), fromValue: toLocalInputValue(range.fromMs),
toValue: toUtcInputValue(range.toMs), toValue: toLocalInputValue(range.toMs),
items: [], items: [],
loading: false, loading: false,
loadedCount: 0, loadedCount: 0,
@@ -78,16 +78,16 @@ export function Tgu2DMapTab({
const mapInvalidRange = invalidRange || mapInterval.tooLarge; const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => { const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseUtc(fromValue); const fromMs = parseLocal(fromValue);
const toMs = parseUtc(toValue); const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try { try {
const result = await fetchRequestsForMap( const result = await fetchRequestsForMap(
{ {
endFrom: new Date(fromMs).toISOString().slice(0, 19), endFrom: toLocalIso(fromMs),
beginTo: new Date(toMs).toISOString().slice(0, 19) beginTo: toLocalIso(toMs)
}, },
(loaded, total) => { (loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })); setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
@@ -124,8 +124,8 @@ export function Tgu2DMapTab({
}, []); }, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => { const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = toUtcInputValue(fromMs); const fromValue = toLocalInputValue(fromMs);
const toValue = toUtcInputValue(toMs); const toValue = toLocalInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue })); setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue); void loadRequests(fromValue, toValue);
}, [loadRequests]); }, [loadRequests]);
@@ -297,7 +297,6 @@ export function Tgu2DMapTab({
function formatDate(ms: number): string { function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", { return new Date(ms).toLocaleString("ru-RU", {
timeZone: "UTC",
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
hour: "2-digit", hour: "2-digit",
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes";
import { getTgu2DMapIntervalState } from "./tgu2DMapInterval"; import { getTgu2DMapIntervalState } from "./tgu2DMapInterval";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
function plan(startTime: string, endTime: string): TguPlanUi { function plan(startTime: string, endTime: string): TguPlanUi {
return { return {
@@ -12,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi {
endTime, endTime,
kppId: "KPP-1", kppId: "KPP-1",
status: "PLANNED", status: "PLANNED",
startMs: parseUtc(startTime), startMs: parseLocal(startTime),
endMs: parseUtc(endTime) endMs: parseLocal(endTime)
}; };
} }
describe("tgu2DMapInterval", () => { describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => { it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({ const state = getTgu2DMapIntervalState({
fromMs: parseUtc("2026-05-29T00:00:00"), fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseUtc("2026-06-05T00:00:01") toMs: parseLocal("2026-06-05T00:00:01")
}); });
expect(state.tooLarge).toBe(true); expect(state.tooLarge).toBe(true);
@@ -31,8 +31,8 @@ describe("tgu2DMapInterval", () => {
const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00"); const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00");
const state = getTgu2DMapIntervalState( const state = getTgu2DMapIntervalState(
{ {
fromMs: parseUtc("2026-05-29T00:00:00"), fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseUtc("2026-06-10T00:00:00") toMs: parseLocal("2026-06-10T00:00:00")
}, },
selectedPlan selectedPlan
); );
@@ -7,7 +7,7 @@ import {
} from "../../api/tguPlanApi"; } from "../../api/tguPlanApi";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { TguApiError } from "../../model/tguTypes"; import { TguApiError } from "../../model/tguTypes";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
type CreateChainPlanDialogProps = { type CreateChainPlanDialogProps = {
@@ -123,7 +123,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
<option value="">Без истории (новая цепочка с нуля)</option> <option value="">Без истории (новая цепочка с нуля)</option>
{baseOptions.map((plan) => ( {baseOptions.map((plan) => (
<option key={plan.planId} value={plan.planId}> <option key={plan.planId} value={plan.planId}>
{formatUtcDateTime(plan.startMs)} · {plan.kppId} · {plan.status} {formatLocalDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
</option> </option>
))} ))}
</select> </select>
@@ -142,7 +142,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
<option value=""> выберите </option> <option value=""> выберите </option>
{zrvOptions.map((option) => ( {zrvOptions.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{formatUtcDateTime(option.startMs)} · {option.kppId} {formatLocalDateTime(option.startMs)} · {option.kppId}
</option> </option>
))} ))}
</select> </select>
@@ -156,7 +156,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
.filter((option) => !start || option.endMs > start.startMs) .filter((option) => !start || option.endMs > start.startMs)
.map((option) => ( .map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{formatUtcDateTime(option.endMs)} · {option.kppId} {formatLocalDateTime(option.endMs)} · {option.kppId}
</option> </option>
))} ))}
</select> </select>
@@ -186,7 +186,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
) : ( ) : (
result.projectedChain.map((plan) => ( result.projectedChain.map((plan) => (
<li key={plan.planId}> <li key={plan.planId}>
{formatUtcDateTime(parseUtc(plan.startTime))} {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "} {formatLocalDateTime(parseLocal(plan.startTime))} {formatLocalDateTime(parseLocal(plan.endTime))} ·{" "}
{plan.kppId} {plan.kppId}
</li> </li>
)) ))
@@ -2,7 +2,7 @@ import type { TguPlanDecision } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "./tguTimelineMapper"; import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus"; import { statusStyle } from "./tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
// Бэкенд (pcp-tgu-service) ждёт решения по плану только waiting-decision-grace-minutes // Бэкенд (pcp-tgu-service) ждёт решения по плану только waiting-decision-grace-minutes
// минут после его startTime; затем IssueDuePlanWorker.tryAdvancePastUnconfirmedPlan // минут после его startTime; затем IssueDuePlanWorker.tryAdvancePastUnconfirmedPlan
@@ -161,7 +161,7 @@ function Detail({ label, value, mono }: { label: string; value?: string; mono?:
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value)); return formatLocalDateTime(parseLocal(value));
} }
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -27,15 +27,15 @@ import {
operationalSpacecraftIds, operationalSpacecraftIds,
pausedSpacecraftIds pausedSpacecraftIds
} from "./tguTimelineMapper"; } from "./tguTimelineMapper";
import { parseUtc, toUtcInputValue } from "../../utils/utcTime"; import { parseLocal, toLocalInputValue } from "../../utils/localTime";
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000; const MINUTE_MS = 60 * 1000;
export function TguPlanningPage() { export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []); const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(toUtcInputValue(initialRange.fromMs)); const [fromValue, setFromValue] = useState(toLocalInputValue(initialRange.fromMs));
const [toValue, setToValue] = useState(toUtcInputValue(initialRange.toMs)); const [toValue, setToValue] = useState(toLocalInputValue(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange); const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]); const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]); const [plans, setPlans] = useState<TguPlanUi[]>([]);
@@ -68,8 +68,8 @@ export function TguPlanningPage() {
const parsedInputRange = useMemo( const parsedInputRange = useMemo(
() => ({ () => ({
fromMs: parseUtc(fromValue), fromMs: parseLocal(fromValue),
toMs: parseUtc(toValue) toMs: parseLocal(toValue)
}), }),
[fromValue, toValue] [fromValue, toValue]
); );
@@ -158,8 +158,8 @@ export function TguPlanningPage() {
return { return {
spacecraftId: selectedSpacecraftId, spacecraftId: selectedSpacecraftId,
windows: zrvWindows.map((zrv) => ({ windows: zrvWindows.map((zrv) => ({
startMs: parseUtc(zrv.startTime), startMs: parseLocal(zrv.startTime),
endMs: parseUtc(zrv.endTime), endMs: parseLocal(zrv.endTime),
kppId: zrv.kppId kppId: zrv.kppId
})) }))
}; };
@@ -221,8 +221,8 @@ export function TguPlanningPage() {
const quickRange = (hours: number) => { const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs(); const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000; const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(toUtcInputValue(fromMs)); setFromValue(toLocalInputValue(fromMs));
setToValue(toUtcInputValue(toMs)); setToValue(toLocalInputValue(toMs));
setAppliedRange({ fromMs, toMs }); setAppliedRange({ fromMs, toMs });
setPageError(undefined); setPageError(undefined);
setDecisionNotice(undefined); setDecisionNotice(undefined);
@@ -442,7 +442,7 @@ function defaultRange(): TimelineRange {
function startOfCurrentDayMs(): number { function startOfCurrentDayMs(): number {
const now = new Date(); const now = new Date();
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
} }
function historyDaysForRange(range: TimelineRange): number { function historyDaysForRange(range: TimelineRange): number {
@@ -12,7 +12,7 @@ import {
} from "./tguTimelineLayout"; } from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper"; import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus"; import { statusStyle } from "./tguStatus";
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime";
export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
@@ -196,7 +196,7 @@ export function TguTimeline({
onSelectZrv(overlapping, Math.max(0, idx)); onSelectZrv(overlapping, Math.max(0, idx));
}} }}
> >
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)}</title> <title>ЗРВ {zrv.kppId} · {formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}</title>
</rect> </rect>
); );
})} })}
@@ -268,13 +268,13 @@ function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePl
} }
function formatTickDate(ms: number): string { function formatTickDate(ms: number): string {
return formatUtcDate(ms); return formatLocalDate(ms);
} }
function formatTickTime(ms: number): string { function formatTickTime(ms: number): string {
return formatUtcTime(ms); return formatLocalTime(ms);
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value)); return formatLocalDateTime(parseLocal(value));
} }
@@ -1,5 +1,5 @@
import type { ZrvWindow } from "./TguTimeline"; import type { ZrvWindow } from "./TguTimeline";
import { formatUtcDateTime } from "../../utils/utcTime"; import { formatLocalDateTime } from "../../utils/localTime";
type Props = { type Props = {
windows: ZrvWindow[]; windows: ZrvWindow[];
@@ -35,7 +35,7 @@ export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props
<span className="tgu-map-request-row__body"> <span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">КПП {zrv.kppId}</span> <span className="tgu-map-request-row__name">КПП {zrv.kppId}</span>
<span className="tgu-map-request-row__meta"> <span className="tgu-map-request-row__meta">
{formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)} {formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}
{" · "} {" · "}
{formatDuration(zrv.endMs - zrv.startMs)} {formatDuration(zrv.endMs - zrv.startMs)}
</span> </span>
@@ -52,10 +52,10 @@ export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props
<dd className="mono">{selected.kppId}</dd> <dd className="mono">{selected.kppId}</dd>
<dt>Начало</dt> <dt>Начало</dt>
<dd className="mono">{formatUtcDateTime(selected.startMs)}</dd> <dd className="mono">{formatLocalDateTime(selected.startMs)}</dd>
<dt>Конец</dt> <dt>Конец</dt>
<dd className="mono">{formatUtcDateTime(selected.endMs)}</dd> <dd className="mono">{formatLocalDateTime(selected.endMs)}</dd>
<dt>Длительность</dt> <dt>Длительность</dt>
<dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd> <dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd>
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
import type { ZrvWindowResponse } from "../../api/tguPlanApi"; import type { ZrvWindowResponse } from "../../api/tguPlanApi";
const NOW = Date.UTC(2026, 5, 3, 12, 0, 0); const NOW = new Date(2026, 5, 3, 12, 0, 0).getTime();
function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse { function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse {
return { startTime: startIso, endTime: endIso, kppId }; return { startTime: startIso, endTime: endIso, kppId };
@@ -11,19 +11,19 @@ function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowRespon
describe("futureZrvOptions", () => { describe("futureZrvOptions", () => {
it("keeps only future zones and sorts by start", () => { it("keeps only future zones and sorts by start", () => {
const windows = [ const windows = [
zrv("2026-06-03T15:00:00Z", "2026-06-03T15:10:00Z"), zrv("2026-06-03T15:00:00", "2026-06-03T15:10:00"),
zrv("2026-06-03T11:00:00Z", "2026-06-03T11:10:00Z"), // в прошлом → отбросить zrv("2026-06-03T11:00:00", "2026-06-03T11:10:00"), // в прошлом → отбросить
zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z") zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")
]; ];
const options = futureZrvOptions(windows, NOW); const options = futureZrvOptions(windows, NOW);
expect(options.map((o) => o.startMs)).toEqual([ expect(options.map((o) => o.startMs)).toEqual([
Date.UTC(2026, 5, 3, 13, 0, 0), new Date(2026, 5, 3, 13, 0, 0).getTime(),
Date.UTC(2026, 5, 3, 15, 0, 0) new Date(2026, 5, 3, 15, 0, 0).getTime()
]); ]);
}); });
it("produces naive UTC (no zone) start/stop strings", () => { it("produces naive (no zone) start/stop strings", () => {
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")], NOW); const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")], NOW);
expect(option.start).toBe("2026-06-03T13:00:00"); expect(option.start).toBe("2026-06-03T13:00:00");
expect(option.stop).toBe("2026-06-03T13:10:00"); expect(option.stop).toBe("2026-06-03T13:10:00");
expect(option.kppId).toBe("KPP-1"); expect(option.kppId).toBe("KPP-1");
@@ -1,9 +1,9 @@
import { parseUtc } from "../../utils/utcTime"; import { parseLocal, toLocalIso } from "../../utils/localTime";
import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi"; import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi";
/** /**
* Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из * Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из
* конечной конец окна. `start`/`stop` naive UTC (ISO без зоны), как ждёт `LocalDateTime` бэка; * конечной конец окна. `start`/`stop` naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка;
* `startMs`/`endMs` для сортировки/сравнения и подсветки на таймлайне. * `startMs`/`endMs` для сортировки/сравнения и подсветки на таймлайне.
*/ */
export type ZrvOption = { export type ZrvOption = {
@@ -11,9 +11,9 @@ export type ZrvOption = {
id: string; id: string;
startMs: number; startMs: number;
endMs: number; endMs: number;
/** Naive UTC начала зоны. */ /** Naive (локальное) начала зоны. */
start: string; start: string;
/** Naive UTC конца зоны. */ /** Naive (локальное) конца зоны. */
stop: string; stop: string;
kppId: string; kppId: string;
}; };
@@ -22,14 +22,14 @@ export type ZrvOption = {
export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] { export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] {
return windows return windows
.map((zrv) => { .map((zrv) => {
const startMs = parseUtc(zrv.startTime); const startMs = parseLocal(zrv.startTime);
const endMs = parseUtc(zrv.endTime); const endMs = parseLocal(zrv.endTime);
return { return {
id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`, id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`,
startMs, startMs,
endMs, endMs,
start: toNaiveUtc(startMs), start: toNaive(startMs),
stop: toNaiveUtc(endMs), stop: toNaive(endMs),
kppId: zrv.kppId kppId: zrv.kppId
}; };
}) })
@@ -60,6 +60,6 @@ export function buildChainPlanRequest(
}; };
} }
function toNaiveUtc(ms: number): string { function toNaive(ms: number): string {
return new Date(ms).toISOString().slice(0, 19); return toLocalIso(ms);
} }
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout"; import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
const range = { const range = {
fromMs: parseUtc("2026-05-29T00:00:00"), fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseUtc("2026-05-30T00:00:00") toMs: parseLocal("2026-05-30T00:00:00")
}; };
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi { function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
@@ -16,14 +16,14 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56
endTime, endTime,
kppId: "KPP-1", kppId: "KPP-1",
status: "PLANNED", status: "PLANNED",
startMs: parseUtc(startTime), startMs: parseLocal(startTime),
endMs: parseUtc(endTime) endMs: parseLocal(endTime)
}; };
} }
describe("tguTimelineLayout", () => { describe("tguTimelineLayout", () => {
it("maps time to x inside the selected interval", () => { it("maps time to x inside the selected interval", () => {
expect(timeToX(parseUtc("2026-05-29T12:00:00"), range, 1200)).toBe(600); expect(timeToX(parseLocal("2026-05-29T12:00:00"), range, 1200)).toBe(600);
}); });
it("clips partially visible plans to the selected interval", () => { it("clips partially visible plans to the selected interval", () => {
@@ -31,7 +31,7 @@ describe("tguTimelineLayout", () => {
expect(clipped).toEqual({ expect(clipped).toEqual({
startMs: range.fromMs, startMs: range.fromMs,
endMs: parseUtc("2026-05-29T02:00:00") endMs: parseLocal("2026-05-29T02:00:00")
}); });
}); });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes"; import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper"; import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
import { toLocalIso } from "../../utils/localTime";
function platform(overrides: Partial<TguPlatform>): TguPlatform { function platform(overrides: Partial<TguPlatform>): TguPlatform {
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides }; return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
@@ -11,8 +12,8 @@ function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): T
return { return {
planId: `${spacecraftId}-${status}-${startMs}`, planId: `${spacecraftId}-${status}-${startMs}`,
spacecraftId, spacecraftId,
startTime: new Date(startMs).toISOString().slice(0, 19), startTime: toLocalIso(startMs),
endTime: new Date(startMs + 3_600_000).toISOString().slice(0, 19), endTime: toLocalIso(startMs + 3_600_000),
kppId: "KPP-1", kppId: "KPP-1",
status, status,
startMs, startMs,
@@ -1,6 +1,6 @@
import type { TguPlan, TguPlatform } from "../../model/tguTypes"; import type { TguPlan, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]);
@@ -27,8 +27,8 @@ export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[
return plans return plans
.map((plan) => { .map((plan) => {
const startMs = parseUtc(plan.startTime); const startMs = parseLocal(plan.startTime);
const endMs = parseUtc(plan.endTime); const endMs = parseLocal(plan.endTime);
const platform = platformByNorad.get(plan.spacecraftId); const platform = platformByNorad.get(plan.spacecraftId);
return { return {
...plan, ...plan,
@@ -6,7 +6,7 @@ import type {
RsaParams, RsaParams,
RsaResultType RsaResultType
} from "../../model/requestTypes"; } from "../../model/requestTypes";
import { parseUtc } from "../../utils/utcTime"; import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields"; import { DateTime24Field } from "../../components/timeFields";
export type RequestFormSubmit = { export type RequestFormSubmit = {
@@ -87,8 +87,8 @@ export function RequestCreatePanel({
const [rsaResolution, setRsaResolution] = useState("1"); const [rsaResolution, setRsaResolution] = useState("1");
const importanceNum = Number(importance); const importanceNum = Number(importance);
const beginMs = parseUtc(begin); const beginMs = parseLocal(begin);
const endMs = parseUtc(end); const endMs = parseLocal(end);
const kppList = parseKpp(kpp); const kppList = parseKpp(kpp);
const kppValid = kppList !== null; const kppValid = kppList !== null;
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
type Props = { type Props = {
requests: RequestAtPoint[]; requests: RequestAtPoint[];
@@ -155,7 +155,7 @@ export function RequestDetailsPanel({
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value)); return formatLocalDateTime(parseLocal(value));
} }
function surveyTypeLabel(surveyType: string): string { function surveyTypeLabel(surveyType: string): string {
@@ -9,7 +9,7 @@ import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid"; import { generateUuid } from "../../utils/uuid";
import { parseUtc, toUtcInputValue, toUtcIso } from "../../utils/utcTime"; import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields"; import { DateTime24Field } from "../../components/timeFields";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel"; import { RequestDetailsPanel } from "./RequestDetailsPanel";
@@ -37,8 +37,8 @@ type PanelMode = "none" | "details" | "create";
function buildInitialLoadState(range: TimelineRange): RequestsLoadState { function buildInitialLoadState(range: TimelineRange): RequestsLoadState {
return { return {
fromValue: toUtcInputValue(range.fromMs), fromValue: toLocalInputValue(range.fromMs),
toValue: toUtcInputValue(range.toMs), toValue: toLocalInputValue(range.toMs),
items: [], items: [],
loading: false, loading: false,
loadedCount: 0, loadedCount: 0,
@@ -75,14 +75,14 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
}, []); }, []);
const loadRequests = useCallback(async (fromValue: string, toValue: string) => { const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseUtc(fromValue); const fromMs = parseLocal(fromValue);
const toMs = parseUtc(toValue); const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try { try {
const result = await fetchRequestsForMap( const result = await fetchRequestsForMap(
{ endFrom: new Date(fromMs).toISOString().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) }, { endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) },
(loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
); );
setRequestsState((current) => ({ setRequestsState((current) => ({
@@ -204,8 +204,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
name: form.name, name: form.name,
geometry, geometry,
importance: form.importance, importance: form.importance,
beginDateTime: toUtcIso(form.beginDateTime), beginDateTime: toLocalIso(parseLocal(form.beginDateTime)),
endDateTime: toUtcIso(form.endDateTime), endDateTime: toLocalIso(parseLocal(form.endDateTime)),
kpp: form.kpp, kpp: form.kpp,
highPriorityTransmit: form.highPriorityTransmit, highPriorityTransmit: form.highPriorityTransmit,
optics: form.optics, optics: form.optics,
@@ -244,8 +244,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
[closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue]
); );
const fromMs = parseUtc(requestsState.fromValue); const fromMs = parseLocal(requestsState.fromValue);
const toMs = parseUtc(requestsState.toValue); const toMs = parseLocal(requestsState.toValue);
const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs;
const hasPanel = panelMode !== "none"; const hasPanel = panelMode !== "none";
@@ -0,0 +1,57 @@
/**
* Единый разбор и форматирование времени в локальной зоне.
*
* Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны
* (например `"2026-05-29T01:00:00"`) это настенные часы локальной зоны, в которой
* работают все сервисы, без UTC-семантики. Браузер по умолчанию трактует такую naive-строку
* как локальное время это нам и нужно: разбор, показ и ввод идут в зоне браузера, а
* сериализация назад отдаёт те же настенные часы naive-строкой без зоны.
*
* Строки с явной зоной (`...Z`/офсет, напр. валидность платформ из НСИ) парсятся как
* абсолютный момент и показываются в локальной зоне.
*/
function pad(n: number): string {
return String(n).padStart(2, "0");
}
/** Разбирает ISO-строку в эпоху-мс: naive — как локальное время, зонированную — как её момент. */
export function parseLocal(value: string): number {
return Date.parse(value);
}
/** Дата-время в локальной зоне, формат ru-RU. */
export function formatLocalDateTime(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
/** Дата (дд.мм) в локальной зоне. */
export function formatLocalDate(ms: number): string {
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
/** Время (чч:мм) в локальной зоне. */
export function formatLocalTime(ms: number): string {
return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
/** Значение для пикеров (`YYYY-MM-DDTHH:mm`) из локальных компонент. */
export function toLocalInputValue(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* naive `LocalDateTime` без зоны (`YYYY-MM-DDTHH:mm:ss`) из локальных компонент эпохи-мс.
* Все бэкенды ожидают `LocalDateTime` без зоны настенные часы локальной зоны.
*/
export function toLocalIso(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
@@ -1,62 +0,0 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Страж инварианта «всё время UTC» (см. docs/standards/DATETIME_UTC.md).
*
* Сканирует исходники и падает на паттернах, которые трактуют время в поясе
* браузера: «голый» разбор строки (`new Date("...")`, `Date.parse`) и показ через
* `toLocale*` без `timeZone`. Парсинг и форматирование времени бэка должны идти
* только через `utils/utcTime` (`parseUtc`, `formatUtc*`). Этот файл и сам
* `utcTime.ts` единственные исключения: в них паттерны присутствуют легитимно
* (как реализация утилиты и как строки-описания запретов).
*/
const SRC_DIR = join(process.cwd(), "src");
// Исключения: сам страж (содержит паттерны как строки-описания) и каноническая
// утилита utcTime.ts (легитимная реализация parseUtc/formatUtc*).
const EXCLUDED = ["utcTime.ts", "utcTime.guard.test.ts"];
interface Rule {
readonly name: string;
readonly pattern: RegExp;
}
const RULES: Rule[] = [
{ name: "Date.parse(...) — парсит строку в поясе браузера; используйте parseUtc", pattern: /\bDate\.parse\s*\(/ },
{ name: "new Date(\"...\") со строкой — трактует naive-строку как локальное время; используйте parseUtc", pattern: /new\s+Date\(\s*['"`]/ },
{ name: "formatDateTimeLocal — локальный хелпер пикеров; используйте toUtcInputValue/parseUtc", pattern: /\bformatDateTimeLocal\b/ },
// toLocale* без опций или только с локалью → пояс браузера.
{ name: "toLocale*() без timeZone — показывает в поясе браузера; используйте formatUtc* или { timeZone: \"UTC\" }", pattern: /\.toLocale(String|DateString|TimeString)\(\s*(['"`][^'"`]*['"`]\s*)?\)/ },
// toLocale* с inline-объектом опций, в котором нет timeZone.
{ name: "toLocale*({...}) без timeZone в опциях — показывает в поясе браузера; добавьте timeZone: \"UTC\"", pattern: /\.toLocale(String|DateString|TimeString)\(\s*['"`][^'"`]*['"`]\s*,\s*\{(?![\s\S]*?timeZone)[\s\S]*?\}/ }
];
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...collectSourceFiles(full));
} else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.includes(entry.name)) {
out.push(full);
}
}
return out;
}
describe("UTC time invariant", () => {
const files = collectSourceFiles(SRC_DIR);
it("scans the source tree", () => {
expect(files.length).toBeGreaterThan(0);
});
for (const rule of RULES) {
it(`forbids: ${rule.name}`, () => {
const offenders = files.filter((file) => rule.pattern.test(readFileSync(file, "utf8")));
expect(offenders.map((file) => relative(SRC_DIR, file))).toEqual([]);
});
}
});
@@ -1,50 +0,0 @@
/**
* Единый разбор и форматирование времени в UTC.
*
* Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны
* (например `"2026-05-29T01:00:00"`) и по соглашению системы это UTC.
* Конструктор `Date`/`Date.parse` трактует такую строку как локальное время
* браузера, поэтому naive-строки нужно парсить явно как UTC, а отображать
* с `timeZone: "UTC"`. Весь интерфейс ТГУ работает в UTC.
*/
/** Разбирает ISO-строку как UTC: если зоны нет — добавляет `Z`. */
export function parseUtc(value: string): number {
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value);
return Date.parse(hasZone ? value : `${value}Z`);
}
/** Дата-время в UTC, формат ru-RU. */
export function formatUtcDateTime(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
timeZone: "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
/** Дата (дд.мм) в UTC. */
export function formatUtcDate(ms: number): string {
return new Date(ms).toLocaleDateString("ru-RU", { timeZone: "UTC", day: "2-digit", month: "2-digit" });
}
/** Время (чч:мм) в UTC. */
export function formatUtcTime(ms: number): string {
return new Date(ms).toLocaleTimeString("ru-RU", { timeZone: "UTC", hour: "2-digit", minute: "2-digit" });
}
/** Значение для `<input type="datetime-local">` в UTC (`YYYY-MM-DDTHH:mm`). */
export function toUtcInputValue(ms: number): string {
return new Date(ms).toISOString().slice(0, 16);
}
/**
* Naive-строку (из `datetime-local`) возвращает как naive ISO без зоны (`YYYY-MM-DDTHH:mm:ss`).
* Все бэкенды ожидают `LocalDateTime` без зоны.
*/
export function toUtcIso(value: string): string {
return new Date(parseUtc(value)).toISOString().slice(0, 19);
}
File diff suppressed because one or more lines are too long
@@ -12,9 +12,9 @@ object DraftMode {
data class CreateDraftRequest( data class CreateDraftRequest(
/** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */ /** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */
val mode: String, val mode: String,
/** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ /** Начало окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */
val windowStart: LocalDateTime? = null, val windowStart: LocalDateTime? = null,
/** Конец окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ /** Конец окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */
val windowStop: LocalDateTime? = null, val windowStop: LocalDateTime? = null,
/** КПП целевой ЗРВ. Обязательно для fork-forward. */ /** КПП целевой ЗРВ. Обязательно для fork-forward. */
val windowKppId: String? = null val windowKppId: String? = null
@@ -33,9 +33,9 @@ data class CreateChainPlanRequest(
val spacecraftId: String, val spacecraftId: String,
/** Прошлый план-предыстория или null для цепочки без истории. */ /** Прошлый план-предыстория или null для цепочки без истории. */
val basePlanId: UUID? = null, val basePlanId: UUID? = null,
/** Начало окна (UTC naive) — старт выбранной стартовой ЗРВ. */ /** Начало окна (naive, локальное время) — старт выбранной стартовой ЗРВ. */
val windowStart: LocalDateTime, val windowStart: LocalDateTime,
/** Конец окна (UTC naive) — конец выбранной конечной ЗРВ. */ /** Конец окна (naive, локальное время) — конец выбранной конечной ЗРВ. */
val windowStop: LocalDateTime, val windowStop: LocalDateTime,
/** КПП стартовой ЗРВ. */ /** КПП стартовой ЗРВ. */
val windowKppId: String val windowKppId: String
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -40,10 +39,10 @@ class AcceptedPlanSlotsUpdateJobEntity(
var lastError: String? = null, var lastError: String? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(planId = UUID.randomUUID()) constructor() : this(planId = UUID.randomUUID())
@@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -46,10 +45,10 @@ class PlanIssueAttemptEntity(
var finishedAt: LocalDateTime? = null, var finishedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0) 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.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -49,10 +48,10 @@ class PlannedPlanEntity(
var acceptedAt: LocalDateTime? = null, var acceptedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this( constructor() : this(
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -19,7 +18,7 @@ class ProcessedPlanDecisionEventEntity(
var eventId: UUID, var eventId: UUID,
@Column(name = "received_at", nullable = false) @Column(name = "received_at", nullable = false)
var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var receivedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(eventId = UUID.randomUUID()) constructor() : this(eventId = UUID.randomUUID())
@@ -6,7 +6,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -43,7 +42,7 @@ class SpacecraftPlanningStateEntity(
var pausedAfterReject: Boolean = false, var pausedAfterReject: Boolean = false,
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "") constructor() : this(spacecraftId = "")
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Persisted snapshot КА из НСИ, используемый как вход для refresh jobs. * Persisted snapshot КА из НСИ, используемый как вход для refresh jobs.
@@ -45,10 +44,10 @@ class SpacecraftSnapshotEntity(
var active: Boolean = true, var active: Boolean = true,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0) constructor() : this(spacecraftId = "", noradId = 0)
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Полный raw snapshot окон видимости КА из баллистики. * Полный raw snapshot окон видимости КА из баллистики.
@@ -41,10 +40,10 @@ class SpacecraftVisibilitySnapshotEntity(
var windowsCount: Int? = null, var windowsCount: Int? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN) 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.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Job на перечитывание окон видимости одного КА у баллистики. * Job на перечитывание окон видимости одного КА у баллистики.
@@ -42,10 +41,10 @@ class VisibilityRefreshJobEntity(
var rerunRequested: Boolean = false, var rerunRequested: Boolean = false,
@Column(name = "requested_at", nullable = 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) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */ /** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "") 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.WebClientException
import org.springframework.web.reactive.function.client.WebClientResponseException import org.springframework.web.reactive.function.client.WebClientResponseException
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
/** /**
@@ -37,7 +36,7 @@ class ExternalPointsClient(
* которые разрешены для работы TGU. * которые разрешены для работы TGU.
*/ */
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> { 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) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadAllowedPlatforms() return platformService.loadAllowedPlatforms()
@@ -8,7 +8,7 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneId
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit 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` * Если время приходит с зоной (`...Z`/офсет), момент проецируем в локальный пояс JVM
* по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет. * (`ZoneId.systemDefault()`): naive-время в системе настенные часы локальной зоны, в которой
* работают все сервисы, без UTC-семантики. Naive-строку без зоны берём как есть.
* *
* Обрезаем до микросекунд это эффективная точность системы: `planned_plan.start_time/end_time` * Обрезаем до микросекунд это эффективная точность системы: `planned_plan.start_time/end_time`
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
@@ -55,7 +56,7 @@ class VisibilityPayloadParser(
*/ */
private fun toLocalDateTime(value: String): LocalDateTime = private fun toLocalDateTime(value: String): LocalDateTime =
try { try {
LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC) LocalDateTime.ofInstant(Instant.parse(value), ZoneId.systemDefault())
} catch (_: DateTimeParseException) { } catch (_: DateTimeParseException) {
LocalDateTime.parse(value) LocalDateTime.parse(value)
}.truncatedTo(ChronoUnit.MICROS) }.truncatedTo(ChronoUnit.MICROS)
@@ -4,7 +4,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -17,6 +16,6 @@ class AcceptedPlanSlotsUpdateJobService(
/** Idempotent enqueue: один planId получает максимум одну job. */ /** Idempotent enqueue: один planId получает максимум одну job. */
@Transactional @Transactional
fun enqueue(planId: UUID) { 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 space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.net.InetAddress import java.net.InetAddress
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -36,7 +35,7 @@ class AcceptedPlanSlotsUpdateWorker(
/** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */ /** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */
fun claimJobs(limit: Int): List<AcceptedPlanSlotsUpdateJobEntity> = fun claimJobs(limit: Int): List<AcceptedPlanSlotsUpdateJobEntity> =
transactionTemplate.execute { transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit) val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit)
jobs.forEach { job -> jobs.forEach { job ->
job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING
@@ -70,7 +69,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markDone(planId: UUID, processInstanceKey: Long) { private fun markDone(planId: UUID, processInstanceKey: Long) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.DONE job.status = AcceptedPlanSlotsUpdateJobStatus.DONE
job.processInstanceKey = processInstanceKey job.processInstanceKey = processInstanceKey
@@ -84,7 +83,7 @@ class AcceptedPlanSlotsUpdateWorker(
private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) { private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult
job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS
job.processInstanceKey = processInstanceKey ?: job.processInstanceKey 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -47,7 +46,7 @@ class CreateChainPlanUseCase(
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse { fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
if (!req.windowStop.isAfter(req.windowStart)) { if (!req.windowStop.isAfter(req.windowStart)) {
throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -46,7 +45,7 @@ class CreateDraftUseCase(
val base = plannedPlanRepository.findById(basePlanId) val base = plannedPlanRepository.findById(basePlanId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") } .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") }
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
// Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик» // Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик»
// не плодит вторую миссию-клон и вторую DRAFT-строку. // не плодит вторую миссию-клон и вторую 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.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
@@ -119,7 +118,7 @@ class HandlePlanDecisionUseCase(
return return
} }
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
plan.status = terminalStatus plan.status = terminalStatus
plan.updatedAt = now plan.updatedAt = now
plannedPlanRepository.save(plan) plannedPlanRepository.save(plan)
@@ -131,7 +130,7 @@ class HandlePlanDecisionUseCase(
message: PlanDecisionMessage, message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity attempt: PlanIssueAttemptEntity
) { ) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
attempt.status = when (message.decision) { attempt.status = when (message.decision) {
PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
@@ -148,7 +147,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity, plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity state: SpacecraftPlanningStateEntity
) { ) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.AWAITING_LAYIN plan.status = PlannedPlanStatus.AWAITING_LAYIN
@@ -175,7 +174,7 @@ class HandlePlanDecisionUseCase(
plan: PlannedPlanEntity, plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity state: SpacecraftPlanningStateEntity
) { ) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.REJECTED plan.status = PlannedPlanStatus.REJECTED
@@ -217,7 +216,7 @@ class HandlePlanDecisionUseCase(
} }
private fun claimEvent(eventId: java.util.UUID): Boolean = 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 { private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) 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.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА.
@@ -26,7 +25,7 @@ class HandleStationParamsChangedUseCase(
/** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */
@Transactional @Transactional
fun handle(message: StationParamsChangedMessage) { 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) { if (!claimed) {
return 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.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -84,7 +83,7 @@ class IssueDuePlanWorker(
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val state = getOrCreateLockedState(spacecraftId) val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
return null return null
@@ -163,7 +162,7 @@ class IssueDuePlanWorker(
} }
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { 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 plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId) val state = getOrCreateLockedState(plan.spacecraftId)
assertReissuableInSlot(plan, state, now) assertReissuableInSlot(plan, state, now)
@@ -185,7 +184,7 @@ class IssueDuePlanWorker(
} }
private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? { 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 val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null
if (plan.status != PlannedPlanStatus.DRAFT) { if (plan.status != PlannedPlanStatus.DRAFT) {
throw ResponseStatusException( throw ResponseStatusException(
@@ -342,7 +341,7 @@ class IssueDuePlanWorker(
private fun markStarted(response: ProcessStartResponse) { private fun markStarted(response: ProcessStartResponse) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(response.planId).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) { private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
attempt.status = PlanIssueAttemptStatus.AMBIGUOUS 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -81,7 +80,7 @@ class PlanQueryService(
.sortedBy { response -> response.startTime } .sortedBy { response -> response.startTime }
private fun historySince(historyDays: Long): LocalDateTime = 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 = private fun PlannedPlanEntity.businessKey(): String =
"$spacecraftId|$startTime|$endTime|$kppId" "$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 space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository
import java.security.MessageDigest import java.security.MessageDigest
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Сохраняет raw visibility payload. Расписание считается on-demand из снапшота * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота
@@ -26,7 +25,7 @@ class SpacecraftVisibilitySnapshotService(
val payloadHash = sha256(payloadJson) val payloadHash = sha256(payloadJson)
val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull() val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull()
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
if (existing == null) { if (existing == null) {
spacecraftVisibilitySnapshotRepository.save( 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.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneId
/** /**
* Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs. * Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs.
@@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase(
} }
private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) { private fun persistAllowedSpacecraft(allowedPlatforms: List<PlatformCatalogItem>) {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() } val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() }
allowedPlatforms.forEach { platform -> allowedPlatforms.forEach { platform ->
@@ -40,10 +40,10 @@ class SyncSpacecraftFromNsiUseCase(
name = platform.name, name = platform.name,
status = platform.status, status = platform.status,
mission = platform.mission, mission = platform.mission,
// validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-стенку, а не пояс JVM // validFrom/validTo приходят как Instant; в naive-поля БД пишем настенные часы локальной
// (по соглашению системы naive LocalDateTime = UTC) — иначе на не-UTC машине время уезжало бы. // зоны JVM (ZoneId.systemDefault()) — naive-время в системе локальное, без UTC-семантики.
validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) },
now = now now = now
) )
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC") 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
/** /**
@@ -67,7 +66,7 @@ class TestPlanDecisionService(
planId = plan.planId, planId = plan.planId,
attemptId = attempt.attemptId, attemptId = attempt.attemptId,
decision = decision, decision = decision,
decisionTime = LocalDateTime.now(ZoneOffset.UTC), decisionTime = LocalDateTime.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
) )
handlePlanDecisionUseCase.handle(message) handlePlanDecisionUseCase.handle(message)
@@ -4,7 +4,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
/** /**
* Coalesce-сервис для visibility_refresh_job по spacecraftId. * Coalesce-сервис для visibility_refresh_job по spacecraftId.
@@ -16,6 +15,6 @@ class VisibilityRefreshJobService(
/** Ставит или переоткрывает refresh job без дублей. */ /** Ставит или переоткрывает refresh job без дублей. */
@Transactional @Transactional
fun enqueue(spacecraftId: String, noradId: Long?, reason: String) { 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 space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository
import java.net.InetAddress import java.net.InetAddress
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
/** /**
@@ -39,7 +38,7 @@ class VisibilityRefreshWorker(
private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> = private fun claimJobs(limit: Int): List<VisibilityRefreshJobEntity> =
transactionTemplate.execute { transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now()
val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit) val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit)
jobs.forEach { job -> jobs.forEach { job ->
job.status = JobStatus.RUNNING job.status = JobStatus.RUNNING
@@ -62,10 +61,10 @@ class VisibilityRefreshWorker(
} }
try { try {
val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES) val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) 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) markDone(spacecraftId)
} catch (ex: Exception) { } catch (ex: Exception) {
log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex) log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex)
@@ -80,7 +79,7 @@ class VisibilityRefreshWorker(
job.rerunRequested = false job.rerunRequested = false
job.lockedBy = null job.lockedBy = null
job.lockedUntil = null job.lockedUntil = null
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job) visibilityRefreshJobRepository.save(job)
} }
} }
@@ -91,7 +90,7 @@ class VisibilityRefreshWorker(
job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING
job.lockedBy = null job.lockedBy = null
job.lockedUntil = null job.lockedUntil = null
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job) visibilityRefreshJobRepository.save(job)
} }
} }
@@ -103,7 +102,7 @@ class VisibilityRefreshWorker(
job.reason = reason job.reason = reason
job.lockedBy = null job.lockedBy = null
job.lockedUntil = null job.lockedUntil = null
job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) job.updatedAt = LocalDateTime.now()
visibilityRefreshJobRepository.save(job) visibilityRefreshJobRepository.save(job)
} }
} }
@@ -19,9 +19,9 @@ class VisibilityPayloadParserTest {
} }
@Test @Test
fun `parses UTC instant as UTC LocalDateTime regardless of JVM default zone`() { fun `projects zoned instant into JVM default zone as naive local LocalDateTime`() {
// planned_plan.start_time/end_time по контракту хранят UTC. Раньше парсер конвертировал момент // naive-время в системе — настенные часы локальной зоны JVM (без UTC-семантики). Парсер проецирует
// через ZoneId.systemDefault(), и на не-UTC JVM (напр. МСК, +3) время уезжало на офсет. // зонированный инстант через ZoneId.systemDefault(), поэтому на МСК (+3) 14:14Z даёт локальные 17:14.
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
val payload = """ val payload = """
[{ [{
@@ -35,9 +35,9 @@ class VisibilityPayloadParserTest {
val window = parser.parse("20", payload).points.single().observationWindows.single() val window = parser.parse("20", payload).points.single().observationWindows.single()
// Значение совпадает с UTC-стенкой инстанта (14:14), а не со сдвигом на пояс JVM (17:14). // Инстант 14:14Z спроецирован в локальную зону JVM (МСК, +3) — настенные 17:14.
assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime) assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), window.startTime)
assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime) assertEquals(LocalDateTime.parse("2026-06-02T17:20:00"), window.endTime)
} }
@Test @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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -227,7 +226,7 @@ class CreateChainPlanUseCaseTest {
// Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в // Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в
// моке сырых ЗРВ совпадает байт-в-байт (без дрейфа now() между вызовами). // моке сырых ЗРВ совпадает байт-в-байт (без дрейфа 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()) private fun time(hour: Int): LocalDateTime = t0.plusHours(hour.toLong())
@Suppress("UNCHECKED_CAST") @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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -267,7 +266,7 @@ class CreateDraftUseCaseTest {
private fun props(freeze: Long): PlanningProperties = private fun props(freeze: Long): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze) 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 параметрах. // Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах.
@Suppress("UNCHECKED_CAST") @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.integration.api.MissionPlaningClient
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -79,8 +78,8 @@ class DeleteDraftUseCaseTest {
PlannedPlanEntity( PlannedPlanEntity(
planId = UUID.randomUUID(), planId = UUID.randomUUID(),
spacecraftId = "25544", spacecraftId = "25544",
startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(3), startTime = LocalDateTime.now().plusHours(3),
endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(4), endTime = LocalDateTime.now().plusHours(4),
kppId = "KPP-1", kppId = "KPP-1",
status = status status = status
) )

Some files were not shown because too many files have changed in this diff Show More