feat(slots-service): #50 M3 — держатели BUSY-слотов в request-cover
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 — нет.
This commit is contained in:
+4
-1
@@ -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 space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class SlotDTO(
|
data class SlotDTO(
|
||||||
val cycle : Long = 0,
|
val cycle : Long = 0,
|
||||||
val satelliteId : Long = 0,
|
val satelliteId : Long = 0,
|
||||||
val tn : LocalDateTime,
|
val tn : LocalDateTime,
|
||||||
@@ -16,4 +16,7 @@ class SlotDTO(
|
|||||||
val state : SlotStatus = SlotStatus.AVAILABLE,
|
val state : SlotStatus = SlotStatus.AVAILABLE,
|
||||||
val latitude : Double = 0.0,
|
val latitude : Double = 0.0,
|
||||||
val longitude : Double = 0.0,
|
val longitude : Double = 0.0,
|
||||||
|
// Держатели слота — заполняются только для BUSY-слотов в обзорном request-cover (all=true).
|
||||||
|
// null/пусто для AVAILABLE/BOOKED и для всех прочих путей (обратно совместимо).
|
||||||
|
val holders : List<SlotHolderDTO>? = null,
|
||||||
)
|
)
|
||||||
+12
@@ -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,
|
||||||
|
)
|
||||||
+4
-2
@@ -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.ballistics.RevolutionSign
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
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.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.pcp_types_lib.utils.wkt.WKTParser
|
||||||
import space.nstart.pcp.slots_service.model.Mar
|
import space.nstart.pcp.slots_service.model.Mar
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -104,7 +105,7 @@ class SlotEntity(
|
|||||||
longitude
|
longitude
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toDTO(booked : Boolean, band : Boolean) = SlotDTO(
|
fun toDTO(booked : Boolean, band : Boolean, holders : List<SlotHolderDTO>? = null) = SlotDTO(
|
||||||
cycle,
|
cycle,
|
||||||
satelliteId,
|
satelliteId,
|
||||||
tn,
|
tn,
|
||||||
@@ -116,6 +117,7 @@ class SlotEntity(
|
|||||||
slotNum,
|
slotNum,
|
||||||
state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE,
|
state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude,
|
||||||
|
holders = holders
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-1
@@ -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.BookedSlotStatus
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
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.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.BookedSlotEntity
|
||||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||||
import space.nstart.pcp.slots_service.model.BookedSlot
|
import space.nstart.pcp.slots_service.model.BookedSlot
|
||||||
@@ -38,11 +39,28 @@ class TestSatelliteImpl(
|
|||||||
states : List<SlotStatus>?
|
states : List<SlotStatus>?
|
||||||
): List<PreparedCoverageSlot> {
|
): List<PreparedCoverageSlot> {
|
||||||
class BookedInfo(val id : Long, val cycle : Long)
|
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<String>
|
||||||
|
)
|
||||||
val selectedSlots = mutableListOf<PreparedCoverageSlot>()
|
val selectedSlots = mutableListOf<PreparedCoverageSlot>()
|
||||||
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
|
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
|
||||||
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
|
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
|
||||||
val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES }
|
val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES }
|
||||||
val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) }
|
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 bookedData = activeBookedSlots.map { bookedSlot ->
|
||||||
val offsetDays = bookedSlot.cycle * durationCalc
|
val offsetDays = bookedSlot.cycle * durationCalc
|
||||||
BookedSlot(
|
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(
|
PreparedCoverageSlot(
|
||||||
sourceSlotId = it.slotId ?: 0L,
|
sourceSlotId = it.slotId ?: 0L,
|
||||||
slot = it.toDTO(
|
slot = it.toDTO(
|
||||||
isBooked,
|
isBooked,
|
||||||
band
|
band,
|
||||||
|
holders
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+27
@@ -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.EarthCellDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
|
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.RequestWithCellsDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,32 @@ class EarthService(
|
|||||||
return Flux.fromIterable(requests)
|
return Flux.fromIterable(requests)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя/важность заявок-держателей по идентификаторам (M3 — обзор конфликтов BUSY-слотов).
|
||||||
|
* Один батч-проход по списку заявок (summary уже несёт name+importance — без N+1 на деталь).
|
||||||
|
* Деградация: при недоступности request-service возвращаем пустую карту — бейдж покажет
|
||||||
|
* только идентификатор заявки, без падения request-cover.
|
||||||
|
*/
|
||||||
|
fun requestHolders(ids: Set<String>): Map<String, SlotHolderDTO> {
|
||||||
|
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 =
|
private fun requestsPage(page: Int): RequestsListResponseDto =
|
||||||
webClientBuilder.build()
|
webClientBuilder.build()
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
+20
-1
@@ -1892,7 +1892,7 @@ class SlotService {
|
|||||||
logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)")
|
logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)")
|
||||||
val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!")
|
val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!")
|
||||||
logger.info("Получена заявка : $req")
|
logger.info("Получена заявка : $req")
|
||||||
return polySlots(
|
val slots = polySlots(
|
||||||
req,
|
req,
|
||||||
timeStart,
|
timeStart,
|
||||||
timeStop,
|
timeStop,
|
||||||
@@ -1905,6 +1905,25 @@ class SlotService {
|
|||||||
coverageStrategy,
|
coverageStrategy,
|
||||||
includeBusy
|
includeBusy
|
||||||
)
|
)
|
||||||
|
return if (includeBusy) enrichHolders(slots) else slots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обогащает держателей BUSY-слотов именем/важностью заявки одним батч-запросом (M3 — обзор
|
||||||
|
// конфликтов). prepareCoverageSlots проставляет держателям только requestId; здесь добавляем
|
||||||
|
// name/importance. Деградация (request-service недоступен) — держатель остаётся с одним id.
|
||||||
|
private fun enrichHolders(slots: Iterable<SlotDTO>): Iterable<SlotDTO> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+47
@@ -3,10 +3,13 @@ package space.nstart.pcp.slots_service.model.satellite
|
|||||||
import org.junit.jupiter.api.Test
|
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.BookedSlotStatus
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
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.BookedSlotEntity
|
||||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class TestSatelliteImplBookedBlockingTest {
|
class TestSatelliteImplBookedBlockingTest {
|
||||||
|
|
||||||
@@ -40,6 +43,50 @@ class TestSatelliteImplBookedBlockingTest {
|
|||||||
assertEquals(SlotStatus.BUSY, result.getValue(2L).state)
|
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(
|
private fun slot(
|
||||||
slotId: Long,
|
slotId: Long,
|
||||||
slotNum: Long,
|
slotNum: Long,
|
||||||
|
|||||||
Reference in New Issue
Block a user