AURORA-1978: удаление пустой программы и проекта на форме 4

«Удалить строку» на строке программы (GROUP) или проекта (ITEM) ничего не
делала: у этих строк нет line_id, окно подтверждения не открывалось, а
события program_deleted / project_deleted, которые сервер уже поддерживает,
фронт не отправлял.

- getDeleteTarget: по выделенной строке выбирает программу, проект или
  строку бюджета; для непустых программы/проекта сразу показывает причину.
- RealtimeContext: deleteProgram / deleteProject и обработка ответов.
- useRealtimeData: строка убирается из дерева, пересчёт ROOT/GROUP из
  ответа сопоставляется по section_code / program_id / project_id
  (alignNodeRows), а не по sort_order — сервер нумерует строки заново, и у
  листа без строк бюджета нет ROOT (найдено при проверке на стенде).
- Окно подтверждения называет удаляемую программу/проект.
- getWsErrorMessage срезает префикс "<class 'asyncpg...'>: " — иначе отказ
  сервера (программа с проектами у устаревшего клиента) показывался с ним.

Refs #303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 20:02:41 +04:00
parent 244c03ac14
commit 1aec5d2c2d
10 changed files with 447 additions and 36 deletions

View File

@ -2,10 +2,25 @@ 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 (
<Modal
title='Удаление строки'
open={isOpen}
title={title}
open={target != null}
onClose={onClose}
customSize={25}
actions={
@ -14,6 +29,7 @@ export const ConfirmDeleteRowModal = ({ isOpen, onClose, onConfirm }) => (
<DangerOutlinedButton onClick={onConfirm}>Удалить</DangerOutlinedButton>
</>
}>
<Alert severity='warning'>Удалить выбранную строку? Данные строки будут потеряны.</Alert>
<Alert severity='warning'>{message}</Alert>
</Modal>
);
};

View File

@ -28,7 +28,7 @@ import { useValidationRules } from './hooks/useValidationRules';
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';
@ -137,7 +137,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]);
@ -171,6 +172,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
updateCell: contextUpdateCell,
addRow: contextAddRow,
deleteRow: contextDeleteRow,
deleteProgram: contextDeleteProgram,
deleteProject: contextDeleteProject,
startEditing: contextStartEditing,
addProgram: contextAddProgram,
addProject: contextAddProject,
@ -962,17 +965,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) {
@ -1142,7 +1156,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
</div>
</div>
</div>
<ConfirmDeleteRowModal isOpen={rowIdPendingDelete != null} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
<SheetModals
ref={modalsRef}
formId={formId}

View File

@ -168,6 +168,14 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
}
break;
// AURORA-1978: удаление программы/проекта формы 4.
case 'program_deleted':
case 'project_deleted':
if (data.result && onNodeDeleteRef.current) {
onNodeDeleteRef.current(data);
}
break;
default:
console.log('Unknown event:', data.event);
}
@ -180,6 +188,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
const onRowDeleteRef = useRef(null);
const onProgramAddRef = useRef(null);
const onProjectAddRef = useRef(null);
const onNodeDeleteRef = useRef(null);
const onErrorRef = useRef(null);
const onAuthSuccessRef = useRef(null);
const onCellEditStartRef = useRef(null);
@ -403,6 +412,44 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
[addCommonField, isConnectedRef, sendMessage],
);
// AURORA-1978: программа/проект формы 4 удаляются своими событиями —
// у их строк нет line_id, row_deleted для них не подходит.
const deleteProgram = useCallback(
(programId) => {
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,

View File

@ -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,

View File

@ -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--;

View File

@ -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],
],
);
});

View File

@ -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;

View File

@ -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);
});

View File

@ -9,6 +9,11 @@ const KNOWN_PREFIX_MESSAGES = [
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
// AURORA-1978: backend (map_sqlalchemy_error) отдаёт str(exc.orig) — для RAISE
// из PL/pgSQL это "<class 'asyncpg.exceptions.RaiseError'>: <текст>".
const EXCEPTION_CLASS_PREFIX = /^<class '[^']*'>:\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;
}

View File

@ -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 текст приходит как "<class 'asyncpg.exceptions.RaiseError'>: ...".
// Технический префикс класса в тост не попадает; известные префиксы после него
// по-прежнему переводятся.
test('AURORA-1978: префикс класса исключения asyncpg срезается', () => {
assert.equal(
getWsErrorMessage({
code: 400,
field: '',
message: "<class 'asyncpg.exceptions.RaiseError'>: Нельзя удалить программу, содержащую проекты",
}),
'Нельзя удалить программу, содержащую проекты',
);
assert.equal(
getWsErrorMessage("<class 'asyncpg.exceptions.RaiseError'>: role_not_allowed: колонка plan.q1"),
'Нет прав на изменение этой колонки',
);
assert.equal(getWsErrorMessage("<class 'asyncpg.exceptions.RaiseError'>: "), 'Неизвестная ошибка сервера');
});