Конфликты (EditCell.jsx, RealtimeTable.jsx, formulaUtils.test.js): - EditCell: сохранены правки 1891 (editorInitialInput, Esc/Tab/Enter, возврат фокуса) и 1892 (warnIfNegativeNotAllowed); поверх них повторена логика 1895: формула из localStorage подставляется только если совпадает с текущим значением, иначе stale и удаляется. - Сохранённая формула не подставляется, если редактор открыт набором символа (getEditorInitialInput) и не затирает набранный текст при изменении value другим пользователем (разрешение один раз при открытии). - Tab/Enter помечают следующую ячейку (openNextEditableCell) только после фактического закрытия редактора: при невычисляемой формуле он остаётся открытым и не оставляет висящую цель перехода. - formulaUtils.js: единственный isSameCellValue из test (с нормализацией разделителей); дубль из ветки удалён. - formulaUtils.test.js: тесты обеих сторон. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.7 KiB
JavaScript
173 lines
7.7 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 '#ОШИБКА';
|
||
}
|
||
};
|
||
|
||
// AURORA-1891: сравнение "было/стало" для handleSave (EditCell.jsx) — если
|
||
// значение фактически не изменилось, cell_updated не шлём (иначе лишняя
|
||
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
|
||
// Формула — особый случай: даже текстуально совпадающая формула всегда
|
||
// считается «изменённой», чтобы handleSave по-прежнему всегда её
|
||
// пересчитывал и писал в localStorage (так было до этой задачи).
|
||
const normalizeComparableValue = (value) => {
|
||
if (value === null || value === undefined) return '';
|
||
if (typeof value === 'number') return String(value);
|
||
if (typeof value !== 'string') return String(value);
|
||
|
||
const trimmed = value.trim();
|
||
if (trimmed === '') return '';
|
||
|
||
// "1 500,0" -> "1500,0": обычный и неразрывный пробел между цифрами —
|
||
// разделитель тысяч. normalizeNumericInput сам такие пробелы не убирает,
|
||
// он только меняет десятичную запятую на точку.
|
||
const withoutThousandsSeparators = trimmed.replace(/(\d)[ ](?=\d)/g, '$1');
|
||
const normalized = normalizeNumericInput(withoutThousandsSeparators);
|
||
|
||
if (typeof normalized === 'string' && /^[+-]?\d+(\.\d+)?$/.test(normalized)) {
|
||
const asNumber = Number(normalized);
|
||
if (Number.isFinite(asNumber)) return String(asNumber);
|
||
}
|
||
|
||
return trimmed;
|
||
};
|
||
|
||
/**
|
||
* Сравнивает старое и новое значение ячейки «по смыслу», а не строго: число
|
||
* и его строковое представление (в т.ч. с разделителем тысяч и запятой вместо
|
||
* точки) считаются равными, как и пустая строка с null/undefined. Формула
|
||
* (строка, начинающаяся с "=") никогда не считается неизменной.
|
||
* @param {string|number|null|undefined} a
|
||
* @param {string|number|null|undefined} b
|
||
* @returns {boolean}
|
||
*/
|
||
export const isSameCellValue = (a, b) => {
|
||
if (isFormula(a) || isFormula(b)) return false;
|
||
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 - Число для форматирования
|
||
* @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+$/, '');
|
||
};
|