diff --git a/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx b/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx
index 6d712db..051e179 100644
--- a/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx
+++ b/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx
@@ -2,18 +2,34 @@ import { Alert } from '@mui/material';
import { DangerOutlinedButton, SecondaryButton } from '../../common/Buttons/Buttons';
import Modal from '../../common/Modal/Modal';
-export const ConfirmDeleteRowModal = ({ isOpen, onClose, onConfirm }) => (
-
- Отмена
- Удалить
- >
- }>
- Удалить выбранную строку? Данные строки будут потеряны.
-
-);
+// AURORA-1978: target — результат getDeleteTarget (строка, программа или проект формы 4).
+const getConfirmTexts = (target) => {
+ const name = target?.name ? ` «${target.name}»` : '';
+ switch (target?.kind) {
+ case 'program':
+ return { title: 'Удаление программы', message: `Удалить программу${name}?` };
+ case 'project':
+ return { title: 'Удаление проекта', message: `Удалить проект${name}?` };
+ default:
+ return { title: 'Удаление строки', message: 'Удалить выбранную строку? Данные строки будут потеряны.' };
+ }
+};
+
+export const ConfirmDeleteRowModal = ({ target, onClose, onConfirm }) => {
+ const { title, message } = getConfirmTexts(target);
+ return (
+
+ Отмена
+ Удалить
+ >
+ }>
+ {message}
+
+ );
+};
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index a223a5c..7f7400e 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -30,7 +30,7 @@ import { getCellMemoResetKey, pickSettledSizing } from './utils/cellMemoReset';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
-import { canAddRowForHeader, getRowId } from './utils/rowUtils';
+import { canAddRowForHeader, getDeleteTarget } from './utils/rowUtils';
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
@@ -144,7 +144,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
- const [rowIdPendingDelete, setRowIdPendingDelete] = useState(null);
+ // AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
+ const [pendingDelete, setPendingDelete] = useState(null);
const modalsRef = useRef(null);
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
@@ -182,6 +183,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
updateCell: contextUpdateCell,
addRow: contextAddRow,
deleteRow: contextDeleteRow,
+ deleteProgram: contextDeleteProgram,
+ deleteProject: contextDeleteProject,
startEditing: contextStartEditing,
addProgram: contextAddProgram,
addProject: contextAddProject,
@@ -1020,17 +1023,28 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
toast.error('Выделите строку для удаления');
return;
}
- setRowIdPendingDelete(getRowId(row));
- }, [canModifyRows, getSelectedRow]);
+ const target = getDeleteTarget(row.original, { isForm4: formType === 'FORM_4' });
+ if (!target) {
+ toast.error('Эту строку нельзя удалить');
+ return;
+ }
+ if (target.blockedReason) {
+ toast.error(target.blockedReason);
+ return;
+ }
+ setPendingDelete(target);
+ }, [canModifyRows, formType, getSelectedRow]);
- const closeConfirmDeleteRow = useCallback(() => setRowIdPendingDelete(null), []);
+ const closeConfirmDeleteRow = useCallback(() => setPendingDelete(null), []);
const confirmDeleteRow = useCallback(() => {
- if (rowIdPendingDelete == null) return;
- contextDeleteRow(rowIdPendingDelete);
- setRowIdPendingDelete(null);
+ if (!pendingDelete) return;
+ if (pendingDelete.kind === 'program') contextDeleteProgram(pendingDelete.id);
+ else if (pendingDelete.kind === 'project') contextDeleteProject(pendingDelete.id);
+ else contextDeleteRow(pendingDelete.id);
+ setPendingDelete(null);
setRowSelection({});
- }, [contextDeleteRow, rowIdPendingDelete]);
+ }, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
const addRow = useCallback(() => {
if (!canModifyRows) {
@@ -1202,7 +1216,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
-
+
{
+ if (!isConnectedRef.current) {
+ onErrorRef.current?.('Нет подключения или авторизации');
+ return false;
+ }
+
+ const message = {
+ event: 'program_deleted',
+ data: { program_id: programId },
+ };
+ addCommonField(message);
+
+ return sendMessage(message);
+ },
+ [addCommonField, isConnectedRef, sendMessage],
+ );
+
+ const deleteProject = useCallback(
+ (projectId) => {
+ if (!isConnectedRef.current) {
+ onErrorRef.current?.('Нет подключения или авторизации');
+ return false;
+ }
+
+ const message = {
+ event: 'project_deleted',
+ data: { project_id: projectId },
+ };
+ addCommonField(message);
+
+ return sendMessage(message);
+ },
+ [addCommonField, isConnectedRef, sendMessage],
+ );
+
const subscribeToCellUpdates = useCallback((callback) => {
onCellUpdateRef.current = callback;
return () => {
@@ -438,6 +485,13 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
};
}, []);
+ const subscribeToNodeDeletes = useCallback((callback) => {
+ onNodeDeleteRef.current = callback;
+ return () => {
+ onNodeDeleteRef.current = null;
+ };
+ }, []);
+
const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback;
return () => {
@@ -482,10 +536,13 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
addProgram,
addProject,
deleteRow,
+ deleteProgram,
+ deleteProject,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
+ subscribeToNodeDeletes,
subscribeToErrors,
subscribeToAuthSuccess,
subscribeToCellEditStart,
@@ -499,6 +556,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
addProgram,
addProject,
addRow,
+ deleteProgram,
+ deleteProject,
deleteRow,
endEditing,
lockedCellsStore,
@@ -509,6 +568,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
subscribeToCellEditStart,
subscribeToCellUpdates,
subscribeToErrors,
+ subscribeToNodeDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToRowAdds,
diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
index 2b4973b..5d3f383 100644
--- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js
+++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
@@ -3,7 +3,16 @@ import { toast } from 'react-toastify';
import { FormsSheetApi } from '../../../api/form_sheet';
import { ProjectsApi } from '../../../api/projects';
import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext';
-import { addRootRow, deleteRow, insertCell, insertProjectRow, updateCells } from '../utils/cellUtils';
+import {
+ addRootRow,
+ alignNodeRows,
+ deleteProgramRow,
+ deleteProjectRow,
+ deleteRow,
+ insertCell,
+ insertProjectRow,
+ updateCells,
+} from '../utils/cellUtils';
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
@@ -22,6 +31,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToRowDeletes,
+ subscribeToNodeDeletes,
subscribeToErrors,
subscribeToCellEditStart,
subscribeToCellEditEnd,
@@ -181,6 +191,22 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
});
});
+ // AURORA-1978: ответ на program_deleted / project_deleted —
+ // [id, [[row_type, depth, sort_order, data], ...]]: пересчитанные
+ // ROOT (и GROUP родительской программы для проекта). Сначала убираем
+ // строку, затем вливаем пересчёт, сопоставляя строки по id, а не по
+ // sort_order сервера (см. alignNodeRows).
+ const unsubscribeNodeDeletes = subscribeToNodeDeletes((msg) => {
+ const [, updatedRows = []] = msg.result;
+ setData((prevData) => {
+ const withoutNode =
+ msg.event === 'program_deleted'
+ ? deleteProgramRow(prevData, msg.data.program_id)
+ : deleteProjectRow(prevData, msg.data.project_id);
+ return updateCells(withoutNode, alignNodeRows(withoutNode, updatedRows));
+ });
+ });
+
// Обработчик начала редактирования ячейки
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
const dataCell = data.data;
@@ -220,6 +246,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
unsubscribeProgramAdds();
unsubscribeProjectAdds();
unsubscribeRowDeletes();
+ unsubscribeNodeDeletes();
unsubscribeErrors();
unsubscribeCellEditStart();
unsubscribeCellEditEnd();
@@ -229,6 +256,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToCellUpdates,
subscribeToRowAdds,
subscribeToRowDeletes,
+ subscribeToNodeDeletes,
subscribeToErrors,
subscribeToCellEditStart,
subscribeToCellEditEnd,
diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js
index 024993a..9e6d247 100644
--- a/web/src/components/RealtimeTable/utils/cellUtils.js
+++ b/web/src/components/RealtimeTable/utils/cellUtils.js
@@ -285,15 +285,61 @@ export function insertProjectRow(data, newRow, program_id) {
}
export function deleteRow(data, lineId) {
+ if (lineId == null) return data;
+ return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId);
+}
+
+// AURORA-1978: строка программы формы 4 (GROUP) по program_id.
+export function deleteProgramRow(data, programId) {
+ return deleteRowWhere(data, (row) => row.row_type === 'GROUP' && row.data?.header?.program_id === programId);
+}
+
+// AURORA-1978: строка проекта формы 4 (ITEM) по project_id.
+export function deleteProjectRow(data, projectId) {
+ return deleteRowWhere(data, (row) => row.row_type === 'ITEM' && row.data?.header?.project_id === projectId);
+}
+
+// AURORA-1978: строки ответа program_deleted / project_deleted сопоставляются с
+// локальными по идентичности (ROOT — section_code, GROUP — program_id, ITEM —
+// project_id), а не по sort_order: сервер нумерует строки заново по всему листу,
+// а у листа без строк бюджета нет ROOT, поэтому его номера расходятся с локальными.
+// Возвращает строки ответа с локальным sort_order (для updateCells); строки без
+// пары в локальных данных отбрасываются.
+const getNodeRowKey = (rowType, header) => {
+ if (rowType === 'ROOT') return `ROOT|${header?.section_code}`;
+ if (rowType === 'GROUP') return `GROUP|${header?.program_id}`;
+ if (rowType === 'ITEM') return `ITEM|${header?.project_id}`;
+ return null;
+};
+
+export function alignNodeRows(data, rows) {
+ const localSortOrders = new Map();
+ const collect = (items) => {
+ for (const row of items) {
+ const key = getNodeRowKey(row.row_type, row.data?.header);
+ if (key && !localSortOrders.has(key)) localSortOrders.set(key, row.sort_order);
+ if (Array.isArray(row.subRows)) collect(row.subRows);
+ }
+ };
+ collect(data);
+
+ return rows.flatMap(([rowType, depth, , rowData]) => {
+ const sortOrder = localSortOrders.get(getNodeRowKey(rowType, rowData?.header));
+ return sortOrder == null ? [] : [[rowType, depth, sortOrder, rowData]];
+ });
+}
+
+// Удаляет первую строку, подходящую под matches, и сдвигает sort_order
+// последующих строк (обход в глубину идёт в порядке sort_order).
+function deleteRowWhere(data, matches) {
const updatedRows = JSON.parse(JSON.stringify(data));
let rowDeleteSortOrder = false;
function findAndDelete(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
- const rowId = getDataRowId(row.data);
- if (rowId === lineId) {
+ if (rowDeleteSortOrder === false && matches(row)) {
rowDeleteSortOrder = row.sort_order;
rows.splice(i, 1);
i--;
diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js
index 92bc357..7e31d11 100644
--- a/web/src/components/RealtimeTable/utils/cellUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { updateCells } from './cellUtils.js';
+import { alignNodeRows, deleteProgramRow, deleteProjectRow, deleteRow, updateCells } from './cellUtils.js';
// Эталон: прежняя реализация updateCells (до AURORA-1838).
function oldUpdateCells(data, newRows) {
@@ -245,3 +245,143 @@ test('AURORA-1848: два обновления одной строки в одн
]);
assert.deepEqual(out[0].data.q1, { m1: 5, m2: 3 });
});
+
+// AURORA-1978: удаление строк программы/проекта формы 4 из дерева по ответу
+// program_deleted / project_deleted; sort_order последующих строк сдвигается
+// на 1, как при удалении строки бюджета.
+const form4Tree = () => [
+ {
+ row_type: 'ROOT',
+ depth: 0,
+ sort_order: 1,
+ data: { line_id: null, header: { section_code: 'S' } },
+ subRows: [
+ {
+ row_type: 'GROUP',
+ depth: 1,
+ sort_order: 2,
+ data: { line_id: null, header: { program_id: 11 } },
+ subRows: [
+ { row_type: 'ITEM', depth: 2, sort_order: 3, data: { line_id: null, header: { program_id: 11, project_id: 21 } }, subRows: [] },
+ {
+ row_type: 'ITEM',
+ depth: 2,
+ sort_order: 4,
+ data: { line_id: null, header: { program_id: 11, project_id: 22 } },
+ subRows: [{ row_type: 'INPUT', depth: 3, sort_order: 5, data: { line_id: 501, header: { project_id: 22 } }, subRows: [] }],
+ },
+ ],
+ },
+ { row_type: 'GROUP', depth: 1, sort_order: 6, data: { line_id: null, header: { program_id: 12 } }, subRows: [] },
+ ],
+ },
+];
+
+const shape = (rows) => rows.map((r) => [r.row_type, r.sort_order, shape(r.subRows ?? [])]);
+
+test('AURORA-1978: deleteProjectRow убирает ITEM проекта и сдвигает sort_order', () => {
+ const data = form4Tree();
+ const result = deleteProjectRow(data, 21);
+ assert.deepEqual(shape(result), [
+ [
+ 'ROOT',
+ 1,
+ [
+ ['GROUP', 2, [['ITEM', 3, [['INPUT', 4, []]]]]],
+ ['GROUP', 5, []],
+ ],
+ ],
+ ]);
+ assert.equal(result[0].subRows[0].subRows[0].data.header.project_id, 22);
+ assert.deepEqual(shape(data), shape(form4Tree()), 'исходные данные не меняются');
+});
+
+test('AURORA-1978: deleteProgramRow убирает GROUP программы, строки бюджета с тем же program_id не трогает', () => {
+ const result = deleteProgramRow(form4Tree(), 12);
+ assert.deepEqual(shape(result), [
+ [
+ 'ROOT',
+ 1,
+ [
+ [
+ 'GROUP',
+ 2,
+ [
+ ['ITEM', 3, []],
+ ['ITEM', 4, [['INPUT', 5, []]]],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ // program_id 11 есть и у ITEM/INPUT, но удаляется только сама GROUP 12;
+ // несуществующая программа — дерево без изменений.
+ assert.equal(result[0].subRows[0].subRows[0].data.header.program_id, 11);
+ assert.deepEqual(shape(deleteProgramRow(form4Tree(), 99)), shape(form4Tree()));
+});
+
+test('AURORA-1978: deleteRow по line_id не задевает строки программ и проектов', () => {
+ const result = deleteRow(form4Tree(), 501);
+ assert.deepEqual(shape(result), [
+ [
+ 'ROOT',
+ 1,
+ [
+ [
+ 'GROUP',
+ 2,
+ [
+ ['ITEM', 3, []],
+ ['ITEM', 4, []],
+ ],
+ ],
+ ['GROUP', 5, []],
+ ],
+ ],
+ ]);
+});
+
+// AURORA-1978: ответ program_deleted / project_deleted нумерует строки заново
+// по всему листу, и у листа без строк бюджета нет ROOT — sort_order сервера
+// расходится с локальным. Найдено вживую: после удаления последнего проекта
+// GROUP «В» (sort_order 2 у сервера) затёр локальную GROUP «Б» (sort_order 2).
+test('AURORA-1978: alignNodeRows сопоставляет строки ответа по program_id / section_code, а не по sort_order', () => {
+ const local = [
+ {
+ row_type: 'ROOT',
+ depth: 0,
+ sort_order: 1,
+ data: { line_id: null, header: { section_code: 'CAP', name: 'Раздел' } },
+ subRows: [
+ { row_type: 'GROUP', depth: 1, sort_order: 2, data: { line_id: null, header: { program_id: 2, name: 'Б' } }, subRows: [] },
+ { row_type: 'GROUP', depth: 1, sort_order: 3, data: { line_id: null, header: { program_id: 3, name: 'В' } }, subRows: [] },
+ ],
+ },
+ ];
+ // Сервер после удаления последнего проекта программы «В»: ROOT нет, «В» — вторая строка листа.
+ const serverRows = [['GROUP', 1, 2, { line_id: null, header: { program_id: 3, name: 'В' }, plan: { q1: 0 } }]];
+
+ const aligned = alignNodeRows(local, serverRows);
+ assert.deepEqual(aligned, [['GROUP', 1, 3, serverRows[0][3]]]);
+
+ const result = updateCells(local, aligned);
+ assert.equal(result[0].subRows[0].data.header.name, 'Б');
+ assert.equal(result[0].subRows[0].data.plan, undefined);
+ assert.deepEqual(result[0].subRows[1].data.plan, { q1: 0 });
+});
+
+test('AURORA-1978: alignNodeRows — ROOT по section_code, строки без пары отбрасываются', () => {
+ const local = form4Tree();
+ const rows = [
+ ['ROOT', 0, 7, { header: { section_code: 'S' } }],
+ ['GROUP', 1, 1, { header: { program_id: 99 } }],
+ ['ITEM', 2, 1, { header: { program_id: 11, project_id: 22 } }],
+ ];
+ assert.deepEqual(
+ alignNodeRows(local, rows).map(([type, depth, sort]) => [type, depth, sort]),
+ [
+ ['ROOT', 0, 1],
+ ['ITEM', 2, 4],
+ ],
+ );
+});
diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js
index 3137c6d..48d556f 100644
--- a/web/src/components/RealtimeTable/utils/rowUtils.js
+++ b/web/src/components/RealtimeTable/utils/rowUtils.js
@@ -37,6 +37,38 @@ export function getRowId(row) {
return getDataRowId(row.original.data);
}
+// AURORA-1978: что удаляет кнопка «Удалить строку» для выделенной строки.
+// На форме 4 у строк программы (GROUP) и проекта (ITEM) нет line_id — их
+// удаляют отдельные события program_deleted / project_deleted по id из header.
+// Непустые программу/проект сервер не удалит (v3.del_form4_program /
+// v3.del_form4_project), поэтому причину показываем сразу, без запроса.
+// rowData — row.original (row_type, data, subRows).
+export function getDeleteTarget(rowData, { isForm4 = false } = {}) {
+ if (!rowData) return null;
+ const header = rowData.data?.header;
+ const hasChildren = Array.isArray(rowData.subRows) && rowData.subRows.length > 0;
+
+ if (isForm4 && rowData.row_type === 'GROUP' && header?.program_id != null) {
+ return {
+ kind: 'program',
+ id: header.program_id,
+ name: header.name ?? '',
+ blockedReason: hasChildren ? 'Нельзя удалить программу: в ней есть проекты или расходы' : null,
+ };
+ }
+ if (isForm4 && rowData.row_type === 'ITEM' && header?.project_id != null) {
+ return {
+ kind: 'project',
+ id: header.project_id,
+ name: header.name ?? '',
+ blockedReason: hasChildren ? 'Нельзя удалить проект: в нём есть расходы' : null,
+ };
+ }
+
+ const id = getDataRowId(rowData.data);
+ return id == null ? null : { kind: 'row', id };
+}
+
export function isVspRow(row) {
if (row?.original?.data?.id) {
return true;
diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js
index ded8b1f..a7b623f 100644
--- a/web/src/components/RealtimeTable/utils/rowUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { buildRowHierarchy, canAddRowForHeader, getDataRowId } from './rowUtils.js';
+import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget } from './rowUtils.js';
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
const rows = [
@@ -14,9 +14,18 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows
const tree = buildRowHierarchy(rows);
- assert.deepEqual(tree.map(({ id }) => id), ['root', 'next-root']);
- assert.deepEqual(tree[0].subRows.map(({ id }) => id), ['item-1', 'item-2']);
- assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']);
+ assert.deepEqual(
+ tree.map(({ id }) => id),
+ ['root', 'next-root'],
+ );
+ assert.deepEqual(
+ tree[0].subRows.map(({ id }) => id),
+ ['item-1', 'item-2'],
+ );
+ assert.deepEqual(
+ tree[0].subRows[0].subRows.map(({ id }) => id),
+ ['input-1', 'input-2'],
+ );
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
});
@@ -38,3 +47,43 @@ test('AURORA-1848: getDataRowId без id и line_id возвращает null
assert.equal(getDataRowId({ line_id: 7 }), 7);
assert.ok(JSON.stringify({ row_id: getDataRowId({ header: {} }) }).includes('"row_id":null'));
});
+
+// AURORA-1978: «Удалить строку» на форме 4 для строки программы (GROUP) и
+// проекта (ITEM). У этих строк нет line_id — раньше кнопка молча ничего не
+// делала; теперь цель удаления — программа/проект по id из header.
+const form4Row = (row_type, header, subRows = []) => ({ row_type, data: { line_id: null, header }, subRows });
+
+test('AURORA-1978: getDeleteTarget — пустая программа и пустой проект формы 4', () => {
+ assert.deepEqual(getDeleteTarget(form4Row('GROUP', { program_id: 11, name: 'Программа А' }), { isForm4: true }), {
+ kind: 'program',
+ id: 11,
+ name: 'Программа А',
+ blockedReason: null,
+ });
+ assert.deepEqual(getDeleteTarget(form4Row('ITEM', { program_id: 11, project_id: 21, name: 'Проект Б' }), { isForm4: true }), {
+ kind: 'project',
+ id: 21,
+ name: 'Проект Б',
+ blockedReason: null,
+ });
+});
+
+test('AURORA-1978: getDeleteTarget — непустые программа и проект запрещены с причиной', () => {
+ const project = form4Row('ITEM', { program_id: 11, project_id: 21, name: 'Проект Б' }, [
+ { row_type: 'INPUT', data: { line_id: 501, header: {} }, subRows: [] },
+ ]);
+ const program = form4Row('GROUP', { program_id: 11, name: 'Программа А' }, [project]);
+
+ assert.equal(getDeleteTarget(program, { isForm4: true }).blockedReason, 'Нельзя удалить программу: в ней есть проекты или расходы');
+ assert.equal(getDeleteTarget(project, { isForm4: true }).blockedReason, 'Нельзя удалить проект: в нём есть расходы');
+});
+
+test('AURORA-1978: getDeleteTarget — строка бюджета и прочие формы удаляются по line_id, как раньше', () => {
+ const input = { row_type: 'INPUT', data: { line_id: 501, header: { program_id: 11, project_id: 21 } }, subRows: [] };
+ assert.deepEqual(getDeleteTarget(input, { isForm4: true }), { kind: 'row', id: 501 });
+ assert.deepEqual(getDeleteTarget({ row_type: 'INPUT', data: { id: 7 } }), { kind: 'row', id: 7 });
+ // GROUP вне формы 4 (иерархия статей) — удалять нечего.
+ assert.equal(getDeleteTarget(form4Row('GROUP', { program_id: 11 })), null);
+ assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null);
+ assert.equal(getDeleteTarget(null, { isForm4: true }), null);
+});
diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.js
index 37bd677..e8def58 100644
--- a/web/src/components/RealtimeTable/utils/wsErrorUtils.js
+++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.js
@@ -9,6 +9,11 @@ const KNOWN_PREFIX_MESSAGES = [
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
+// AURORA-1978: backend (map_sqlalchemy_error) отдаёт str(exc.orig) — для RAISE
+// из PL/pgSQL это ": <текст>".
+const EXCEPTION_CLASS_PREFIX = /^:\s*/;
+const stripExceptionClass = (text) => String(text ?? '').replace(EXCEPTION_CLASS_PREFIX, '');
+
// Если текст начинается с известного технического префикса — возвращает человеческую замену, иначе null.
const resolveKnownPrefix = (text) => {
const normalized = String(text ?? '').trim();
@@ -22,12 +27,13 @@ const resolveKnownPrefix = (text) => {
// Никогда не возвращает undefined/null и не добавляет технический префикс "Server error:".
export const getWsErrorMessage = (error) => {
if (typeof error === 'string') {
- return resolveKnownPrefix(error) ?? (error.trim() || DEFAULT_ERROR_MESSAGE);
+ const text = stripExceptionClass(error);
+ return resolveKnownPrefix(text) ?? (text.trim() || DEFAULT_ERROR_MESSAGE);
}
if (error && typeof error === 'object') {
- const raw = error.message || error.detail;
- if (!raw) return DEFAULT_ERROR_MESSAGE;
+ const raw = stripExceptionClass(error.message || error.detail);
+ if (!raw.trim()) return DEFAULT_ERROR_MESSAGE;
return resolveKnownPrefix(raw) ?? raw;
}
diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js
index 23336e4..1dbbda0 100644
--- a/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js
@@ -51,3 +51,23 @@ test('пустая строка и отсутствующая ошибка не
assert.notEqual(getWsErrorMessage(undefined), undefined);
assert.ok(!String(getWsErrorMessage(undefined)).includes('undefined'));
});
+
+// AURORA-1978: map_sqlalchemy_error берёт str(exc.orig), и для RAISE из
+// PL/pgSQL текст приходит как ": ...".
+// Технический префикс класса в тост не попадает; известные префиксы после него
+// по-прежнему переводятся.
+test('AURORA-1978: префикс класса исключения asyncpg срезается', () => {
+ assert.equal(
+ getWsErrorMessage({
+ code: 400,
+ field: '',
+ message: ": Нельзя удалить программу, содержащую проекты",
+ }),
+ 'Нельзя удалить программу, содержащую проекты',
+ );
+ assert.equal(
+ getWsErrorMessage(": role_not_allowed: колонка plan.q1"),
+ 'Нет прав на изменение этой колонки',
+ );
+ assert.equal(getWsErrorMessage(": "), 'Неизвестная ошибка сервера');
+});