feat(table): отрицательные суммы красным и правило «минус запрещён» (AURORA-1892)

Cell.jsx: formatNumber вынесен в utils/numberFormat.js и возвращает
{ text, isNegative }; отрицательные числа выделяются цветом (#C62828 на
светлом фоне, #FFCDD2 на тёмном) и font-weight 600, ноль остаётся «-».

constants/validation.js: фабрика nonNegativeRule и общий список
nonNegativeColumnKeys (план, бронь на 2026/2027, факт по месяцам и СПОД,
сумма платежа, предельная стоимость договора/ЦКК, сателлиты АХР
Аренда/Коммунальные/Безопасность, АХР Лимиты целиком). Корректировки и
переносы (adj_*, base_correction, revision_*, seq_*, target_change,
transfer_*) не включены — там минус — нормальная операция. Запрет
мягкий: подсветка и запись в «Контроль ошибок» через существующий
useValidationRules/isCellInvalid, сохранение не блокируется.

EditCell.jsx: handleSave предупреждает toast.warning при сохранении
отрицательного числа в защищённой колонке, значение всё равно
сохраняется. useValidationRules и RealtimeTable прокидывают
человекочитаемый заголовок колонки для текста предупреждения.

Тесты (node --test): numberFormat.test.js, validationUtils.test.js —
новые, 10/10 зелёных.
This commit is contained in:
Andrey Epifancev 2026-11-01 12:20:06 +04:00
parent 9229ee5ce3
commit fa586c85cc
8 changed files with 267 additions and 23 deletions

View File

@ -1,5 +1,6 @@
import React, { useMemo, useCallback } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext';
import { formatNumber } from '../../utils/numberFormat';
const BASE_CELL_STYLES = {
width: '100%',
@ -29,6 +30,11 @@ const INVALID_STYLES = {
color: '#C60C0C',
};
// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел,
// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634).
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
const EDITABLE_STYLES = {
cursor: 'pointer',
border: '2px solid #d8e4ea94',
@ -95,17 +101,6 @@ const getColorBrightness = (hexColor) => {
return 0.299 * r + 0.587 * g + 0.114 * b;
};
const formatNumber = (value, asInteger = false) => {
const num = Number(value);
if (Number.isNaN(num)) return String(value);
if (num === 0) return '-';
return num.toLocaleString('ru-RU', {
minimumFractionDigits: asInteger ? 0 : 1,
maximumFractionDigits: asInteger ? 0 : 1,
useGrouping: true,
});
};
// Оптимизированная функция подсветки
const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
const cleanQueries = searchQueries.filter(Boolean);
@ -144,22 +139,25 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8;
const { rawValue, displayValue, isNumeric } = useMemo(() => {
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
const value = cell.getValue();
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !TEXT_COLUMN_IDS.has(column.id);
let display = String(value || '');
let negative = false;
if (isNum) {
display = formatNumber(Number(display) / displayDivisor, false);
const formatted = formatNumber(Number(display) / displayDivisor, false);
display = formatted.text;
negative = formatted.isNegative;
}
if (isVspDropdown && value != null) {
const vsp = vspOptions?.find((v) => v.id === Number(value));
if (vsp) {
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false };
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false, isNegative: false };
}
}
return { rawValue: value, displayValue: display, isNumeric: isNum };
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
}, [cell, column.id, isVspDropdown, vspOptions, displayDivisor]);
const highlightedContent = useMemo(() => {
@ -170,8 +168,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
const textColor = useMemo(() => {
if (isLocked) return '#999999';
const brightness = getColorBrightness(backgroundColor);
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isLocked]);
}, [backgroundColor, isLocked, isNegative]);
const cellStyles = useMemo(
() => ({
@ -181,11 +180,12 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
textAlign: isNumeric ? 'right' : 'left',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: textColor,
...(isNegative ? { fontWeight: 600 } : {}),
...(isLocked ? LOCKED_STYLES : {}),
...(isInvalid ? INVALID_STYLES : {}),
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
}),
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent],
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent, isNegative],
);
const lockBadge = useMemo(() => {

View File

@ -1,4 +1,6 @@
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 { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils';
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
@ -29,7 +31,7 @@ const TEXTAREA_STYLES = {
fontSize: 'inherit',
};
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
const storageKey = `formula_${tableId}_${cellId}`;
@ -114,6 +116,20 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
return () => clearTimeout(timeoutId);
}, [disabled]);
// AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем,
// но значение всё равно сохраняется (жёсткий запрет в этой задаче не делается).
const warnIfNegativeNotAllowed = useCallback(
(newValue) => {
const columnId = cell?.column?.id;
if (!columnId || !nonNegativeColumnKeys.includes(columnId)) return;
if (!Number.isFinite(Number(newValue)) || Number(newValue) >= 0) return;
const columnTitle = table?.options?.meta?.getColumnTitle?.(columnId) ?? columnId;
toast.warning(`В колонке «${columnTitle}» отрицательные суммы запрещены`);
},
[cell, table],
);
const handleSave = useCallback(() => {
const { currentValue } = state;
@ -128,6 +144,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: formattedResult,
isFormulaMode: true,
}));
warnIfNegativeNotAllowed(formattedResult);
onChange?.(formattedResult);
} catch {
setState((prev) => ({
@ -144,9 +161,11 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: currentValue,
isFormulaMode: false,
}));
onChange?.(normalizeNumericInput(currentValue));
const normalizedValue = normalizeNumericInput(currentValue);
warnIfNegativeNotAllowed(normalizedValue);
onChange?.(normalizedValue);
}
}, [state, storageKey, onChange]);
}, [state, storageKey, onChange, warnIfNegativeNotAllowed]);
const finishEditing = useCallback(() => {
if (refFinished.current) return;

View File

@ -97,7 +97,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null);
const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const { errors: validationErrors, isCellInvalid, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
@ -300,8 +300,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
// AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса.
getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId,
}),
[handleUpdateCell],
[handleUpdateCell, headerPaths],
);
const vspRegistrationById = useMemo(

View File

@ -84,6 +84,103 @@ const bookingLessBalanceRuleScope = {
includedScopes: [{ formType: "FORM_2" }],
};
// Требование AURORA-1892. Колонки, в которых отрицательное значение считается ошибкой.
// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
// там минус — нормальная операция.
export const nonNegativeColumnKeys = [
// Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
"data.plan.q1",
"data.plan.q2",
"data.plan.q3",
"data.plan.q4",
"data.plan.year",
// Ввод брони на 2026 и 2027 годы (форма 2).
"data.booking.y2026.q1",
"data.booking.y2026.q2",
"data.booking.y2026.q3",
"data.booking.y2026.q4",
"data.booking.y2027.q1",
"data.booking.y2027.q2",
"data.booking.y2027.q3",
"data.booking.y2027.q4",
// Факт по месяцам и СПОД.
"data.q1.actual_m1",
"data.q1.actual_m2",
"data.q1.actual_m3",
"data.q2.actual_m1",
"data.q2.actual_m2",
"data.q2.actual_m3",
"data.q3.actual_m1",
"data.q3.actual_m2",
"data.q3.actual_m3",
"data.q4.actual_m1",
"data.q4.actual_m2",
"data.q4.actual_m3",
"data.q4.actual_spod",
// Сумма платежа.
"data.q1.pay_amount",
"data.q2.pay_amount",
"data.q3.pay_amount",
"data.q4.pay_amount",
// Предельная стоимость договора / ЦКК.
"data.contract.ceiling",
"data.contract_detail.ceiling",
"data.ckk.ceiling",
"data.contract_summary.total",
// Сателлиты формы 2 — АХР Аренда/Коммунальные услуги/Безопасность: факт по месяцам
// и квартальный итог (data.plan.qN уже учтён выше — ключ общий с основными листами).
"data.fact_q1.jan",
"data.fact_q1.feb",
"data.fact_q1.mar",
"data.fact_q1.total",
"data.fact_q2.apr",
"data.fact_q2.may",
"data.fact_q2.jun",
"data.fact_q2.total",
"data.fact_q3.jul",
"data.fact_q3.aug",
"data.fact_q3.sep",
"data.fact_q3.total",
"data.fact_q4.oct",
"data.fact_q4.nov",
"data.fact_q4.dec",
"data.fact_q4.total",
// АХР Лимиты — по вложению Салмина минусов не должно быть вообще во всех
// числовых показателях листа (шире, чем исходный набор qty_qN/amount_qN).
"data.qty_q1",
"data.qty_q2",
"data.qty_q3",
"data.qty_q4",
"data.amount_q1",
"data.amount_q2",
"data.amount_q3",
"data.amount_q4",
"data.limit_with_vat",
"data.limit_without_vat",
];
const isNegativeNumber = (value) =>
value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0;
// Фабрика правила «минус запрещён» для заданного перечня колонок.
// appliesToRow намеренно не ограничивается — правило проверяет все строки.
export const nonNegativeRule = ({ id, columnKeys, message, includedScopes, excludedScopes } = {}) => ({
id,
columnKeys,
message,
...(includedScopes ? { includedScopes } : {}),
...(excludedScopes ? { excludedScopes } : {}),
isInvalid: isNegativeNumber,
});
export const validationRules = [
{
...remainingBookingRuleScope,
@ -210,4 +307,9 @@ export const validationRules = [
);
},
},
nonNegativeRule({
id: "non-negative",
columnKeys: nonNegativeColumnKeys,
message: "Отрицательное значение в этой колонке недопустимо",
}),
];

