Andrey Epifancev 455393cc76 Merge origin/test в feat/264-aurora-1895-formula-localstorage
Конфликты (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>
2026-11-08 10:24:55 +04:00

173 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '#ОШИБКА';
}
};
// 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+$/, '');
};