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:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package space.nstart.pcp.pcp_types_lib.dto.requests.slots
|
||||||
|
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Забронированный слот для отображения и снятия брони (GET /api/slots/booking/by-request/{id}).
|
||||||
|
*
|
||||||
|
* В отличие от [SlotDTO] несёт [bookedSlotId] — он нужен фронту, чтобы снимать бронь по списку id
|
||||||
|
* (DELETE /api/slots/booking/batch). В отличие от [BookedSlotDTO] несёт контур и координаты — нужны
|
||||||
|
* карте. Критично: [cycle] — это цикл БРОНИ (BookedSlotEntity.cycle), а не базовый slot.cycle,
|
||||||
|
* иначе ключ sat:slotNum:cycle на фронте не совпадёт с ответом POST booking/request.
|
||||||
|
*/
|
||||||
|
class BookedSlotViewDTO(
|
||||||
|
val bookedSlotId : Long = 0,
|
||||||
|
val cycle : Long = 0,
|
||||||
|
val satelliteId : Long = 0,
|
||||||
|
val tn : LocalDateTime,
|
||||||
|
val tk : LocalDateTime,
|
||||||
|
val roll : Double,
|
||||||
|
val contour : String,
|
||||||
|
val revolution : Long = 0,
|
||||||
|
val revolutionSign: RevolutionSign = RevolutionSign.ASC,
|
||||||
|
val slotNumber : Long = 0,
|
||||||
|
val state : SlotStatus = SlotStatus.BOOKED,
|
||||||
|
val latitude : Double = 0.0,
|
||||||
|
val longitude : Double = 0.0,
|
||||||
|
)
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { bookRequestSlots, fetchRequestSlots } from "./slotsApi";
|
import {
|
||||||
|
bookRequestSlots,
|
||||||
|
cancelBookedSlots,
|
||||||
|
fetchBookedSlotsByRequest,
|
||||||
|
fetchRequestSlots,
|
||||||
|
} from "./slotsApi";
|
||||||
|
|
||||||
function jsonResponse(body: unknown): Response {
|
function jsonResponse(body: unknown): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -8,6 +13,10 @@ function jsonResponse(body: unknown): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textResponse(body: string): Response {
|
||||||
|
return new Response(body, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
|
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
@@ -61,3 +70,59 @@ describe("bookRequestSlots", () => {
|
|||||||
expect(res).toHaveLength(1);
|
expect(res).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cancelBookedSlots", () => {
|
||||||
|
it("DELETE booking/batch с requestId и списком ids в query, возвращает count", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("3"));
|
||||||
|
const res = await cancelBookedSlots("r1", [11, 12, 13]);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toContain("booking/batch");
|
||||||
|
expect(String(url)).toContain("requestId=r1");
|
||||||
|
expect(String(url)).toContain("ids=11");
|
||||||
|
expect(String(url)).toContain("ids=12");
|
||||||
|
expect(String(url)).toContain("ids=13");
|
||||||
|
expect(init?.method).toBe("DELETE");
|
||||||
|
expect(res).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("пустой список ids — не зовёт fetch и возвращает 0", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("0"));
|
||||||
|
const res = await cancelBookedSlots("r1", []);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(res).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("на не-ok бросает с текстом ошибки сервера", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
|
||||||
|
await expect(cancelBookedSlots("r1", [1])).rejects.toThrow("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchBookedSlotsByRequest", () => {
|
||||||
|
it("возвращает брони с bookedSlotId и контуром", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
jsonResponse([
|
||||||
|
{
|
||||||
|
bookedSlotId: 42,
|
||||||
|
cycle: 3,
|
||||||
|
satelliteId: 12,
|
||||||
|
slotNumber: 340,
|
||||||
|
tn: "2026-01-01T10:00:00",
|
||||||
|
tk: "2026-01-01T10:01:00",
|
||||||
|
roll: 0,
|
||||||
|
contour: "POLYGON ((0 0, 1 0, 1 1, 0 0))",
|
||||||
|
revolution: 1,
|
||||||
|
revolutionSign: "ASC",
|
||||||
|
state: "BOOKED",
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const res = await fetchBookedSlotsByRequest("r1");
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].bookedSlotId).toBe(42);
|
||||||
|
expect(res[0].cycle).toBe(3);
|
||||||
|
expect(res[0].contour).toContain("POLYGON");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
BookedSlotDTO,
|
BookedSlotDTO,
|
||||||
|
BookedSlotView,
|
||||||
RequestSlotsParams,
|
RequestSlotsParams,
|
||||||
SlotBookingRequest,
|
SlotBookingRequest,
|
||||||
SlotDTO,
|
SlotDTO,
|
||||||
@@ -103,13 +104,47 @@ export async function cancelRequestBooking(requestId: string): Promise<number> {
|
|||||||
return Number.isFinite(value) ? value : 0
|
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}).
|
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
|
||||||
* @param requestId Идентификатор заявки.
|
* @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(
|
const response = await apiFetch(
|
||||||
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
|
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
|
||||||
{ headers: { Accept: 'application/json' } },
|
{ headers: { Accept: 'application/json' } },
|
||||||
@@ -119,5 +154,5 @@ export async function fetchBookedSlotsByRequest(requestId: string): Promise<Slot
|
|||||||
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
|
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<SlotDTO[]>
|
return response.json() as Promise<BookedSlotView[]>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { fetchRequestsForMap } from "../../api/requestApi";
|
||||||
import {
|
import {
|
||||||
bookRequestSlots,
|
bookRequestSlots,
|
||||||
cancelRequestBooking,
|
cancelBookedSlots,
|
||||||
fetchBookedSlotsByRequest,
|
fetchBookedSlotsByRequest,
|
||||||
fetchRequestSlots
|
fetchRequestSlots
|
||||||
} from "../../api/slotsApi";
|
} from "../../api/slotsApi";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import type {
|
import type {
|
||||||
BookedSlotDTO,
|
BookedSlotView,
|
||||||
RevolutionSign,
|
RevolutionSign,
|
||||||
SlotBookingRequest,
|
SlotBookingRequest,
|
||||||
SlotCoverageStrategy,
|
SlotCoverageStrategy,
|
||||||
@@ -26,7 +26,9 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
|||||||
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
||||||
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
|
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
|
||||||
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
|
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;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -80,13 +82,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
|
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
|
||||||
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
|
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
|
||||||
const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
|
const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
|
||||||
const [withCoverage, setWithCoverage] = useState(false);
|
|
||||||
// Стратегия автоформирования покрытия (кнопка «Автоподбор»).
|
// Стратегия автоформирования покрытия (кнопка «Автоподбор»).
|
||||||
const [strategy, setStrategy] = useState<SlotCoverageStrategy>("GREEDY");
|
const [strategy, setStrategy] = useState<SlotCoverageStrategy>("GREEDY");
|
||||||
|
|
||||||
// Результаты: слоты-кандидаты и уже забронированные слоты заявки.
|
// Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id).
|
||||||
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
||||||
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
|
||||||
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
|
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
|
||||||
@@ -96,9 +97,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const [calculating, setCalculating] = useState(false);
|
const [calculating, setCalculating] = useState(false);
|
||||||
const [autoFilling, setAutoFilling] = useState(false);
|
const [autoFilling, setAutoFilling] = useState(false);
|
||||||
const [booking, setBooking] = useState(false);
|
const [booking, setBooking] = useState(false);
|
||||||
// Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь).
|
// Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте».
|
||||||
const [bookProgress, setBookProgress] = useState<string>();
|
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);
|
const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
|
||||||
|
// «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение.
|
||||||
|
// cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных.
|
||||||
|
const [pendingCancel, setPendingCancel] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
|
|
||||||
@@ -141,8 +148,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
!Number.isFinite(parsedRange.toMs) ||
|
!Number.isFinite(parsedRange.toMs) ||
|
||||||
parsedRange.fromMs >= parsedRange.toMs;
|
parsedRange.fromMs >= parsedRange.toMs;
|
||||||
|
|
||||||
// Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте.
|
// Ключи забронированных слотов — для бейджа «бронь» в таблице и слоя на карте.
|
||||||
const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]);
|
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 полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте.
|
// id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте.
|
||||||
const markedSlotIds = useMemo(
|
const markedSlotIds = useMemo(
|
||||||
@@ -158,10 +171,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [slots, bookedSlots]);
|
}, [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 days = useMemo<SlotDay[]>(() => {
|
||||||
const buckets = new Map<number, number>();
|
const buckets = new Map<number, number>();
|
||||||
for (const slot of slots) {
|
for (const slot of displaySlots) {
|
||||||
const ms = parseLocal(slot.tn);
|
const ms = parseLocal(slot.tn);
|
||||||
if (!Number.isFinite(ms)) continue;
|
if (!Number.isFinite(ms)) continue;
|
||||||
const start = dayStartMs(ms);
|
const start = dayStartMs(ms);
|
||||||
@@ -170,7 +207,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
return Array.from(buckets.entries())
|
return Array.from(buckets.entries())
|
||||||
.sort(([a], [b]) => a - b)
|
.sort(([a], [b]) => a - b)
|
||||||
.map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
|
.map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
|
||||||
}, [slots]);
|
}, [displaySlots]);
|
||||||
|
|
||||||
// Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
|
// Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,16 +218,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
|
const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
|
||||||
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1));
|
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1));
|
||||||
|
|
||||||
// Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте.
|
// Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте (кандидаты + брони).
|
||||||
const filteredSlots = useMemo(() => {
|
const filteredSlots = useMemo(() => {
|
||||||
if (days.length === 0) return slots;
|
if (days.length === 0) return displaySlots;
|
||||||
const fromMs = days[effDayFrom - 1].dayMs;
|
const fromMs = days[effDayFrom - 1].dayMs;
|
||||||
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
|
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
|
||||||
return slots.filter((slot) => {
|
return displaySlots.filter((slot) => {
|
||||||
const ms = parseLocal(slot.tn);
|
const ms = parseLocal(slot.tn);
|
||||||
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
|
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
|
||||||
});
|
});
|
||||||
}, [slots, days, effDayFrom, effDayTo]);
|
}, [displaySlots, days, effDayFrom, effDayTo]);
|
||||||
|
|
||||||
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
|
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
|
||||||
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
|
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
|
||||||
@@ -279,6 +316,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setBookedSlots([]);
|
setBookedSlots([]);
|
||||||
setPendingBook(null);
|
setPendingBook(null);
|
||||||
|
setPendingCancel(false);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setActiveSlotId(undefined);
|
setActiveSlotId(undefined);
|
||||||
@@ -328,7 +366,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
timeStart: toLocalIso(parsedRange.fromMs),
|
timeStart: toLocalIso(parsedRange.fromMs),
|
||||||
timeStop: toLocalIso(parsedRange.toMs),
|
timeStop: toLocalIso(parsedRange.toMs),
|
||||||
revSign: revSign || undefined,
|
revSign: revSign || undefined,
|
||||||
cov: withCoverage,
|
// cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
|
||||||
|
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов.
|
||||||
|
cov: auto,
|
||||||
coverageStrategy
|
coverageStrategy
|
||||||
},
|
},
|
||||||
ctrl.signal
|
ctrl.signal
|
||||||
@@ -370,29 +410,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
const autoCover = () => void runCover(strategy);
|
const autoCover = () => void runCover(strategy);
|
||||||
const cancelCalculation = () => abortRef.current?.abort();
|
const cancelCalculation = () => abortRef.current?.abort();
|
||||||
|
|
||||||
// Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор
|
// Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных /
|
||||||
// бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс.
|
// снятие всего). Отделён от busy (расчёт/автоподбор).
|
||||||
|
const opBusy = booking || cancelling || cancellingSel;
|
||||||
|
|
||||||
|
// Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части
|
||||||
|
// через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST.
|
||||||
const performBook = async (toBook: SlotBookingRequest[]) => {
|
const performBook = async (toBook: SlotBookingRequest[]) => {
|
||||||
setBooking(true);
|
setBooking(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setNotice(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 {
|
try {
|
||||||
const collected: BookedSlotDTO[] = [];
|
const parts = await runChunked(
|
||||||
let done = 0;
|
toBook,
|
||||||
for (const part of chunk(toBook, BOOK_CHUNK)) {
|
BOOK_CHUNK,
|
||||||
const booked = await bookRequestSlots(selectedRequestId, part);
|
(part) => bookRequestSlots(selectedRequestId, part),
|
||||||
collected.push(...booked);
|
(done, total) => setProgress({ label: "Бронирование", done, total }),
|
||||||
done += part.length;
|
);
|
||||||
if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`);
|
const collected = parts.flat();
|
||||||
}
|
// Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
|
||||||
// Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
|
// отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
|
||||||
// рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
|
// by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
|
||||||
// (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
|
const idByKey = new Map(
|
||||||
// бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
|
collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const),
|
||||||
// (с контуром), что вернул POST, не перечитывая by-request.
|
);
|
||||||
const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
|
const justBooked: BookedSlotView[] = slots
|
||||||
const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot)));
|
.filter((slot) => idByKey.has(slotKey(slot)))
|
||||||
|
.map((slot) => ({ ...slot, bookedSlotId: idByKey.get(slotKey(slot))! }));
|
||||||
setBookedSlots((prev) => {
|
setBookedSlots((prev) => {
|
||||||
const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
|
const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
|
||||||
for (const slot of justBooked) merged.set(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 : "Не удалось забронировать слоты.");
|
setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
|
||||||
} finally {
|
} finally {
|
||||||
setBooking(false);
|
setBooking(false);
|
||||||
setBookProgress(undefined);
|
setProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем).
|
||||||
const collectSelected = (): SlotBookingRequest[] =>
|
const collectSelected = (): SlotBookingRequest[] =>
|
||||||
slots
|
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 }));
|
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
|
||||||
|
|
||||||
const book = () => {
|
const book = () => {
|
||||||
if (!selectedRequestId || booking || selectedKeys.size === 0) return;
|
if (!selectedRequestId || opBusy || selectedBookableCount === 0) return;
|
||||||
const toBook = collectSelected();
|
const toBook = collectSelected();
|
||||||
if (toBook.length === 0) return;
|
if (toBook.length === 0) return;
|
||||||
// Крупная бронь — сначала подтверждение, потом чанкованный POST.
|
// Крупная бронь — сначала подтверждение, потом чанкованный POST.
|
||||||
@@ -432,23 +478,65 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
};
|
};
|
||||||
const cancelPendingBook = () => setPendingBook(null);
|
const cancelPendingBook = () => setPendingBook(null);
|
||||||
|
|
||||||
const cancelBooking = async () => {
|
// Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс
|
||||||
if (!selectedRequestId || booking || bookedSlots.length === 0) return;
|
// (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
|
||||||
setBooking(true);
|
// и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
|
||||||
|
// одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
|
||||||
|
const runCancel = async (ids: number[], setBusy: (value: boolean) => void) => {
|
||||||
|
setBusy(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
|
setProgress(ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
|
||||||
try {
|
try {
|
||||||
const cancelled = await cancelRequestBooking(selectedRequestId);
|
const counts = await runChunked(
|
||||||
// Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()).
|
ids,
|
||||||
setBookedSlots([]);
|
BOOK_CHUNK,
|
||||||
setNotice(`Снято броней: ${cancelled}.`);
|
(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) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
|
setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
|
||||||
} finally {
|
} 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+клик — выбрать диапазон от якоря (предыдущего клика) до
|
// Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
|
||||||
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
// текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
|
||||||
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
const handleSlotCheck = (index: number, shiftKey: boolean) => {
|
||||||
@@ -471,7 +559,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
anchorIndexRef.current = null;
|
anchorIndexRef.current = null;
|
||||||
};
|
};
|
||||||
// Выбрать все слоты расчёта (по всем суткам), снять весь выбор.
|
// Выбрать все слоты расчёта (по всем суткам), снять весь выбор.
|
||||||
const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey)));
|
const selectAllDays = () => setSelectedKeys(new Set(displaySlots.map(slotKey)));
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
anchorIndexRef.current = null;
|
anchorIndexRef.current = null;
|
||||||
@@ -563,8 +651,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно.
|
// Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем,
|
||||||
const slotKeysShown = new Set(slots.map(slotKey));
|
// чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше.
|
||||||
|
const slotKeysShown = new Set(filteredSlots.map(slotKey));
|
||||||
for (const slot of bookedSlots) {
|
for (const slot of bookedSlots) {
|
||||||
const key = slotKey(slot);
|
const key = slotKey(slot);
|
||||||
if (slotKeysShown.has(key)) continue;
|
if (slotKeysShown.has(key)) continue;
|
||||||
@@ -679,7 +768,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
? "Загрузка заявок…"
|
? "Загрузка заявок…"
|
||||||
: undefined
|
: undefined
|
||||||
: slots.length === 0 && bookedSlots.length === 0
|
: slots.length === 0 && bookedSlots.length === 0
|
||||||
? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
|
? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
|
// Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
|
||||||
@@ -723,19 +812,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<option value="DESC">нисходящая</option>
|
<option value="DESC">нисходящая</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
|
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate} title="Показать весь набор слотов-кандидатов для ручного отбора">
|
||||||
<input
|
{calculating ? "Расчёт…" : "Показать все слоты"}
|
||||||
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>
|
</button>
|
||||||
<label className="tgu-field" title="Стратегия автоформирования покрытия">
|
<label className="tgu-field" title="Стратегия отбора покрытия для «Подобрать покрытие»">
|
||||||
<span>Автоподбор</span>
|
<span>Стратегия покрытия</span>
|
||||||
<select
|
<select
|
||||||
className="tgu-select"
|
className="tgu-select"
|
||||||
value={strategy}
|
value={strategy}
|
||||||
@@ -746,28 +827,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<option value="CONTINUOUS">непрерывный</option>
|
<option value="CONTINUOUS">непрерывный</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Сформировать покрытие автоматически и предложить под бронь">
|
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Отобрать достаточное покрытие по стратегии и предложить под бронь">
|
||||||
{autoFilling ? "Подбор…" : "Подобрать"}
|
{autoFilling ? "Подбор…" : "Подобрать покрытие"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="tgu-button"
|
className="tgu-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={book}
|
onClick={book}
|
||||||
disabled={booking || selectedKeys.size === 0}
|
disabled={opBusy || selectedBookableCount === 0}
|
||||||
|
title="Забронировать выбранные слоты-кандидаты"
|
||||||
>
|
>
|
||||||
{booking
|
{booking
|
||||||
? bookProgress
|
? "Бронь…"
|
||||||
? `Бронь… ${bookProgress}`
|
: `Забронировать${selectedBookableCount > 0 ? ` (${selectedBookableCount})` : ""}`}
|
||||||
: "Бронь…"
|
|
||||||
: `Забронировать${selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}`}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="tgu-button"
|
className="tgu-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={cancelBooking}
|
onClick={cancelSelected}
|
||||||
disabled={booking || bookedSlots.length === 0}
|
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>
|
</button>
|
||||||
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
||||||
{(slots.length > 0 || bookedSlots.length > 0) && (
|
{(slots.length > 0 || bookedSlots.length > 0) && (
|
||||||
@@ -796,8 +888,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
className="tgu-button tgu-button--sm"
|
className="tgu-button tgu-button--sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={selectAllDays}
|
onClick={selectAllDays}
|
||||||
disabled={slots.length === 0}
|
disabled={displaySlots.length === 0}
|
||||||
title="Выбрать все слоты расчёта (по всем суткам)"
|
title="Выбрать все строки (кандидаты и брони, по всем суткам)"
|
||||||
>
|
>
|
||||||
Все сутки
|
Все сутки
|
||||||
</button>
|
</button>
|
||||||
@@ -814,7 +906,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<div className="tgu-slots-scroll" ref={listRef}>
|
<div className="tgu-slots-scroll" ref={listRef}>
|
||||||
{filteredSlots.length === 0 ? (
|
{filteredSlots.length === 0 ? (
|
||||||
<div className="tgu-slots-empty">
|
<div className="tgu-slots-empty">
|
||||||
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
|
{displaySlots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="tgu-slots-table">
|
<table className="tgu-slots-table">
|
||||||
@@ -875,7 +967,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
<td>{slot.roll.toFixed(1)}°</td>
|
<td>{slot.roll.toFixed(1)}°</td>
|
||||||
<td>
|
<td>
|
||||||
{booked ? (
|
{booked ? (
|
||||||
|
<span className="tgu-slots-status-cell">
|
||||||
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
|
<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
|
<span
|
||||||
className={
|
className={
|
||||||
@@ -984,6 +1092,22 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{hasRequest && (
|
||||||
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
|
<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;
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ export type BookedSlotDTO = {
|
|||||||
cycle: number;
|
cycle: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Забронированный слот для отображения и снятия (slots-service: BookedSlotViewDTO,
|
||||||
|
* GET booking/by-request). Поля слота (контур, координаты) + bookedSlotId для снятия по id.
|
||||||
|
* cycle здесь — цикл БРОНИ, поэтому slotKey совпадает с ответом POST booking/request.
|
||||||
|
*/
|
||||||
|
export type BookedSlotView = SlotDTO & { bookedSlotId: number };
|
||||||
|
|
||||||
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
|
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
|
||||||
export type SlotBookingRequest = {
|
export type SlotBookingRequest = {
|
||||||
satelliteId: number;
|
satelliteId: number;
|
||||||
|
|||||||
@@ -1404,7 +1404,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
top: 0.85rem;
|
top: 0.85rem;
|
||||||
left: 0.85rem;
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
max-width: 23rem;
|
max-width: 23rem;
|
||||||
border: 1px solid rgba(240, 173, 46, 0.42);
|
border: 1px solid rgba(240, 173, 46, 0.42);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -1462,6 +1463,83 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Прогресс-бар чанковой операции (бронь и снятие). В одной палитре и позиции с оверлеем
|
||||||
|
«Расчёт слотов…»: сверху по центру, янтарный на тёмном — чтобы не перекрывать нижнюю подсказку
|
||||||
|
«много слотов в окне» и читаться как одно семейство уведомлений. */
|
||||||
|
.tgu-chunk-progress {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(23rem, 80%);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid rgba(240, 173, 46, 0.42);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(17, 19, 21, 0.88);
|
||||||
|
color: #ffe7b5;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-chunk-progress__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-chunk-progress__count {
|
||||||
|
color: rgba(255, 231, 181, 0.7);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-chunk-progress__track {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-chunk-progress__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f0ad2e;
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ячейка статуса брони с кнопкой снятия одного слота. */
|
||||||
|
.tgu-slots-status-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-slots-cancel-one {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-slots-cancel-one:hover:not(:disabled) {
|
||||||
|
border-color: var(--danger, #c0524f);
|
||||||
|
color: var(--danger, #c0524f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-slots-cancel-one:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-map-errors {
|
.tgu-map-errors {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|||||||
+9
-1
@@ -50,8 +50,16 @@ class BookingController {
|
|||||||
|
|
||||||
@PostMapping()
|
@PostMapping()
|
||||||
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
|
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
|
||||||
|
// Снятие одной брони: request-aware (см. SlotService.cancelSlot) — нужен requestId,
|
||||||
|
// чтобы при шаренном слоте порвать только связь этой заявки, а не снести бронь у других.
|
||||||
@DeleteMapping
|
@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")
|
@PostMapping("request")
|
||||||
fun bookReq(@RequestBody req : BookingRequestDTO) =
|
fun bookReq(@RequestBody req : BookingRequestDTO) =
|
||||||
slotService.bookReq(req)
|
slotService.bookReq(req)
|
||||||
|
|||||||
+24
@@ -13,8 +13,11 @@ import jakarta.persistence.OneToMany
|
|||||||
import jakarta.persistence.Table
|
import jakarta.persistence.Table
|
||||||
import org.hibernate.annotations.Fetch
|
import org.hibernate.annotations.Fetch
|
||||||
import org.hibernate.annotations.FetchMode
|
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.BookedSlotDTO
|
||||||
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.BookedSlotViewDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||||
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -51,4 +54,25 @@ class BookedSlotEntity(
|
|||||||
slotNum = slot.slotNum,
|
slotNum = slot.slotNum,
|
||||||
cycle = cycle
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun bookedByReqId(id : String) : List<SlotDTO> {
|
fun bookedByReqId(id : String) : List<BookedSlotViewDTO> {
|
||||||
return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.slot.toDTO(SlotStatus.valueOf(req.bookedSlot.status)) }
|
// 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
|
@Transactional
|
||||||
fun cancelReq(req : String) : Int{
|
fun cancelReq(req : String) : Int{
|
||||||
logger.info("Запрос на отмену бронирование по заявке ${req}")
|
logger.info("Запрос на отмену бронирование по заявке ${req}")
|
||||||
var cnt = 0
|
var cnt = 0
|
||||||
requestRepository.findByRequestId(req).forEach { booked ->
|
requestRepository.findByRequestId(req).forEach { link ->
|
||||||
if (booked.bookedSlot.requests?.size == 1)
|
removeBookedRequest(link)
|
||||||
bookedSlotsRepository.deleteByBookedSlotId(booked.bookedSlot.bookedSlotId?:-1)
|
|
||||||
else
|
|
||||||
requestRepository.deleteByBookedSlotRequestId( booked.bookedSlotRequestId?:-1)
|
|
||||||
++cnt
|
++cnt
|
||||||
}
|
}
|
||||||
return 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
|
@Transactional
|
||||||
fun cancelSlot(bookedSlotId : Long) =
|
fun cancelSlot(requestId : String, bookedSlotId : Long) : Int =
|
||||||
bookedSlotsRepository.deleteByBookedSlotId(bookedSlotId)
|
cancelReqSlots(requestId, listOf(bookedSlotId))
|
||||||
|
|
||||||
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
|
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
|
||||||
val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null
|
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