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:
Дмитрий Соловьев
2026-06-17 09:05:52 +03:00
parent 5869994690
commit bd35280691
7 changed files with 149 additions and 5 deletions
@@ -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,
) )
@@ -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,
)
@@ -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
) )
} }
@@ -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
) )
) )
}) })
@@ -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()
@@ -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 })
}
} }
@@ -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,