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:
parent
9229ee5ce3
commit
fa586c85cc
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: "Отрицательное значение в этой колонке недопустимо",
|
||||
}),
|
||||
];
|
||||
|
||||
@ -48,5 +48,5 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
|
||||
[activeRules],
|
||||
);
|
||||
|
||||
return { errors, isCellInvalid };
|
||||
return { errors, isCellInvalid, headerPaths };
|
||||
};
|
||||
|
||||
17
web/src/components/RealtimeTable/utils/numberFormat.js
Normal file
17
web/src/components/RealtimeTable/utils/numberFormat.js
Normal 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 };
|
||||
};
|
||||
37
web/src/components/RealtimeTable/utils/numberFormat.test.js
Normal file
37
web/src/components/RealtimeTable/utils/numberFormat.test.js
Normal 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);
|
||||
});
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user