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
@@ -0,0 +1,35 @@
// Визуальный прогресс-бар чанковой операции (бронь и снятие). Общий компонент, чтобы обе
// операции показывали прогресс одинаково (DRY) — раньше прогресс брони был только текстом в кнопке.
type ChunkProgressProps = {
/** Подпись операции, напр. «Бронирование» / «Снятие брони». */
label: string;
/** Сколько элементов обработано. */
done: number;
/** Всего элементов в операции. */
total: number;
};
export function ChunkProgress({ label, done, total }: ChunkProgressProps) {
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
return (
<div
className="tgu-chunk-progress"
role="progressbar"
aria-valuenow={done}
aria-valuemin={0}
aria-valuemax={total}
aria-label={label}
>
<div className="tgu-chunk-progress__head">
<span>{label}</span>
<span className="tgu-chunk-progress__count">
{done}/{total}
</span>
</div>
<div className="tgu-chunk-progress__track">
<div className="tgu-chunk-progress__fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
@@ -3,13 +3,13 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
bookRequestSlots,
cancelRequestBooking,
cancelBookedSlots,
fetchBookedSlotsByRequest,
fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type {
BookedSlotDTO,
BookedSlotView,
RevolutionSign,
SlotBookingRequest,
SlotCoverageStrategy,
@@ -26,7 +26,9 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { chunk, selectionAfterCheck } from "./slotSelection";
import { selectionAfterCheck } from "./slotSelection";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -80,13 +82,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
const [withCoverage, setWithCoverage] = useState(false);
// Стратегия автоформирования покрытия (кнопка «Автоподбор»).
const [strategy, setStrategy] = useState<SlotCoverageStrategy>("GREEDY");
// Результаты: слоты-кандидаты и уже забронированные слоты заявки.
// Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id).
const [slots, setSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
@@ -96,9 +97,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [calculating, setCalculating] = useState(false);
const [autoFilling, setAutoFilling] = useState(false);
const [booking, setBooking] = useState(false);
// Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь).
const [bookProgress, setBookProgress] = useState<string>();
// Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте».
const [cancellingSel, setCancellingSel] = useState(false);
// Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар <ChunkProgress>.
const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null);
const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
// «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение.
// cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных.
const [pendingCancel, setPendingCancel] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
@@ -141,8 +148,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
!Number.isFinite(parsedRange.toMs) ||
parsedRange.fromMs >= parsedRange.toMs;
// Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте.
// Ключи забронированных слотов — для бейджа «бронь» в таблице и слоя на карте.
const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]);
// Бронь по ключу слота — чтобы из выбранной/кликнутой строки достать bookedSlotId для снятия.
const bookedByKey = useMemo(() => {
const map = new Map<string, BookedSlotView>();
for (const slot of bookedSlots) map.set(slotKey(slot), slot);
return map;
}, [bookedSlots]);
// id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте.
const markedSlotIds = useMemo(
@@ -158,10 +171,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
return map;
}, [slots, bookedSlots]);
// Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения).
// Строки таблицы = кандидаты + брони, которых нет среди кандидатов (Finding 2-A). Так бронь
// выбираема и снимаема, даже если кандидаты ещё не рассчитаны или бронь в другом наборе.
const displaySlots = useMemo<SlotDTO[]>(() => {
const slotKeySet = new Set(slots.map(slotKey));
const orphanBooked = bookedSlots.filter((slot) => !slotKeySet.has(slotKey(slot)));
return orphanBooked.length === 0 ? slots : [...slots, ...orphanBooked];
}, [slots, bookedSlots]);
// bookedSlotId выбранных броней (для «Снять выбранные», чанкуется) и число выбранных кандидатов
// под бронь (для «Забронировать»). Бронь и кандидат различаем по bookedKeys.
const selectedBookedIds = useMemo(() => {
const ids: number[] = [];
for (const key of selectedKeys) {
const booked = bookedByKey.get(key);
if (booked) ids.push(booked.bookedSlotId);
}
return ids;
}, [selectedKeys, bookedByKey]);
const selectedBookableCount = useMemo(() => {
let n = 0;
for (const key of selectedKeys) if (!bookedKeys.has(key)) n += 1;
return n;
}, [selectedKeys, bookedKeys]);
// Сутки, по которым раскладываются строки таблицы (для фильтра отображения).
const days = useMemo<SlotDay[]>(() => {
const buckets = new Map<number, number>();
for (const slot of slots) {
for (const slot of displaySlots) {
const ms = parseLocal(slot.tn);
if (!Number.isFinite(ms)) continue;
const start = dayStartMs(ms);
@@ -170,7 +207,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
return Array.from(buckets.entries())
.sort(([a], [b]) => a - b)
.map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
}, [slots]);
}, [displaySlots]);
// Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
useEffect(() => {
@@ -181,16 +218,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1));
// Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте.
// Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте (кандидаты + брони).
const filteredSlots = useMemo(() => {
if (days.length === 0) return slots;
if (days.length === 0) return displaySlots;
const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
return slots.filter((slot) => {
return displaySlots.filter((slot) => {
const ms = parseLocal(slot.tn);
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
});
}, [slots, days, effDayFrom, effDayTo]);
}, [displaySlots, days, effDayFrom, effDayTo]);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
@@ -279,6 +316,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setSelectedKeys(new Set());
setBookedSlots([]);
setPendingBook(null);
setPendingCancel(false);
setNotice(undefined);
setError(undefined);
setActiveSlotId(undefined);
@@ -328,7 +366,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
timeStart: toLocalIso(parsedRange.fromMs),
timeStop: toLocalIso(parsedRange.toMs),
revSign: revSign || undefined,
cov: withCoverage,
// cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов.
cov: auto,
coverageStrategy
},
ctrl.signal
@@ -370,29 +410,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const autoCover = () => void runCover(strategy);
const cancelCalculation = () => abortRef.current?.abort();
// Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор
// бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс.
// Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных /
// снятие всего). Отделён от busy (расчёт/автоподбор).
const opBusy = booking || cancelling || cancellingSel;
// Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части
// через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST.
const performBook = async (toBook: SlotBookingRequest[]) => {
setBooking(true);
setError(undefined);
setNotice(undefined);
setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined);
setProgress(toBook.length > BOOK_CHUNK ? { label: "Бронирование", done: 0, total: toBook.length } : null);
try {
const collected: BookedSlotDTO[] = [];
let done = 0;
for (const part of chunk(toBook, BOOK_CHUNK)) {
const booked = await bookRequestSlots(selectedRequestId, part);
collected.push(...booked);
done += part.length;
if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`);
}
// Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
// рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
// (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
// бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
// (с контуром), что вернул POST, не перечитывая by-request.
const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot)));
const parts = await runChunked(
toBook,
BOOK_CHUNK,
(part) => bookRequestSlots(selectedRequestId, part),
(done, total) => setProgress({ label: "Бронирование", done, total }),
);
const collected = parts.flat();
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
const idByKey = new Map(
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const),
);
const justBooked: BookedSlotView[] = slots
.filter((slot) => idByKey.has(slotKey(slot)))
.map((slot) => ({ ...slot, bookedSlotId: idByKey.get(slotKey(slot))! }));
setBookedSlots((prev) => {
const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
for (const slot of justBooked) merged.set(slotKey(slot), slot);
@@ -404,17 +449,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
} finally {
setBooking(false);
setBookProgress(undefined);
setProgress(null);
}
};
// Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем).
const collectSelected = (): SlotBookingRequest[] =>
slots
.filter((slot) => selectedKeys.has(slotKey(slot)))
.filter((slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)))
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
const book = () => {
if (!selectedRequestId || booking || selectedKeys.size === 0) return;
if (!selectedRequestId || opBusy || selectedBookableCount === 0) return;
const toBook = collectSelected();
if (toBook.length === 0) return;
// Крупная бронь — сначала подтверждение, потом чанкованный POST.
@@ -432,23 +478,65 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
};
const cancelPendingBook = () => setPendingBook(null);
const cancelBooking = async () => {
if (!selectedRequestId || booking || bookedSlots.length === 0) return;
setBooking(true);
// Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
// одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
const runCancel = async (ids: number[], setBusy: (value: boolean) => void) => {
setBusy(true);
setError(undefined);
setNotice(undefined);
setProgress(ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
try {
const cancelled = await cancelRequestBooking(selectedRequestId);
// Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()).
setBookedSlots([]);
setNotice(`Снято броней: ${cancelled}.`);
const counts = await runChunked(
ids,
BOOK_CHUNK,
(part) => cancelBookedSlots(selectedRequestId, part),
(done, total) => setProgress({ label: "Снятие брони", done, total }),
);
const removed = counts.reduce((a, b) => a + b, 0);
const idSet = new Set(ids);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const slot of bookedSlots) if (idSet.has(slot.bookedSlotId)) next.delete(slotKey(slot));
return next;
});
setBookedSlots((prev) => prev.filter((slot) => !idSet.has(slot.bookedSlotId)));
setNotice(`Снято броней: ${removed}.`);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
} finally {
setBooking(false);
setBusy(false);
setProgress(null);
}
};
// Снять выбранные брони (по галочкам). Выбор явный — без подтверждения.
const cancelSelected = () => {
if (!selectedRequestId || opBusy || selectedBookedIds.length === 0) return;
void runCancel(selectedBookedIds, setCancellingSel);
};
// Снять одну бронь из строки таблицы — батч из одного id (обратимо ре-бронью, без подтверждения).
const cancelOne = (bookedSlotId: number) => {
if (!selectedRequestId || opBusy) return;
void runCancel([bookedSlotId], setCancellingSel);
};
// Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо →
// подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке.
const performCancelAll = () => runCancel(bookedSlots.map((slot) => slot.bookedSlotId), setCancelling);
const cancelAll = () => {
if (!selectedRequestId || opBusy || bookedSlots.length === 0) return;
setPendingCancel(true);
};
const confirmPendingCancel = () => {
setPendingCancel(false);
void performCancelAll();
};
const cancelPendingCancel = () => setPendingCancel(false);
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
const handleSlotCheck = (index: number, shiftKey: boolean) => {
@@ -471,7 +559,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
anchorIndexRef.current = null;
};
// Выбрать все слоты расчёта (по всем суткам), снять весь выбор.
const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey)));
const selectAllDays = () => setSelectedKeys(new Set(displaySlots.map(slotKey)));
const clearSelection = () => {
setSelectedKeys(new Set());
anchorIndexRef.current = null;
@@ -563,8 +651,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
}
}
// Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно.
const slotKeysShown = new Set(slots.map(slotKey));
// Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем,
// чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше.
const slotKeysShown = new Set(filteredSlots.map(slotKey));
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
@@ -679,7 +768,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
? "Загрузка заявок…"
: undefined
: slots.length === 0 && bookedSlots.length === 0
? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
: undefined;
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
@@ -723,19 +812,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<option value="DESC">нисходящая</option>
</select>
</label>
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
<input
type="checkbox"
checked={withCoverage}
onChange={(event) => setWithCoverage(event.target.checked)}
/>
<span>Покрытие</span>
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
{calculating ? "Расчёт…" : "Рассчитать слоты"}
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate} title="Показать весь набор слотов-кандидатов для ручного отбора">
{calculating ? "Расчёт…" : "Показать все слоты"}
</button>
<label className="tgu-field" title="Стратегия автоформирования покрытия">
<span>Автоподбор</span>
<label className="tgu-field" title="Стратегия отбора покрытия для «Подобрать покрытие»">
<span>Стратегия покрытия</span>
<select
className="tgu-select"
value={strategy}
@@ -746,28 +827,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<option value="CONTINUOUS">непрерывный</option>
</select>
</label>
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Сформировать покрытие автоматически и предложить под бронь">
{autoFilling ? "Подбор…" : "Подобрать"}
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Отобрать достаточное покрытие по стратегии и предложить под бронь">
{autoFilling ? "Подбор…" : "Подобрать покрытие"}
</button>
<button
className="tgu-button"
type="button"
onClick={book}
disabled={booking || selectedKeys.size === 0}
disabled={opBusy || selectedBookableCount === 0}
title="Забронировать выбранные слоты-кандидаты"
>
{booking
? bookProgress
? `Бронь… ${bookProgress}`
: "Бронь…"
: `Забронировать${selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}`}
? "Бронь…"
: `Забронировать${selectedBookableCount > 0 ? ` (${selectedBookableCount})` : ""}`}
</button>
<button
className="tgu-button"
type="button"
onClick={cancelBooking}
disabled={booking || bookedSlots.length === 0}
onClick={cancelSelected}
disabled={opBusy || selectedBookedIds.length === 0}
title="Снять бронь с выбранных слотов (пачками)"
>
Снять бронь{bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}
{cancellingSel
? "Снятие…"
: `Снять выбранные${selectedBookedIds.length > 0 ? ` (${selectedBookedIds.length})` : ""}`}
</button>
<button
className="tgu-button"
type="button"
onClick={cancelAll}
disabled={opBusy || bookedSlots.length === 0}
title="Снять все брони заявки"
>
{cancelling ? "Снятие…" : `Снять всё${bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}`}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{(slots.length > 0 || bookedSlots.length > 0) && (
@@ -796,8 +888,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
className="tgu-button tgu-button--sm"
type="button"
onClick={selectAllDays}
disabled={slots.length === 0}
title="Выбрать все слоты расчёта (по всем суткам)"
disabled={displaySlots.length === 0}
title="Выбрать все строки (кандидаты и брони, по всем суткам)"
>
Все сутки
</button>
@@ -814,7 +906,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<div className="tgu-slots-scroll" ref={listRef}>
{filteredSlots.length === 0 ? (
<div className="tgu-slots-empty">
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
{displaySlots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
</div>
) : (
<table className="tgu-slots-table">
@@ -875,7 +967,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<td>{slot.roll.toFixed(1)}°</td>
<td>
{booked ? (
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
<span className="tgu-slots-status-cell">
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
<button
type="button"
className="tgu-slots-cancel-one"
title="Снять бронь с этого слота"
aria-label="Снять бронь с этого слота"
disabled={opBusy}
onClick={(event) => {
event.stopPropagation();
const id = bookedByKey.get(key)?.bookedSlotId;
if (id != null) cancelOne(id);
}}
>
</button>
</span>
) : (
<span
className={
@@ -984,6 +1092,22 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
</div>
</div>
)}
{pendingCancel && (
<div className="tgu-slots-confirm">
<span>Снять все брони заявки ({bookedSlots.length})? Действие необратимо.</span>
<div className="tgu-slots-confirm__actions">
<button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingCancel}>
Снять всё
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingCancel}>
Отмена
</button>
</div>
</div>
)}
{progress && (
<ChunkProgress label={progress.label} done={progress.done} total={progress.total} />
)}
{hasRequest && (
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
Сброс
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { runChunked } from "./runChunked";
describe("runChunked", () => {
it("бьёт набор на чанки и шлёт их по очереди", async () => {
const items = Array.from({ length: 450 }, (_, i) => i);
const sizes: number[] = [];
const fn = vi.fn(async (part: number[]) => {
sizes.push(part.length);
return part.length; // снятие: число снятых в чанке
});
const results = await runChunked(items, 200, fn);
expect(fn).toHaveBeenCalledTimes(3);
expect(sizes).toEqual([200, 200, 50]);
expect(results).toEqual([200, 200, 50]);
// суммирование на стороне вызова
expect(results.reduce((a, b) => a + b, 0)).toBe(450);
});
it("сообщает прогресс (обработано/всего) после каждого чанка", async () => {
const items = Array.from({ length: 450 }, (_, i) => i);
const progress: Array<[number, number]> = [];
await runChunked(items, 200, async () => 0, (done, total) => progress.push([done, total]));
expect(progress).toEqual([
[200, 450],
[400, 450],
[450, 450],
]);
});
it("пустой набор — не зовёт fn и возвращает пусто", async () => {
const fn = vi.fn(async () => 0);
const results = await runChunked([], 200, fn);
expect(fn).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
it("ошибка чанка пробрасывается наружу", async () => {
const items = [1, 2, 3];
await expect(
runChunked(items, 2, async (part) => {
if (part.includes(3)) throw new Error("boom");
return part.length;
}),
).rejects.toThrow("boom");
});
it("аккумулирует списки (бронь) с сохранением порядка", async () => {
const items = [1, 2, 3, 4, 5];
const results = await runChunked(items, 2, async (part) => part.map((n) => n * 10));
expect(results.flat()).toEqual([10, 20, 30, 40, 50]);
});
});
@@ -0,0 +1,33 @@
// Общий чанковый раннер для крупных операций над слотами (бронь и снятие). Бэк работает по
// одному слоту в транзакции, поэтому крупный набор бьём на части (см. BOOK_CHUNK) и шлём
// последовательно, сообщая прогресс. Выделено из TguSlotsTab, чтобы покрыть юнит-тестами и не
// дублировать цикл в performBook/performCancel (DRY).
import { chunk } from "./slotSelection";
/**
* Прогоняет `fn` по чанкам `items` последовательно, после каждого чанка зовёт `onProgress`.
* Возвращает по одному результату на чанк (порядок сохраняется): для брони это списки
* забронированных слотов, для снятия — числа снятых. Сведение (flat/сумма) — на стороне вызова.
*
* @param items Полный набор элементов операции.
* @param size Размер чанка; <= 0 — один чанк со всем набором (см. chunk()).
* @param fn Операция над одним чанком; ошибка пробрасывается наружу (частичный прогресс уже учтён).
* @param onProgress Коллбек прогресса: (обработано, всего) после каждого успешного чанка.
*/
export async function runChunked<TItem, TChunk>(
items: readonly TItem[],
size: number,
fn: (part: TItem[]) => Promise<TChunk>,
onProgress?: (done: number, total: number) => void,
): Promise<TChunk[]> {
const total = items.length;
const results: TChunk[] = [];
let done = 0;
for (const part of chunk(items, size)) {
results.push(await fn(part));
done += part.length;
onProgress?.(done, total);
}
return results;
}