From 3bee5e795f21ae3c3ec3cc94a59838d339d606ac Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:58:10 +0400 Subject: [PATCH] =?UTF-8?q?feat(formulas):=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20fx=20=D1=83=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8,=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B0=20=D1=81?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20localStorage=20(AURORA-1896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RealtimeContext: стор карты формул (formulaStore), загрузка при открытии листа и переподключении (формы и отчёты проектов), обновление на каждом cell_updated, защита от устаревших ответов при смене листа; useCellFormula; updateCell(..., { formula }) - Cell: маркер fx только при актуальной формуле, подсказка с единицей при displayFactor != 1 - EditCell: начальный текст «=выражение», выражение уходит в data.formula, без localStorage; ввод «100+50» без «=» не отправляется (тост «Формула начинается с =») - удалены resolveEditorState и clearLegacyFormulaKeys; T6.1/T6.2 в cellFormulaUtils Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 34 ++++++++- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 69 ++++++++----------- .../RealtimeTable/RealtimeTable.jsx | 11 +-- .../RealtimeTable/contexts/RealtimeContext.js | 69 +++++++++++++++++-- .../components/RealtimeTable/tableColumns.jsx | 18 ++--- .../RealtimeTable/utils/cellFormulaUtils.js | 39 ++++++++++- .../utils/cellFormulaUtils.test.js | 51 ++++++++++++++ .../RealtimeTable/utils/formulaStore.js | 66 ++++++++++++++++++ .../RealtimeTable/utils/formulaStore.test.js | 66 ++++++++++++++++++ .../RealtimeTable/utils/formulaUtils.js | 33 +-------- .../RealtimeTable/utils/formulaUtils.test.js | 34 +-------- .../RealtimeTable/utils/localStorageUtils.js | 17 ----- .../RealtimeTable/utils/messageUtils.js | 4 +- .../RealtimeTable/utils/messageUtils.test.js | 10 +++ 14 files changed, 368 insertions(+), 153 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/formulaStore.js create mode 100644 web/src/components/RealtimeTable/utils/formulaStore.test.js 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); + } +});