feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896) #323

Merged
aep merged 25 commits from feat/320-aurora-1896-cell-formulas into test 2026-11-12 17:03:43 +03:00
14 changed files with 368 additions and 153 deletions
Showing only changes of commit 3bee5e795f - Show all commits

View File

@ -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 = ({
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
{lockBadge}
{showFormulaMark && (
<Tooltip title={formatFormulaHintWithUnit(formula, displayFactor, moneyUnit)} arrow placement='top'>
<span style={FORMULA_MARK_STYLES}>fx</span>
</Tooltip>
)}
</div>
);
@ -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
);
});

View File

@ -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: к этому моменту

View File

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

View File

@ -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,
<RealtimeActionsContext.Provider value={actions}>
<RealtimeConnectionContext.Provider value={connection}>
<RealtimeVspOptionsContext.Provider value={vspOptions}>
<LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
<LockedCellsStoreContext.Provider value={lockedCellsStore}>
<FormulaStoreContext.Provider value={formulaStore}>{children}</FormulaStoreContext.Provider>
</LockedCellsStoreContext.Provider>
</RealtimeVspOptionsContext.Provider>
</RealtimeConnectionContext.Provider>
</RealtimeActionsContext.Provider>

View File

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

View File

@ -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 = [];

View File

@ -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, 'Формула начинается с =');
});

View File

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

View File

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

View File

@ -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 - Число для форматирования

View File

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

View File

@ -81,23 +81,6 @@ export const removeFromStorage = (key) => {
}
};
/**
* Удаляет устаревшие ключи формул вида `formula_<tableId>_<cellId>`,
* привязанные к позиции строки в дереве таблицы (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 - Массив ключей, которые нужно сохранить

View File

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

View File

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