diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
index bd07a07..b86cc73 100644
--- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
+++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
@@ -1,9 +1,11 @@
import { Tooltip } from '@mui/material';
import React, { useMemo, useCallback, useState } from 'react';
-import { useCellLock } from '../../contexts/RealtimeContext';
+import { getColumnRequestKey, useCellFormula, useCellLock } from '../../contexts/RealtimeContext';
+import { formatFormulaHintWithUnit, isFormulaActual } from '../../utils/cellFormulaUtils';
import { resolveCellTextColor } from '../../utils/cellTextColor';
-import { formatNumber } from '../../utils/numberFormat';
import { isTextColumn } from '../../utils/moneyUtils';
+import { formatNumber } from '../../utils/numberFormat';
+import { getRowId } from '../../utils/rowUtils';
const BASE_CELL_STYLES = {
width: '100%',
@@ -75,6 +77,23 @@ const LOCK_BADGE_STYLES = {
border: '1px solid rgba(255, 255, 255, 0.1)',
};
+// AURORA-1896: маркер «fx» у ячейки со значением, посчитанным по сохранённой формуле.
+// Угол слева-снизу: значок блокировки (AURORA-1890) занят справа-сверху, рамка редактируемости —
+// граница ячейки, поэтому маркер ничего из этого не перекрывает.
+const FORMULA_MARK_STYLES = {
+ position: 'absolute',
+ left: '3px',
+ bottom: '1px',
+ fontSize: '9px',
+ lineHeight: 1,
+ fontStyle: 'italic',
+ fontWeight: 600,
+ color: '#4a90d9',
+ cursor: 'help',
+ userSelect: 'none',
+ zIndex: 5,
+};
+
const CONTAINER_STYLES = {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
@@ -144,6 +163,7 @@ const CellComponent = ({
invalidMessage,
vspOptions,
displayFactor = 1,
+ moneyUnit = null,
}) => {
const cellKey = `${row.id}_${column.id}`;
const isLocked = useCellLock(cellKey);
@@ -183,6 +203,10 @@ const CellComponent = ({
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);
+ // AURORA-1896: формула с сервера; маркер только если она даёт текущее значение ячейки.
+ const formula = useCellFormula(getRowId(row), getColumnRequestKey(column));
+ const showFormulaMark = isFormulaActual(formula, rawValue);
+
const highlightedContent = useMemo(() => {
const searchQueries = [globalFilter, columnFilter];
return createHighlightedContent(rawValue, displayValue, searchQueries);
@@ -230,6 +254,11 @@ const CellComponent = ({
{highlightedContent}
{isOverLimit && сверх лимита}
{lockBadge}
+ {showFormulaMark && (
+
+ fx
+
+ )}
);
@@ -261,6 +290,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.isEditable === nextProps.isEditable &&
prevProps.vspOptions === nextProps.vspOptions &&
prevProps.displayFactor === nextProps.displayFactor &&
+ prevProps.moneyUnit === nextProps.moneyUnit &&
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
);
});
diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
index b43ce31..2dd9d03 100644
--- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
+++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
@@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { nonNegativeColumnKeys } from '../../constants/validation';
-import { useRealtimeActions } from '../../contexts/RealtimeContext';
+import { getColumnRequestKey, useCellFormula, useRealtimeActions } from '../../contexts/RealtimeContext';
+import { FORMULA_NEEDS_EQUALS_MESSAGE, looksLikeExpressionWithoutEquals, resolveInitialEditorText } from '../../utils/cellFormulaUtils';
import {
calculateFormula,
extractFormula,
@@ -9,9 +10,9 @@ import {
isFormula,
isSameCellValue,
normalizeNumericInput,
- resolveEditorState,
} from '../../utils/formulaUtils';
-import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils';
+import { isTextColumn } from '../../utils/moneyUtils';
+import { getRowId } from '../../utils/rowUtils';
// Вычисляет отображаемое значение формулы, не бросая исключение наружу.
const computeFormulaDisplay = (formulaValue) => {
@@ -48,9 +49,10 @@ const TEXTAREA_STYLES = {
fontSize: 'inherit',
};
-const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
+const EditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
- const storageKey = `formula_v2_${tableId}_${cellId}`;
+ // AURORA-1896: формулу хранит сервер; карта приходит из контекста (localStorage не используется).
+ const formula = useCellFormula(getRowId(cell.row), getColumnRequestKey(cell.column));
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
@@ -60,26 +62,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
// Инициализатор useState вызывается один раз при монтировании — ровно
// тогда, когда этот редактор открылся именно так.
- // hasInitialInputRef: редактор открыт набором символа на выделенной ячейке —
- // пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя.
- const hasInitialInputRef = useRef(false);
- const [state, setState] = useState(() => {
+ // Начальный текст: набор символа на выделенной ячейке заменяет значение с нуля
+ // (формулу не подставляем); иначе актуальная формула открывается как «=выражение»,
+ // неактуальная (значение изменилось без ввода) — просто значение (AURORA-1896).
+ // Инициализатор useState вызывается один раз при монтировании, поэтому набранный
+ // текст не затирается, если формула или значение изменятся, пока ячейка открыта.
+ const [initial] = 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 };
+ const { text, isFormulaMode } = resolveInitialEditorText(formula, value);
+ return { currentValue: text, displayValue: isFormulaMode ? computeFormulaDisplay(text) : text, isFormulaMode };
});
+ const [state, setState] = useState(initial);
// Исходное значение редактора на момент открытия — чтобы не отправлять
// на сервер открытие и закрытие ячейки без правки (AURORA-1895).
- const initialEditorValueRef = useRef(value);
- // Актуальное значение ячейки для эффекта разрешения формулы: эффект
- // срабатывает один раз при открытии и не должен затирать набранный текст,
- // если другой пользователь изменит ячейку, пока она открыта.
- const valueRef = useRef(value);
- valueRef.current = value;
+ const initialEditorValueRef = useRef(initial.isFormulaMode ? initial.currentValue : value);
// Позиция ячейки
const [position, setPosition] = useState();
@@ -91,27 +91,6 @@ 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);
- const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, currentCellValue);
-
- if (stale) {
- removeFromStorage(storageKey);
- }
-
- initialEditorValueRef.current = currentValue;
-
- if (isFormulaMode) {
- setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true });
- }
- }, [storageKey]);
-
// Вычисление позиции
useLayoutEffect(() => {
if (!refCell?.current) return;
@@ -211,18 +190,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}
const formattedResult = formatNumber(result);
- saveToStorage(storageKey, currentValue);
setState((prev) => ({
...prev,
displayValue: formattedResult,
isFormulaMode: true,
}));
warnIfNegativeNotAllowed(formattedResult);
- onChange?.(formattedResult);
+ // AURORA-1896: уходит значение и выражение без «=» — формулу сохраняет сервер.
+ onChange?.(formattedResult, { formula: extractFormula(currentValue) });
return true;
}
- removeFromStorage(storageKey);
+ // AURORA-1896: «100+50» без «=» — не отправляем (иначе на сервер уйдёт текст), подсказываем
+ // и оставляем редактор открытым. Текстовые колонки не проверяем: там «5-1» — обычный текст.
+ if (!isTextColumn(cell?.column) && looksLikeExpressionWithoutEquals(currentValue)) {
+ toast.info(FORMULA_NEEDS_EQUALS_MESSAGE);
+ return false;
+ }
+
setState((prev) => ({
...prev,
displayValue: currentValue,
@@ -236,7 +221,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
if (isSameCellValue(normalizedValue, value)) return true;
onChange?.(normalizedValue);
return true;
- }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
+ }, [state, cell, onChange, warnIfNegativeNotAllowed, value]);
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
// table.getState().editingCell, а не cell.row/cell.column: к этому моменту
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index 93e456d..50417fb 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -32,7 +32,6 @@ import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/c
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils';
-import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
import { DictVspApi } from '../../api/dict-vsp';
@@ -269,12 +268,6 @@ 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;
@@ -320,8 +313,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
}, [formId, columnsConfig, setVspOptions]);
const handleUpdateCell = useCallback(
- (row, column, value) => {
- return contextUpdateCell(row, column, value);
+ (row, column, value, options) => {
+ return contextUpdateCell(row, column, value, options);
},
[contextUpdateCell],
);
diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
index b45d02a..7f7dd19 100644
--- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js
+++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
@@ -1,6 +1,10 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { toast } from 'react-toastify';
+import { FormsSheetApi } from '../../../api/form_sheet';
+import { ProjectsApi } from '../../../api/projects';
import { useWebSocket } from '../hooks/useWebSocket';
+import { clearLegacyFormulaStorage, formulaKey } from '../utils/cellFormulaUtils';
+import { createFormulaStore } from '../utils/formulaStore';
import { buildCellUpdatedMessage } from '../utils/messageUtils';
import { getRowId } from '../utils/rowUtils';
import { getWsErrorMessage } from '../utils/wsErrorUtils';
@@ -9,8 +13,17 @@ const RealtimeActionsContext = createContext(null);
const RealtimeConnectionContext = createContext(null);
const RealtimeVspOptionsContext = createContext(null);
const LockedCellsStoreContext = createContext(null);
+const FormulaStoreContext = createContext(null);
-const getColumnRequestKey = (column) => {
+// AURORA-1896: формулы теперь хранятся на сервере — прежние ключи formula_* в localStorage
+// удаляем один раз при загрузке модуля (идемпотентно, ошибки хранилища не критичны).
+try {
+ clearLegacyFormulaStorage(window.localStorage);
+} catch {
+ // localStorage недоступен (приватный режим и т.п.) — чистить нечего
+}
+
+export const getColumnRequestKey = (column) => {
const columnKey = column?.columnDef?.id ?? column?.id;
if (columnKey === null || columnKey === undefined) return '';
@@ -86,6 +99,19 @@ export const useCellLock = (cellKey) => {
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
+export const useFormulaStore = () => useRequiredContext(FormulaStoreContext, 'useFormulaStore');
+
+// AURORA-1896: формула ячейки (line_id + ключ колонки без `data.`) или undefined.
+// Подписка по ключу — при событии перерисовывается только затронутая ячейка.
+export const useCellFormula = (lineId, columnKey) => {
+ const store = useFormulaStore();
+ const key = formulaKey(lineId, columnKey);
+ const subscribe = useCallback((listener) => store.subscribe(key, listener), [key, store]);
+ const getSnapshot = useCallback(() => store.get(key), [key, store]);
+
+ return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
+};
+
export const useRealtime = () => {
const actions = useRealtimeActions();
const connection = useRealtimeConnection();
@@ -110,6 +136,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
lockedCellsStoreRef.current = createLockedCellsStore();
}
const lockedCellsStore = lockedCellsStoreRef.current;
+ const formulaStoreRef = useRef(null);
+ if (formulaStoreRef.current === null) {
+ formulaStoreRef.current = createFormulaStore();
+ }
+ const formulaStore = formulaStoreRef.current;
const handleMessage = useCallback((data) => {
// Обработка ошибок
@@ -141,6 +172,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
break;
case 'cell_updated':
+ // AURORA-1896: ответы с ошибкой отсекаются выше (data.error), сюда доходят только
+ // успешные события — и свои, и чужие. Фоновое событие без line_id/column карту не меняет.
+ formulaStoreRef.current.applyEvent(data.data);
if (data.result && onCellUpdateRef.current) {
const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
onCellUpdateRef.current(updatedCells);
@@ -197,6 +231,31 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
+ // AURORA-1896: смена листа/формы/года — карта прежнего листа не должна протекать.
+ // Ответы загрузок, начатых до сброса, стор отбрасывает по токену (reset/beginLoad).
+ // biome-ignore lint/correctness/useExhaustiveDependencies: идентификаторы листа — триггеры сброса карты
+ useEffect(() => {
+ formulaStore.reset();
+ return () => formulaStore.reset();
+ }, [formulaStore, formId, sheetName, year, isProject]);
+
+ // Карта формул грузится при открытии листа и при каждом (пере)подключении WS:
+ // за время обрыва другие пользователи могли изменить формулы.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: isConnected — триггер перезагрузки при переподключении
+ useEffect(() => {
+ if (formId === undefined || formId === null || !sheetName) return;
+ const token = formulaStore.beginLoad();
+ const request = isProject ? ProjectsApi.getReportFormulas(formId, year, sheetName) : FormsSheetApi.getFormulas(formId, sheetName);
+ Promise.resolve(request)
+ .then((list) => {
+ formulaStore.finishLoad(token, Array.isArray(list) ? list : []);
+ })
+ .catch((error) => {
+ console.warn('Не удалось загрузить формулы листа:', error);
+ formulaStore.failLoad(token);
+ });
+ }, [formulaStore, formId, sheetName, year, isProject, isConnected]);
+
const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null);
@@ -336,7 +395,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
);
const updateCell = useCallback(
- (row, column, value) => {
+ (row, column, value, { formula } = {}) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('WebSocket не подключен');
return false;
@@ -344,7 +403,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
const lineId = getRowId(row);
const columnKey = getColumnRequestKey(column);
- const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value });
+ const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value, formula });
const sent = sendMessage(message);
if (!sent) {
@@ -638,7 +697,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
- {children}
+
+ {children}
+
diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx
index 5cbece6..2205d60 100644
--- a/web/src/components/RealtimeTable/tableColumns.jsx
+++ b/web/src/components/RealtimeTable/tableColumns.jsx
@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
-import { useParams } from 'react-router';
import { useVspOptions } from './contexts/RealtimeContext';
import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils';
@@ -11,11 +10,9 @@ import { isCellEditable } from './utils/cellEditability';
import { getColumnValue } from './utils/columnUtils';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
-import { getForm4TypeLabel, getRowId } from './utils/rowUtils';
+import { getForm4TypeLabel } from './utils/rowUtils';
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
- const { formId, formType, sheetName, direction } = useParams();
- const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
const ref = useRef(null);
const [refTbody, setRefTbody] = useState(null);
useEffect(() => {
@@ -28,11 +25,11 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
}, []);
const handleChange = useCallback(
- (val) => {
+ (val, options) => {
table.setEditingCell(null);
try {
if (table.options.meta?.updateCell) {
- const success = table.options.meta.updateCell(cell.row, cell.column, val);
+ const success = table.options.meta.updateCell(cell.row, cell.column, val, options);
if (!success) {
console.error('Failed to update cell via WebSocket');
onError?.('Не удалось сохранить изменение');
@@ -53,13 +50,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
value: cell.getValue(),
disabled: !isEditable,
onChange: handleChange,
- tableId: tableKey,
table,
- // Привязан к line_id из данных строки, а не к её позиции в дереве
- // (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк).
- cellId: `${getRowId(cell.row)}_${cell.column.id}`,
}),
- [cell, isEditable, handleChange, tableKey, table],
+ [cell, isEditable, handleChange, table],
);
const editType = cell.column.columnDef?.editType;
@@ -182,6 +175,7 @@ export const getTableColumns = ({
const isEditableCell = isEditable(row, column);
const backgroundColor = getColorCell(columnColors, column, row);
const displayFactor = getCellDisplayFactor(column);
+ const moneyUnit = getMoneyUnit(column, sheetName);
if (!cellPropsCache.has(row)) {
cellPropsCache.set(row, {});
}
@@ -197,6 +191,7 @@ export const getTableColumns = ({
cached.columnFilter === currentColumnFilter &&
cached.value === value &&
cached.displayFactor === displayFactor &&
+ cached.moneyUnit === moneyUnit &&
cached.isInvalid === isInvalid &&
cached.invalidMessage === invalidMessage &&
cached.backgroundColor === backgroundColor
@@ -217,6 +212,7 @@ export const getTableColumns = ({
invalidMessage,
value,
displayFactor,
+ moneyUnit,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`,
};
diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js
index 0964c8d..a7b7174 100644
--- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js
+++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js
@@ -49,8 +49,10 @@ const pad = (n) => String(n).padStart(2, '0');
export const formatFormulaHint = (formula) => {
const author = formula.updated_by;
- const name = author?.full_name || `Пользователь #${author?.id}`;
- const parts = [`=${formula.expression}`, name];
+ const parts = [`=${formula.expression}`];
+ if (author && (author.full_name || author.id != null)) {
+ parts.push(author.full_name || `Пользователь #${author.id}`);
+ }
if (formula.updated_at) {
const d = new Date(formula.updated_at);
if (!Number.isNaN(d.getTime())) {
@@ -60,6 +62,39 @@ export const formatFormulaHint = (formula) => {
return parts.join(' · ');
};
+const MONEY_UNIT_LABELS = { thousand: 'тыс. руб.', rub: 'руб.' };
+
+// Подсказка маркера. Выражение хранится в базовых единицах колонки; если ячейка
+// показана в другой разрядности (displayFactor !== 1), добавляем единицу выражения.
+export const formatFormulaHintWithUnit = (formula, displayFactor = 1, moneyUnit = null) => {
+ const hint = formatFormulaHint(formula);
+ const label = MONEY_UNIT_LABELS[moneyUnit];
+ return displayFactor !== 1 && label ? `${hint} (в ${label})` : hint;
+};
+
+// Начальный текст редактора: «=» + выражение, если формула актуальна для текущего
+// значения, иначе значение ячейки.
+export const resolveInitialEditorText = (formula, value) => {
+ if (isFormulaActual(formula, value)) {
+ return { text: `=${formula.expression}`, isFormulaMode: true };
+ }
+ return { text: value, isFormulaMode: false };
+};
+
+export const FORMULA_NEEDS_EQUALS_MESSAGE = 'Формула начинается с =';
+
+// Ввод вида «100+50» без ведущего «=»: символы только из набора формулы и после первой
+// цифры встречается оператор или скобка. Одиночное число (в т.ч. с знаком и запятой) — не выражение.
+export const looksLikeExpressionWithoutEquals = (text) => {
+ if (typeof text !== 'string') return false;
+ const trimmed = text.trim();
+ if (trimmed === '' || trimmed.startsWith('=')) return false;
+ if (!/^[0-9+\-*/%(),. \u00a0]+$/.test(trimmed)) return false;
+ const firstDigit = trimmed.search(/\d/);
+ if (firstDigit === -1) return false;
+ return /[+\-*/%()]/.test(trimmed.slice(firstDigit + 1));
+};
+
export const clearLegacyFormulaStorage = (storage) => {
try {
const toRemove = [];
diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
index 0320013..f871eb3 100644
--- a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
@@ -1,12 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
+ FORMULA_NEEDS_EQUALS_MESSAGE,
applyFormulaEvent,
buildFormulaMap,
clearLegacyFormulaStorage,
formatFormulaHint,
+ formatFormulaHintWithUnit,
formulaKey,
isFormulaActual,
+ looksLikeExpressionWithoutEquals,
+ resolveInitialEditorText,
} from './cellFormulaUtils.js';
const user = { id: 7, full_name: 'Иванов И.И.' };
@@ -108,3 +112,50 @@ test('clearLegacyFormulaStorage', () => {
assert.equal(clearLegacyFormulaStorage(broken), 0);
assert.equal(clearLegacyFormulaStorage(null), 0);
});
+
+test('formatFormulaHint: без автора часть автора опускается (T6.1)', () => {
+ assert.equal(formatFormulaHint({ expression: '1+2', updated_at: '2026-11-11T10:05:00' }), '=1+2 · 11.11.2026 10:05');
+ assert.equal(formatFormulaHint({ expression: '1+2', updated_by: {}, updated_at: null }), '=1+2');
+ assert.ok(!formatFormulaHint({ expression: '1+2', updated_by: {} }).includes('undefined'));
+ assert.equal(formatFormulaHint({ expression: '1+2', updated_by: { id: 0 } }), '=1+2 · Пользователь #0');
+});
+
+test('formatFormulaHint: некорректная updated_at — без даты и без NaN (T6.2)', () => {
+ const hint = formatFormulaHint({ expression: '1+2', updated_by: user, updated_at: 'не дата' });
+ assert.equal(hint, '=1+2 · Иванов И.И.');
+ assert.ok(!hint.includes('NaN'));
+});
+
+test('applyFormulaEvent: line_id 0 и column "" не считаются отсутствующими (T6.2)', () => {
+ const next = applyFormulaEvent(new Map(), { line_id: 0, column: 'a', value: 5, formula: '2+3' });
+ assert.equal(next.get('0|a').expression, '2+3');
+ const next2 = applyFormulaEvent(new Map(), { line_id: 3, column: '', value: 5, formula: '2+3' });
+ assert.equal(next2.get('3|').expression, '2+3');
+ assert.equal(applyFormulaEvent(next, { line_id: 0, column: 'a', value: 5, formula: null }).size, 0);
+});
+
+test('formatFormulaHintWithUnit добавляет единицу при displayFactor !== 1', () => {
+ const f = { expression: '1+2', updated_by: user };
+ assert.equal(formatFormulaHintWithUnit(f, 1, 'thousand'), '=1+2 · Иванов И.И.');
+ assert.equal(formatFormulaHintWithUnit(f, 0.001, 'thousand'), '=1+2 · Иванов И.И. (в тыс. руб.)');
+ assert.equal(formatFormulaHintWithUnit(f, 1000, 'rub'), '=1+2 · Иванов И.И. (в руб.)');
+ assert.equal(formatFormulaHintWithUnit(f, 1000, null), '=1+2 · Иванов И.И.');
+});
+
+test('resolveInitialEditorText: актуальная формула -> "=" + выражение', () => {
+ const f = { expression: '100+50', value: 150 };
+ assert.deepEqual(resolveInitialEditorText(f, 150), { text: '=100+50', isFormulaMode: true });
+ assert.deepEqual(resolveInitialEditorText(f, '150'), { text: '=100+50', isFormulaMode: true });
+ assert.deepEqual(resolveInitialEditorText(f, 200), { text: 200, isFormulaMode: false });
+ assert.deepEqual(resolveInitialEditorText(undefined, 150), { text: 150, isFormulaMode: false });
+});
+
+test('looksLikeExpressionWithoutEquals', () => {
+ for (const s of ['100+50', ' 10*2 ', '(1+2)*3', '100/4', '50%', '1,5+2', '10 - 3']) {
+ assert.equal(looksLikeExpressionWithoutEquals(s), true, s);
+ }
+ for (const s of ['150', '-5', '+5', '1 500,5', '', '=1+2', 'abc', 'Договор 5-1', '01.11-05.11 ab', null, 12, '-', '1.5', ' 12,5 ']) {
+ assert.equal(looksLikeExpressionWithoutEquals(s), false, String(s));
+ }
+ assert.equal(FORMULA_NEEDS_EQUALS_MESSAGE, 'Формула начинается с =');
+});
diff --git a/web/src/components/RealtimeTable/utils/formulaStore.js b/web/src/components/RealtimeTable/utils/formulaStore.js
new file mode 100644
index 0000000..a749943
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/formulaStore.js
@@ -0,0 +1,66 @@
+// AURORA-1896: стор карты формул листа (без React). Подписка по ключу ячейки,
+// защита от устаревших ответов загрузки и повтор WS-событий, пришедших во время загрузки.
+import { applyFormulaEvent, buildFormulaMap } from './cellFormulaUtils.js';
+
+export const createFormulaStore = () => {
+ let map = new Map();
+ let loadSeq = 0;
+ let pending = null;
+ const listenersByKey = new Map();
+
+ const notify = (prev, next) => {
+ const keys = new Set([...prev.keys(), ...next.keys()]);
+ for (const key of keys) {
+ if (prev.get(key) !== next.get(key)) {
+ listenersByKey.get(key)?.forEach((listener) => listener());
+ }
+ }
+ };
+
+ const replace = (next) => {
+ const prev = map;
+ map = next;
+ notify(prev, next);
+ };
+
+ return {
+ get: (key) => map.get(key),
+ size: () => map.size,
+ subscribe: (key, listener) => {
+ const listeners = listenersByKey.get(key) || new Set();
+ listeners.add(listener);
+ listenersByKey.set(key, listeners);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) listenersByKey.delete(key);
+ };
+ },
+ // Смена листа/формы: карта очищается, ответы прежних загрузок игнорируются.
+ reset: () => {
+ loadSeq += 1;
+ pending = null;
+ replace(new Map());
+ },
+ beginLoad: () => {
+ loadSeq += 1;
+ pending = [];
+ return loadSeq;
+ },
+ finishLoad: (token, list) => {
+ if (token !== loadSeq) return false;
+ let next = buildFormulaMap(list);
+ for (const data of pending ?? []) next = applyFormulaEvent(next, data);
+ pending = null;
+ replace(next);
+ return true;
+ },
+ failLoad: (token) => {
+ if (token === loadSeq) pending = null;
+ },
+ applyEvent: (data) => {
+ if (pending) pending.push(data);
+ const next = applyFormulaEvent(map, data);
+ if (next !== map) replace(next);
+ },
+ };
+};
diff --git a/web/src/components/RealtimeTable/utils/formulaStore.test.js b/web/src/components/RealtimeTable/utils/formulaStore.test.js
new file mode 100644
index 0000000..1eb6bfe
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/formulaStore.test.js
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createFormulaStore } from './formulaStore.js';
+
+const item = (line_id, column, expression, value) => ({ line_id, column, expression, value });
+
+test('finishLoad заполняет карту и уведомляет подписчика ключа', () => {
+ const store = createFormulaStore();
+ let calls = 0;
+ store.subscribe('1|a', () => calls++);
+ const token = store.beginLoad();
+ assert.equal(store.finishLoad(token, [item(1, 'a', '1+2', 3), item(2, 'b', '5*2', 10)]), true);
+ assert.equal(store.get('1|a').expression, '1+2');
+ assert.equal(calls, 1);
+});
+
+test('устаревший ответ после reset или новой загрузки игнорируется', () => {
+ const store = createFormulaStore();
+ const old = store.beginLoad();
+ store.reset();
+ assert.equal(store.finishLoad(old, [item(1, 'a', '1+2', 3)]), false);
+ assert.equal(store.size(), 0);
+
+ const first = store.beginLoad();
+ const second = store.beginLoad();
+ assert.equal(store.finishLoad(first, [item(1, 'a', 'old', 1)]), false);
+ assert.equal(store.finishLoad(second, [item(1, 'a', 'new', 1)]), true);
+ assert.equal(store.get('1|a').expression, 'new');
+});
+
+test('reset очищает карту и уведомляет подписчиков', () => {
+ const store = createFormulaStore();
+ store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
+ let calls = 0;
+ store.subscribe('1|a', () => calls++);
+ store.reset();
+ assert.equal(store.get('1|a'), undefined);
+ assert.equal(calls, 1);
+});
+
+test('WS-событие во время загрузки повторяется поверх ответа', () => {
+ const store = createFormulaStore();
+ const token = store.beginLoad();
+ store.applyEvent({ line_id: 1, column: 'a', value: 7, formula: '3+4', formula_meta: null });
+ store.applyEvent({ line_id: 2, column: 'b', value: 1, formula: null, formula_meta: null });
+ store.finishLoad(token, [item(1, 'a', 'stale', 1), item(2, 'b', '9', 9)]);
+ assert.equal(store.get('1|a').expression, '3+4');
+ assert.equal(store.get('2|b'), undefined);
+});
+
+test('applyEvent: фоновое событие без line_id карту не меняет и не уведомляет', () => {
+ const store = createFormulaStore();
+ store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
+ let calls = 0;
+ store.subscribe('1|a', () => calls++);
+ store.applyEvent({ source_sheet: 'X' });
+ assert.equal(store.size(), 1);
+ assert.equal(calls, 0);
+});
+
+test('failLoad не стирает карту', () => {
+ const store = createFormulaStore();
+ store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
+ store.failLoad(store.beginLoad());
+ assert.equal(store.get('1|a').expression, '1+2');
+});
diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js
index 25fd36b..74fa0e9 100644
--- a/web/src/components/RealtimeTable/utils/formulaUtils.js
+++ b/web/src/components/RealtimeTable/utils/formulaUtils.js
@@ -86,7 +86,7 @@ export const evaluateCellValue = (value) => {
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
// Формула — особый случай: даже текстуально совпадающая формула всегда
// считается «изменённой», чтобы handleSave по-прежнему всегда её
-// пересчитывал и писал в localStorage (так было до этой задачи).
+// пересчитывал и отправлял выражение на сервер (AURORA-1896).
const normalizeComparableValue = (value) => {
if (value === null || value === undefined) return '';
if (typeof value === 'number') return String(value);
@@ -123,37 +123,6 @@ 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 824887b..aae8af1 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, resolveEditorState } from './formulaUtils.js';
+import { isSameCellValue } from './formulaUtils.js';
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
@@ -37,38 +37,6 @@ 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);
});
diff --git a/web/src/components/RealtimeTable/utils/localStorageUtils.js b/web/src/components/RealtimeTable/utils/localStorageUtils.js
index dfd40a6..38fd5a4 100644
--- a/web/src/components/RealtimeTable/utils/localStorageUtils.js
+++ b/web/src/components/RealtimeTable/utils/localStorageUtils.js
@@ -81,23 +81,6 @@ 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 - Массив ключей, которые нужно сохранить
diff --git a/web/src/components/RealtimeTable/utils/messageUtils.js b/web/src/components/RealtimeTable/utils/messageUtils.js
index 5e305ed..06f10c8 100644
--- a/web/src/components/RealtimeTable/utils/messageUtils.js
+++ b/web/src/components/RealtimeTable/utils/messageUtils.js
@@ -1,10 +1,12 @@
// value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2
-export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value }) => ({
+// formula (AURORA-1896) — выражение без «=»; поле data.formula добавляется только когда оно передано.
+export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value, formula }) => ({
event: 'cell_updated',
data: {
line_id: lineId,
line_id_code: lineIdCode,
column,
value: value === undefined ? null : value,
+ ...(typeof formula === 'string' && formula !== '' ? { formula } : {}),
},
});
diff --git a/web/src/components/RealtimeTable/utils/messageUtils.test.js b/web/src/components/RealtimeTable/utils/messageUtils.test.js
index 2e7bb6c..3bcd4d6 100644
--- a/web/src/components/RealtimeTable/utils/messageUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/messageUtils.test.js
@@ -15,3 +15,13 @@ test('buildCellUpdatedMessage: прочие значения не меняютс
assert.equal(msg(v).data.value, v);
}
});
+
+test('buildCellUpdatedMessage: formula кладётся в data.formula только когда передана (AURORA-1896)', () => {
+ const withFormula = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 150, formula: '100+50' });
+ assert.equal(withFormula.data.formula, '100+50');
+ assert.equal(Object.hasOwn(msg(150).data, 'formula'), false);
+ for (const f of [undefined, null, '']) {
+ const m = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 1, formula: f });
+ assert.equal(Object.hasOwn(m.data, 'formula'), false);
+ }
+});