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:
+9
-1
@@ -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)
|
||||
|
||||
+24
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+37
-9
@@ -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
|
||||
|
||||
+143
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user