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 1ac2247..d9ae991 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -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 }) => { - + { + 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(": "), 'Неизвестная ошибка сервера'); +});