pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ

Бэкенд (pcp-tgu-service):
- REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ
  взводится, цепочка встаёт на паузу (новый флаг
  SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические
  негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу.
- Пауза централизована в PlanProjectionService.project(): для отклонённой
  цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг
  снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался).
- Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT.
- Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT,
  повторное создание окна -> 409.
- ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с),
  устойчиво к усечению ISO на фронте до целых секунд.

Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана,
панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков.

Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:04:47 +03:00
parent 3638afdb85
commit 5049395911
34 changed files with 1062 additions and 458 deletions
@@ -1,11 +1,25 @@
import { describe, expect, it } from "vitest";
import type { TguPlatform } from "../../model/tguTypes";
import { isOperationalPlatform, operationalSpacecraftIds } from "./tguTimelineMapper";
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
function platform(overrides: Partial<TguPlatform>): TguPlatform {
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
}
function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): TguPlanUi {
return {
planId: `${spacecraftId}-${status}-${startMs}`,
spacecraftId,
startTime: new Date(startMs).toISOString().slice(0, 19),
endTime: new Date(startMs + 3_600_000).toISOString().slice(0, 19),
kppId: "KPP-1",
status,
startMs,
endMs: startMs + 3_600_000
};
}
describe("isOperationalPlatform", () => {
it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => {
expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true);
@@ -28,3 +42,38 @@ describe("operationalSpacecraftIds", () => {
expect(ids).toEqual(new Set(["2", "30"]));
});
});
describe("pausedSpacecraftIds", () => {
const t0 = Date.UTC(2026, 5, 4, 0, 0, 0);
const hour = 3_600_000;
it("помечает КА паузой, если есть REJECTED и нет продвинувшего цепочку плана после него", () => {
const paused = pausedSpacecraftIds([
uiPlan("100", "REJECTED", t0),
// проекция и черновик паузу не снимают
uiPlan("100", "PLANNED", t0 + 2 * hour),
uiPlan("100", "DRAFT", t0)
]);
expect(paused).toEqual(new Set(["100"]));
});
it("снимает паузу, если оператор отправил исправленный план (в полёте) на том же/более позднем окне", () => {
const paused = pausedSpacecraftIds([
uiPlan("100", "REJECTED", t0),
uiPlan("100", "WAITING_DECISION", t0)
]);
expect(paused.has("100")).toBe(false);
});
it("не помечает паузой, если цепочка ушла вперёд (ACCEPTED после отказа)", () => {
const paused = pausedSpacecraftIds([
uiPlan("100", "REJECTED", t0),
uiPlan("100", "ACCEPTED", t0 + 5 * hour)
]);
expect(paused.has("100")).toBe(false);
});
it("без REJECTED никого не помечает", () => {
expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set());
});
});