отображение забронированных слотов и тестовые НУ
This commit is contained in:
@@ -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()
|
||||
|
||||
Executable
+198
@@ -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"
|
||||
+25
@@ -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<String> = emptyList(),
|
||||
)
|
||||
+18
@@ -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<Long> = emptyList()
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
+88
@@ -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<BookingRequestOptionDTO> { 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<Long>?,
|
||||
): List<BookedSlotViewDTO> {
|
||||
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<BookedSlotViewDTO> { 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,
|
||||
)
|
||||
}
|
||||
+3
@@ -20,4 +20,7 @@ class CatalogPageController {
|
||||
|
||||
@GetMapping("/current-plans")
|
||||
fun currentPlansPage(): String = "current-plans"
|
||||
|
||||
@GetMapping("/bookings")
|
||||
fun bookingsPage(): String = "bookings"
|
||||
}
|
||||
|
||||
+36
@@ -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<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class BookingRequestOptionDTO(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class BookingFilterOptionsDTO(
|
||||
val satellites: List<SatelliteSummaryDTO> = emptyList(),
|
||||
val requests: List<BookingRequestOptionDTO> = emptyList(),
|
||||
)
|
||||
+84
@@ -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<WebClient.Builder>) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun allBooked(): List<BookedSlotDTO> =
|
||||
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<Long>?
|
||||
): List<BookedSlotDetailsDTO> = 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<SlotDTO> =
|
||||
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<BookedSlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
@@ -244,6 +307,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
}
|
||||
.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,
|
||||
|
||||
@@ -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 = '<option value="">Все заявки</option>' + state.requests.map((request) => {
|
||||
const title = request.name ? `${request.name} / ${request.id}` : request.id;
|
||||
return `<option value="${escapeHtml(request.id)}">${escapeHtml(title)}</option>`;
|
||||
}).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 `
|
||||
<tr class="${selected ? 'bookings-selected-row' : ''}" data-satellite-id="${escapeHtml(satellite.id)}">
|
||||
<td><input class="form-check-input" type="checkbox" ${selected ? 'checked' : ''} aria-label="Выбрать спутник"></td>
|
||||
<td class="bookings-id">${escapeHtml(satellite.id ?? '—')}</td>
|
||||
<td>
|
||||
<div class="bookings-main-text">${escapeHtml(satellite.name || satellite.code || 'КА')}</div>
|
||||
<div class="bookings-sub-text">${escapeHtml([satellite.code, satellite.typeCode, satellite.noradId ? `NORAD ${satellite.noradId}` : null].filter(Boolean).join(' / ') || '—')}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).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) => `
|
||||
<tr>
|
||||
<td class="bookings-id">${escapeHtml(booking.bookedSlotId ?? '—')}</td>
|
||||
<td>
|
||||
<div class="bookings-main-text">${escapeHtml(booking.satelliteName || booking.satelliteCode || `КА ${booking.satelliteId}`)}</div>
|
||||
<div class="bookings-sub-text">ID ${escapeHtml(booking.satelliteId)}${booking.satelliteTypeCode ? ` / ${escapeHtml(booking.satelliteTypeCode)}` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Слот ${escapeHtml(booking.slotNum ?? '—')}</div>
|
||||
<div class="bookings-sub-text">Цикл ${escapeHtml(booking.cycle ?? '—')}</div>
|
||||
</td>
|
||||
<td class="bookings-time-cell">
|
||||
<div>${formatDateTime(booking.timeStart)}</div>
|
||||
<div class="bookings-sub-text">${formatDateTime(booking.timeStop)}${booking.durationSeconds != null ? ` / ${formatNumber(booking.durationSeconds)} c` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>${escapeHtml(booking.revolution ?? '—')}</div>
|
||||
<div class="bookings-sub-text">${escapeHtml(booking.revolutionSign || '—')}</div>
|
||||
</td>
|
||||
<td>${formatNumber(booking.roll)}</td>
|
||||
<td><span class="bookings-badge ${statusClass(booking.status)}">${escapeHtml(booking.status || '—')}</span></td>
|
||||
<td>${formatRequestIds(booking.requestIds)}</td>
|
||||
</tr>`).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 `<tr><td colspan="${colspan}" class="catalog-empty">${escapeHtml(text)}</td></tr>`;
|
||||
}
|
||||
|
||||
function formatRequestIds(ids) {
|
||||
if (!Array.isArray(ids) || !ids.length) return '—';
|
||||
return ids.map((id) => `<span class="bookings-badge">${escapeHtml(id)}</span>`).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 = `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-send-time {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-status {
|
||||
display: inline-flex;
|
||||
width: 0.75rem;
|
||||
|
||||
@@ -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 = '<div class="catalog-helper">Выберите спутник слева.</div>';
|
||||
renderExportState(null);
|
||||
renderSendInitialConditionsState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,6 +212,7 @@
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Бронирования</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/bookings.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="bookings-page" class="catalog-page bookings-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Бронирования</h2>
|
||||
<div class="catalog-helper">Табличный просмотр забронированных слотов с фильтрацией по времени, заявке и спутникам.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="bookings-reset" type="button" class="btn btn-outline-secondary">Сбросить</button>
|
||||
<button id="bookings-refresh" type="button" class="btn btn-primary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bookings-alert"></div>
|
||||
|
||||
<div class="card catalog-card bookings-filter-card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-start" class="form-label">Начало интервала</label>
|
||||
<input id="bookings-time-start" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-stop" class="form-label">Конец интервала</label>
|
||||
<input id="bookings-time-stop" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-request" class="form-label">Заявка</label>
|
||||
<select id="bookings-request" class="form-select form-select-sm">
|
||||
<option value="">Все заявки</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-satellite-search" class="form-label">Фильтр спутников</label>
|
||||
<input id="bookings-satellite-search"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 bookings-layout">
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="card catalog-card bookings-satellites-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите один или несколько КА.</div>
|
||||
</div>
|
||||
<button id="bookings-satellites-clear" type="button" class="btn btn-outline-secondary btn-sm">Выбрать все</button>
|
||||
</div>
|
||||
<div class="card-body p-0 bookings-satellites-wrap">
|
||||
<table class="table table-hover mb-0 bookings-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="bookings-check-col"></th>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-9">
|
||||
<div class="card catalog-card bookings-table-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Забронированные слоты</div>
|
||||
<div id="bookings-summary" class="catalog-helper mt-1">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="bookings-empty" class="catalog-empty d-none">Забронированные слоты не найдены.</div>
|
||||
<div id="bookings-table-wrap" class="table-responsive bookings-table-wrap">
|
||||
<table class="table table-hover mb-0 bookings-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Бронь</th>
|
||||
<th>КА</th>
|
||||
<th>Слот / цикл</th>
|
||||
<th>Время</th>
|
||||
<th>Виток</th>
|
||||
<th>Крен</th>
|
||||
<th>Статус</th>
|
||||
<th>Заявки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/bookings_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -71,6 +71,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/current-plans">Текущие планы</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/bookings">Бронирования</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
+40
@@ -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
|
||||
}
|
||||
|
||||
+15
@@ -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<Long>?
|
||||
) = 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}")
|
||||
|
||||
+23
@@ -29,6 +29,29 @@ interface BookedSlotsRepository : JpaRepository<BookedSlotEntity, Long>{
|
||||
@EntityGraph(attributePaths = ["slot"])
|
||||
fun findAllByStatus(status: String): List<BookedSlotEntity>
|
||||
|
||||
@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<Long>,
|
||||
@Param("cycleBegin") cycleBegin: Long,
|
||||
@Param("cycleEnd") cycleEnd: Long
|
||||
): List<BookedSlotEntity>
|
||||
|
||||
@EntityGraph(attributePaths = ["slot", "requests"])
|
||||
@Query("""
|
||||
select distinct bookedSlot
|
||||
from BookedSlotEntity bookedSlot
|
||||
where bookedSlot.slot.satelliteId in :satelliteIds
|
||||
""")
|
||||
fun findBookingDetailsBySatelliteIds(
|
||||
@Param("satelliteIds") satelliteIds: Collection<Long>
|
||||
): List<BookedSlotEntity>
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM booked_slot WHERE booked_slot_id =:id", nativeQuery = true)
|
||||
|
||||
+101
-8
@@ -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<Long>?
|
||||
): List<BookedSlotDetailsDTO> {
|
||||
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<BookedSlotDetailsDTO> { 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 }
|
||||
|
||||
Reference in New Issue
Block a user