feat: формулы ячеек на сервере, маркер fx для всех пользователей (AURORA-1896) #323
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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: к этому моменту
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`,
|
||||
};
|
||||
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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, 'Формула начинается с =');
|
||||
});
|
||||
|
||||
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal 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');
|
||||
});
|
||||
@ -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 - Число для форматирования
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 - Массив ключей, которые нужно сохранить
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user