View File

@ -48,5 +48,5 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
[activeRules],
);
return { errors, isCellInvalid };
return { errors, isCellInvalid, headerPaths };
};

View File

@ -0,0 +1,17 @@
// Форматирует число ячейки таблицы для отображения.
// Ноль по-прежнему выводится как «-» (привычный формат), а isNegative
// позволяет вызывающей стороне (Cell.jsx) подобрать более заметный цвет
// для отрицательных значений, не путая их с нулём.
export const formatNumber = (value, asInteger = false) => {
const num = Number(value);
if (Number.isNaN(num)) return { text: String(value), isNegative: false };
if (num === 0) return { text: '-', isNegative: false };
const text = num.toLocaleString('ru-RU', {
minimumFractionDigits: asInteger ? 0 : 1,
maximumFractionDigits: asInteger ? 0 : 1,
useGrouping: true,
});
return { text, isNegative: num < 0 };
};

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatNumber } from './numberFormat.js';
test('negative numbers are formatted ru-RU and flagged as negative', () => {
const result = formatNumber(-1234.5);
assert.equal(result.text, '-1 234,5');
assert.equal(result.isNegative, true);
});
test('zero is rendered as a dash and is never negative', () => {
const result = formatNumber(0);
assert.equal(result.text, '-');
assert.equal(result.isNegative, false);
});
test('positive numbers keep the familiar ru-RU grouping and are not negative', () => {
const result = formatNumber(1234.5);
assert.equal(result.text, '1 234,5');
assert.equal(result.isNegative, false);
});
test('asInteger drops the fractional digit', () => {
const integer = formatNumber(1234, true);
assert.equal(integer.text, '1 234');
assert.equal(integer.isNegative, false);
const negativeInteger = formatNumber(-1234, true);
assert.equal(negativeInteger.text, '-1 234');
assert.equal(negativeInteger.isNegative, true);
});
test('non-numeric input is returned as a string without crashing', () => {
const result = formatNumber('не число');
assert.equal(result.text, 'не число');
assert.equal(result.isNegative, false);
});

