feat(slots): снятие брони пачками с прогрессом (симметрично бронированию)

Бэк (slots-service):
- DELETE /booking/batch (cancelReqSlots) — снятие выбранных броней пачкой;
  одиночный DELETE теперь request-aware (нужен requestId), чтобы шаренный
  между заявками слот рвал только связь своей заявки, а не сносил бронь.
- BookedSlotViewDTO (+toViewDTO) с cycle БРОНИ — slotKey совпадает с ответом
  POST booking/request; GET booking/by-request отдаёт View.

Фронт (pcp-tgu-ui-service):
- runChunked + ChunkProgress: бронь и снятие идут пачками по 200 с общим
  прогресс-баром (стиль/позиция как у оверлея «Расчёт слотов…», сверху).
- «Снять выбранные», «Снять всё» и ✕ в строке — единый чанковый движок
  runCancel (а не bulk-запрос), брони слиты в строки таблицы и выбираемы.

Тесты: SlotServiceCancelTest (шаринг/единоличная/cycle-регрессия), runChunked,
slotsApi.cancelBookedSlots/fetchBookedSlotsByRequest.
This commit is contained in:
Дмитрий Соловьев
2026-06-15 14:26:06 +03:00
parent b9adbce207
commit 33b56ba951
13 changed files with 756 additions and 91 deletions
@@ -50,8 +50,16 @@ class BookingController {
@PostMapping()
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
// Снятие одной брони: request-aware (см. SlotService.cancelSlot) — нужен requestId,
// чтобы при шаренном слоте порвать только связь этой заявки, а не снести бронь у других.
@DeleteMapping
fun cancelSlot(@RequestParam("id") id : Long) = slotService.cancelSlot(id)
fun cancelSlot(@RequestParam("id") id : Long, @RequestParam requestId : String) =
slotService.cancelSlot(requestId, id)
// Снятие выбранных броней пачкой (фронт чанкует по 200). Список id в query (?ids=1&ids=2),
// а не в теле — тело у DELETE через gateway ненадёжно.
@DeleteMapping("batch")
fun cancelBatch(@RequestParam requestId : String, @RequestParam ids : List<Long>) =
slotService.cancelReqSlots(requestId, ids)
@PostMapping("request")
fun bookReq(@RequestBody req : BookingRequestDTO) =
slotService.bookReq(req)
@@ -13,8 +13,11 @@ import jakarta.persistence.OneToMany
import jakarta.persistence.Table
import org.hibernate.annotations.Fetch
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.BookedSlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotViewDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
@Entity
@@ -51,4 +54,25 @@ class BookedSlotEntity(
slotNum = slot.slotNum,
cycle = cycle
)
/**
* Представление брони для фронта: поля слота (контур, координаты) + bookedSlotId + cycle БРОНИ.
* cycle берём из самой брони (this.cycle), а не из slot.cycle — иначе ключ sat:slotNum:cycle
* не совпадёт с тем, что вернул POST booking/request (рассинхрон cycle).
*/
fun toViewDTO() = BookedSlotViewDTO(
bookedSlotId = bookedSlotId ?: 0,
cycle = cycle,
satelliteId = slot.satelliteId,
tn = slot.tn,
tk = slot.tk,
roll = slot.roll,
contour = slot.contour,
revolution = slot.revolution,
revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
slotNumber = slot.slotNum,
state = SlotStatus.BOOKED,
latitude = slot.latitude,
longitude = slot.longitude
)
}
@@ -2439,8 +2439,10 @@ class SlotService {
}
@Transactional(readOnly = true)
fun bookedByReqId(id : String) : List<SlotDTO> {
return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.slot.toDTO(SlotStatus.valueOf(req.bookedSlot.status)) }
fun bookedByReqId(id : String) : List<BookedSlotViewDTO> {
// toViewDTO() несёт bookedSlotId (для снятия по id) и cycle БРОНИ (а не slot.cycle),
// чтобы ключ слота на фронте совпал с ответом POST booking/request.
return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.toViewDTO() }
}
@@ -2485,24 +2487,50 @@ class SlotService {
}
/**
* Снимает одну бронь-связь заявки. Если слот забронирован только под эту заявку
* (requests.size == 1) — удаляем сам слот; иначе рвём только связь заявки, а слот
* остаётся у других заявок. Единый источник правды для cancelReq / cancelReqSlots /
* cancelSlot — нельзя удалять BookedSlot по id напрямую, иначе снос брони у чужой заявки.
*/
private fun removeBookedRequest(link: BookedRequestEntity) {
if (link.bookedSlot.requests?.size == 1)
bookedSlotsRepository.deleteByBookedSlotId(link.bookedSlot.bookedSlotId ?: -1)
else
requestRepository.deleteByBookedSlotRequestId(link.bookedSlotRequestId ?: -1)
}
@Transactional
fun cancelReq(req : String) : Int{
logger.info("Запрос на отмену бронирование по заявке ${req}")
var cnt = 0
requestRepository.findByRequestId(req).forEach { booked ->
if (booked.bookedSlot.requests?.size == 1)
bookedSlotsRepository.deleteByBookedSlotId(booked.bookedSlot.bookedSlotId?:-1)
else
requestRepository.deleteByBookedSlotRequestId( booked.bookedSlotRequestId?:-1)
requestRepository.findByRequestId(req).forEach { link ->
removeBookedRequest(link)
++cnt
}
return cnt
}
/**
* Снимает брони заявки по списку bookedSlotId (фронт «Снять выбранные», чанк 200).
* request-aware через removeBookedRequest — безопасно для броней, шаренных между заявками.
* id без брони у этой заявки молча пропускаются; возвращает число фактически снятых.
*/
@Transactional
fun cancelReqSlots(requestId : String, bookedSlotIds : List<Long>) : Int {
logger.info("Запрос на отмену ${bookedSlotIds.size} броней заявки ${requestId}")
var cnt = 0
for (id in bookedSlotIds) {
val link = requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(id, requestId) ?: continue
removeBookedRequest(link)
++cnt
}
return cnt
}
@Transactional
fun cancelSlot(bookedSlotId : Long) =
bookedSlotsRepository.deleteByBookedSlotId(bookedSlotId)
fun cancelSlot(requestId : String, bookedSlotId : Long) : Int =
cancelReqSlots(requestId, listOf(bookedSlotId))
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null
@@ -0,0 +1,143 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.test.util.ReflectionTestUtils
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 space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import java.time.LocalDateTime
import kotlin.test.assertEquals
/**
* Снятие брони request-aware. Ключевой инвариант: снятие НИКОГДА не удаляет BookedSlot,
* шаренный между заявками, по id напрямую — рвёт только связь текущей заявки.
*/
class SlotServiceCancelTest {
private val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
private val requestRepository = mock(BookedRequestRepository::class.java)
private fun service(): SlotService {
val service = SlotService()
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
ReflectionTestUtils.setField(service, "requestRepository", requestRepository)
return service
}
@Test
fun `cancelReqSlots deletes the slot when booked by a single request`() {
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(10L))
assertEquals(1, cnt)
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
verify(requestRepository, never()).deleteByBookedSlotRequestId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `cancelReqSlots only unlinks when slot is shared across requests`() {
// Один и тот же слот забронирован под R1 и R2 — снятие для R1 не должно трогать сам слот.
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(20L))
assertEquals(1, cnt)
verify(requestRepository).deleteByBookedSlotRequestId(200L)
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `cancelReqSlots skips ids not booked by the request`() {
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(99L, "R1")).thenReturn(null)
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(10L, 99L))
assertEquals(1, cnt) // 99 пропущен
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
}
@Test
fun `cancelSlot delegates to request-aware single removal`() {
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
val cnt = service().cancelSlot("R1", 20L)
assertEquals(1, cnt)
verify(requestRepository).deleteByBookedSlotRequestId(200L) // только связь, слот цел
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `bookedByReqId uses booking cycle and carries id plus contour`() {
// slot.cycle (5) != booking cycle (3) — view ДОЛЖЕН вернуть 3, иначе ключ на фронте не сойдётся.
val link = soleLink(
bookedSlotId = 10L, linkId = 100L, requestId = "R1",
bookingCycle = 3L, slotCycle = 5L, contour = "POLYGON ((0 0, 1 0, 1 1, 0 0))"
)
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
val view = service().bookedByReqId("R1")
assertEquals(1, view.size)
assertEquals(10L, view[0].bookedSlotId)
assertEquals(3L, view[0].cycle) // cycle БРОНИ, не slot.cycle (5)
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 0))", view[0].contour)
}
// --- builders ---
private fun slot(
slotId: Long, slotCycle: Long = 0L, contour: String = ""
) = SlotEntity(
slotId = slotId,
slotNum = slotId,
cycle = slotCycle,
satelliteId = 77L,
contour = contour,
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
tk = LocalDateTime.of(2026, 3, 31, 10, 10)
)
private fun soleLink(
bookedSlotId: Long, linkId: Long, requestId: String,
bookingCycle: Long = 0L, slotCycle: Long = 0L, contour: String = ""
): BookedRequestEntity {
val booked = BookedSlotEntity(
bookedSlotId = bookedSlotId,
slot = slot(bookedSlotId, slotCycle, contour),
cycle = bookingCycle,
requests = mutableListOf()
)
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
booked.requests!!.add(link)
return link
}
private fun sharedLink(
bookedSlotId: Long, linkId: Long, otherLinkId: Long, requestId: String
): BookedRequestEntity {
val booked = BookedSlotEntity(
bookedSlotId = bookedSlotId,
slot = slot(bookedSlotId),
cycle = 0L,
requests = mutableListOf()
)
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
val other = BookedRequestEntity(bookedSlotRequestId = otherLinkId, bookedSlot = booked, requestId = "R2")
booked.requests!!.add(link)
booked.requests!!.add(other)
return link
}
}