fix(tgu-ui): подхватывать существующий черновик вместо ошибки «Conflict» при добавлении съёмки

При первой правке плана ensureDraft→createDraft(replace) падал 409, если по КА
уже есть открытый черновик на это окно (осиротевший с прошлой сессии — авто-отмена
best-effort). В UI это показывалось голым «Conflict» (на стенде не подхвачен
include-message у tgu).

Теперь на 409 от createDraft редактор подхватывает существующий черновик того же
КА с тем же окном (adoptExistingDraft через fetchDraftPlans, сверка окна ±2 c) и
продолжает в нём; если подходящего нет — читаемое сообщение со ссылкой на вкладку
«Планы» вместо «Conflict».
This commit is contained in:
Дмитрий Соловьев
2026-06-10 16:13:59 +03:00
parent 7202f7cb02
commit 802c966347
@@ -15,7 +15,9 @@ import {
validateMission, validateMission,
type SurveyRoute type SurveyRoute
} from "../../api/missionPlanningApi"; } from "../../api/missionPlanningApi";
import { abandonDraft, createDraft } from "../../api/tguPlanApi"; import { abandonDraft, createDraft, fetchDraftPlans } from "../../api/tguPlanApi";
import { TguApiError } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal, toLocalIso } from "../../utils/localTime"; import { parseLocal, toLocalIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -627,6 +629,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы». // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
// Ищет уже открытый DRAFT этого КА с тем же окном, что у базового плана (replace 1:1 совпадает
// по окну). Используется при 409 от createDraft, чтобы подхватить осиротевший черновик.
const adoptExistingDraft = useCallback(async (basePlan: TguPlanUi): Promise<string | undefined> => {
if (!basePlan.spacecraftId) return undefined;
const drafts = await fetchDraftPlans(basePlan.spacecraftId).catch(() => [] as Awaited<ReturnType<typeof fetchDraftPlans>>);
const match = drafts.find(
(draft) =>
Math.abs(parseLocal(draft.startTime) - basePlan.startMs) < 2000 &&
Math.abs(parseLocal(draft.endTime) - basePlan.endMs) < 2000
);
return match?.planId;
}, []);
const ensureDraft = useCallback(async (): Promise<string> => { const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId; if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
@@ -635,11 +650,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDraftPlanId(selectedPlan.planId); setDraftPlanId(selectedPlan.planId);
return selectedPlan.planId; return selectedPlan.planId;
} }
const created = await createDraft(selectedPlan.planId, { mode: "replace" }); try {
createdImplicitlyRef.current = true; const created = await createDraft(selectedPlan.planId, { mode: "replace" });
setDraftPlanId(created.draftPlanId); createdImplicitlyRef.current = true;
return created.draftPlanId; setDraftPlanId(created.draftPlanId);
}, [draftPlanId, selectedPlan]); return created.draftPlanId;
} catch (error) {
// 409: по этому КА уже открыт черновик на это же окно (осиротевший после прошлой сессии —
// авто-отмена при уходе best-effort и могла не отработать). Вместо тупиковой ошибки
// «Conflict» подхватываем существующий черновик того же окна и правим его (replace 1:1 —
// окно совпадает с базой). Не нашли подходящий — пробрасываем читаемую ошибку.
if (error instanceof TguApiError && error.status === 409) {
const adopted = await adoptExistingDraft(selectedPlan);
if (adopted) {
createdImplicitlyRef.current = true;
setDraftPlanId(adopted);
return adopted;
}
throw new TguApiError(
"По этому КА уже есть открытый черновик на это окно. Отмените его во вкладке «Планы» (раздел «Черновики») или продолжите редактирование там.",
"http",
409,
);
}
throw error;
}
}, [draftPlanId, selectedPlan, adoptExistingDraft]);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.