pcp-tgu интерфейс

This commit is contained in:
Дмитрий Соловьев
2026-05-29 15:15:02 +03:00
parent e7c2060e2e
commit 4556592e63
12 changed files with 1424 additions and 1 deletions
@@ -6,6 +6,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.support.WebExchangeBindException
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono
class CustomValidationException(message: String) : RuntimeException(message)
@@ -25,6 +26,11 @@ class GlobalExceptionHandler {
return ResponseEntity.internalServerError()
.body(mapOf("error" to ex.message!!))
}
@ExceptionHandler(ResponseStatusException::class)
fun handleStatusError(ex: ResponseStatusException): ResponseEntity<Map<String, String>> {
return ResponseEntity.status(ex.statusCode)
.body(mapOf("error" to (ex.reason ?: ex.message)))
}
@ExceptionHandler(WebExchangeBindException::class)
fun handleValidationExceptions(ex: WebExchangeBindException): Mono<ResponseEntity<Map<String, String>>> {
val errors = mutableMapOf<String, String>()
@@ -43,4 +49,4 @@ class GlobalExceptionHandler {
}
return ResponseEntity.badRequest().body(errors)
}
}
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
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.slots_service.dto.tgu.TguPlanDecision
import space.nstart.pcp.slots_service.service.TguPlanningService
import java.util.UUID
@Controller
class TguPlanningPageController {
@GetMapping("/tgu-planning")
fun tguPlanningPage(): String = "tgu-planning"
}
@RestController
@RequestMapping("/api/tgu-planning")
class TguPlanningRestController(
private val tguPlanningService: TguPlanningService
) {
@GetMapping("/platforms")
fun platforms() = tguPlanningService.platforms()
@GetMapping("/plans")
fun plans(@RequestParam(defaultValue = "7") historyDays: Long) =
tguPlanningService.plans(historyDays)
@GetMapping("/spacecraft/{spacecraftId}/plans")
fun spacecraftPlans(
@PathVariable spacecraftId: String,
@RequestParam(defaultValue = "7") historyDays: Long
) = tguPlanningService.spacecraftPlans(spacecraftId, historyDays)
@PostMapping("/plans/{planId}/decision")
fun sendDecision(
@PathVariable planId: UUID,
@RequestParam decision: TguPlanDecision,
@RequestParam(required = false) reason: String?
) = tguPlanningService.sendDecision(planId, decision, reason)
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.slots_service.dto.tgu
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
data class TguPlatformResponse(
val id: String?,
val businessKey: String?,
val name: String?,
val status: String?,
val noradId: Long?,
val mission: String?,
val validFrom: Instant?,
val validTo: Instant?
)
data class TguPlanResponse(
val planId: UUID,
val spacecraftId: String,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val kppId: String,
val status: String
)
enum class TguPlanDecision {
ACCEPTED,
REJECTED
}
data class TguPlanDecisionMessage(
val eventId: UUID,
val spacecraftId: String,
val planId: UUID,
val attemptId: UUID,
val decision: TguPlanDecision,
val decisionTime: LocalDateTime,
val reason: String?
)
@@ -0,0 +1,154 @@
package space.nstart.pcp.slots_service.service
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientRequestException
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.util.UriComponentsBuilder
import reactor.core.publisher.Mono
import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision
import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecisionMessage
import space.nstart.pcp.slots_service.dto.tgu.TguPlanResponse
import space.nstart.pcp.slots_service.dto.tgu.TguPlatformResponse
import tools.jackson.databind.ObjectMapper
import java.util.UUID
@Service
class TguPlanningService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.tgu-service:pcp-tgu-service}")
private lateinit var tguServiceUrl: String
fun platforms(): List<TguPlatformResponse> =
webClientBuilder.build()
.get()
.uri(buildUri("/api/platforms"))
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToFlux(TguPlatformResponse::class.java)
.collectList()
.block()
?: emptyList()
fun plans(historyDays: Long): List<TguPlanResponse> =
webClientBuilder.build()
.get()
.uri(buildUri("/api/plans") { builder ->
builder.queryParam("historyDays", historyDays)
})
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToFlux(TguPlanResponse::class.java)
.collectList()
.block()
?: emptyList()
fun spacecraftPlans(spacecraftId: String, historyDays: Long): List<TguPlanResponse> =
webClientBuilder.build()
.get()
.uri(buildUri("/api/plans/{spacecraftId}") { builder ->
builder.queryParam("historyDays", historyDays)
.buildAndExpand(spacecraftId)
})
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToFlux(TguPlanResponse::class.java)
.collectList()
.block()
?: emptyList()
fun sendDecision(planId: UUID, decision: TguPlanDecision, reason: String?): TguPlanDecisionMessage =
runCatching {
webClientBuilder.build()
.post()
.uri(buildUri("/test/tgu/plans/{planId}/decision") { builder ->
builder.queryParam("decision", decision.name)
reason?.takeIf { it.isNotBlank() }?.let { builder.queryParam("reason", it) }
builder.buildAndExpand(planId)
})
.retrieve()
.onStatus({ status -> status.isError }, ::mapDecisionError)
.bodyToMono(TguPlanDecisionMessage::class.java)
.block()
?: throw ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
TEST_CONTROLLER_UNAVAILABLE_MESSAGE
)
}.getOrElse { error ->
if (error is WebClientRequestException) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, TEST_CONTROLLER_UNAVAILABLE_MESSAGE, error)
}
throw error
}
private fun buildUri(
path: String,
customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() }
): java.net.URI {
val builder = UriComponentsBuilder.fromUriString(tguServiceUrl.trimEnd('/')).path(path)
val result = customizer(builder)
return when (result) {
is java.net.URI -> result
is org.springframework.web.util.UriComponents -> result.toUri()
else -> builder.build().toUri()
}
}
private fun mapReadError(response: ClientResponse): Mono<Throwable> =
response.bodyToMono(String::class.java)
.defaultIfEmpty("")
.map { body ->
ResponseStatusException(response.statusCode(), extractErrorMessage(body, "Ошибка pcp-tgu-service"))
}
private fun mapDecisionError(response: ClientResponse): Mono<Throwable> =
response.bodyToMono(String::class.java)
.defaultIfEmpty("")
.map { body ->
ResponseStatusException(response.statusCode(), decisionErrorMessage(response.statusCode(), body))
}
private fun decisionErrorMessage(status: HttpStatusCode, body: String): String =
when (status.value()) {
404 -> {
val message = extractErrorMessage(body, "")
if (message.contains("Plan", ignoreCase = true) || message.contains("planId", ignoreCase = true)) {
PLAN_NOT_FOUND_MESSAGE
} else {
TEST_CONTROLLER_UNAVAILABLE_MESSAGE
}
}
409 -> PLAN_NOT_WAITING_MESSAGE
in 500..599 -> TEST_CONTROLLER_UNAVAILABLE_MESSAGE
else -> extractErrorMessage(body, "Не удалось отправить тестовое решение по плану.")
}
private fun extractErrorMessage(body: String, defaultMessage: String): String {
val text = body.trim()
if (text.isEmpty()) return defaultMessage
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("reason") -> node["reason"].asText()
node.hasNonNull("message") -> node["message"].asText()
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("detail") -> node["detail"].asText()
else -> text
}
}.getOrDefault(text)
}
private companion object {
const val PLAN_NOT_FOUND_MESSAGE = "План не найден."
const val PLAN_NOT_WAITING_MESSAGE =
"План не является активным планом, ожидающим решения, или для него нет active attempt."
const val TEST_CONTROLLER_UNAVAILABLE_MESSAGE = "Test controller недоступен или отключён."
}
}
@@ -0,0 +1,169 @@
.tgu-planning-tabs {
margin-bottom: -0.75rem;
}
.tgu-chart-controls {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid #dbe5ef;
}
.tgu-chart-controls .form-control {
min-width: 12.5rem;
}
.tgu-chart-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.85rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #526b83;
font-size: 0.82rem;
}
.tgu-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.tgu-legend-note {
color: #687f96;
}
.tgu-legend-swatch {
display: inline-block;
width: 1rem;
height: 0.45rem;
border-radius: 999px;
background: #8d99a6;
}
.tgu-status-accepted {
background: #198754;
}
.tgu-status-rejected,
.tgu-status-ambiguous {
background: #dc3545;
}
.tgu-status-planned {
background: #8d99a6;
}
.tgu-status-waiting {
background: #f0ad2e;
}
.tgu-status-issuing {
background: #0d6efd;
}
.tgu-status-expired {
background: #495057;
}
.tgu-status-superseded {
background: #c7ced6;
}
.tgu-status-ambiguous {
border: 1px dashed #7d1f2a;
}
.tgu-timeline-scroll {
max-height: 64vh;
overflow-y: auto;
overflow-x: hidden;
border-top: 1px solid #edf2f7;
background: #fbfdff;
}
.tgu-timeline-content {
min-width: 0;
}
.tgu-timeline-svg {
display: block;
width: 100%;
min-height: 12rem;
background: #fbfdff;
}
.tgu-timeline-label {
fill: #314963;
font-size: 12px;
font-weight: 600;
}
.tgu-timeline-sub-label {
fill: #72879d;
font-size: 10px;
}
.tgu-timeline-axis,
.tgu-timeline-grid {
stroke: #cdd9e5;
stroke-width: 1;
}
.tgu-timeline-grid {
stroke-dasharray: 3 4;
}
.tgu-timeline-tick-label {
fill: #5d7288;
font-size: 11px;
}
.tgu-timeline-plan {
cursor: pointer;
stroke-linecap: round;
}
.tgu-timeline-plan:hover,
.tgu-timeline-plan-selected {
filter: drop-shadow(0 0 4px rgba(24, 80, 140, 0.45));
}
.tgu-sequence-arrow {
stroke: #8ea1b5;
stroke-width: 1.6;
fill: none;
}
.tgu-plan-details {
border-top: 1px solid #dbe5ef;
padding: 1rem;
background: #ffffff;
}
.tgu-plan-details:empty {
display: none;
}
.tgu-plan-details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.75rem;
}
.tgu-plan-detail-label {
color: #6b7f95;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.tgu-plan-detail-value {
color: #24364a;
font-size: 0.92rem;
overflow-wrap: anywhere;
}
@@ -0,0 +1,667 @@
(() => {
const DAY_MS = 24 * 60 * 60 * 1000;
const TIMELINE_LEFT_WIDTH = 180;
const TIMELINE_RIGHT_PADDING = 24;
const TIMELINE_TOP_PADDING = 24;
const TIMELINE_ROW_HEIGHT = 54;
const TIMELINE_AXIS_HEIGHT = 52;
const TIMELINE_MIN_WIDTH = 760;
const STATUS_COLORS = {
ACCEPTED: '#198754',
REJECTED: '#dc3545',
PLANNED: '#8d99a6',
WAITING_DECISION: '#f0ad2e',
ISSUING: '#0d6efd',
EXPIRED: '#495057',
SUPERSEDED: '#c7ced6',
START_AMBIGUOUS: '#dc3545'
};
const state = {
platforms: [],
plans: [],
selectedNoradId: null,
activeTab: 'table',
loadedHistoryDays: null,
selectedPlanId: null
};
const el = (id) => document.getElementById(id);
document.addEventListener('DOMContentLoaded', () => {
setDefaultTimelineInterval();
bindEvents();
reloadAll();
});
function bindEvents() {
el('tgu-refresh')?.addEventListener('click', () => reloadAll());
el('tgu-show-all')?.addEventListener('click', () => {
state.selectedNoradId = null;
state.selectedPlanId = null;
renderPlatforms();
updateSelectedPlatformText();
reloadPlans();
});
el('tgu-history-days')?.addEventListener('change', () => reloadPlans());
document.querySelectorAll('[data-tgu-tab]').forEach((button) => {
button.addEventListener('click', () => switchTab(button.dataset.tguTab));
});
el('tgu-chart-show')?.addEventListener('click', () => applyTimelineInterval());
document.querySelectorAll('[data-tgu-range-hours]').forEach((button) => {
button.addEventListener('click', () => setQuickTimelineRange(Number(button.dataset.tguRangeHours)));
});
window.addEventListener('resize', () => {
if (state.activeTab === 'chart') renderTimeline();
});
}
async function reloadAll() {
showAlert('');
await loadPlatforms();
await reloadPlans();
}
async function loadPlatforms() {
setTableLoading('tgu-platforms-body', 4, 'Загрузка платформ...');
try {
const platforms = await fetchJson('/api/tgu-planning/platforms');
state.platforms = Array.isArray(platforms) ? platforms : [];
renderPlatforms();
renderTimeline();
} catch (error) {
state.platforms = [];
renderPlatforms();
renderTimeline();
showAlert(error.message || 'Не удалось загрузить платформы.', 'danger');
}
}
async function reloadPlans(historyDaysOverride = null) {
const historyDays = historyDaysOverride || historyDaysForCurrentView();
const url = state.selectedNoradId
? `/api/tgu-planning/spacecraft/${encodeURIComponent(state.selectedNoradId)}/plans?historyDays=${historyDays}`
: `/api/tgu-planning/plans?historyDays=${historyDays}`;
setTableLoading('tgu-plans-body', 8, 'Загрузка планов...');
setTimelineLoading('Загрузка планов.');
el('tgu-plans-meta').textContent = state.selectedNoradId
? `Планы для NORAD ${state.selectedNoradId}, история ${historyDays} дн.`
: `Все планы, история ${historyDays} дн.`;
try {
const plans = await fetchJson(url);
state.loadedHistoryDays = historyDays;
state.plans = Array.isArray(plans) ? plans : [];
if (state.selectedPlanId && !state.plans.some((plan) => String(plan.planId) === String(state.selectedPlanId))) {
state.selectedPlanId = null;
}
renderPlans();
renderTimeline();
renderSelectedPlanDetails();
} catch (error) {
state.plans = [];
state.selectedPlanId = null;
renderPlans();
renderTimeline();
renderSelectedPlanDetails();
showAlert(error.message || 'Не удалось загрузить планы.', 'danger');
}
}
function switchTab(tab) {
if (!['table', 'chart'].includes(tab)) return;
state.activeTab = tab;
document.querySelectorAll('[data-tgu-tab]').forEach((button) => {
button.classList.toggle('active', button.dataset.tguTab === tab);
});
el('tgu-table-panel')?.classList.toggle('d-none', tab !== 'table');
el('tgu-chart-panel')?.classList.toggle('d-none', tab !== 'chart');
if (tab === 'chart') {
applyTimelineInterval();
}
}
function renderPlatforms() {
const tbody = el('tgu-platforms-body');
if (!tbody) return;
if (!state.platforms.length) {
tbody.innerHTML = emptyRow(4, 'Платформы не найдены.');
return;
}
tbody.innerHTML = state.platforms.map((platform) => {
const selected = String(platform.noradId || '') === String(state.selectedNoradId || '') ? ' table-active' : '';
const norad = platform.noradId ?? '—';
return `
<tr class="${selected}" data-norad-id="${escapeHtml(platform.noradId || '')}">
<td>${escapeHtml(platform.name || platform.businessKey || platform.id || '—')}</td>
<td>${escapeHtml(norad)}</td>
<td><span class="badge text-bg-light">${escapeHtml(platform.status || '—')}</span></td>
<td>${escapeHtml(platform.mission || '—')}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('tr[data-norad-id]').forEach((row) => {
row.addEventListener('click', () => selectPlatform(row.dataset.noradId));
});
}
function selectPlatform(noradId) {
if (!noradId) {
showAlert('У выбранной платформы нет NORAD ID, фильтрация планов невозможна.', 'warning');
return;
}
state.selectedNoradId = noradId;
state.selectedPlanId = null;
renderPlatforms();
updateSelectedPlatformText();
reloadPlans();
}
function renderPlans() {
const tbody = el('tgu-plans-body');
if (!tbody) return;
if (!state.plans.length) {
tbody.innerHTML = emptyRow(8, 'Планы не найдены.');
return;
}
tbody.innerHTML = state.plans.map((plan) => {
const platform = platformBySpacecraftId(plan.spacecraftId);
return `
<tr>
<td title="${escapeHtml(plan.planId)}">${shortUuid(plan.planId)}</td>
<td>${escapeHtml(plan.spacecraftId || '—')}</td>
<td>${escapeHtml(platform?.name || '—')}</td>
<td>${formatDateTime(plan.startTime)}</td>
<td>${formatDateTime(plan.endTime)}</td>
<td>${escapeHtml(plan.kppId || '—')}</td>
<td><span class="badge text-bg-light">${escapeHtml(plan.status || '—')}</span></td>
<td>${planActions(plan)}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('button[data-decision]').forEach((button) => {
button.addEventListener('click', () => sendDecision(
button.dataset.planId,
button.dataset.decision,
button
));
});
}
function applyTimelineInterval() {
const interval = readTimelineInterval();
if (!interval.valid) {
renderTimelineError(interval.message);
return;
}
const requiredHistoryDays = historyDaysForTimelineFrom(interval.from);
if (!state.loadedHistoryDays || requiredHistoryDays > state.loadedHistoryDays) {
reloadPlans(requiredHistoryDays);
return;
}
renderTimeline();
renderSelectedPlanDetails();
}
function setQuickTimelineRange(hours) {
if (!Number.isFinite(hours) || hours <= 0) return;
const from = startOfToday();
const to = new Date(from.getTime() + hours * 60 * 60 * 1000);
el('tgu-chart-from').value = toDatetimeLocalValue(from);
el('tgu-chart-to').value = toDatetimeLocalValue(to);
applyTimelineInterval();
}
function renderTimeline() {
if (!el('tgu-timeline-content')) return;
const interval = readTimelineInterval();
if (!interval.valid) {
renderTimelineError(interval.message);
return;
}
clearTimelineError();
const visiblePlans = state.plans
.map((plan) => ({plan, start: parsePlanDate(plan.startTime), end: parsePlanDate(plan.endTime)}))
.filter((item) => item.start && item.end && item.end.getTime() >= interval.from.getTime() && item.start.getTime() <= interval.to.getTime())
.sort((left, right) => {
const spacecraftCompare = String(left.plan.spacecraftId || '').localeCompare(String(right.plan.spacecraftId || ''));
return spacecraftCompare || left.start.getTime() - right.start.getTime();
});
if (!visiblePlans.length) {
el('tgu-timeline-content').innerHTML = '<div class="text-muted text-center py-4">Нет планов в выбранном интервале</div>';
return;
}
const grouped = groupBySpacecraft(visiblePlans);
const scroll = el('tgu-timeline-scroll');
const svgWidth = Math.max(scroll?.clientWidth || TIMELINE_MIN_WIDTH, TIMELINE_MIN_WIDTH);
const chartWidth = svgWidth - TIMELINE_LEFT_WIDTH - TIMELINE_RIGHT_PADDING;
const svgHeight = TIMELINE_TOP_PADDING + grouped.length * TIMELINE_ROW_HEIGHT + TIMELINE_AXIS_HEIGHT;
const axisY = TIMELINE_TOP_PADDING + grouped.length * TIMELINE_ROW_HEIGHT + 14;
const ticks = timelineTicks(interval.from, interval.to);
const intervalMs = interval.to.getTime() - interval.from.getTime();
const xForTime = (time) => {
// Координата считается от выбранного интервала, а не от фактически загруженной истории.
return TIMELINE_LEFT_WIDTH + ((time.getTime() - interval.from.getTime()) / intervalMs) * chartWidth;
};
const rows = grouped.map((group, index) => {
const rowY = TIMELINE_TOP_PADDING + index * TIMELINE_ROW_HEIGHT + 24;
const label = spacecraftLabel(group.spacecraftId);
const subLabel = label === String(group.spacecraftId) ? '' : String(group.spacecraftId);
const sortedPlans = group.items.sort((left, right) => left.start.getTime() - right.start.getTime());
const planLines = sortedPlans.map((item) => {
const x1 = Math.max(TIMELINE_LEFT_WIDTH, xForTime(item.start));
const x2 = Math.min(TIMELINE_LEFT_WIDTH + chartWidth, xForTime(item.end));
const widthAdjustedX2 = x2 - x1 < 4 ? Math.min(TIMELINE_LEFT_WIDTH + chartWidth, x1 + 4) : x2;
const status = String(item.plan.status || '');
const selected = String(item.plan.planId) === String(state.selectedPlanId);
const dashArray = status === 'START_AMBIGUOUS' ? '7 5' : '';
return `
<line class="tgu-timeline-plan${selected ? ' tgu-timeline-plan-selected' : ''}"
x1="${x1.toFixed(2)}" y1="${rowY}" x2="${widthAdjustedX2.toFixed(2)}" y2="${rowY}"
stroke="${escapeHtml(statusColor(status))}" stroke-width="${selected ? 15 : 11}"
${dashArray ? `stroke-dasharray="${dashArray}"` : ''}
data-plan-id="${escapeHtml(item.plan.planId)}">
<title>${escapeHtml(planTooltip(item.plan))}</title>
</line>`;
}).join('');
const arrows = sortedPlans.slice(0, -1).map((item, planIndex) => {
const next = sortedPlans[planIndex + 1];
const startX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 8, Math.max(TIMELINE_LEFT_WIDTH + 8, xForTime(item.end) + 7));
const endX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 4, Math.max(TIMELINE_LEFT_WIDTH + 12, xForTime(next.start) - 7));
if (endX - startX >= 16) {
return `<line class="tgu-sequence-arrow" x1="${startX.toFixed(2)}" y1="${rowY - 13}" x2="${endX.toFixed(2)}" y2="${rowY - 13}" marker-end="url(#tgu-arrowhead)" />`;
}
const markerX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 20, Math.max(TIMELINE_LEFT_WIDTH + 20, xForTime(next.start)));
return `<path class="tgu-sequence-arrow" d="M ${markerX - 12} ${rowY - 16} C ${markerX - 2} ${rowY - 26}, ${markerX + 16} ${rowY - 22}, ${markerX + 12} ${rowY - 8}" marker-end="url(#tgu-arrowhead)" />`;
}).join('');
return `
<g>
<line class="tgu-timeline-grid" x1="${TIMELINE_LEFT_WIDTH}" y1="${rowY + 24}" x2="${TIMELINE_LEFT_WIDTH + chartWidth}" y2="${rowY + 24}" />
<text class="tgu-timeline-label" x="12" y="${rowY - 4}">
<title>${escapeHtml(String(group.spacecraftId))}</title>${escapeHtml(label)}
</text>
${subLabel ? `<text class="tgu-timeline-sub-label" x="12" y="${rowY + 12}">${escapeHtml(subLabel)}</text>` : ''}
${arrows}
${planLines}
</g>`;
}).join('');
const tickMarkup = ticks.map((tick) => {
const x = xForTime(tick);
return `
<g>
<line class="tgu-timeline-grid" x1="${x.toFixed(2)}" y1="${TIMELINE_TOP_PADDING - 8}" x2="${x.toFixed(2)}" y2="${axisY}" />
<line class="tgu-timeline-axis" x1="${x.toFixed(2)}" y1="${axisY}" x2="${x.toFixed(2)}" y2="${axisY + 6}" />
<text class="tgu-timeline-tick-label" x="${x.toFixed(2)}" y="${axisY + 22}" text-anchor="middle">${escapeHtml(formatAxisTick(tick))}</text>
</g>`;
}).join('');
el('tgu-timeline-content').innerHTML = `
<svg class="tgu-timeline-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="img" aria-label="Последовательность планов по времени">
<defs>
<marker id="tgu-arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,4 L0,8 Z" fill="#8ea1b5"></path>
</marker>
</defs>
<text class="tgu-timeline-label" x="${TIMELINE_LEFT_WIDTH}" y="16">Последовательность планов по времени</text>
${tickMarkup}
<line class="tgu-timeline-axis" x1="${TIMELINE_LEFT_WIDTH}" y1="${axisY}" x2="${TIMELINE_LEFT_WIDTH + chartWidth}" y2="${axisY}" />
${rows}
</svg>`;
el('tgu-timeline-content').querySelectorAll('[data-plan-id]').forEach((line) => {
line.addEventListener('click', () => {
state.selectedPlanId = line.dataset.planId;
renderTimeline();
renderSelectedPlanDetails();
});
});
}
function renderSelectedPlanDetails() {
const target = el('tgu-plan-details');
if (!target) return;
const plan = state.plans.find((item) => String(item.planId) === String(state.selectedPlanId));
if (!plan) {
target.innerHTML = '';
return;
}
const platform = platformBySpacecraftId(plan.spacecraftId);
target.innerHTML = `
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<div class="catalog-section-title">Детали плана</div>
<div class="catalog-helper mt-1">${escapeHtml(platform?.name || plan.spacecraftId || '—')}</div>
</div>
${planActions(plan)}
</div>
<div class="tgu-plan-details-grid">
${detailItem('planId', plan.planId)}
${detailItem('spacecraftId', plan.spacecraftId)}
${detailItem('startTime', formatDateTime(plan.startTime))}
${detailItem('endTime', formatDateTime(plan.endTime))}
${detailItem('kppId', plan.kppId || '—')}
${detailItem('status', plan.status || '—')}
</div>`;
target.querySelectorAll('button[data-decision]').forEach((button) => {
button.addEventListener('click', () => sendDecision(
button.dataset.planId,
button.dataset.decision,
button
));
});
}
function renderTimelineError(message) {
const error = el('tgu-chart-error');
if (error) {
error.textContent = message;
error.classList.remove('d-none');
}
const target = el('tgu-timeline-content');
if (target) target.innerHTML = '';
}
function clearTimelineError() {
const error = el('tgu-chart-error');
if (!error) return;
error.textContent = '';
error.classList.add('d-none');
}
function setTimelineLoading(message) {
const target = el('tgu-timeline-content');
if (target) target.innerHTML = `<div class="text-muted text-center py-4">${escapeHtml(message)}</div>`;
}
function planActions(plan) {
if (plan.status !== 'WAITING_DECISION') return '<span class="text-muted">—</span>';
const planId = escapeHtml(plan.planId);
return `
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-success btn-sm" data-decision="ACCEPTED" data-plan-id="${planId}">
Принять
</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-decision="REJECTED" data-plan-id="${planId}">
Отклонить
</button>
</div>`;
}
async function sendDecision(planId, decision, button) {
let reason = decision === 'ACCEPTED' ? 'UI_TEST_ACCEPTED' : '';
if (decision === 'REJECTED') {
reason = window.prompt('Причина отклонения плана', 'UI_TEST_REJECTED');
if (reason === null) return;
reason = reason.trim();
if (!reason) {
showAlert('Для REJECTED укажите reason.', 'warning');
return;
}
}
setButtonLoading(button, true, 'Отправка...');
try {
const query = new URLSearchParams({decision, reason});
const result = await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, {
method: 'POST',
headers: {'Accept': 'application/json'},
decisionRequest: true
});
const attempt = result?.attemptId ? ` attemptId=${result.attemptId}` : '';
const message = decision === 'REJECTED'
? `Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи.${attempt}`
: `Тестовое решение ACCEPTED отправлено.${attempt}`;
showAlert(message, decision === 'REJECTED' ? 'warning' : 'success');
await reloadPlans(state.loadedHistoryDays || selectedHistoryDays());
} catch (error) {
showAlert(error.message || 'Не удалось отправить тестовое решение по плану.', 'danger');
} finally {
setButtonLoading(button, false);
}
}
function selectedHistoryDays() {
const value = Number(el('tgu-history-days')?.value || 7);
return [1, 3, 7, 14].includes(value) ? value : 7;
}
function historyDaysForCurrentView() {
const selected = selectedHistoryDays();
if (state.activeTab !== 'chart') return selected;
const interval = readTimelineInterval();
return interval.valid ? Math.max(selected, historyDaysForTimelineFrom(interval.from)) : selected;
}
function historyDaysForTimelineFrom(from) {
const now = new Date();
if (from.getTime() >= now.getTime()) return 1;
return Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS));
}
function setDefaultTimelineInterval() {
const from = startOfToday();
const to = new Date(from.getTime() + DAY_MS);
if (el('tgu-chart-from')) el('tgu-chart-from').value = toDatetimeLocalValue(from);
if (el('tgu-chart-to')) el('tgu-chart-to').value = toDatetimeLocalValue(to);
}
function readTimelineInterval() {
const from = parseDatetimeLocalValue(el('tgu-chart-from')?.value);
const to = parseDatetimeLocalValue(el('tgu-chart-to')?.value);
if (!from || !to) return {valid: false, message: 'Укажите from и to.'};
if (from.getTime() >= to.getTime()) return {valid: false, message: 'from должен быть раньше to.'};
return {valid: true, from, to};
}
function parseDatetimeLocalValue(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function parsePlanDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function startOfToday() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
}
function toDatetimeLocalValue(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 timelineTicks(from, to) {
const duration = to.getTime() - from.getTime();
const tickCount = duration <= DAY_MS ? 6 : 7;
const ticks = [];
for (let index = 0; index <= tickCount; index += 1) {
ticks.push(new Date(from.getTime() + (duration * index) / tickCount));
}
return ticks;
}
function groupBySpacecraft(items) {
const groups = new Map();
items.forEach((item) => {
const spacecraftId = item.plan.spacecraftId || '—';
if (!groups.has(spacecraftId)) groups.set(spacecraftId, []);
groups.get(spacecraftId).push(item);
});
return Array.from(groups.entries()).map(([spacecraftId, groupItems]) => ({spacecraftId, items: groupItems}));
}
function spacecraftLabel(spacecraftId) {
const platform = platformBySpacecraftId(spacecraftId);
return platform?.name || String(spacecraftId || '—');
}
function statusColor(status) {
return STATUS_COLORS[status] || '#8d99a6';
}
function planTooltip(plan) {
return [
`planId: ${plan.planId || '—'}`,
`spacecraftId: ${plan.spacecraftId || '—'}`,
`startTime: ${plan.startTime || '—'}`,
`endTime: ${plan.endTime || '—'}`,
`kppId: ${plan.kppId || '—'}`,
`status: ${plan.status || '—'}`
].join('\n');
}
function detailItem(label, value) {
return `
<div>
<div class="tgu-plan-detail-label">${escapeHtml(label)}</div>
<div class="tgu-plan-detail-value">${escapeHtml(value)}</div>
</div>`;
}
function updateSelectedPlatformText() {
if (!state.selectedNoradId) {
el('tgu-planning-selected-platform').textContent = 'Все планы по доступным платформам.';
return;
}
const platform = platformBySpacecraftId(state.selectedNoradId);
const name = platform?.name || 'платформа';
el('tgu-planning-selected-platform').textContent = `${name}, NORAD ${state.selectedNoradId}.`;
}
function platformBySpacecraftId(spacecraftId) {
return state.platforms.find((platform) => String(platform.noradId) === String(spacecraftId)) || null;
}
async function fetchJson(url, options = {}) {
const decisionRequest = Boolean(options.decisionRequest);
const requestOptions = {...options};
delete requestOptions.decisionRequest;
const response = await fetch(
url,
requestOptions.headers ? requestOptions : {...requestOptions, headers: {'Accept': 'application/json'}}
);
if (!response.ok) {
const text = await response.text();
throw new Error(errorMessage(response.status, text, decisionRequest));
}
if (response.status === 204) return null;
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function errorMessage(status, text, decisionRequest) {
if (decisionRequest && status === 409) {
return 'План не является активным планом, ожидающим решения, или для него нет active attempt.';
}
if (decisionRequest && status === 404) {
const extracted = extractError(text);
return extracted === 'Test controller недоступен или отключён.' ? extracted : 'План не найден.';
}
if (decisionRequest && status >= 500) {
const extracted = extractError(text);
return extracted || 'Test controller недоступен или отключён.';
}
return extractError(text) || `HTTP ${status}`;
}
function extractError(text) {
if (!text) return '';
try {
const json = JSON.parse(text);
return json.reason || json.message || json.error || json.detail || text;
} catch (_) {
return text;
}
}
function showAlert(message, type = 'danger') {
const target = el('tgu-planning-alert');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
target.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="Close"></button>
</div>`;
}
function setTableLoading(bodyId, colspan, message) {
const tbody = el(bodyId);
if (tbody) tbody.innerHTML = emptyRow(colspan, message);
}
function emptyRow(colspan, message) {
return `<tr><td colspan="${colspan}" class="text-muted text-center py-4">${escapeHtml(message)}</td></tr>`;
}
function shortUuid(value) {
if (!value) return '—';
return `${String(value).slice(0, 8)}...`;
}
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 formatAxisTick(value) {
return value.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function setButtonLoading(button, loading, text = 'Загрузка...') {
if (!button) return;
if (loading) {
button.dataset.originalText = button.textContent;
button.textContent = text;
button.disabled = true;
} else {
button.textContent = button.dataset.originalText || button.textContent;
button.disabled = false;
}
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
@@ -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="/tgu-planning">Планирование ТГУ</a>
</li>
</ul>
</div>
@@ -0,0 +1,149 @@
<!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/tgu-planning.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="tgu-planning-page" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Планирование ТГУ</h2>
<div id="tgu-planning-selected-platform" class="catalog-helper">Все планы по доступным платформам.</div>
</div>
<div class="d-flex align-items-end gap-2 flex-wrap">
<div>
<label for="tgu-history-days" class="form-label mb-1">История, дней</label>
<select id="tgu-history-days" class="form-select form-select-sm">
<option value="1">1</option>
<option value="3">3</option>
<option value="7" selected>7</option>
<option value="14">14</option>
</select>
</div>
<button id="tgu-show-all" type="button" class="btn btn-outline-secondary btn-sm">Все планы</button>
<button id="tgu-refresh" type="button" class="btn btn-primary btn-sm">Обновить</button>
</div>
</div>
<div id="tgu-planning-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card catalog-card h-100">
<div class="card-header">
<div class="catalog-section-title">Платформы</div>
<div class="catalog-helper mt-1">Выберите строку с NORAD для фильтрации планов.</div>
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Платформа</th>
<th>NORAD</th>
<th>Статус</th>
<th>Миссия</th>
</tr>
</thead>
<tbody id="tgu-platforms-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card catalog-card h-100">
<div class="card-header">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<div class="catalog-section-title">Планы ТГУ</div>
<div id="tgu-plans-meta" class="catalog-helper mt-1">Загрузка планов.</div>
</div>
<ul class="nav nav-tabs card-header-tabs tgu-planning-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button id="tgu-tab-table" type="button" class="nav-link active" data-tgu-tab="table" role="tab">
Таблица
</button>
</li>
<li class="nav-item" role="presentation">
<button id="tgu-tab-chart" type="button" class="nav-link" data-tgu-tab="chart" role="tab">
График
</button>
</li>
</ul>
</div>
</div>
<div class="card-body p-0">
<div id="tgu-table-panel" class="tgu-tab-panel">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>planId</th>
<th>spacecraftId</th>
<th>Платформа</th>
<th>Начало</th>
<th>Конец</th>
<th>kppId</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tgu-plans-body"></tbody>
</table>
</div>
</div>
<div id="tgu-chart-panel" class="tgu-tab-panel d-none">
<div class="tgu-chart-controls">
<div>
<label for="tgu-chart-from" class="form-label mb-1">from</label>
<input id="tgu-chart-from" type="datetime-local" class="form-control form-control-sm"/>
</div>
<div>
<label for="tgu-chart-to" class="form-label mb-1">to</label>
<input id="tgu-chart-to" type="datetime-local" class="form-control form-control-sm"/>
</div>
<button id="tgu-chart-show" type="button" class="btn btn-primary btn-sm">Показать</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Быстрый выбор интервала">
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="24">24ч</button>
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="72">3 суток</button>
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="168">7 суток</button>
</div>
</div>
<div id="tgu-chart-error" class="text-danger small px-3 pb-2 d-none"></div>
<div class="tgu-chart-legend px-3">
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-accepted"></span>ACCEPTED</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-rejected"></span>REJECTED</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-planned"></span>PLANNED</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-waiting"></span>WAITING_DECISION</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-issuing"></span>ISSUING</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-expired"></span>EXPIRED</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-superseded"></span>SUPERSEDED</span>
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-ambiguous"></span>START_AMBIGUOUS</span>
<span class="tgu-legend-note">Стрелки показывают следующий план того же КА по времени.</span>
</div>
<div id="tgu-timeline-scroll" class="tgu-timeline-scroll">
<div id="tgu-timeline-content" class="tgu-timeline-content text-muted text-center py-4">
Загрузка планов.
</div>
</div>
<div id="tgu-plan-details" class="tgu-plan-details"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/tgu_planning_scripts.js"></script>
</th:block>
</body>
</html>
@@ -26,6 +26,7 @@ import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO
import space.nstart.pcp.slots_service.dto.tgu.TguPlanResponse
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
@@ -38,6 +39,7 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import space.nstart.pcp.slots_service.service.TguPlanningService
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
@@ -72,6 +74,9 @@ class CatalogControllerTest {
@MockitoBean
private lateinit var satellitePdcmService: SatellitePdcmService
@MockitoBean
private lateinit var tguPlanningService: TguPlanningService
@Test
fun `groups page is rendered`() {
val body = WebClient.create("http://localhost:$port")
@@ -155,6 +160,48 @@ class CatalogControllerTest {
assertFalse(body.contains("<th>UUID</th>"))
}
@Test
fun `tgu planning page is rendered from menu`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/tgu-planning")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Планирование ТГУ"))
assertTrue(body.contains("/tgu_planning_scripts.js"))
assertTrue(body.contains("href=\"/tgu-planning\""))
}
@Test
fun `tgu spacecraft plans endpoint proxies norad spacecraft id`() {
val planId = UUID.fromString("6d0941e4-61d6-4f96-8fa8-90ba11db121e")
val response = listOf(
TguPlanResponse(
planId = planId,
spacecraftId = "56756",
startTime = LocalDateTime.of(2026, 5, 29, 10, 0),
endTime = LocalDateTime.of(2026, 5, 29, 10, 15),
kppId = "KPP-1",
status = "WAITING_DECISION"
)
)
doReturn(response).`when`(tguPlanningService).spacecraftPlans("56756", 14L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/tgu-planning/spacecraft/56756/plans?historyDays=14")
.retrieve()
.bodyToFlux(TguPlanResponse::class.java)
.collectList()
.block()!!
assertEquals(planId, actual.single().planId)
assertEquals("56756", actual.single().spacecraftId)
verify(tguPlanningService).spacecraftPlans("56756", 14L)
}
@Test
fun `group create endpoint proxies request`() {
val response = SatelliteGroupDTO(
@@ -0,0 +1,140 @@
package space.nstart.pcp.slots_service.service
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.ObjectProvider
import org.springframework.http.HttpStatus
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision
import java.net.InetSocketAddress
import java.net.URI
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
class TguPlanningServiceTest {
private var server: HttpServer? = null
@AfterEach
fun tearDown() {
server?.stop(0)
}
@Test
fun `read methods call existing tgu endpoints`() {
val requests = CopyOnWriteArrayList<URI>()
val service = createService(startServer(requests))
val platforms = service.platforms()
val plans = service.plans(7)
val spacecraftPlans = service.spacecraftPlans("56756", 14)
assertEquals("Kondor-FKA No.2", platforms.single().name)
assertEquals("56756", plans.single().spacecraftId)
assertEquals("WAITING_DECISION", spacecraftPlans.single().status)
assertEquals(
listOf("/api/platforms", "/api/plans", "/api/plans/56756"),
requests.map { it.path }
)
assertEquals(listOf(null, "historyDays=7", "historyDays=14"), requests.map { it.rawQuery })
}
@Test
fun `decision method calls test controller endpoint`() {
val requests = CopyOnWriteArrayList<URI>()
val service = createService(startServer(requests))
val response = service.sendDecision(
java.util.UUID.fromString("6d0941e4-61d6-4f96-8fa8-90ba11db121e"),
TguPlanDecision.ACCEPTED,
"UI TEST ACCEPTED"
)
assertEquals(TguPlanDecision.ACCEPTED, response.decision)
assertEquals("/test/tgu/plans/6d0941e4-61d6-4f96-8fa8-90ba11db121e/decision", requests.last().path)
assertEquals("decision=ACCEPTED&reason=UI%20TEST%20ACCEPTED", requests.last().rawQuery)
}
@Test
fun `decision conflict is mapped to operator message`() {
val service = createService(
startServer(
statusByPath = mapOf(
"/test/tgu/plans/00000000-0000-0000-0000-000000000000/decision" to 409
)
)
)
val exception = assertThrows<ResponseStatusException> {
service.sendDecision(
java.util.UUID.fromString("00000000-0000-0000-0000-000000000000"),
TguPlanDecision.REJECTED,
"reason"
)
}
assertEquals(HttpStatus.CONFLICT, exception.statusCode)
assertEquals(
"План не является активным планом, ожидающим решения, или для него нет active attempt.",
exception.reason
)
}
private fun createService(serverUrl: String): TguPlanningService {
val provider = object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder()
override fun getObject(): WebClient.Builder = WebClient.builder()
override fun getIfAvailable(): WebClient.Builder = WebClient.builder()
override fun getIfUnique(): WebClient.Builder = WebClient.builder()
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf<WebClient.Builder>().iterator()
}
val service = TguPlanningService(provider)
ReflectionTestUtils.setField(service, "tguServiceUrl", serverUrl)
return service
}
private fun startServer(
requests: MutableList<URI> = CopyOnWriteArrayList(),
statusByPath: Map<String, Int> = emptyMap()
): String {
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
startedServer.createContext("/") { exchange ->
requests.add(exchange.requestURI)
val forcedStatus = statusByPath[exchange.requestURI.path]
when {
forcedStatus != null -> respond(exchange, forcedStatus, """{"error":"conflict"}""")
exchange.requestURI.path == "/api/platforms" -> respond(
exchange,
200,
"""[{"id":"nsi-id","businessKey":"2024-149E","name":"Kondor-FKA No.2","status":"OPERATIONAL","noradId":56756,"mission":"KONDOR_FKA","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z"}]"""
)
exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond(
exchange,
200,
"""[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]"""
)
exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond(
exchange,
200,
"""{"eventId":"1c857555-f02b-4051-b56a-f112b842624a","spacecraftId":"56756","planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","attemptId":"a82394f2-545f-4cd6-9ab1-876ca403d33a","decision":"ACCEPTED","decisionTime":"2026-05-29T10:01:00","reason":"UI TEST ACCEPTED"}"""
)
else -> respond(exchange, 404, """{"error":"not found"}""")
}
}
startedServer.start()
server = startedServer
return "http://localhost:${startedServer.address.port}"
}
private fun respond(exchange: HttpExchange, status: Int, body: String) {
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = body.toByteArray()
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { output -> output.write(bytes) }
}
}
@@ -13,3 +13,4 @@ spring:
settings:
dynamic-plan-service: http://localhost:65535
tgu-service: http://localhost:65535