diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index 619bfe2..b43ce31 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -2,8 +2,25 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { toast } from 'react-toastify'; import { nonNegativeColumnKeys } from '../../constants/validation'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; -import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, 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', @@ -33,7 +50,7 @@ const TEXTAREA_STYLES = { const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); - const storageKey = `formula_${tableId}_${cellId}`; + const storageKey = `formula_v2_${tableId}_${cellId}`; // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не // редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и @@ -43,14 +60,27 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta // применяется для getSelectedColumnId/getColumnTitle в этом файле. // Инициализатор useState вызывается один раз при монтировании — ровно // тогда, когда этот редактор открылся именно так. + // hasInitialInputRef: редактор открыт набором символа на выделенной ячейке — + // пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя. + const hasInitialInputRef = useRef(false); const [state, setState] = useState(() => { const initialInput = table?.options?.meta?.getEditorInitialInput?.(); if (initialInput !== null && initialInput !== undefined) { + hasInitialInputRef.current = true; return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false }; } return { currentValue: value, displayValue: value, isFormulaMode: false }; }); + // Исходное значение редактора на момент открытия — чтобы не отправлять + // на сервер открытие и закрытие ячейки без правки (AURORA-1895). + const initialEditorValueRef = useRef(value); + // Актуальное значение ячейки для эффекта разрешения формулы: эффект + // срабатывает один раз при открытии и не должен затирать набранный текст, + // если другой пользователь изменит ячейку, пока она открыта. + const valueRef = useRef(value); + valueRef.current = value; + // Позиция ячейки const [position, setPosition] = useState(); const textareaRef = useRef(null); @@ -61,25 +91,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const refFinished = useRef(false); - // Загрузка сохраненной формулы + // Загрузка сохраненной формулы (AURORA-1895): подставляем её только если + // результат совпадает с текущим значением ячейки, иначе формула устарела + // (stale: значение изменил другой пользователь) и удаляется из хранилища. useEffect(() => { + if (hasInitialInputRef.current) return; + + const currentCellValue = valueRef.current; 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, currentCellValue); + + if (stale) { + removeFromStorage(storageKey); + } + + initialEditorValueRef.current = currentValue; + + if (isFormulaMode) { + setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true }); } }, [storageKey]); @@ -157,45 +186,56 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta [cell, table], ); + // Возвращает true, если можно закрывать редактор (сохранено либо не было + // изменений), false — если формула некорректна и редактор должен остаться + // открытым (ничего не отправляем на сервер в этом случае). const handleSave = useCallback(() => { const { currentValue } = state; + // AURORA-1891/1895: ячейку открыли и закрыли без правки — на сервер + // ничего не шлём (ни cell_updated, ни запись в аудит). + if (currentValue === initialEditorValueRef.current) return true; + if (isFormula(currentValue)) { - saveToStorage(storageKey, currentValue); + let result; try { - const formula = extractFormula(currentValue); - const result = calculateFormula(formula); - const formattedResult = formatNumber(result); - setState((prev) => ({ - ...prev, - displayValue: formattedResult, - isFormulaMode: true, - })); - warnIfNegativeNotAllowed(formattedResult); - onChange?.(formattedResult); + result = calculateFormula(extractFormula(currentValue)); } 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, })); - const normalizedValue = normalizeNumericInput(currentValue); - warnIfNegativeNotAllowed(normalizedValue); - // AURORA-1891: значение не изменилось — не шлём onChange (а значит и - // cell_updated): открытие и закрытие ячейки без правки не должно - // создавать запись в журнале аудита. - if (isSameCellValue(normalizedValue, value)) return; - onChange?.(normalizedValue); + warnIfNegativeNotAllowed(formattedResult); + onChange?.(formattedResult); + return true; } + + removeFromStorage(storageKey); + setState((prev) => ({ + ...prev, + displayValue: currentValue, + isFormulaMode: false, + })); + + const normalizedValue = normalizeNumericInput(currentValue); + warnIfNegativeNotAllowed(normalizedValue); + // Значение фактически не изменилось (напр. '150' вместо 150) — не шлём + // onChange (а значит и cell_updated). + if (isSameCellValue(normalizedValue, value)) return true; + onChange?.(normalizedValue); + return true; }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]); // Завершает редактирование текущей ячейки (cell_edit_end) — читает @@ -212,13 +252,14 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta } }, [table, contextEndEditing]); + // Возвращает true, если редактор закрыт; false — если остался открытым + // (формула не вычисляется) либо уже был закрыт ранее. const finishEditing = useCallback(() => { - if (refFinished.current) return; + if (refFinished.current) return false; + if (!disabled && !handleSave()) return false; refFinished.current = true; - if (!disabled) { - handleSave(); - } endCurrentEditing(); + return true; }, [disabled, handleSave, endCurrentEditing]); // AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого @@ -267,8 +308,11 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta // текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем. if (e.key === 'Tab') { e.preventDefault(); - table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right'); - finishEditing(); + // AURORA-1895: цель перехода помечаем только если редактор реально + // закрылся — при невычисляемой формуле он остаётся открытым. + if (finishEditing()) { + table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right'); + } return; } @@ -276,8 +320,9 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta // e.preventDefault() не вызывается). if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down'); - finishEditing(); + if (finishEditing()) { + table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down'); + } } }, [cancelEditing, finishEditing, table, cell], diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index dbd3778..9300ae3 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -28,6 +28,7 @@ import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/c import { isCellEditable } from './utils/cellEditability'; import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; import { canAddRowForHeader, getRowId } from './utils/rowUtils'; +import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; @@ -224,6 +225,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 e1ef1f7..f928581 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -11,6 +11,7 @@ import { isCellEditable } from './utils/cellEditability'; import { getColumnValue } from './utils/columnUtils'; 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(); @@ -54,7 +55,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 ca73d3b..25fd36b 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.js @@ -123,6 +123,37 @@ export const isSameCellValue = (a, b) => { return normalizeComparableValue(a) === normalizeComparableValue(b); }; +/** + * Начальное состояние редактора ячейки. + * Решает, показывать ли сохранённую формулу или текущее значение ячейки, + * и помечает формулу как устаревшую (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 }; + } +}; + /** * Форматирует число для отображения * @param {number} value - Число для форматирования diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.test.js b/web/src/components/RealtimeTable/utils/formulaUtils.test.js index 9325d24..824887b 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.test.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isSameCellValue } from './formulaUtils.js'; +import { isSameCellValue, resolveEditorState } from './formulaUtils.js'; // AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если // значение фактически не изменилось — иначе лишняя запись в БД/аудите при @@ -36,3 +36,51 @@ test('different values (numeric or text) are not the same', () => { test('non-numeric text compares as-is after trimming surrounding whitespace', () => { assert.equal(isSameCellValue(' текст ', 'текст'), true); }); + +// AURORA-1895: формула из localStorage не перезаписывает чужое значение. + +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 - Массив ключей, которые нужно сохранить