From fa586c85cc678d0fb94b7d442f693aa6b08f5069 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 12:20:06 +0400 Subject: [PATCH] =?UTF-8?q?feat(table):=20=D0=BE=D1=82=D1=80=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=83?= =?UTF-8?q?=D0=BC=D0=BC=D1=8B=20=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D1=8B=D0=BC?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20=C2=AB?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D1=83=D1=81=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=89=D1=91=D0=BD=C2=BB=20(AURORA-1892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 зелёных. --- .../RealtimeTable/Cell/Cell/Cell.jsx | 34 +++--- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 25 ++++- .../RealtimeTable/RealtimeTable.jsx | 6 +- .../RealtimeTable/constants/validation.js | 102 ++++++++++++++++++ .../RealtimeTable/hooks/useValidationRules.js | 2 +- .../RealtimeTable/utils/numberFormat.js | 17 +++ .../RealtimeTable/utils/numberFormat.test.js | 37 +++++++ .../utils/validationUtils.test.js | 67 ++++++++++++ 8 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/numberFormat.js create mode 100644 web/src/components/RealtimeTable/utils/numberFormat.test.js create mode 100644 web/src/components/RealtimeTable/utils/validationUtils.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 7ada266..8a7faa0 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -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(() => { diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index acdf758..ae33a0d 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -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; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 7aded64..7d0f231 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -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( diff --git a/web/src/components/RealtimeTable/constants/validation.js b/web/src/components/RealtimeTable/constants/validation.js index 0abee24..197f6f9 100644 --- a/web/src/components/RealtimeTable/constants/validation.js +++ b/web/src/components/RealtimeTable/constants/validation.js @@ -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: "Отрицательное значение в этой колонке недопустимо", + }), ]; diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js index b234f12..1758ec6 100644 --- a/web/src/components/RealtimeTable/hooks/useValidationRules.js +++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js @@ -48,5 +48,5 @@ export const useValidationRules = (data, columns, formType, sheetName) => { [activeRules], ); - return { errors, isCellInvalid }; + return { errors, isCellInvalid, headerPaths }; }; diff --git a/web/src/components/RealtimeTable/utils/numberFormat.js b/web/src/components/RealtimeTable/utils/numberFormat.js new file mode 100644 index 0000000..9e8db1b --- /dev/null +++ b/web/src/components/RealtimeTable/utils/numberFormat.js @@ -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 }; +}; diff --git a/web/src/components/RealtimeTable/utils/numberFormat.test.js b/web/src/components/RealtimeTable/utils/numberFormat.test.js new file mode 100644 index 0000000..02757e8 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/numberFormat.test.js @@ -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); +}); diff --git a/web/src/components/RealtimeTable/utils/validationUtils.test.js b/web/src/components/RealtimeTable/utils/validationUtils.test.js new file mode 100644 index 0000000..6989933 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js @@ -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`, + ); + } + } +});