From f549e15c632c9f1dc310cdd7be31575804788a37 Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 25 May 2026 21:02:50 +0300 Subject: [PATCH 01/35] =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=9D=D0=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 6 + create-chatgpt-archive.sh | 198 ++++++++++++ .../pcp_types_lib/dto/BookedSlotDetailsDTO.kt | 25 ++ .../requests/slots/BookedSlotDetailsDTO.kt | 18 ++ .../src/main/resources/application.yaml | 2 +- .../controller/BookingsController.kt | 88 ++++++ .../controller/CatalogPageController.kt | 3 + .../slots_service/dto/bookings/BookingsDTO.kt | 36 +++ .../pcp/slots_service/service/SlotService.kt | 84 +++++ .../main/resources/static/bookings_scripts.js | 295 ++++++++++++++++++ .../main/resources/static/css/bookings.css | 164 ++++++++++ .../resources/static/css/satellite-pdcm.css | 4 + .../static/satellite_pdcm_scripts.js | 100 ++++++ .../main/resources/templates/bookings.html | 115 +++++++ .../templates/fragments/base/menu.html | 3 + .../controller/CatalogControllerTest.kt | 40 +++ .../controller/BookingController.kt | 15 + .../repository/BookedSlotsRepository.kt | 23 ++ .../pcp/slots_service/service/SlotService.kt | 109 ++++++- 19 files changed, 1319 insertions(+), 9 deletions(-) create mode 100755 create-chatgpt-archive.sh create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt create mode 100644 services/pcp-ui-service/src/main/resources/static/bookings_scripts.js create mode 100644 services/pcp-ui-service/src/main/resources/static/css/bookings.css create mode 100644 services/pcp-ui-service/src/main/resources/templates/bookings.html diff --git a/build.gradle.kts b/build.gradle.kts index 7c86792..2ab25b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,12 @@ allprojects { maven { url = uri("https://repo.nstart.cloud/repository/maven-proxy/") } + mavenCentral { + content { + includeGroup("org.jacoco") + includeGroup("org.ow2.asm") + } + } // mavenCentral() // mavenLocal() // gradlePluginPortal() diff --git a/create-chatgpt-archive.sh b/create-chatgpt-archive.sh new file mode 100755 index 0000000..ccb3e32 --- /dev/null +++ b/create-chatgpt-archive.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + ./create-chatgpt-archive.sh [archive.zip] + ./create-chatgpt-archive.sh --list + +Creates a zip archive with project files useful for code analysis. + +Environment: + MAX_FILE_SIZE_BYTES Per-file size limit. Default: 1048576 + +The script includes source, configuration, build metadata, CI, Helm and docs. +It excludes build outputs, logs, local caches, IDE files, archives, binaries and env files. +USAGE +} + +mode="create" +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" == "--list" ]]; then + mode="list" + shift +fi + +if (( $# > 1 )); then + usage >&2 + exit 2 +fi + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +archive_arg="${1:-pcp-chatgpt-context.zip}" +max_file_size_bytes="${MAX_FILE_SIZE_BYTES:-1048576}" + +if [[ "$archive_arg" = /* ]]; then + archive_path="$archive_arg" +else + archive_path="$project_root/$archive_arg" +fi + +include_paths=( + "AGENTS.md" + "README.md" + "HELP.md" + ".dockerignore" + ".gitattributes" + ".gitignore" + ".gitlab-ci.yml" + "build.gradle.kts" + "settings.gradle.kts" + "gradle.properties" + "gradlew" + "gradlew.bat" + "create-chatgpt-archive.sh" + "changeLogsDynamicSlots.md" + ".gitlab" + "config-repo" + "docs" + "gradle" + "helm" + "libs" + "schemes" + "services" +) + +should_skip_path() { + local path="$1" + + case "$path" in + .git|.git/*|.gradle|.gradle/*|.gradle-local|.gradle-local/*|.idea|.idea/*|.kotlin|.kotlin/*|.vscode|.vscode/*) + return 0 + ;; + build|build/*|logs|logs/*|out|out/*|bin|bin/*|target|target/*|node_modules|node_modules/*) + return 0 + ;; + */build|*/build/*|*/logs|*/logs/*|*/out|*/out/*|*/bin|*/bin/*|*/target|*/target/*|*/node_modules|*/node_modules/*) + return 0 + ;; + esac + + case "$path" in + .env|.env.*|*/.env|*/.env.*) + return 0 + ;; + *.log|*.tmp|*.bak|*.orig|*.class|*.jar|*.war|*.ear|*.zip|*.tar|*.tgz|*.tar.gz|*.gz|*.7z|*.rar) + return 0 + ;; + *.png|*.jpg|*.jpeg|*.gif|*.webp|*.ico|*.pdf|*.doc|*.docx|*.xls|*.xlsx|*.ppt|*.pptx) + return 0 + ;; + *.keystore|*.jks|*.p12|*.pem|*.key|*.crt) + return 0 + ;; + esac + + return 1 +} + +is_allowed_file() { + local path="$1" + + case "$path" in + AGENTS.md|README.md|HELP.md|.dockerignore|.gitattributes|.gitignore|.gitlab-ci.yml) + return 0 + ;; + build.gradle.kts|settings.gradle.kts|gradle.properties|gradlew|gradlew.bat|create-chatgpt-archive.sh|changeLogsDynamicSlots.md) + return 0 + ;; + Dockerfile|Makefile|Jenkinsfile) + return 0 + ;; + *.kt|*.kts|*.java|*.groovy|*.xml|*.yaml|*.yml|*.properties|*.json|*.toml|*.md|*.txt) + return 0 + ;; + *.sql|*.graphql|*.graphqls|*.proto|*.bpmn|*.dmn|*.tpl|*.sh) + return 0 + ;; + *.html|*.css|*.scss|*.js|*.jsx|*.ts|*.tsx|*.vue) + return 0 + ;; + esac + + return 1 +} + +manifest_file="$(mktemp)" +trap 'rm -f "$manifest_file"' EXIT + +collect_files() { + local root_path + local relative_path + local file_size + + for root_path in "${include_paths[@]}"; do + if [[ ! -e "$project_root/$root_path" ]]; then + continue + fi + + if [[ -f "$project_root/$root_path" ]]; then + printf '%s\n' "$root_path" + else + (cd "$project_root" && find "$root_path" -type f -print) + fi + done | while IFS= read -r relative_path; do + relative_path="${relative_path#./}" + + if should_skip_path "$relative_path"; then + continue + fi + + if ! is_allowed_file "$relative_path"; then + continue + fi + + file_size="$(stat -c '%s' "$project_root/$relative_path")" + if (( file_size > max_file_size_bytes )); then + continue + fi + + printf '%s\n' "$relative_path" + done | sort -u +} + +collect_files > "$manifest_file" + +file_count="$(wc -l < "$manifest_file" | tr -d ' ')" +if (( file_count == 0 )); then + echo "No files selected for archive." >&2 + exit 1 +fi + +if [[ "$mode" == "list" ]]; then + cat "$manifest_file" + echo + echo "Selected files: $file_count" >&2 + exit 0 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "zip command is required but was not found." >&2 + exit 1 +fi + +mkdir -p "$(dirname "$archive_path")" +rm -f "$archive_path" + +(cd "$project_root" && zip -q -X "$archive_path" -@ < "$manifest_file") + +archive_size="$(du -h "$archive_path" | awk '{print $1}')" +echo "Archive created: $archive_path" +echo "Selected files: $file_count" +echo "Archive size: $archive_size" diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt new file mode 100644 index 0000000..c96d4a7 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp.pcp_types_lib.dto + +import java.time.LocalDateTime + +/** + * Read model for booked slots pages and integrations. + * + * The DTO is intentionally stored in pcp-types-lib because it is returned by slots-service + * and consumed by ui-service. It contains only booking/slot fields; UI-specific enrichment + * such as satellite name/type is performed in ui-service. + */ +data class BookedSlotDetailsDTO( + val bookedSlotId: Long, + val satelliteId: Long, + val slotNum: Long, + val cycle: Long, + val timeStart: LocalDateTime, + val timeStop: LocalDateTime, + val durationSeconds: Long, + val roll: Double?, + val revolution: Long?, + val revolutionSign: String?, + val status: String?, + val requestIds: List = emptyList(), +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt new file mode 100644 index 0000000..f98a1c1 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt @@ -0,0 +1,18 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import java.time.LocalDateTime + +data class BookedSlotDetailsDTO( + val bookedSlotId: Long, + val satelliteId: Long, + val slotNum: Long, + val cycle: Long, + val timeStart: LocalDateTime, + val timeStop: LocalDateTime, + val durationSeconds: Long, + val roll: Double?, + val revolution: Long?, + val revolutionSign: Int?, + val status: String?, + val requestIds: List = emptyList() +) \ No newline at end of file diff --git a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml index 9c2430b..442fbcd 100644 --- a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml +++ b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt new file mode 100644 index 0000000..f9cf81b --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt @@ -0,0 +1,88 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import space.nstart.pcp.slots_service.dto.bookings.BookedSlotViewDTO +import space.nstart.pcp.slots_service.dto.bookings.BookingFilterOptionsDTO +import space.nstart.pcp.slots_service.dto.bookings.BookingRequestOptionDTO +import space.nstart.pcp.slots_service.service.EarthService +import space.nstart.pcp.slots_service.service.SatelliteCatalogService +import space.nstart.pcp.slots_service.service.SlotService +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/bookings") +class BookingsController( + private val slotService: SlotService, + private val satelliteCatalogService: SatelliteCatalogService, + private val earthService: EarthService, +) { + + @GetMapping("/options") + fun options(): BookingFilterOptionsDTO { + val satellites = satelliteCatalogService.allSatellites() + val requests = runCatching { + earthService.reqs() + .collectList() + .block() + .orEmpty() + .mapNotNull { request -> + val id = request.requestId?.toString() ?: return@mapNotNull null + BookingRequestOptionDTO(id = id, name = request.name) + } + .sortedWith(compareBy { it.name.ifBlank { it.id } }.thenBy { it.id }) + }.getOrDefault(emptyList()) + + return BookingFilterOptionsDTO( + satellites = satellites, + requests = requests, + ) + } + + @GetMapping + fun bookedSlots( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStart: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStop: LocalDateTime?, + @RequestParam(required = false) requestId: String?, + @RequestParam(required = false) satelliteIds: List?, + ): List { + val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id } + return slotService.searchBooked( + timeStart = timeStart, + timeStop = timeStop, + requestId = requestId, + satelliteIds = satelliteIds, + ) + .map { booking -> booking.toView(satellitesById[booking.satelliteId]) } + .sortedWith( + compareBy { it.timeStart ?: LocalDateTime.MAX } + .thenBy { it.satelliteId } + .thenBy { it.slotNum } + .thenBy { it.cycle } + .thenBy { it.bookedSlotId } + ) + } + + private fun BookedSlotDetailsDTO.toView(satellite: SatelliteSummaryDTO?): BookedSlotViewDTO = BookedSlotViewDTO( + bookedSlotId = bookedSlotId, + satelliteId = satelliteId, + satelliteCode = satellite?.code, + satelliteName = satellite?.name, + satelliteTypeCode = satellite?.typeCode, + slotNum = slotNum, + cycle = cycle, + timeStart = timeStart, + timeStop = timeStop, + durationSeconds = durationSeconds, + roll = roll, + revolution = revolution, + revolutionSign = revolutionSign, + status = status, + requestIds = requestIds, + ) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt index 106d8b7..e2b3be2 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -20,4 +20,7 @@ class CatalogPageController { @GetMapping("/current-plans") fun currentPlansPage(): String = "current-plans" + + @GetMapping("/bookings") + fun bookingsPage(): String = "bookings" } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt new file mode 100644 index 0000000..e7e3e96 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp.slots_service.dto.bookings + +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import java.time.LocalDateTime + +/** + * UI DTO for the bookings page. It intentionally lives in ui-service because the page combines + * data from slots-service, request-service and satellite-catalog-service without changing their APIs. + */ +data class BookedSlotViewDTO( + val bookedSlotId: Long, + val satelliteId: Long, + val satelliteCode: String? = null, + val satelliteName: String? = null, + val satelliteTypeCode: String? = null, + val slotNum: Long, + val cycle: Long, + val timeStart: LocalDateTime? = null, + val timeStop: LocalDateTime? = null, + val durationSeconds: Long? = null, + val roll: Double? = null, + val revolution: Long? = null, + val revolutionSign: String? = null, + val status: String? = null, + val requestIds: List = emptyList(), +) + +data class BookingRequestOptionDTO( + val id: String, + val name: String = "", +) + +data class BookingFilterOptionsDTO( + val satellites: List = emptyList(), + val requests: List = emptyList(), +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index d86b4f0..996c79d 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -22,6 +22,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO import tools.jackson.databind.ObjectMapper import space.nstart.pcp.slots_service.configuration.CustomErrorException +import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO import java.time.LocalDateTime @Service @@ -148,6 +149,68 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { } + + fun allBooked(): List = + webClientBuilder.build() + .get() + .uri("$url/api/slots/booking") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(BookedSlotDTO::class.java) + .collectList() + .block() + ?: emptyList() + + + fun searchBooked( + timeStart: LocalDateTime?, + timeStop: LocalDateTime?, + requestId: String?, + satelliteIds: List? + ): List = webClientBuilder.build() + .get() + .uri( + UriComponentsBuilder + .fromUriString("$url/api/slots/booking/search") + .queryParamIfPresent("timeStart", java.util.Optional.ofNullable(timeStart)) + .queryParamIfPresent("timeStop", java.util.Optional.ofNullable(timeStop)) + .queryParamIfPresent("requestId", java.util.Optional.ofNullable(requestId?.trim()?.takeIf { it.isNotEmpty() })) + .apply { + satelliteIds.orEmpty().distinct().forEach { queryParam("satelliteIds", it) } + } + .build() + .toUriString() + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(BookedSlotDetailsDTO::class.java) + .collectList() + .block() + ?: emptyList() + + fun bookedByRequest(requestId: String): List = + webClientBuilder.build() + .get() + .uri("$url/api/slots/booking/by-request/$requestId") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToFlux(SlotDTO::class.java) + .collectList() + .block() + ?: emptyList() + fun bookReq(req: BookingRequestDTO): List = webClientBuilder.build() .post() @@ -244,6 +307,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { } .block() + + fun sendSatelliteInitialConditions(satelliteId: Long, time: LocalDateTime) { + webClientBuilder.build() + .post() + .uri( + UriComponentsBuilder + .fromUriString("$url/api/satellite/{satelliteId}/send-ic") + .queryParam("time", time) + .buildAndExpand(satelliteId) + .toUriString() + ) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .toBodilessEntity() + .block() + } + fun saveSatelliteInitialConditions( satelliteId: Long, request: InitialConditionsDTO, diff --git a/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js b/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js new file mode 100644 index 0000000..c21d5fd --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js @@ -0,0 +1,295 @@ +(function () { + const state = { + satellites: [], + requests: [], + selectedSatelliteIds: new Set(), + bookings: [] + }; + + const el = (id) => document.getElementById(id); + + document.addEventListener('DOMContentLoaded', () => { + setDefaultInterval(); + bindEvents(); + loadOptions().then(() => loadBookings()); + }); + + function bindEvents() { + el('bookings-refresh')?.addEventListener('click', () => loadBookings()); + el('bookings-reset')?.addEventListener('click', resetFilters); + el('bookings-satellites-clear')?.addEventListener('click', () => { + selectVisibleSatellites(); + }); + el('bookings-satellite-search')?.addEventListener('input', renderSatellites); + el('bookings-request')?.addEventListener('change', () => loadBookings()); + el('bookings-time-start')?.addEventListener('change', () => loadBookings()); + el('bookings-time-stop')?.addEventListener('change', () => loadBookings()); + } + + function setDefaultInterval() { + const now = new Date(); + now.setMinutes(0, 0, 0); + const stop = new Date(now.getTime()); + stop.setDate(stop.getDate() + 7); + if (el('bookings-time-start')) el('bookings-time-start').value = toDateTimeLocal(now); + if (el('bookings-time-stop')) el('bookings-time-stop').value = toDateTimeLocal(stop); + } + + function resetFilters() { + state.selectedSatelliteIds.clear(); + if (el('bookings-request')) el('bookings-request').value = ''; + if (el('bookings-satellite-search')) el('bookings-satellite-search').value = ''; + setDefaultInterval(); + renderSatellites(); + loadBookings(); + } + + async function loadOptions() { + try { + const options = await fetchJson('/api/bookings/options'); + state.satellites = Array.isArray(options.satellites) ? options.satellites : []; + state.requests = Array.isArray(options.requests) ? options.requests : []; + renderRequests(); + renderSatellites(); + } catch (error) { + showAlert(error.message || 'Не удалось загрузить справочники страницы бронирований.', 'danger'); + } + } + + async function loadBookings() { + setTableLoading(); + const params = new URLSearchParams(); + const timeStart = el('bookings-time-start')?.value; + const timeStop = el('bookings-time-stop')?.value; + const requestId = el('bookings-request')?.value; + + if (timeStart) params.set('timeStart', toIsoLocal(timeStart)); + if (timeStop) params.set('timeStop', toIsoLocal(timeStop)); + if (requestId) params.set('requestId', requestId); + state.selectedSatelliteIds.forEach((id) => params.append('satelliteIds', id)); + + try { + const items = await fetchJson(`/api/bookings?${params.toString()}`); + state.bookings = Array.isArray(items) ? items : []; + renderBookings(); + } catch (error) { + state.bookings = []; + renderBookings(); + showAlert(error.message || 'Не удалось загрузить забронированные слоты.', 'danger'); + } + } + + function visibleSatellites() { + const query = (el('bookings-satellite-search')?.value || '').trim().toLowerCase(); + return state.satellites.filter((satellite) => satelliteMatches(satellite, query)); + } + + function selectVisibleSatellites() { + const satellites = visibleSatellites(); + satellites.forEach((satellite) => { + const id = Number(satellite.id); + if (!Number.isNaN(id)) { + state.selectedSatelliteIds.add(id); + } + }); + renderSatellites(); + loadBookings(); + } + + function renderRequests() { + const select = el('bookings-request'); + if (!select) return; + const current = select.value; + select.innerHTML = '' + state.requests.map((request) => { + const title = request.name ? `${request.name} / ${request.id}` : request.id; + return ``; + }).join(''); + select.value = current; + } + + function renderSatellites() { + const tbody = el('bookings-satellites-body'); + if (!tbody) return; + const satellites = visibleSatellites(); + + if (!satellites.length) { + tbody.innerHTML = emptyRow(3, 'Спутники не найдены.'); + return; + } + + tbody.innerHTML = satellites.map((satellite) => { + const selected = state.selectedSatelliteIds.has(Number(satellite.id)); + return ` + + + ${escapeHtml(satellite.id ?? '—')} + +
${escapeHtml(satellite.name || satellite.code || 'КА')}
+
${escapeHtml([satellite.code, satellite.typeCode, satellite.noradId ? `NORAD ${satellite.noradId}` : null].filter(Boolean).join(' / ') || '—')}
+ + `; + }).join(''); + + tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => { + row.addEventListener('click', (event) => { + event.preventDefault(); + const id = Number(row.dataset.satelliteId); + if (state.selectedSatelliteIds.has(id)) { + state.selectedSatelliteIds.delete(id); + } else { + state.selectedSatelliteIds.add(id); + } + renderSatellites(); + loadBookings(); + }); + }); + } + + function renderBookings() { + const tbody = el('bookings-body'); + const empty = el('bookings-empty'); + const wrap = el('bookings-table-wrap'); + if (!tbody) return; + + if (!state.bookings.length) { + tbody.innerHTML = ''; + empty?.classList.remove('d-none'); + wrap?.classList.add('d-none'); + updateSummary(); + return; + } + + empty?.classList.add('d-none'); + wrap?.classList.remove('d-none'); + tbody.innerHTML = state.bookings.map((booking) => ` + + ${escapeHtml(booking.bookedSlotId ?? '—')} + +
${escapeHtml(booking.satelliteName || booking.satelliteCode || `КА ${booking.satelliteId}`)}
+
ID ${escapeHtml(booking.satelliteId)}${booking.satelliteTypeCode ? ` / ${escapeHtml(booking.satelliteTypeCode)}` : ''}
+ + +
Слот ${escapeHtml(booking.slotNum ?? '—')}
+
Цикл ${escapeHtml(booking.cycle ?? '—')}
+ + +
${formatDateTime(booking.timeStart)}
+
${formatDateTime(booking.timeStop)}${booking.durationSeconds != null ? ` / ${formatNumber(booking.durationSeconds)} c` : ''}
+ + +
${escapeHtml(booking.revolution ?? '—')}
+
${escapeHtml(booking.revolutionSign || '—')}
+ + ${formatNumber(booking.roll)} + ${escapeHtml(booking.status || '—')} + ${formatRequestIds(booking.requestIds)} + `).join(''); + updateSummary(); + } + + function updateSummary() { + const summary = el('bookings-summary'); + if (!summary) return; + const requestId = el('bookings-request')?.value; + const satellites = state.selectedSatelliteIds.size ? `, спутников: ${state.selectedSatelliteIds.size}` : ''; + const request = requestId ? `, заявка: ${requestId}` : ''; + summary.textContent = `Найдено бронирований: ${state.bookings.length}${satellites}${request}`; + } + + function setTableLoading() { + el('bookings-empty')?.classList.add('d-none'); + el('bookings-table-wrap')?.classList.remove('d-none'); + const tbody = el('bookings-body'); + if (tbody) tbody.innerHTML = emptyRow(8, 'Загрузка бронирований...'); + const summary = el('bookings-summary'); + if (summary) summary.textContent = 'Загрузка данных...'; + } + + async function fetchJson(url, options = {}) { + const response = await fetch(url, { + headers: { 'Accept': 'application/json', ...(options.headers || {}) }, + ...options + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(extractError(text) || `HTTP ${response.status}`); + } + if (response.status === 204) return null; + return response.json(); + } + + function satelliteMatches(satellite, query) { + if (!query) return true; + return [satellite.id, satellite.noradId, satellite.code, satellite.name, satellite.typeCode] + .filter((value) => value !== undefined && value !== null) + .some((value) => String(value).toLowerCase().includes(query)); + } + + function emptyRow(colspan, text) { + return `${escapeHtml(text)}`; + } + + function formatRequestIds(ids) { + if (!Array.isArray(ids) || !ids.length) return '—'; + return ids.map((id) => `${escapeHtml(id)}`).join(' '); + } + + function statusClass(status) { + if (!status) return ''; + return `bookings-status-${String(status).toLowerCase()}`; + } + + function formatDateTime(value) { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return escapeHtml(value); + return date.toLocaleString('ru-RU', { + year: '2-digit', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + } + + function formatNumber(value) { + if (value === null || value === undefined || value === '') return '—'; + const number = Number(value); + if (Number.isNaN(number)) return escapeHtml(value); + return number.toLocaleString('ru-RU', { maximumFractionDigits: 3 }); + } + + function toDateTimeLocal(date) { + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + + function toIsoLocal(value) { + return value && value.length === 16 ? `${value}:00` : value; + } + + function showAlert(message, type = 'info') { + const box = el('bookings-alert'); + if (!box) return; + box.innerHTML = ``; + } + + function extractError(text) { + if (!text) return ''; + try { + const json = JSON.parse(text); + return json.message || json.error || text; + } catch (_) { + return text; + } + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/css/bookings.css b/services/pcp-ui-service/src/main/resources/static/css/bookings.css new file mode 100644 index 0000000..7a52ecd --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/bookings.css @@ -0,0 +1,164 @@ +.bookings-page { + height: calc(100vh - 4.25rem); + max-height: calc(100vh - 4.25rem); + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + padding-bottom: 0 !important; +} + +.bookings-page > .d-flex:first-child, +#bookings-alert, +.bookings-filter-card { + flex: 0 0 auto; +} + +.bookings-layout { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.bookings-layout > [class*="col-"] { + min-height: 0; + display: flex; + flex-direction: column; +} + +.bookings-layout > [class*="col-"] > .catalog-card { + flex: 1 1 auto; + min-height: 0; + width: 100%; +} + +.bookings-satellites-card, +.bookings-table-card { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + max-height: calc(100vh - 17rem); +} + +.bookings-satellites-card .card-header, +.bookings-table-card .card-header { + flex: 0 0 auto; +} + +.bookings-satellites-wrap, +.bookings-table-card .card-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.bookings-satellites-wrap { + height: calc(100vh - 23rem); + max-height: calc(100vh - 23rem); + overflow-y: auto; + overflow-x: hidden; +} + +.bookings-table-card .card-body { + display: flex; + flex-direction: column; +} + +.bookings-table-wrap { + flex: 1 1 auto; + min-height: 0; + height: calc(100vh - 21rem); + max-height: calc(100vh - 21rem); + overflow: auto; +} + +.bookings-check-col { + width: 2rem; +} + +.bookings-satellites-table tbody tr { + cursor: pointer; +} + +.bookings-selected-row > td { + background: rgba(13, 110, 253, 0.12) !important; +} + +.bookings-id { + font-family: var(--bs-font-monospace); + font-size: 0.82rem; +} + +.bookings-main-text { + font-weight: 600; +} + +.bookings-sub-text { + color: #6c757d; + font-size: 0.82rem; +} + +.bookings-badge { + display: inline-flex; + align-items: center; + border: 1px solid rgba(108, 117, 125, 0.35); + border-radius: 999px; + padding: 0.1rem 0.45rem; + font-size: 0.78rem; + color: #495057; + background: rgba(248, 249, 250, 0.9); +} + +.bookings-time-cell { + min-width: 15rem; +} + +.bookings-status-booked { + border-color: rgba(13, 110, 253, 0.35); + color: #084298; + background: rgba(13, 110, 253, 0.12); +} + +.bookings-status-processed, +.bookings-status-finished { + border-color: rgba(25, 135, 84, 0.4); + color: #0f5132; + background: rgba(25, 135, 84, 0.14); +} + +.bookings-status-failed, +.bookings-status-canceled, +.bookings-status-cancelled { + border-color: rgba(220, 53, 69, 0.4); + color: #842029; + background: rgba(220, 53, 69, 0.14); +} + +@media (max-width: 1199.98px) { + .bookings-page { + height: auto; + max-height: none; + overflow: visible; + padding-bottom: 1rem !important; + } + + .bookings-layout, + .bookings-layout > [class*="col-"], + .bookings-layout > [class*="col-"] > .catalog-card, + .bookings-satellites-card, + .bookings-table-card, + .bookings-satellites-wrap, + .bookings-table-wrap, + .bookings-table-card .card-body { + overflow: visible; + height: auto; + max-height: none; + min-height: initial; + } + + .bookings-layout > [class*="col-"] { + display: block; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css b/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css index 077b9cf..2c60ebd 100644 --- a/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css +++ b/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css @@ -11,6 +11,10 @@ min-height: 1.5rem; } +.satellite-pdcm-send-time { + width: 15rem; +} + .satellite-pdcm-status { display: inline-flex; width: 0.75rem; diff --git a/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js b/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js index afa23eb..8a8ffb4 100644 --- a/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js @@ -10,6 +10,7 @@ selectedSatelliteId: null, details: null }; + const sendInitialConditionsDateTimePattern = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/; function showAlert(message, type = 'danger') { const container = document.getElementById('satellite-pdcm-alert'); @@ -58,6 +59,57 @@ return value ? value.replace('T', ' ') : '-'; } + function pad(value, length = 2) { + return String(value).padStart(length, '0'); + } + + function currentDateTimeInputValue() { + const now = new Date(); + return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`; + } + + function parseSendInitialConditionsTime() { + const input = document.getElementById('satellite-pdcm-send-ic-time'); + const value = input?.value.trim() || ''; + const match = value.match(sendInitialConditionsDateTimePattern); + if (!match) { + return { + valid: false, + message: 'Введите время НУ в формате dd.mm.yyyy hh:MM:ss.zzz' + }; + } + + const [, day, month, year, hour, minute, second, millisecond] = match; + const parsed = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + Number(millisecond) + ); + const validDate = parsed.getFullYear() === Number(year) + && parsed.getMonth() === Number(month) - 1 + && parsed.getDate() === Number(day) + && parsed.getHours() === Number(hour) + && parsed.getMinutes() === Number(minute) + && parsed.getSeconds() === Number(second) + && parsed.getMilliseconds() === Number(millisecond); + + if (!validDate) { + return { + valid: false, + message: 'Время НУ содержит некорректную дату или время' + }; + } + + return { + valid: true, + text: value + }; + } + function formatNumber(value) { return Number(value ?? 0).toFixed(3); } @@ -71,6 +123,25 @@ exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length; } + function renderSendInitialConditionsState(details, loading = false) { + const button = document.getElementById('satellite-pdcm-send-ic'); + if (!button) { + return; + } + + const time = parseSendInitialConditionsTime(); + const enabled = !!details && time.valid && !loading; + button.disabled = !enabled; + button.textContent = loading ? 'Передача...' : 'Передать НУ в slot-service'; + if (!details) { + button.title = 'Выберите спутник'; + } else if (!time.valid) { + button.title = time.message; + } else { + button.title = ''; + } + } + function renderList() { const tableBody = document.getElementById('satellite-pdcm-list'); tableBody.innerHTML = ''; @@ -110,6 +181,7 @@ if (!details) { summary.innerHTML = '
Выберите спутник слева.
'; renderExportState(null); + renderSendInitialConditionsState(null); return; } @@ -140,6 +212,7 @@ `; renderExportState(details); + renderSendInitialConditionsState(details); } function renderTable(details) { @@ -270,9 +343,32 @@ URL.revokeObjectURL(url); } + async function sendInitialConditions() { + if (!state.selectedSatelliteId || !state.details) { + showAlert('Выберите спутник для передачи начальных условий'); + return; + } + const time = parseSendInitialConditionsTime(); + if (!time.valid) { + showAlert(time.message); + return; + } + renderSendInitialConditionsState(state.details, true); + showAlert(''); + try { + await requestJson(`${apiBase}/${state.selectedSatelliteId}/send-ic?time=${encodeURIComponent(time.text)}`, {method: 'POST'}); + showAlert('Начальные условия спутника переданы в slot-service', 'success'); + } catch (error) { + showAlert(error.message || 'Не удалось передать начальные условия в slot-service'); + } finally { + renderSendInitialConditionsState(state.details); + } + } + async function selectSatellite(satelliteId) { state.selectedSatelliteId = satelliteId; renderList(); + renderSendInitialConditionsState(null); showAlert(''); try { @@ -317,6 +413,10 @@ loadSatellites(); }); document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv); + document.getElementById('satellite-pdcm-send-ic').addEventListener('click', sendInitialConditions); + const sendInitialConditionsTimeInput = document.getElementById('satellite-pdcm-send-ic-time'); + sendInitialConditionsTimeInput.value = currentDateTimeInputValue(); + sendInitialConditionsTimeInput.addEventListener('input', () => renderSendInitialConditionsState(state.details)); loadSatellites(); })(); diff --git a/services/pcp-ui-service/src/main/resources/templates/bookings.html b/services/pcp-ui-service/src/main/resources/templates/bookings.html new file mode 100644 index 0000000..3eb16b1 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/bookings.html @@ -0,0 +1,115 @@ + + + + Бронирования + + + + + +
+
+
+

Бронирования

+
Табличный просмотр забронированных слотов с фильтрацией по времени, заявке и спутникам.
+
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
Спутники
+
Выберите один или несколько КА.
+
+ +
+
+ + + + + + + + + +
IDСпутник
+
+
+
+
+
+
+
+
Забронированные слоты
+
Загрузка данных...
+
+
+
+
Забронированные слоты не найдены.
+
+ + + + + + + + + + + + + + +
БроньКАСлот / циклВремяВитокКренСтатусЗаявки
+
+
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index 2af2716..f3534ac 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -71,6 +71,9 @@ + diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index e22cffa..0c16504 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -5,6 +5,7 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.doReturn import org.mockito.Mockito.doThrow +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort @@ -134,6 +135,8 @@ class CatalogControllerTest { assertTrue(body.contains("Asc-node")) assertTrue(body.contains("Спутники")) assertTrue(body.contains("Скачать CSV")) + assertTrue(body.contains("satellite-pdcm-send-ic-time")) + assertTrue(body.contains("dd.mm.yyyy hh:MM:ss.zzz")) assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css")) } @@ -287,6 +290,42 @@ class CatalogControllerTest { verify(satellitePdcmService).satelliteSummaries() } + @Test + fun `satellite pdcm send initial conditions endpoint parses ui time and proxies request`() { + val requestedTime = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000) + + val status = WebClient.create("http://localhost:$port") + .post() + .uri { builder -> + builder + .path("/api/catalog/satellites/pdcm/501/send-ic") + .queryParam("time", "24.04.2026 10:15:30.123") + .build() + } + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + assertEquals(HttpStatus.ACCEPTED, status) + verify(slotService).sendSatelliteInitialConditions(501L, requestedTime) + } + + @Test + fun `satellite pdcm send initial conditions endpoint rejects invalid ui time`() { + val status = WebClient.create("http://localhost:$port") + .post() + .uri { builder -> + builder + .path("/api/catalog/satellites/pdcm/501/send-ic") + .queryParam("time", "2026-04-24T10:15:30") + .build() + } + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + assertEquals(HttpStatus.BAD_REQUEST, status) + verify(slotService, never()).sendSatelliteInitialConditions(eq(501L), anyLocalDateTime()) + } + @Test fun `satellite create endpoint proxies request`() { val request = SatelliteCreateDTO( @@ -521,4 +560,5 @@ class CatalogControllerTest { private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO() private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO() private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO() + private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt index 76efd43..7199e4b 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt @@ -1,6 +1,7 @@ package space.nstart.pcp.slots_service.controller import org.springframework.beans.factory.annotation.Autowired +import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -28,6 +29,20 @@ class BookingController { @GetMapping fun all() = slotService.allBooked() + + @GetMapping("/search") + fun search( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStart: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStop: LocalDateTime?, + @RequestParam(required = false) requestId: String?, + @RequestParam(required = false) satelliteIds: List? + ) = slotService.searchBookedSlots( + timeStart = timeStart, + timeStop = timeStop, + requestId = requestId, + satelliteIds = satelliteIds + ) + @GetMapping("/{booked_slot_id}") fun byId(@PathVariable("booked_slot_id") id : Long) = slotService.bookedById(id) @GetMapping("/by-request/{request_id}") diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt index 40f1bad..bdcab0c 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt @@ -29,6 +29,29 @@ interface BookedSlotsRepository : JpaRepository{ @EntityGraph(attributePaths = ["slot"]) fun findAllByStatus(status: String): List + @EntityGraph(attributePaths = ["slot", "requests"]) + @Query(""" + select distinct bookedSlot + from BookedSlotEntity bookedSlot + where bookedSlot.slot.satelliteId in :satelliteIds + and bookedSlot.cycle between :cycleBegin and :cycleEnd + """) + fun findBookingDetailsBySatelliteIdsAndCycleBetween( + @Param("satelliteIds") satelliteIds: Collection, + @Param("cycleBegin") cycleBegin: Long, + @Param("cycleEnd") cycleEnd: Long + ): List + + @EntityGraph(attributePaths = ["slot", "requests"]) + @Query(""" + select distinct bookedSlot + from BookedSlotEntity bookedSlot + where bookedSlot.slot.satelliteId in :satelliteIds + """) + fun findBookingDetailsBySatelliteIds( + @Param("satelliteIds") satelliteIds: Collection + ): List + @Modifying @Transactional @Query("DELETE FROM booked_slot WHERE booked_slot_id =:id", nativeQuery = true) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 9edcbd5..beaa26f 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -47,6 +47,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO +import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.* import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO @@ -1451,8 +1452,8 @@ class SlotService { val cycleWindows = selectedSatellites.associate { satellite -> satellite.id to SatelliteCycleWindow( satellite = satellite, - cycleBegin = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc, - cycleEnd = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc, + cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc ) } @@ -1883,6 +1884,98 @@ class SlotService { @Transactional(readOnly = true) fun allBooked() = bookedSlotsRepository.findAll().map { it.toDTO() } + + @Transactional(readOnly = true) + fun searchBookedSlots( + timeStart: LocalDateTime?, + timeStop: LocalDateTime?, + requestId: String?, + satelliteIds: List? + ): List { + if ((timeStart == null) != (timeStop == null)) { + throw CustomValidationException("Для фильтра по времени должны быть заданы timeStart и timeStop") + } + if (timeStart != null && timeStop != null && timeStop < timeStart) { + throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart") + } + + val requestedSatelliteIds = satelliteIds.orEmpty().filter { it > 0 }.distinct() + val satellites = if (requestedSatelliteIds.isEmpty()) { + satelliteCatalogClient.allSatellites() + } else { + loadSatellites(requestedSatelliteIds) + } + if (satellites.isEmpty()) { + return emptyList() + } + + val satellitesById = satellites.associateBy { it.id } + val selectedSatelliteIds = satellites.map { it.id } + val normalizedRequestId = requestId?.trim()?.takeIf { it.isNotEmpty() } + val hasInterval = timeStart != null && timeStop != null + + val bookedSlots = if (hasInterval) { + val cycleWindows = satellites.associate { satellite -> + val cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc + satellite.id to (cycleBegin - 1 to cycleEnd + 1) + } + val minCycle = cycleWindows.values.minOf { it.first } + val maxCycle = cycleWindows.values.maxOf { it.second } + bookedSlotsRepository.findBookingDetailsBySatelliteIdsAndCycleBetween( + satelliteIds = selectedSatelliteIds, + cycleBegin = minCycle, + cycleEnd = maxCycle + ) + } else { + bookedSlotsRepository.findBookingDetailsBySatelliteIds(selectedSatelliteIds) + } + + return bookedSlots + .asSequence() + .filter { booked -> normalizedRequestId == null || booked.requests.orEmpty().any { it.requestId == normalizedRequestId } } + .mapNotNull { booked -> + val satellite = satellitesById[booked.slot.satelliteId] ?: return@mapNotNull null + booked.toDetails(satellite) + } + .filter { details -> + if (!hasInterval) { + true + } else { + val start = details.timeStart + val stop = details.timeStop + start != null && stop != null && stop > timeStart!! && start < timeStop!! + } + } + .sortedWith( + compareBy { it.timeStart ?: LocalDateTime.MAX } + .thenBy { it.satelliteId } + .thenBy { it.slotNum } + .thenBy { it.cycle } + .thenBy { it.bookedSlotId } + ) + .toList() + } + + private fun BookedSlotEntity.toDetails(satellite: AbstractSatellite): BookedSlotDetailsDTO { + val timeStart = slot.tn.plusDays(cycle * satellite.durationCalc) + val timeStop = slot.tk.plusDays(cycle * satellite.durationCalc) + return BookedSlotDetailsDTO( + bookedSlotId = bookedSlotId ?: 0, + satelliteId = slot.satelliteId, + slotNum = slot.slotNum, + cycle = cycle, + timeStart = timeStart, + timeStop = timeStop, + durationSeconds = Duration.between(timeStart, timeStop).seconds, + roll = slot.roll, + revolution = slot.revolution + cycle * satellite.cycleRevs, + revolutionSign = slot.revolutionSign, + status = status, + requestIds = requests.orEmpty().map { it.requestId }.distinct().sorted() + ) + } + @Transactional(readOnly = true) fun allByInterval( satelliteId: Long, @@ -1894,8 +1987,8 @@ class SlotService { } val satellite = satelliteById(satelliteId) - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc val cycleBegin = startCycle - 1 val cycleEnd = stopCycle + 1 @@ -1957,8 +2050,8 @@ class SlotService { val satellite = satelliteById(satelliteId) - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc val cycleBegin = startCycle - 1 val cycleEnd = stopCycle + 1 @@ -1982,8 +2075,8 @@ class SlotService { val selectedSatellites = loadSatellites(satelliteIds) val cycleWindows = selectedSatellites.associate { satellite -> - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc satellite.id to (startCycle - 1 to stopCycle + 1) } val minCycle = cycleWindows.values.minOf { it.first } From d4682fdeb7de7b829e33275d7c7d1bac2bfd6088 Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 25 May 2026 21:02:53 +0300 Subject: [PATCH 02/35] =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=9D=D0=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CatalogRestController.kt | 32 +++++++++++++++++++ .../resources/templates/satellites-pdcm.html | 16 +++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt index 8a6d82d..5600791 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt @@ -1,6 +1,7 @@ package space.nstart.pcp.slots_service.controller import jakarta.validation.Valid +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO @@ -24,6 +26,9 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SlotService import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.format.ResolverStyle @RestController @RequestMapping("/api/catalog") @@ -33,6 +38,8 @@ class CatalogRestController( private val groupStateService: GroupStateService, private val satellitePdcmService: SatellitePdcmService ) { + private val sendInitialConditionsTimeFormatter = + DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT) @GetMapping("/satellites") fun allSatellites() = satelliteCatalogService.allSatellites() @@ -56,6 +63,31 @@ class CatalogRestController( @GetMapping("/satellites/pdcm/{satelliteId}") fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(satelliteId) + @PostMapping("/satellites/pdcm/{satelliteId}/send-ic") + fun sendSatelliteInitialConditions( + @PathVariable satelliteId: Long, + @RequestParam(required = false) time: String? + ): ResponseEntity { + val requestedTime = parseSendInitialConditionsTime(time) + slotService.sendSatelliteInitialConditions(satelliteId, requestedTime) + return ResponseEntity.accepted().build() + } + + private fun parseSendInitialConditionsTime(time: String?): LocalDateTime { + val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() } + ?: return LocalDateTime.now() + + return try { + LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter) + } catch (exception: DateTimeParseException) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Параметр time должен быть в формате dd.mm.yyyy hh:MM:ss.zzz", + exception + ) + } + } + @PostMapping("/satellites") fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) = satelliteCatalogService.createSatellite(request) diff --git a/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html b/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html index 142da35..b33487b 100644 --- a/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html +++ b/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html @@ -15,7 +15,21 @@

Параметры движения центра масс

Табличное представление asc-node по выбранному спутнику.
- +
+
+ + +
+ + +
From 1229995f90fb9b2acce7ed61108828346939b59c Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 25 May 2026 22:02:17 +0300 Subject: [PATCH 03/35] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CurrentPlansController.kt | 10 +- .../slots_service/service/MissionService.kt | 9 + .../pcp/slots_service/service/SlotService.kt | 14 ++ .../resources/static/current_plans_scripts.js | 176 +++++++++++++++++- 4 files changed, 200 insertions(+), 9 deletions(-) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt index dc8a956..5ac91f3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -11,13 +11,15 @@ import org.springframework.web.bind.annotation.RestController import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO import space.nstart.pcp.slots_service.service.MissionService import space.nstart.pcp.slots_service.service.SatelliteCatalogService +import space.nstart.pcp.slots_service.service.SlotService import java.util.UUID @RestController @RequestMapping("/api/current-plans") class CurrentPlansController( private val satelliteCatalogService: SatelliteCatalogService, - private val missionService: MissionService + private val missionService: MissionService, + private val slotService: SlotService ) { @GetMapping("/satellites") @@ -36,6 +38,9 @@ class CurrentPlansController( @GetMapping("/missions/{missionId}/modes") fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId) + @GetMapping("/booked-slots") + fun bookedSlots(@RequestParam ids: List) = slotService.bookedByIds(ids) + @PostMapping("/missions/{missionId}/surveys/calculate") fun calculateSurveys( @PathVariable missionId: UUID, @@ -44,4 +49,7 @@ class CurrentPlansController( @PostMapping("/missions/{missionId}/drops/calculate") fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId) + + @PostMapping("/missions/{missionId}/confirm") + fun confirmMission(@PathVariable missionId: UUID) = missionService.confirmMission(missionId) } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt index b6802de..f3fa13a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt @@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider .block() } + fun confirmMission(missionId: UUID) { + webClientBuilder.build() + .post() + .uri("$url/api/missions/$missionId/confirm") + .retrieve() + .toBodilessEntity() + .block() + } + fun modesByMission(missionId: UUID): List = webClientBuilder.build() .get() diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 996c79d..8e2360b 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -165,6 +165,20 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { .block() ?: emptyList() + fun bookedDetailsByIds(bookedSlotIds: List): List { + val ids = bookedSlotIds.filter { it > 0 }.distinct().toSet() + if (ids.isEmpty()) { + return emptyList() + } + + return searchBooked( + timeStart = null, + timeStop = null, + requestId = null, + satelliteIds = null + ).filter { it.bookedSlotId in ids } + } + fun searchBooked( timeStart: LocalDateTime?, diff --git a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js index 29138a6..7ee0e33 100644 --- a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js @@ -4,6 +4,7 @@ filteredSatellites: [], missions: [], currentModes: [], + bookedSlotsById: {}, selectedSatelliteId: null, selectedMissionId: null, selectedMission: null, @@ -168,6 +169,10 @@ class="btn btn-outline-success btn-sm current-plans-action-btn" data-action="calculate-drops" data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов + `; }).join(''); @@ -184,6 +189,8 @@ calculateSurveys(missionId, button); } else if (button.dataset.action === 'calculate-drops') { calculateDrops(missionId, button); + } else if (button.dataset.action === 'confirm-mission') { + confirmMission(missionId, button); } }); }); @@ -208,6 +215,7 @@ try { const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`); state.currentModes = Array.isArray(modes) ? modes : []; + state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes); renderModes(state.currentModes); setExportEnabled(true); return state.currentModes; @@ -218,6 +226,28 @@ } } + async function loadBookedSlotsForModes(modes) { + const bookedSlotIds = [...new Set((modes || []).flatMap((mode) => mode.bookedSlotIds || []))] + .filter((slotId) => Number(slotId) > 0); + if (!bookedSlotIds.length) { + return {}; + } + + const params = new URLSearchParams(); + bookedSlotIds.forEach((slotId) => params.append('ids', slotId)); + + try { + const bookedSlots = await fetchJson(`/api/current-plans/booked-slots?${params.toString()}`); + return (bookedSlots || []).reduce((acc, slot) => { + acc[slot.bookedSlotId] = slot; + return acc; + }, {}); + } catch (error) { + showAlert(error.message || 'Не удалось загрузить детали забронированных слотов.', 'warning'); + return {}; + } + } + function renderModes(modes) { const tbody = el('current-plans-modes-body'); if (!tbody) return; @@ -227,35 +257,133 @@ return; } - tbody.innerHTML = modes.map((mode) => { + tbody.innerHTML = modes.flatMap((mode) => { const type = mode.type || '—'; const duration = formatNumber(mode.duration); const rowClass = modeRowClass(mode); const badgeClass = modeBadgeClass(mode); - return ` - - ${escapeHtml(type)} + const expandable = isExpandableMode(mode); + const expandMarker = expandable ? '' : ''; + const row = ` + + ${expandMarker}${escapeHtml(type)} ${formatDateTime(mode.timeStart)} ${escapeHtml(mode.revolution ?? '—')} ${duration} ${modeParams(mode)} ${modeStatus(mode)} `; + const details = expandable ? modeDetailsRow(mode) : null; + return details ? [row, details] : [row]; }).join(''); + + tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => { + row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId)); + }); } function modeRowClass(mode) { if (mode.type === 'DROP') return 'current-plans-mode-row-drop'; - if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots'; + if (isSlotsLikeSource(mode.source)) return 'current-plans-mode-row-slots'; return ''; } function modeBadgeClass(mode) { if (mode.type === 'DROP') return 'current-plans-badge-drop'; - if (mode.source === 'SLOTS') return 'current-plans-badge-slots'; + if (isSlotsLikeSource(mode.source)) return 'current-plans-badge-slots'; return ''; } + function isSlotsLikeSource(source) { + return source === 'SLOTS' || source === 'MIXED'; + } + + function isExpandableMode(mode) { + return (Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length > 0) + || (mode.type === 'DROP' && Array.isArray(mode.surveys) && mode.surveys.length > 0); + } + + function toggleModeDetails(modeId) { + const row = document.querySelector(`#current-plans-modes-body tr[data-mode-id="${cssEscape(modeId)}"]`); + const details = document.querySelector(`#current-plans-modes-body tr[data-parent-mode-id="${cssEscape(modeId)}"]`); + if (!row || !details) return; + + const collapsed = details.classList.toggle('d-none'); + row.classList.toggle('current-plans-expanded-row', !collapsed); + const marker = row.querySelector('.current-plans-expand-marker'); + if (marker) marker.textContent = collapsed ? '▸' : '▾'; + } + + function modeDetailsRow(mode) { + const content = mode.type === 'DROP' + ? dropDetails(mode) + : bookedSlotDetails(mode); + + return ` + + +
${content}
+ + `; + } + + function bookedSlotDetails(mode) { + const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : []; + const slots = slotIds.map((slotId) => { + const slot = state.bookedSlotsById[slotId]; + if (!slot) { + return `#${escapeHtml(slotId)}`; + } + return `#${escapeHtml(slot.bookedSlotId)} · slot ${escapeHtml(slot.slotNum)} · cycle ${escapeHtml(slot.cycle)} · sat ${escapeHtml(slot.satelliteId)}`; + }).join(''); + return ` +
Забронированные слоты (${slotIds.length})
+
${slots}
`; + } + + function dropDetails(mode) { + const surveyIds = Array.isArray(mode.surveys) ? mode.surveys : []; + const surveysById = new Map((state.currentModes || []).map((item) => [Number(item.id), item])); + const rows = surveyIds.map((surveyId) => { + const survey = surveysById.get(Number(surveyId)); + if (!survey) { + return `#${escapeHtml(surveyId)}Съёмка не найдена в текущем списке режимов`; + } + + const booked = Array.isArray(survey.bookedSlotIds) && survey.bookedSlotIds.length + ? survey.bookedSlotIds.map((slotId) => { + const slot = state.bookedSlotsById[slotId]; + return slot ? `#${slot.bookedSlotId} slot ${slot.slotNum}/cycle ${slot.cycle}` : `#${slotId}`; + }).join(', ') + : '—'; + return ` + + #${escapeHtml(survey.id)} + ${formatDateTime(survey.timeStart)} + ${escapeHtml(survey.revolution ?? '—')} + ${escapeHtml(survey.source || '—')} + ${escapeHtml(booked)} + `; + }).join(''); + + return ` +
Сбрасываемые съёмки (${surveyIds.length})
+
+ + + + + + + + + + + ${rows} +
IDНачалоВитокИсточникBooked slots
+
`; + } + function modeParams(mode) { if (mode.type === 'DROP') { const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0; @@ -276,7 +404,7 @@ if (mode.type === 'DROP') { return 'DROP'; } - const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : ''; + const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : ''; return `
${escapeHtml(mode.status || '—')}
${escapeHtml(mode.source || '—')}
`; @@ -379,6 +507,24 @@ } } + async function confirmMission(missionId, button) { + setButtonLoading(button, true, 'Утверждение...'); + try { + await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/confirm`, { + method: 'POST', + headers: {'Accept': 'application/json'} + }); + showAlert('План утверждён.', 'success'); + if (state.selectedSatelliteId) { + await loadMissions(state.selectedSatelliteId, state.missionsPage, missionId); + } + } catch (error) { + showAlert(error.message || 'Не удалось утвердить план.', 'danger'); + } finally { + setButtonLoading(button, false); + } + } + function exportSelectedMissionCsv() { if (!state.selectedMissionId) { showAlert('Выберите миссию для сохранения CSV.', 'warning'); @@ -410,7 +556,7 @@ mode.type || '', mode.id ?? '', mode.planId ?? '', - mode.timeStart || '', + formatDateTime(mode.timeStart), mode.revolution ?? '', mode.duration ?? '', mode.status || '', @@ -469,6 +615,7 @@ el('current-plans-modes-body').innerHTML = ''; el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.'; state.currentModes = []; + state.bookedSlotsById = {}; setExportEnabled(false); } @@ -570,6 +717,12 @@ function formatDateTime(value) { if (!value) return '—'; + const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/); + if (match) { + const [, year, month, day, hour, minute, second] = match; + return `${day}.${month}.${year.slice(2)}, ${hour}:${minute}:${second || '00'}`; + } + const date = new Date(value); if (Number.isNaN(date.getTime())) return escapeHtml(value); return date.toLocaleString('ru-RU', { @@ -652,4 +805,11 @@ .replaceAll('"', '"') .replaceAll("'", '''); } + + function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === 'function') { + return window.CSS.escape(String(value ?? '')); + } + return String(value ?? '').replaceAll('"', '\\"'); + } })(); From 38c1449e97fe9952ed6e0c6500d730bbdbff7bd0 Mon Sep 17 00:00:00 2001 From: emelianov Date: Tue, 26 May 2026 12:32:33 +0300 Subject: [PATCH 04/35] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pcp/slots_service/service/SlotService.kt | 3 ++ .../resources/static/current_plans_scripts.js | 48 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 8e2360b..18a6ce6 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -165,6 +165,9 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { .block() ?: emptyList() + fun bookedByIds(bookedSlotIds: List): List = + bookedDetailsByIds(bookedSlotIds) + fun bookedDetailsByIds(bookedSlotIds: List): List { val ids = bookedSlotIds.filter { it > 0 }.distinct().toSet() if (ids.isEmpty()) { diff --git a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js index 7ee0e33..962ed3b 100644 --- a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js @@ -329,16 +329,41 @@ function bookedSlotDetails(mode) { const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : []; - const slots = slotIds.map((slotId) => { + const rows = slotIds.map((slotId) => { const slot = state.bookedSlotsById[slotId]; if (!slot) { - return `#${escapeHtml(slotId)}`; + return `#${escapeHtml(slotId)}Детали слота не найдены`; } - return `#${escapeHtml(slot.bookedSlotId)} · slot ${escapeHtml(slot.slotNum)} · cycle ${escapeHtml(slot.cycle)} · sat ${escapeHtml(slot.satelliteId)}`; + return ` + + #${escapeHtml(slot.bookedSlotId)} + ${formatDateTime(slot.timeStart)} + ${formatDuration(slot.durationSeconds)} + ${escapeHtml(slot.slotNum ?? '—')} + ${escapeHtml(slot.cycle ?? '—')} + ${escapeHtml(slot.satelliteId ?? '—')} + ${escapeHtml(slot.status || '—')} + `; }).join(''); + return `
Забронированные слоты (${slotIds.length})
-
${slots}
`; +
+ + + + + + + + + + + + + ${rows} +
IDНачалоДлительностьСлотЦиклСпутникСтатус
+
`; } function dropDetails(mode) { @@ -741,6 +766,21 @@ return number.toLocaleString('ru-RU', {maximumFractionDigits: 2}); } + function formatDuration(seconds) { + const totalSeconds = Number(seconds); + if (!Number.isFinite(totalSeconds)) return '—'; + + const rounded = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const remainingSeconds = rounded % 60; + const pad = (number) => String(number).padStart(2, '0'); + + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(remainingSeconds)}` + : `${minutes}:${pad(remainingSeconds)}`; + } + function toDateTimeLocalValue(date) { const pad = (number) => String(number).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; From 467f91a3744fb0d8cb95b61d3cc4407744911af0 Mon Sep 17 00:00:00 2001 From: emelianov Date: Tue, 26 May 2026 12:53:04 +0300 Subject: [PATCH 05/35] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e5389b1..518fa1f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -124,6 +124,9 @@ default: tags: - nstart +variables: + INTERNAL_REGISTRY: "$CI_REGISTRY_IMAGE" + stages: - test - build From 23c540ead0f6d68279a294e5bdf07e43aa10eb1e Mon Sep 17 00:00:00 2001 From: emelianov Date: Tue, 26 May 2026 12:55:21 +0300 Subject: [PATCH 06/35] test --- .../pcp/slots_service/controller/CurrentPlansController.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt index 5ac91f3..a23bcec 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -32,6 +32,7 @@ class CurrentPlansController( @RequestParam(defaultValue = "4") size: Int ) = missionService.missionsBySatellite(satelliteId, page, size) + @PostMapping("/missions") fun createMission(@Valid @RequestBody body: MissionRequestDTO) = missionService.createMission(body) From 4d2a5952c945fdd66cc62867d01e1f3cf6b7024c Mon Sep 17 00:00:00 2001 From: emelianov Date: Tue, 26 May 2026 13:09:39 +0300 Subject: [PATCH 07/35] test --- .../pcp/slots_service/controller/CurrentPlansController.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt index a23bcec..5ac91f3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -32,7 +32,6 @@ class CurrentPlansController( @RequestParam(defaultValue = "4") size: Int ) = missionService.missionsBySatellite(satelliteId, page, size) - @PostMapping("/missions") fun createMission(@Valid @RequestBody body: MissionRequestDTO) = missionService.createMission(body) From 2aafa561edeee5a2ffd1d87dcfd44b07236002f5 Mon Sep 17 00:00:00 2001 From: emelianov Date: Wed, 27 May 2026 10:03:23 +0300 Subject: [PATCH 08/35] =?UTF-8?q?=D0=A3=D1=87=D0=B5=D1=82=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ComplexPlanCalculationService.kt | 166 ++++++++++++++---- .../ComplexPlanRunOrchestrationService.kt | 2 +- .../service/CoverageBatchClient.kt | 33 +++- .../CoverageSchemeCalculationClient.kt | 14 ++ .../service/SurveyPlacementService.kt | 18 ++ .../service/SurveyPlacementServiceTest.kt | 33 ++++ 6 files changed, 231 insertions(+), 35 deletions(-) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt index 4abe92b..61dc722 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt @@ -18,8 +18,11 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import java.time.Duration import java.time.LocalDateTime +import kotlin.math.abs import kotlin.system.measureTimeMillis @Service @@ -36,6 +39,11 @@ class ComplexPlanCalculationService( private val logger: Logger = LoggerFactory.getLogger(this::class.java) + companion object { + private const val LOG_INTERVAL_SAMPLE_LIMIT = 10 + private const val ROLL_EPSILON = 0.01 + } + fun process( intervalStart: LocalDateTime, intervalEnd: LocalDateTime, @@ -134,7 +142,7 @@ class ComplexPlanCalculationService( logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId) val plannedModesMs = measureTimeMillis { - addPlannedModes(workingSatellites, intervalStart, intervalEnd) + addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd) } logger.info( "Stage planned-modes-load: runId={}, satellites={}, durationMs={}", @@ -161,8 +169,9 @@ class ComplexPlanCalculationService( } val targetsChunk = buildTargetsChunk(chunk) - var coverageByTargetId = emptyMap>() + var coverageByTargetId = emptyMap>() val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites) + logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals) val coverageFetchMs = measureTimeMillis { coverageByTargetId = coverageCalculationClient.coverageModes( targets = targetsChunk, @@ -189,6 +198,7 @@ class ComplexPlanCalculationService( var acceptedForChunk = 0 val chunkProcessMs = measureTimeMillis { acceptedForChunk = processChunk( + runId = runId, workingSatellites = workingSatellites, cells = chunk, coverageByTargetId = coverageByTargetId, @@ -294,6 +304,7 @@ class ComplexPlanCalculationService( } private fun addPlannedModes( + runId: Long?, workingSatellites: List, intervalStart: LocalDateTime, intervalEnd: LocalDateTime @@ -308,35 +319,35 @@ class ComplexPlanCalculationService( ) workingSatellites.forEach { satellite -> - val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() - plannedModesBySatelliteId[satellite.satelliteId] - .orEmpty() - .forEach { slot -> - surveyPlacementService.mergeSurvey( - existingSurveys = satellite.longMission.surveys, - candidateSurvey = SurveyMode( - revolution = slot.revolution, - time = slot.tn, - timeStop = slot.tk, - roll = slot.roll, - latitude = slot.latitude, - longitude = slot.longitude, - duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(), - contourWKT = slot.contour, - cellNum = -1, - source = SatelliteModeSource.SLOTS, - bookedSlotIds = slot.slotIds, - cellNums = emptyList() - ), - satellite = satellite - ) - } - logger.info( - "Добавлены booked-маршруты для satelliteId={}: count={}", - satellite.satelliteId, - plannedForSatellite.size + val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() + logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite) + plannedForSatellite.forEach { slot -> + surveyPlacementService.mergeSurvey( + existingSurveys = satellite.longMission.surveys, + candidateSurvey = SurveyMode( + revolution = slot.revolution, + time = slot.tn, + timeStop = slot.tk, + roll = slot.roll, + latitude = slot.latitude, + longitude = slot.longitude, + duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(), + contourWKT = slot.contour, + cellNum = -1, + source = SatelliteModeSource.SLOTS, + bookedSlotIds = slot.slotIds, + cellNums = emptyList() + ), + satellite = satellite ) } + logger.info( + "Добавлены booked-маршруты: runId={}, satelliteId={}, count={}", + runId, + satellite.satelliteId, + plannedForSatellite.size + ) + } } private fun buildCoverageTargets(cells: List): List = @@ -367,10 +378,59 @@ class ComplexPlanCalculationService( .sortedWith(compareBy { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) .toList() + private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List) { + if (slots.isEmpty()) { + logger.info("Booked slots loaded: runId={}, satelliteId={}, count=0", runId, satelliteId) + return + } + + logger.info( + "Booked slots loaded: runId={}, satelliteId={}, count={}, interval=[{} - {}], sample={}", + runId, + satelliteId, + slots.size, + slots.minOf { it.tn }, + slots.maxOf { it.tk }, + slots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { slot -> + "start=${slot.tn},end=${slot.tk},roll=${slot.roll},revolution=${slot.revolution},slotIds=${slot.slotIds}" + } + ) + } + + private fun logOccupiedIntervalsSnapshot( + runId: Long?, + chunkNumber: Int, + chunkCount: Int, + occupiedIntervals: List + ) { + val bySource = occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount() + val slotsBySatellite = occupiedIntervals + .filter { it.source == SatelliteModeSource.SLOTS.name } + .groupingBy { it.satelliteId } + .eachCount() + + logger.info( + "Occupied intervals snapshot before coverage request: runId={}, chunk={}/{}, total={}, bySource={}, slotsBySatellite={}, slotsSample={}", + runId, + chunkNumber, + chunkCount, + occupiedIntervals.size, + bySource, + slotsBySatellite, + occupiedIntervals + .filter { it.source == SatelliteModeSource.SLOTS.name } + .take(LOG_INTERVAL_SAMPLE_LIMIT) + .map { interval -> + "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}" + } + ) + } + private fun processChunk( + runId: Long?, workingSatellites: List, cells: List, - coverageByTargetId: Map>, + coverageByTargetId: Map>, intervalStart: LocalDateTime, intervalEnd: LocalDateTime ): Int { @@ -391,6 +451,13 @@ class ComplexPlanCalculationService( val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId } ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван") + logComplanCandidateBookedSlotOverlaps( + runId = runId, + cell = cell, + slot = slot, + existingSurveys = satellite.longMission.surveys + ) + val decision = surveyPlacementService.tryPlaceSurvey( existingSurveys = satellite.longMission.surveys, candidateSurvey = SurveyMode( @@ -425,6 +492,45 @@ class ComplexPlanCalculationService( return acceptedModes } + private fun logComplanCandidateBookedSlotOverlaps( + runId: Long?, + cell: EarthCellWithRequestsDTO, + slot: SlotDTO, + existingSurveys: List + ) { + val overlappingBookedSlots = existingSurveys + .filter { survey -> survey.source == SatelliteModeSource.SLOTS } + .filter { survey -> intersectsInTime(survey.time, survey.timeStop, slot.tn, slot.tk) } + + if (overlappingBookedSlots.isEmpty()) { + return + } + + logger.warn( + "COMPLAN candidate overlaps booked SLOTS before placement: runId={}, satelliteId={}, targetId={}, cellNum={}, candidateInterval=[{} - {}], candidateRoll={}, candidateRevolution={}, overlaps={}, sameRollOverlaps={}, bookedSlotsSample={}", + runId, + slot.satelliteId, + cell.targetId(), + cell.num, + slot.tn, + slot.tk, + slot.roll, + slot.revolution, + overlappingBookedSlots.size, + overlappingBookedSlots.count { survey -> abs(survey.roll - slot.roll) <= ROLL_EPSILON }, + overlappingBookedSlots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { survey -> + "start=${survey.time},end=${survey.timeStop},roll=${survey.roll},source=${survey.source},bookedSlotIds=${survey.bookedSlotIds}" + } + ) + } + + private fun intersectsInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean = start1 < stop2 && start2 < stop1 + private fun hasRemainingCapacity( workingSatellites: List, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt index 32665c6..634b419 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt @@ -36,7 +36,7 @@ class ComplexPlanRunOrchestrationService( ) lateinit var createdRun: ComplexPlanRunEntity - val createRunMs = measureTimeMillis { + val createRunMs = measureTimeMillis { createdRun = complexPlanRunPersistenceService.createRun( ComplexPlanRunCreateDTO( intervalStart = request.intervalStart, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt index cb741ab..8d34c2c 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt @@ -18,6 +18,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRespon import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime @@ -161,18 +162,42 @@ class CoverageBatchClient(webClientBuilderProvider: ObjectProvider + ): List = + response.map { targetResponse -> + targetResponse.copy( + slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE } + ) + } + private fun requestCoverageModes( targets: List, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt index 7527735..8ae27d1 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt @@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider + "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}" + } + ) + } val result = linkedMapOf>() val durationMs = measureTimeMillis { diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt index b58ce9f..dcf343d 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt @@ -226,6 +226,9 @@ class SurveyPlacementService { if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) { return@forEach } + if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) { + return@forEach + } val overlapsInTime = intersectsInTime( existing.time, existing.timeStop, @@ -377,6 +380,21 @@ class SurveyPlacementService { return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS } + private fun shouldSkipComplanInsideSlotsMerge( + left: SurveyMode, + right: SurveyMode + ): Boolean { + val complanSurvey = when { + left.source == SatelliteModeSource.COMPLAN && right.source == SatelliteModeSource.SLOTS -> left + right.source == SatelliteModeSource.COMPLAN && left.source == SatelliteModeSource.SLOTS -> right + else -> return false + } + val slotsSurvey = if (complanSurvey === left) right else left + + return !complanSurvey.time.isBefore(slotsSurvey.time) && + !complanSurvey.timeStop.isAfter(slotsSurvey.timeStop) + } + private fun intersectsInTime( start1: LocalDateTime, stop1: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt index a2dfaa5..51f46bf 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt @@ -121,6 +121,39 @@ class SurveyPlacementServiceTest { assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) } + @Test + fun `complan inside booked slot is not merged to mixed`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 100, + roll = 10.0, + source = SatelliteModeSource.SLOTS, + contour = "POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 0, + end = 40, + roll = 10.0, + source = SatelliteModeSource.COMPLAN, + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(2, surveys.size) + assertTrue(decision.absorbedSurveys.isEmpty()) + assertEquals(1, surveys.count { it.source == SatelliteModeSource.SLOTS }) + assertEquals(1, surveys.count { it.source == SatelliteModeSource.COMPLAN }) + assertFalse(surveys.any { it.source == SatelliteModeSource.MIXED }) + } + @Test fun `different roll conflict resolved by trimming`() { val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0)) From 1a6e51bf2530a0b874f8b6d4e25c2b8d5baa9885 Mon Sep 17 00:00:00 2001 From: emelianov Date: Wed, 27 May 2026 10:24:14 +0300 Subject: [PATCH 09/35] =?UTF-8?q?=D0=A3=D1=87=D0=B5=D1=82=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/satellite/TestSatelliteImpl.kt | 77 +++++++++++++++---- .../service/OccupiedIntervalPrefilter.kt | 20 +++++ .../pcp/slots_service/service/SlotService.kt | 16 +++- .../TestSatelliteImplBookedBlockingTest.kt | 62 +++++++++++++++ .../service/OccupiedIntervalPrefilterTest.kt | 53 ++++++++++++- 5 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt index c83c350..cd4e733 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt @@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.model.satellite import ballistics.types.InitialConditions import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.slots_service.entity.BookedSlotEntity @@ -40,10 +41,19 @@ class TestSatelliteImpl( val selectedSlots = mutableListOf() val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc - val booked = bookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } - val bookedData = ( - bookedSlots.map { BookedSlot(it.slot.tn, it.slot.tk,it.slot.roll, 0, it.cycle) }.toMutableList() - ) + val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES } + val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } + val bookedData = activeBookedSlots.map { bookedSlot -> + val offsetDays = bookedSlot.cycle * durationCalc + BookedSlot( + bookedSlot.slot.tn.plusDays(offsetDays), + bookedSlot.slot.tk.plusDays(offsetDays), + bookedSlot.slot.roll, + 0, + bookedSlot.cycle + ) + }.toMutableList() + val bookedIntervals = mergeBookedIntervals(bookedData.map { BookedInterval(it.t, it.tEnd) }) calculateChainLengths(bookedData) for (cycleN in cycleBegin..cycleEnd) { val currentSlots = slots.map { ent -> @@ -68,24 +78,27 @@ class TestSatelliteImpl( slot.tn >= timeStart && slot.tk <= timeStop && sign?.let { slot.revolutionSign == it.toString() } ?: true }.map { - val band = bookedData.filter { booked -> - val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd) + val isBooked = booked.any { booked -> booked.cycle == it.cycle && booked.id == it.slotId } + val insideBookedInterval = !isBooked && bookedIntervals.any { booked -> booked.contains(it.tn, it.tk) } + val band = if (insideBookedInterval) { + true + } else { + bookedData.any { booked -> + val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd) + val gam = abs(it.roll - booked.roll) - val gam = abs(it.roll - booked.roll) - - if (dif) { - if (gam < 0.01 && booked.neighbours < maxChainLength) { -// if (gam < 0.01) { -// booked.neighbours++ + if (dif) { + !(gam < 0.01 && booked.neighbours < maxChainLength) + } else { false - } else true - } else false + } + } } PreparedCoverageSlot( sourceSlotId = it.slotId ?: 0L, slot = it.toDTO( - booked.count { booked -> booked.cycle == it.cycle && booked.id == it.slotId } > 0, - band.isNotEmpty() + isBooked, + band ) ) }) @@ -95,6 +108,30 @@ class TestSatelliteImpl( return selectedSlots } + private data class BookedInterval(val start: LocalDateTime, val end: LocalDateTime) { + fun contains(slotStart: LocalDateTime, slotEnd: LocalDateTime): Boolean = + !slotStart.isBefore(start) && !slotEnd.isAfter(end) + } + + private fun mergeBookedIntervals(intervals: List): List { + if (intervals.isEmpty()) { + return emptyList() + } + val merged = mutableListOf() + intervals.sortedBy { it.start }.forEach { interval -> + val last = merged.lastOrNull() + if (last != null && !interval.start.isAfter(last.end)) { + merged[merged.lastIndex] = BookedInterval( + start = minOf(last.start, interval.start), + end = maxOf(last.end, interval.end) + ) + } else { + merged += interval + } + } + return merged + } + private fun calculateChainLengths(slots: MutableList) { if (slots.isEmpty()) return @@ -127,4 +164,12 @@ class TestSatelliteImpl( slots[j].neighbours = lastChainLength } } + + + companion object { + private val ACTIVE_BOOKED_STATUSES = setOf( + BookedSlotStatus.BOOKED.name, + BookedSlotStatus.PROCESSED.name + ) + } } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt index 0ef5518..7efbc14 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt @@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.service import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite import java.time.Duration import kotlin.math.abs @@ -71,6 +72,15 @@ internal object OccupiedIntervalPrefilter { occupiedIntervals: List, satellite: AbstractSatellite ): Boolean { + val insideStrictReservedInterval = candidate.state != SlotStatus.BOOKED && + occupiedIntervals.any { occupied -> + isStrictReservedSource(occupied.source) && + isInside(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime) + } + if (insideStrictReservedInterval) { + return false + } + val conflictingDifferentRoll = occupiedIntervals.any { occupied -> !sameRoll(candidate.roll, occupied.roll) && intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi) @@ -101,5 +111,15 @@ internal object OccupiedIntervalPrefilter { mmi: Long ): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi) + private fun isInside( + start: java.time.LocalDateTime, + end: java.time.LocalDateTime, + containerStart: java.time.LocalDateTime, + containerEnd: java.time.LocalDateTime + ): Boolean = !start.isBefore(containerStart) && !end.isAfter(containerEnd) + + private fun isStrictReservedSource(source: String?): Boolean = + source.equals("SLOTS", ignoreCase = true) + private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index beaa26f..2709591 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -1686,16 +1686,26 @@ class SlotService { val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach val preparedSlotsBySourceSlotId = batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty() - val preparedSlots = sourceSlotIdsForSatellite.flatMap { slotId -> + val preparedSlotsIncludingBlocked = sourceSlotIdsForSatellite.flatMap { slotId -> preparedSlotsBySourceSlotId[slotId].orEmpty() }.map { it.slot } - preparedSlotsBeforeOccupiedFilter += preparedSlots.size + preparedSlotsBeforeOccupiedFilter += preparedSlotsIncludingBlocked.size + + // BUSY слоты возникают, в частности, когда слот по времени попадает внутрь + // активного забронированного интервала. Такие слоты не должны попадать + // в дальнейший расчет покрытия: booked-слоты учитываются как BOOKED, + // а все остальные слоты внутри их времени считаются заблокированными. + val preparedSlots = preparedSlotsIncludingBlocked.filter { slot -> + slot.state == SlotStatus.AVAILABLE || slot.state == SlotStatus.BOOKED + } + val blockedByBookedSlots = preparedSlotsIncludingBlocked.size - preparedSlots.size + val filteredSlots = OccupiedIntervalPrefilter.filterCandidates( candidateSlots = preparedSlots, occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(), satellite = satellite ) - occupiedFilteredSlots += preparedSlots.size - filteredSlots.size + occupiedFilteredSlots += blockedByBookedSlots + (preparedSlots.size - filteredSlots.size) candidateSlotsBeforeSunFilter += filteredSlots.size val sunFilterResult = filterBySunAngle(filteredSlots, sun) sunFilteredSlots += sunFilterResult.filteredCount diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt new file mode 100644 index 0000000..bd886eb --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt @@ -0,0 +1,62 @@ +package space.nstart.pcp.slots_service.model.satellite + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import java.time.LocalDateTime +import kotlin.test.assertEquals + +class TestSatelliteImplBookedBlockingTest { + + @Test + fun `non-booked slot inside active booked interval is marked busy and exact booked slot stays booked`() { + val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0) + val satellite = TestSatelliteImpl( + id = 1L, + tnCalc = baseTime, + durationCalc = 1, + maxChainLength = 3, + maxSurveyDuration = 300, + mmi = 10 + ) + val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10) + val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8) + val booked = BookedSlotEntity(slot = bookedRawSlot, cycle = 0).apply { + status = BookedSlotStatus.PROCESSED.name + } + + val result = satellite.prepareCoverageSlots( + slots = listOf(bookedRawSlot, candidateInsideBooked), + bookedSlots = listOf(booked), + timeStart = baseTime, + timeStop = baseTime.plusSeconds(20), + sign = null, + states = null + ).map { it.slot }.associateBy { it.slotNumber } + + assertEquals(SlotStatus.BOOKED, result.getValue(1L).state) + assertEquals(SlotStatus.BUSY, result.getValue(2L).state) + } + + private fun slot( + slotId: Long, + slotNum: Long, + baseTime: LocalDateTime, + startSecond: Long, + endSecond: Long + ) = SlotEntity( + slotId = slotId, + slotNum = slotNum, + cycle = 0, + satelliteId = 1L, + coveringType = 0, + tn = baseTime.plusSeconds(startSecond), + tk = baseTime.plusSeconds(endSecond), + roll = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 1L, + revolutionSign = "ASC" + ) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt index 4bd5259..02f7c89 100644 --- a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt @@ -81,6 +81,48 @@ class OccupiedIntervalPrefilterTest { assertEquals(listOf(candidate), filtered) } + + @Test + fun `available candidate inside reserved slots interval is rejected even with same roll`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 10, endOffsetSeconds = 20) + val occupied = listOf( + occupiedInterval( + roll = 10.0, + startOffsetSeconds = 0, + endOffsetSeconds = 30, + source = "SLOTS" + ) + ) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertTrue(filtered.isEmpty()) + } + + @Test + fun `booked candidate inside reserved slots interval is kept for coverage accounting`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot( + roll = 10.0, + startOffsetSeconds = 10, + endOffsetSeconds = 20, + state = SlotStatus.BOOKED + ) + val occupied = listOf( + occupiedInterval( + roll = 10.0, + startOffsetSeconds = 0, + endOffsetSeconds = 30, + source = "SLOTS" + ) + ) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertEquals(listOf(candidate), filtered) + } + private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite = object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) { override fun prepareCoverageSlots( @@ -96,26 +138,29 @@ class OccupiedIntervalPrefilterTest { private fun occupiedInterval( roll: Double, startOffsetSeconds: Long, - endOffsetSeconds: Long + endOffsetSeconds: Long, + source: String = "TEST" ) = OccupiedIntervalDTO( satelliteId = 22, startTime = baseTime().plusSeconds(startOffsetSeconds), endTime = baseTime().plusSeconds(endOffsetSeconds), roll = roll, - source = "TEST" + source = source ) private fun candidateSlot( roll: Double, startOffsetSeconds: Long, - endOffsetSeconds: Long + endOffsetSeconds: Long, + state: SlotStatus = SlotStatus.AVAILABLE ) = SlotDTO( cycle = 0, satelliteId = 22, tn = baseTime().plusSeconds(startOffsetSeconds), tk = baseTime().plusSeconds(endOffsetSeconds), roll = roll, - contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + state = state ) private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0) From 29c06a4c9ca0f6a853dc7dfff2bcff49b1bfcf02 Mon Sep 17 00:00:00 2001 From: emelianov Date: Wed, 27 May 2026 11:40:31 +0300 Subject: [PATCH 10/35] =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20TLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/pcp-mission-planing-service.yaml | 2 +- .../controller/CatalogPageController.kt | 3 + .../controller/TleComparisonController.kt | 135 +++++++++++ .../dto/tlecomparison/TleComparisonDTO.kt | 47 ++++ .../service/BallisticsService.kt | 12 + .../resources/static/css/tle-comparison.css | 92 ++++++++ .../static/tle_comparison_scripts.js | 210 ++++++++++++++++++ .../templates/fragments/base/menu.html | 1 + .../resources/templates/tle-comparison.html | 118 ++++++++++ 9 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt create mode 100644 services/pcp-ui-service/src/main/resources/static/css/tle-comparison.css create mode 100644 services/pcp-ui-service/src/main/resources/static/tle_comparison_scripts.js create mode 100644 services/pcp-ui-service/src/main/resources/templates/tle-comparison.html diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index 0bfa911..ce70309 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -75,7 +75,7 @@ spring: camunda: client: mode: self-managed - grpc-address: http://camunda.k8s.265.nstart.local:30901 + grpc-address: http://camunda.k8s.265.nstart.cloud:30901 auth: method: none prefer-rest-over-grpc: false diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt index e2b3be2..57dcfff 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -18,6 +18,9 @@ class CatalogPageController { @GetMapping("/satellites/pdcm") fun satellitesPdcmPage(): String = "satellites-pdcm" + @GetMapping("/tle-comparison") + fun tleComparisonPage(): String = "tle-comparison" + @GetMapping("/current-plans") fun currentPlansPage(): String = "current-plans" diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt new file mode 100644 index 0000000..8b8cae2 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt @@ -0,0 +1,135 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO +import space.nstart.pcp.slots_service.configuration.CustomValidationException +import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRequestDTO +import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareResponseDTO +import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRowDTO +import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO +import space.nstart.pcp.slots_service.dto.tlecomparison.TleTextRequestDTO +import space.nstart.pcp.slots_service.service.BallisticsService +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs + +@RestController +@RequestMapping("/api/tle-comparison") +class TleComparisonController( + private val ballisticsService: BallisticsService +) { + + @PostMapping("/parse") + fun parse(@RequestBody request: TleTextRequestDTO): TleParsedDTO = + ballisticsService.parseTle(parseTleText(request.tle)) + + @PostMapping("/compare") + fun compare(@RequestBody request: TleCompareRequestDTO): TleCompareResponseDTO { + val first = ballisticsService.parseTle(parseTleText(request.first)) + val second = ballisticsService.parseTle(parseTleText(request.second)) + return TleCompareResponseDTO( + first = first, + second = second, + rows = buildRows(first, second) + ) + } + + private fun parseTleText(raw: String): TLEDTO { + val lines = raw.lines() + .map { it.trim() } + .filter { it.isNotBlank() } + + if (lines.isEmpty()) { + throw CustomValidationException("Введите TLE") + } + + val firstIndex = lines.indexOfFirst { it.startsWith("1 ") } + val secondIndex = lines.indexOfFirst { it.startsWith("2 ") } + if (firstIndex < 0 || secondIndex < 0) { + throw CustomValidationException("TLE должен содержать строки 1 и 2") + } + + val first = lines[firstIndex] + val second = lines[secondIndex] + val header = lines.take(firstIndex) + .firstOrNull { !it.startsWith("1 ") && !it.startsWith("2 ") } + + if (first.length < 69 || second.length < 69) { + throw CustomValidationException("Строки TLE должны содержать не менее 69 символов") + } + + return TLEDTO( + header = header, + first = first.take(69), + second = second.take(69) + ) + } + + private fun buildRows(first: TleParsedDTO, second: TleParsedDTO): List = listOf( + textRow("satName", "Название", first.satName, second.satName), + textRow("noradId", "NORAD ID", first.noradId?.toString(), second.noradId?.toString()), + textRow("revolution", "Номер витка", first.revolution?.toString(), second.revolution?.toString()), + timeRow("time", "Эпоха", first.time, second.time), + numberRow("inclination", "Наклонение", first.inclination, second.inclination), + numberRow("ascendingNode", "ВУЗ", first.ascendingNode, second.ascendingNode), + numberRow("argPerigee", "Аргумент перигея", first.argPerigee, second.argPerigee), + numberRow("meanAnomaly", "Средняя аномалия", first.meanAnomaly, second.meanAnomaly), + numberRow("eccentricity", "Эксцентриситет", first.eccentricity, second.eccentricity), + numberRow("perigee", "Перигей", first.perigee, second.perigee), + numberRow("apogee", "Апогей", first.apogee, second.apogee), + numberRow("period", "Период", first.period, second.period), + numberRow("semiMajor", "Большая полуось", first.semiMajor, second.semiMajor), + numberRow("semiMinor", "Малая полуось", first.semiMinor, second.semiMinor), + numberRow("meanMotion", "Среднее движение", first.meanMotion, second.meanMotion), + numberRow("meanMotionTLE", "Среднее движение TLE", first.meanMotionTLE, second.meanMotionTLE) + ) + + private fun textRow(code: String, name: String, first: String?, second: String?): TleCompareRowDTO = + TleCompareRowDTO( + code = code, + name = name, + first = first, + second = second, + delta = if (first != null && second != null && first != second) "изменено" else null + ) + + private fun timeRow(code: String, name: String, first: LocalDateTime?, second: LocalDateTime?): TleCompareRowDTO { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val delta = if (first != null && second != null) { + val seconds = java.time.Duration.between(first, second).seconds + formatSecondsDelta(seconds) + } else { + null + } + return TleCompareRowDTO(code, name, first?.format(formatter), second?.format(formatter), delta) + } + + private fun numberRow(code: String, name: String, first: Double?, second: Double?): TleCompareRowDTO = + TleCompareRowDTO( + code = code, + name = name, + first = first?.formatNumber(), + second = second?.formatNumber(), + delta = if (first != null && second != null) (second - first).formatSignedNumber() else null + ) + + private fun Double.formatNumber(): String = String.format(Locale.US, "%.8f", this) + + private fun Double.formatSignedNumber(): String { + val sign = if (this > 0) "+" else "" + return sign + String.format(Locale.US, "%.8f", this) + } + + private fun formatSecondsDelta(seconds: Long): String { + val sign = if (seconds > 0) "+" else if (seconds < 0) "-" else "" + val absolute = abs(seconds) + val hours = absolute / 3600 + val minutes = (absolute % 3600) / 60 + val secs = absolute % 60 + return "$sign${hours}ч ${minutes}м ${secs}с" + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt new file mode 100644 index 0000000..6b89fbd --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -0,0 +1,47 @@ +package space.nstart.pcp.slots_service.dto.tlecomparison + +import java.time.LocalDateTime + +class TleTextRequestDTO( + var tle: String = "" +) + +class TleCompareRequestDTO( + var first: String = "", + var second: String = "" +) + +data class TleParsedDTO( + val satName: String? = null, + val noradId: Long? = null, + val revolution: Long? = null, + val time: LocalDateTime? = null, + val inclination: Double? = null, + val perigee: Double? = null, + val apogee: Double? = null, + val argPerigee: Double? = null, + val eccentricity: Double? = null, + val major: Double? = null, + val minor: Double? = null, + val meanAnomaly: Double? = null, + val period: Double? = null, + val semiMajor: Double? = null, + val semiMinor: Double? = null, + val ascendingNode: Double? = null, + val meanMotion: Double? = null, + val meanMotionTLE: Double? = null +) + +data class TleCompareRowDTO( + val code: String, + val name: String, + val first: String?, + val second: String?, + val delta: String? = null +) + +data class TleCompareResponseDTO( + val first: TleParsedDTO, + val second: TleParsedDTO, + val rows: List +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt index ac49885..b268c6c 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt @@ -10,6 +10,8 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO +import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO import space.nstart.pcp.slots_service.configuration.CustomErrorException import java.net.URI import java.time.LocalDateTime @@ -68,6 +70,16 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider { + const state = { + first: null, + second: null, + parseTimers: {} + }; + + document.addEventListener('DOMContentLoaded', () => { + bindEvents(); + updateReference('first', null); + updateReference('second', null); + }); + + function bindEvents() { + const first = el('tle-first-text'); + const second = el('tle-second-text'); + first?.addEventListener('input', () => scheduleParse('first')); + second?.addEventListener('input', () => scheduleParse('second')); + el('tle-compare-submit')?.addEventListener('click', compareTle); + el('tle-compare-clear')?.addEventListener('click', clearPage); + } + + function scheduleParse(side) { + window.clearTimeout(state.parseTimers[side]); + const text = getTleText(side); + if (!text.trim()) { + state[side] = null; + updateReference(side, null); + return; + } + setStatus(side, 'Разбор...', 'text-muted'); + state.parseTimers[side] = window.setTimeout(() => parseTle(side), 350); + } + + async function parseTle(side) { + try { + const parsed = await postJson('/api/tle-comparison/parse', { tle: getTleText(side) }); + state[side] = parsed; + updateReference(side, parsed); + setStatus(side, 'Разобрано', 'tle-parse-ok'); + } catch (error) { + state[side] = null; + updateReference(side, null); + setStatus(side, error.message || 'Ошибка разбора', 'tle-parse-error'); + } + } + + async function compareTle() { + showAlert(''); + const first = getTleText('first'); + const second = getTleText('second'); + if (!first.trim() || !second.trim()) { + showAlert('Введите оба TLE для сравнения.', 'warning'); + return; + } + + setCompareLoading(true); + try { + const response = await postJson('/api/tle-comparison/compare', { first, second }); + state.first = response.first; + state.second = response.second; + updateReference('first', response.first); + updateReference('second', response.second); + setStatus('first', 'Разобрано', 'tle-parse-ok'); + setStatus('second', 'Разобрано', 'tle-parse-ok'); + renderComparison(response.rows || []); + } catch (error) { + showAlert(error.message || 'Не удалось выполнить сравнение TLE.', 'danger'); + } finally { + setCompareLoading(false); + } + } + + function renderComparison(rows) { + const body = el('tle-comparison-result-body'); + const empty = el('tle-comparison-empty'); + const wrap = el('tle-comparison-table-wrap'); + const meta = el('tle-comparison-result-meta'); + if (!body || !empty || !wrap) return; + + if (!rows.length) { + body.innerHTML = ''; + empty.classList.remove('d-none'); + wrap.classList.add('d-none'); + if (meta) meta.textContent = 'Нет данных для сравнения.'; + return; + } + + body.innerHTML = rows.map((row) => ` + + ${escapeHtml(row.name || row.code || '')} + ${escapeHtml(emptyToDash(row.first))} + ${escapeHtml(emptyToDash(row.second))} + ${escapeHtml(emptyToDash(row.delta))} + + `).join(''); + + empty.classList.add('d-none'); + wrap.classList.remove('d-none'); + if (meta) { + const firstEpoch = formatDateTime(state.first?.time); + const secondEpoch = formatDateTime(state.second?.time); + meta.textContent = `Эпохи: TLE 1 — ${firstEpoch || '—'}, TLE 2 — ${secondEpoch || '—'}`; + } + } + + function updateReference(side, parsed) { + setText(`tle-${side}-epoch`, formatDateTime(parsed?.time) || '—'); + setText(`tle-${side}-norad`, parsed?.noradId ?? '—'); + setText(`tle-${side}-revolution`, parsed?.revolution ?? '—'); + if (!parsed) { + const text = getTleText(side); + setStatus(side, text.trim() ? 'Ошибка разбора' : 'Нет данных', text.trim() ? 'tle-parse-error' : 'text-muted'); + } + } + + function clearPage() { + el('tle-first-text').value = ''; + el('tle-second-text').value = ''; + state.first = null; + state.second = null; + updateReference('first', null); + updateReference('second', null); + showAlert(''); + renderComparison([]); + setText('tle-comparison-result-meta', 'Нажмите “Сравнить” после ввода двух TLE.'); + } + + async function postJson(url, payload) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const error = await response.json(); + message = error.error || Object.values(error)[0] || message; + } catch (_) { + message = await response.text() || message; + } + throw new Error(message); + } + return response.json(); + } + + function getTleText(side) { + return el(`tle-${side}-text`)?.value || ''; + } + + function setStatus(side, text, cssClass) { + const node = el(`tle-${side}-status`); + if (!node) return; + node.className = `tle-parse-status ${cssClass || 'text-muted'}`; + node.textContent = text; + } + + function setCompareLoading(loading) { + const button = el('tle-compare-submit'); + if (!button) return; + button.disabled = loading; + button.textContent = loading ? 'Сравнение...' : 'Сравнить'; + } + + function showAlert(message, type = 'info') { + const container = el('tle-comparison-alert'); + if (!container) return; + container.innerHTML = message + ? `` + : ''; + } + + function formatDateTime(value) { + if (!value) return ''; + return String(value).replace('T', ' '); + } + + function emptyToDash(value) { + return value === null || value === undefined || value === '' ? '—' : value; + } + + function deltaClass(value) { + const text = String(value || '').trim(); + if (text.startsWith('+')) return 'tle-delta-positive'; + if (text.startsWith('-')) return 'tle-delta-negative'; + return ''; + } + + function setText(id, value) { + const node = el(id); + if (node) node.textContent = value; + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function el(id) { + return document.getElementById(id); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index f3534ac..b8160a6 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -46,6 +46,7 @@