Пока 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>
155 lines
6.0 KiB
JavaScript
155 lines
6.0 KiB
JavaScript
/**
|
||
* Проверяет, является ли строка формулой
|
||
* @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+$/, '');
|
||
};
|