отображение забронированных слотов и тестовые НУ

This commit is contained in:
emelianov
2026-05-25 21:02:50 +03:00
parent d48ddd2657
commit f549e15c63
19 changed files with 1319 additions and 9 deletions
@@ -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,
)
}
@@ -20,4 +20,7 @@ class CatalogPageController {
@GetMapping("/current-plans")
fun currentPlansPage(): String = "current-plans"
@GetMapping("/bookings")
fun bookingsPage(): String = "bookings"
}
@@ -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(),
)
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
})();
@@ -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>
@@ -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
}