From f549e15c632c9f1dc310cdd7be31575804788a37 Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 25 May 2026 21:02:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B1=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=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 }