View File

@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js';
import { isValidationRuleEnabled } from './validationUtils.js';
test('nonNegativeRule flags negative numbers as invalid', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.isInvalid(-1), true);
assert.equal(rule.isInvalid(-0.01), true);
});
test('nonNegativeRule does not flag zero, empty or positive values', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.isInvalid(0), false);
assert.equal(rule.isInvalid(''), false);
assert.equal(rule.isInvalid(null), false);
assert.equal(rule.isInvalid(undefined), false);
assert.equal(rule.isInvalid(100), false);
});
test('nonNegativeRule does not restrict which rows it applies to', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.appliesToRow, undefined);
});
test('a scoped rule is disabled outside its form/sheet scope', () => {
const rule = nonNegativeRule({
id: 'non-negative-scoped-test',
columnKeys: ['data.qty_q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
includedScopes: [{ formType: 'FORM_2', sheetName: 'AHR_LIMIT' }],
});
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR_LIMIT' }), true);
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_1', sheetName: 'AHR' }), false);
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR' }), false);
});
test('the shared nonNegativeColumnKeys list excludes correction and transfer columns', () => {
const excludedPrefixes = ['adj_', 'base_correction', 'revision_', 'seq_', 'target_change', 'transfer_'];
for (const key of nonNegativeColumnKeys) {
const segment = key.split('.').pop();
for (const prefix of excludedPrefixes) {
assert.equal(
segment.startsWith(prefix),
false,
`${key} looks like a correction/transfer column and must not be in nonNegativeColumnKeys`,
);
}
}
});