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
@@ -1,5 +1,6 @@
import type {
BookedSlotDTO,
BookedSlotView,
RequestSlotsParams,
SlotBookingRequest,
SlotDTO,
@@ -103,13 +104,47 @@ export async function cancelRequestBooking(requestId: string): Promise<number> {
return Number.isFinite(value) ? value : 0
}
/**
* Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE
* /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий
* (runChunked), как при бронировании. Список id передаётся в query (?ids=1&ids=2), а не в теле —
* тело у DELETE через gateway ненадёжно. requestId обязателен: снятие request-aware, чтобы
* шаренный между заявками слот не сносился у других.
* @param requestId Идентификатор заявки.
* @param bookedSlotIds Идентификаторы броней (bookedSlotId) одного чанка.
*
* @returns Сколько броней снято в этом чанке.
*/
export async function cancelBookedSlots(
requestId: string,
bookedSlotIds: number[],
): Promise<number> {
if (bookedSlotIds.length === 0) return 0
const query = new URLSearchParams()
query.set('requestId', requestId)
bookedSlotIds.forEach((id) => query.append('ids', String(id)))
const response = await apiFetch(`${BASE}/booking/batch?${query}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
}
const value = Number(await response.text())
return Number.isFinite(value) ? value : 0
}
/**
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки.
*
* @returns Список забронированных слотов с контурами.
* @returns Список забронированных слотов с контурами и bookedSlotId (для снятия по id);
* cycle здесь — цикл брони, поэтому slotKey совпадает с ответом POST booking/request.
*/
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> {
export async function fetchBookedSlotsByRequest(requestId: string): Promise<BookedSlotView[]> {
const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } },
@@ -119,5 +154,5 @@ export async function fetchBookedSlotsByRequest(requestId: string): Promise<Slot
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
}
return response.json() as Promise<SlotDTO[]>
return response.json() as Promise<BookedSlotView[]>
}