Andrey Epifancev 2e6c94fb07 fix(table): формула из localStorage не перезаписывает чужое значение ячейки
Пока AURORA-1896 не перенесёт формулы на сервер, временно исправляем
потерю данных в EditCell:

- resolveEditorState/isSameCellValue (formulaUtils.js) — чистые функции:
  формула подставляется в редактор только если её результат совпадает
  с текущим значением ячейки, иначе она устарела (stale) и удаляется
  из localStorage.
- EditCell: открытие и закрытие ячейки без правки больше не шлёт
  cell_updated (сверка с initialEditorValue при выходе из редактора;
  для обычных значений — дополнительно через isSameCellValue). Если
  введённая формула не вычисляется — toast и редактор остаётся открытым,
  на сервер ничего не уходит.
- tableColumns.jsx: ключ формулы строится от getRowId(cell.row) (line_id
  из данных), а не от cell.row.id (позиционный индекс-путь в дереве,
  переезжающий при вставке/удалении строк). Префикс ключа — formula_v2_.
- RealtimeTable: при монтировании once-очистка устаревших ключей
  formula_* без суффикса _v2 — они привязаны к позиции строки и стали
  недостоверны.

Тесты: web/src/components/RealtimeTable/utils/formulaUtils.test.js,
node --test — 9/9 новых + 5/5 ранее существующих в utils/ зелёные.

Closes #264.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-31 14:11:34 +04:00

155 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Проверяет, является ли строка формулой
* @param {string} value - Значение ячейки
* @returns {boolean}
*/
export const isFormula = (value) => {
if (typeof value !== 'string') return false;
return value.startsWith('=');
};
export const normalizeNumericInput = (value) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!/^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(trimmed)) return value;
return trimmed.replace(',', '.');
};
/**
* Извлекает формулу из строки (удаляет знак =)
* @param {string} value - Значение ячейки
* @returns {string} - Формула без знака =
*/
export const extractFormula = (value) => {
if (!isFormula(value)) return value;
return value.substring(1).trim();
};
/**
* Вычисляет математическое выражение
* Поддерживает: +, -, *, /, %, скобки
* @param {string} expression - Математическое выражение
* @returns {number} - Результат вычисления
* @throws {Error} - Если выражение некорректно
*/
export const calculateFormula = (expression) => {
try {
// Удаляем пробелы и приводим десятичную запятую к точке
const cleanExpr = expression.replace(/\s/g, '').replace(/,/g, '.');
// Проверяем на допустимые символы (только цифры, операторы, скобки, точка)
const validChars = /^[\d+\-*/%().]+$/;
if (!validChars.test(cleanExpr)) {
throw new Error('Недопустимые символы в формуле');
}
// Заменяем % на /100 для корректного вычисления процентов
const exprWithPercent = cleanExpr.replace(/%/g, '/100');
// Безопасное вычисление с использованием Function
// ВНИМАНИЕ: Это безопасно, так как мы проверяем допустимые символы
const result = new Function(`return (${exprWithPercent})`)();
// Проверяем, что результат - число
if (typeof result !== 'number' || !isFinite(result)) {
throw new Error('Некорректный результат вычисления');
}
// Округляем до 10 знаков после запятой для избежания проблем с плавающей точкой
return Math.round(result * 1e10) / 1e10;
} catch (error) {
console.error('Formula calculation error:', error);
throw new Error(`Ошибка вычисления: ${error.message}`);
}
};
/**
* Вычисляет значение ячейки, если это формула
* @param {string} value - Значение ячейки
* @returns {string|number} - Вычисленное значение или исходное значение
*/
export const evaluateCellValue = (value) => {
if (!isFormula(value)) return value;
try {
const formula = extractFormula(value);
const result = calculateFormula(formula);
return result;
} catch (error) {
console.error('Error evaluating cell:', error);
return '#ОШИБКА';
}
};
/**
* Начальное состояние редактора ячейки.
* Решает, показывать ли сохранённую формулу или текущее значение ячейки,
* и помечает формулу как устаревшую (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 };
}
};
const isEmptyCellValue = (value) => value === null || value === undefined || value === '';
const isNumericCellValue = (value) => !isEmptyCellValue(value) && !Number.isNaN(Number(value));
/**
* Сравнивает два значения ячейки так, как их видит пользователь:
* числа сравниваются как числа ('150' == 150, '150.0' == 150), '' == null.
* @param {string|number|null} a
* @param {string|number|null} b
* @returns {boolean}
*/
export const isSameCellValue = (a, b) => {
const aEmpty = isEmptyCellValue(a);
const bEmpty = isEmptyCellValue(b);
if (aEmpty || bEmpty) return aEmpty && bEmpty;
if (isNumericCellValue(a) && isNumericCellValue(b)) {
return Number(a) === Number(b);
}
return a === b;
};
/**
* Форматирует число для отображения
* @param {number} value - Число для форматирования
* @returns {string} - Отформатированная строка
*/
export const formatNumber = (value) => {
if (typeof value !== 'number') return String(value);
// Если число целое, отображаем без десятичной части
if (Number.isInteger(value)) {
return value.toString();
}
// Иначе показываем до 10 знаков, но убираем лишние нули
return value.toFixed(10).replace(/\.?0+$/, '');
};