From bd352806912750ce338cb121c59b92bd7ccb0e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 17 Jun 2026 09:05:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(slots-service):=20#50=20M3=20=E2=80=94=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20BUSY-?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B2=20request-cover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqCover(all=true) обогащает занятые (BUSY) слоты держателями: какие заявки блокируют слот, с именем и важностью (обзор конфликтов при ручной броне). - SlotHolderDTO (requestId + опц. name/importance), поле holders в SlotDTO (null для AVAILABLE/BOOKED и прочих путей — обратно совместимо). - TestSatelliteImpl: для BUSY-слота собирает держателей по пересечению сдвинутых на cycle интервалов активных броней (та же логика, что band, с mmi), distinct по requestId (множественный держатель). - EarthService.requestHolders: батч-резолв name/importance заявок; деградация через runCatching → emptyMap (request-service недоступен → только id, без падения). - SlotService.enrichHolders: один батч-запрос на обзор, slot.copy(holders=...). - SlotDTO переведён в data class (copy вместо ручного перечисления полей). - Тест: BUSY несёт держателей, BOOKED/AVAILABLE — нет. --- .../dto/requests/slots/SlotDTO.kt | 5 +- .../dto/requests/slots/SlotHolderDTO.kt | 12 +++++ .../pcp/slots_service/entity/SlotEntity.kt | 6 ++- .../model/satellite/TestSatelliteImpl.kt | 36 +++++++++++++- .../pcp/slots_service/service/EarthService.kt | 27 +++++++++++ .../pcp/slots_service/service/SlotService.kt | 21 ++++++++- .../TestSatelliteImplBookedBlockingTest.kt | 47 +++++++++++++++++++ 7 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt index 66e64f6..89cad2a 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotDTO.kt @@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.requests.slots import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime -class SlotDTO( +data class SlotDTO( val cycle : Long = 0, val satelliteId : Long = 0, val tn : LocalDateTime, @@ -16,4 +16,7 @@ class SlotDTO( val state : SlotStatus = SlotStatus.AVAILABLE, val latitude : Double = 0.0, val longitude : Double = 0.0, + // Держатели слота — заполняются только для BUSY-слотов в обзорном request-cover (all=true). + // null/пусто для AVAILABLE/BOOKED и для всех прочих путей (обратно совместимо). + val holders : List? = null, ) \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt new file mode 100644 index 0000000..54d02fc --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/SlotHolderDTO.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +/** + * Держатель занятого (BUSY) слота — заявка, чья бронь блокирует слот на этом проходе КА. + * Заполняется только в обзорном ответе request-cover (all=true); name/importance могут быть null, + * если request-service недоступен (деградация — бейдж показывает только идентификатор заявки). + */ +data class SlotHolderDTO( + val requestId: String, + val name: String? = null, + val importance: Double? = null, +) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt index 757b134..4c4dd8c 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt @@ -6,6 +6,7 @@ import org.hibernate.annotations.FetchMode import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser import space.nstart.pcp.slots_service.model.Mar import java.time.LocalDateTime @@ -104,7 +105,7 @@ class SlotEntity( longitude ) - fun toDTO(booked : Boolean, band : Boolean) = SlotDTO( + fun toDTO(booked : Boolean, band : Boolean, holders : List? = null) = SlotDTO( cycle, satelliteId, tn, @@ -116,6 +117,7 @@ class SlotEntity( slotNum, state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE, latitude, - longitude + longitude, + holders = holders ) } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt index cd4e733..d5d259f 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt @@ -5,6 +5,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO import space.nstart.pcp.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.SlotEntity import space.nstart.pcp.slots_service.model.BookedSlot @@ -38,11 +39,28 @@ class TestSatelliteImpl( states : List? ): List { class BookedInfo(val id : Long, val cycle : Long) + // Держатель блокирующей брони: сдвинутый на cycle интервал + крен + заявки-держатели. + // Нужен, чтобы у BUSY-слота показать, чья бронь его блокирует (M3 — обзор конфликтов). + class HolderInterval( + val start : LocalDateTime, + val end : LocalDateTime, + val roll : Double, + val requestIds : List + ) val selectedSlots = mutableListOf() val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES } val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } + val holderIntervals = activeBookedSlots.map { bookedSlot -> + val offsetDays = bookedSlot.cycle * durationCalc + HolderInterval( + bookedSlot.slot.tn.plusDays(offsetDays), + bookedSlot.slot.tk.plusDays(offsetDays), + bookedSlot.slot.roll, + bookedSlot.requests.orEmpty().map { it.requestId }.distinct() + ) + } val bookedData = activeBookedSlots.map { bookedSlot -> val offsetDays = bookedSlot.cycle * durationCalc BookedSlot( @@ -94,11 +112,27 @@ class TestSatelliteImpl( } } } + // Для BUSY-слота собираем держателей: активные брони, чей сдвинутый интервал + // пересекается со слотом (с учётом mmi) — та же логика наложения, что и в band. + // distinct по requestId: одну бронь могут держать несколько заявок (множ. держатель). + val holders = if (band) { + holderIntervals + .filter { holder -> + intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), holder.start, holder.end) + } + .flatMap { holder -> holder.requestIds } + .distinct() + .map { reqId -> SlotHolderDTO(reqId) } + .takeIf { holders -> holders.isNotEmpty() } + } else { + null + } PreparedCoverageSlot( sourceSlotId = it.slotId ?: 0L, slot = it.toDTO( isBooked, - band + band, + holders ) ) }) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt index 1f07834..242c96c 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/EarthService.kt @@ -9,6 +9,7 @@ import reactor.core.publisher.Flux import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO import java.util.UUID @@ -67,6 +68,32 @@ class EarthService( return Flux.fromIterable(requests) } + /** + * Имя/важность заявок-держателей по идентификаторам (M3 — обзор конфликтов BUSY-слотов). + * Один батч-проход по списку заявок (summary уже несёт name+importance — без N+1 на деталь). + * Деградация: при недоступности request-service возвращаем пустую карту — бейдж покажет + * только идентификатор заявки, без падения request-cover. + */ + fun requestHolders(ids: Set): Map { + if (ids.isEmpty()) return emptyMap() + return runCatching { + val firstPage = requestsPage(page = 0) + val items = firstPage.items.toMutableList() + for (page in 1 until firstPage.totalPages) { + items += requestsPage(page).items + } + items.asSequence() + .filter { ids.contains(it.id.toString()) } + .associate { summary -> + summary.id.toString() to SlotHolderDTO( + requestId = summary.id.toString(), + name = summary.name.takeIf { it.isNotBlank() }, + importance = summary.importance, + ) + } + }.getOrElse { emptyMap() } + } + private fun requestsPage(page: Int): RequestsListResponseDto = webClientBuilder.build() .get() diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 7c09607..1e0fa35 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -1892,7 +1892,7 @@ class SlotService { logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)") val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!") logger.info("Получена заявка : $req") - return polySlots( + val slots = polySlots( req, timeStart, timeStop, @@ -1905,6 +1905,25 @@ class SlotService { coverageStrategy, includeBusy ) + return if (includeBusy) enrichHolders(slots) else slots + } + + // Обогащает держателей BUSY-слотов именем/важностью заявки одним батч-запросом (M3 — обзор + // конфликтов). prepareCoverageSlots проставляет держателям только requestId; здесь добавляем + // name/importance. Деградация (request-service недоступен) — держатель остаётся с одним id. + private fun enrichHolders(slots: Iterable): Iterable { + val slotList = slots.toList() + val holderIds = slotList + .flatMap { it.holders.orEmpty() } + .map { it.requestId } + .toSet() + if (holderIds.isEmpty()) return slotList + val infoById = earthService.requestHolders(holderIds) + if (infoById.isEmpty()) return slotList + return slotList.map { slot -> + val holders = slot.holders ?: return@map slot + slot.copy(holders = holders.map { infoById[it.requestId] ?: it }) + } } diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt index bd886eb..7e62c90 100644 --- a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt @@ -3,10 +3,13 @@ package space.nstart.pcp.slots_service.model.satellite import org.junit.jupiter.api.Test import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus +import space.nstart.pcp.slots_service.entity.BookedRequestEntity import space.nstart.pcp.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.SlotEntity import java.time.LocalDateTime import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue class TestSatelliteImplBookedBlockingTest { @@ -40,6 +43,50 @@ class TestSatelliteImplBookedBlockingTest { assertEquals(SlotStatus.BUSY, result.getValue(2L).state) } + @Test + fun `busy slot carries holders from blocking booking, booked and available slots carry none`() { + val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0) + val satellite = TestSatelliteImpl( + id = 1L, + tnCalc = baseTime, + durationCalc = 1, + maxChainLength = 3, + maxSurveyDuration = 300, + mmi = 10 + ) + val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10) + val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8) + // Свободный кандидат вне интервала и mmi-окна брони — держателей быть не должно. + val freeSlot = slot(slotId = 3L, slotNum = 3L, baseTime = baseTime, startSecond = 100, endSecond = 110) + // Множественный держатель: одну бронь держат две заявки. + val bookedWithRequests = BookedSlotEntity( + slot = bookedRawSlot, + cycle = 0, + requests = mutableListOf( + BookedRequestEntity(requestId = "req-A"), + BookedRequestEntity(requestId = "req-B"), + ), + status = BookedSlotStatus.PROCESSED.name, + ) + + val result = satellite.prepareCoverageSlots( + slots = listOf(bookedRawSlot, candidateInsideBooked, freeSlot), + bookedSlots = listOf(bookedWithRequests), + timeStart = baseTime, + timeStop = baseTime.plusSeconds(120), + sign = null, + states = null + ).map { it.slot }.associateBy { it.slotNumber } + + val busy = result.getValue(2L) + assertEquals(SlotStatus.BUSY, busy.state) + assertEquals(listOf("req-A", "req-B"), busy.holders.orEmpty().map { it.requestId }.sorted()) + // На уровне прогноза держателям проставлен только requestId — name/importance добавит reqCover. + assertTrue(busy.holders.orEmpty().all { it.name == null && it.importance == null }) + assertNull(result.getValue(1L).holders, "у самой брони (BOOKED) держателей не показываем") + assertNull(result.getValue(3L).holders, "у свободного слота держателей нет") + } + private fun slot( slotId: Long, slotNum: Long,