diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index acdf758..0fd906c 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,7 +1,25 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; -import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils'; -import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; +import { + calculateFormula, + extractFormula, + formatNumber, + isFormula, + isSameCellValue, + normalizeNumericInput, + resolveEditorState, +} from '../../utils/formulaUtils'; +import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils'; + +// Вычисляет отображаемое значение формулы, не бросая исключение наружу. +const computeFormulaDisplay = (formulaValue) => { + try { + return formatNumber(calculateFormula(extractFormula(formulaValue))); + } catch { + return '#ОШИБКА'; + } +}; const EDITOR_STYLES = { position: 'absolute', @@ -31,7 +49,7 @@ const TEXTAREA_STYLES = { const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); - const storageKey = `formula_${tableId}_${cellId}`; + const storageKey = `formula_v2_${tableId}_${cellId}`; const [state, setState] = useState(() => ({ currentValue: value, @@ -39,33 +57,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta isFormulaMode: false, })); + // Исходное значение редактора на момент открытия — чтобы не отправлять + // на сервер открытие и закрытие ячейки без правки. + const initialEditorValueRef = useRef(value); + // Позиция ячейки const [position, setPosition] = useState(); const textareaRef = useRef(null); const refFinished = useRef(false); - // Загрузка сохраненной формулы + // Загрузка сохраненной формулы: подставляем её только если результат + // совпадает с текущим значением ячейки, иначе формула устарела (stale) + // и удаляется из хранилища. useEffect(() => { const savedFormula = loadFromStorage(storageKey); - if (savedFormula && isFormula(savedFormula)) { - try { - const formula = extractFormula(savedFormula); - const result = calculateFormula(formula); - setState({ - currentValue: savedFormula, - displayValue: formatNumber(result), - isFormulaMode: true, - }); - } catch { - setState({ - currentValue: savedFormula, - displayValue: '#ОШИБКА', - isFormulaMode: true, - }); - } + const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, value); + + if (stale) { + removeFromStorage(storageKey); } - }, [storageKey]); + + initialEditorValueRef.current = currentValue; + + if (isFormulaMode) { + setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true }); + return; + } + + setState({ currentValue, displayValue: currentValue, isFormulaMode: false }); + }, [storageKey, value]); // Вычисление позиции useLayoutEffect(() => { @@ -114,46 +135,67 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta return () => clearTimeout(timeoutId); }, [disabled]); + // Возвращает true, если можно закрывать редактор (сохранено либо не было + // изменений), false — если формула некорректна и редактор должен остаться + // открытым (ничего не отправляем на сервер в этом случае). const handleSave = useCallback(() => { const { currentValue } = state; + if (currentValue === initialEditorValueRef.current) { + // Ячейку открыли и закрыли без правки — на сервер ничего не шлём. + return true; + } + if (isFormula(currentValue)) { - saveToStorage(storageKey, currentValue); + const formula = extractFormula(currentValue); + let result; try { - const formula = extractFormula(currentValue); - const result = calculateFormula(formula); - const formattedResult = formatNumber(result); - setState((prev) => ({ - ...prev, - displayValue: formattedResult, - isFormulaMode: true, - })); - onChange?.(formattedResult); + result = calculateFormula(formula); } catch { setState((prev) => ({ ...prev, displayValue: '#ОШИБКА', isFormulaMode: true, })); - onChange?.(currentValue); + toast.error('Формула не вычисляется, проверьте выражение'); + return false; } - } else { - localStorage.removeItem(storageKey); + + const formattedResult = formatNumber(result); + saveToStorage(storageKey, currentValue); setState((prev) => ({ ...prev, - displayValue: currentValue, - isFormulaMode: false, + displayValue: formattedResult, + isFormulaMode: true, })); - onChange?.(normalizeNumericInput(currentValue)); + onChange?.(formattedResult); + return true; } - }, [state, storageKey, onChange]); + + removeFromStorage(storageKey); + setState((prev) => ({ + ...prev, + displayValue: currentValue, + isFormulaMode: false, + })); + + const normalized = normalizeNumericInput(currentValue); + if (isSameCellValue(normalized, value)) { + // Значение фактически не изменилось (напр. '150' вместо 150). + return true; + } + + onChange?.(normalized); + return true; + }, [state, storageKey, onChange, value]); const finishEditing = useCallback(() => { if (refFinished.current) return; - refFinished.current = true; if (!disabled) { - handleSave(); + const canFinish = handleSave(); + if (!canFinish) return; } + refFinished.current = true; const editingCell = table.getState().editingCell; if (editingCell) { contextEndEditing?.(editingCell.row, editingCell.column); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 539e4b3..51443d1 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -25,6 +25,7 @@ import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; import { getRowId } from './utils/rowUtils'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; +import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; @@ -170,6 +171,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnVirtualizerRef.current?.measure?.(); }, [sizeMult]); + // Разовая очистка устаревших ключей формул, привязанных к позиции строки + // (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*. + useEffect(() => { + clearLegacyFormulaKeys(); + }, []); + useEffect(() => { if (!formType || !sheetName) return; let isCancelled = false; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index cf557e0..b021008 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -9,6 +9,7 @@ import { blueColumn } from './constants/columnColors'; import { sectionCodeColor } from './constants/columnConfig'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; +import { getRowId } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); @@ -52,7 +53,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable onChange: handleChange, tableId: tableKey, table, - cellId: `${cell.row.id}_${cell.column.id}`, + // Привязан к line_id из данных строки, а не к её позиции в дереве + // (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк). + cellId: `${getRowId(cell.row)}_${cell.column.id}`, }), [cell, isEditable, handleChange, tableKey, table], ); diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js index b36151c..3d0742e 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.js @@ -81,6 +81,61 @@ export const evaluateCellValue = (value) => { } }; +/** + * Начальное состояние редактора ячейки. + * Решает, показывать ли сохранённую формулу или текущее значение ячейки, + * и помечает формулу как устаревшую (stale), если её результат расходится + * с текущим значением — значит, значение изменил кто-то другой, либо + * формула больше не вычисляется. + * @param {string|null} savedFormula - строка вида '=...' из localStorage, либо null + * @param {string|number|null} value - текущее значение ячейки (с сервера) + * @returns {{ currentValue: string|number|null, isFormulaMode: boolean, stale: boolean }} + */ +export const resolveEditorState = (savedFormula, value) => { + if (!savedFormula || !isFormula(savedFormula)) { + return { currentValue: value, isFormulaMode: false, stale: false }; + } + + try { + const expression = extractFormula(savedFormula); + const result = calculateFormula(expression); + const numericValue = Number(value); + const stale = Number.isNaN(numericValue) || Math.abs(result - numericValue) > 1e-9; + + if (stale) { + return { currentValue: value, isFormulaMode: false, stale: true }; + } + + return { currentValue: savedFormula, isFormulaMode: true, stale: false }; + } catch { + return { currentValue: value, isFormulaMode: false, stale: true }; + } +}; + +const isEmptyCellValue = (value) => value === null || value === undefined || value === ''; + +const isNumericCellValue = (value) => !isEmptyCellValue(value) && !Number.isNaN(Number(value)); + +/** + * Сравнивает два значения ячейки так, как их видит пользователь: + * числа сравниваются как числа ('150' == 150, '150.0' == 150), '' == null. + * @param {string|number|null} a + * @param {string|number|null} b + * @returns {boolean} + */ +export const isSameCellValue = (a, b) => { + const aEmpty = isEmptyCellValue(a); + const bEmpty = isEmptyCellValue(b); + + if (aEmpty || bEmpty) return aEmpty && bEmpty; + + if (isNumericCellValue(a) && isNumericCellValue(b)) { + return Number(a) === Number(b); + } + + return a === b; +}; + /** * Форматирует число для отображения * @param {number} value - Число для форматирования diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.test.js b/web/src/components/RealtimeTable/utils/formulaUtils.test.js new file mode 100644 index 0000000..228e45b --- /dev/null +++ b/web/src/components/RealtimeTable/utils/formulaUtils.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isSameCellValue, resolveEditorState } from './formulaUtils.js'; + +test('resolveEditorState keeps formula mode when the formula result matches the current value', () => { + const result = resolveEditorState('=100+50', 150); + assert.equal(result.isFormulaMode, true); + assert.equal(result.stale, false); +}); + +test('resolveEditorState marks the formula stale when the current value diverged (another user changed it)', () => { + const result = resolveEditorState('=100+50', 200); + assert.equal(result.stale, true); + assert.equal(result.currentValue, 200); +}); + +test('resolveEditorState keeps formula mode when the current value is a numeric string that matches', () => { + const result = resolveEditorState('=100+50', '150'); + assert.equal(result.stale, false); + assert.equal(result.isFormulaMode, true); +}); + +test('resolveEditorState marks the formula stale when it cannot be calculated', () => { + const result = resolveEditorState('=abc', 0); + assert.equal(result.stale, true); +}); + +test('resolveEditorState falls back to plain value mode when there is no saved formula', () => { + const result = resolveEditorState(null, 5); + assert.equal(result.isFormulaMode, false); + assert.equal(result.stale, false); + assert.equal(result.currentValue, 5); +}); + +test('isSameCellValue treats a numeric string and a number as equal', () => { + assert.equal(isSameCellValue('150', 150), true); +}); + +test('isSameCellValue treats an empty string and null as equal', () => { + assert.equal(isSameCellValue('', null), true); +}); + +test('isSameCellValue treats a numeric string with a trailing .0 as equal to the plain number', () => { + assert.equal(isSameCellValue('150.0', 150), true); +}); + +test('isSameCellValue treats different numbers as not equal', () => { + assert.equal(isSameCellValue('151', 150), false); +}); diff --git a/web/src/components/RealtimeTable/utils/localStorageUtils.js b/web/src/components/RealtimeTable/utils/localStorageUtils.js index 38fd5a4..dfd40a6 100644 --- a/web/src/components/RealtimeTable/utils/localStorageUtils.js +++ b/web/src/components/RealtimeTable/utils/localStorageUtils.js @@ -81,6 +81,23 @@ export const removeFromStorage = (key) => { } }; +/** + * Удаляет устаревшие ключи формул вида `formula__`, + * привязанные к позиции строки в дереве таблицы (AURORA-1895). Текущие ключи + * формул имеют префикс `formula_v2_` и привязаны к line_id — их не трогаем. + * @returns {number} - количество удалённых ключей + */ +export const clearLegacyFormulaKeys = () => { + try { + const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith('formula_') && !key.startsWith('formula_v2_')); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + return keysToRemove.length; + } catch (error) { + console.error('Error clearing legacy formula keys from localStorage:', error); + return 0; + } +}; + /** * Очистка всех данных приложения из localStorage * @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить