Merge pull request 'AURORA-1892: доработка по сверке с исходным перечнем 02.11.2026' (#293) from feat/276-aurora-1892-negative-amounts into test
This commit is contained in:
commit
244c03ac14
@ -1,6 +1,7 @@
|
|||||||
import { Tooltip } from '@mui/material';
|
import { Tooltip } from '@mui/material';
|
||||||
import React, { useMemo, useCallback, useState } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useCellLock } from '../../contexts/RealtimeContext';
|
import { useCellLock } from '../../contexts/RealtimeContext';
|
||||||
|
import { resolveCellTextColor } from '../../utils/cellTextColor';
|
||||||
import { formatNumber } from '../../utils/numberFormat';
|
import { formatNumber } from '../../utils/numberFormat';
|
||||||
import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils';
|
import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils';
|
||||||
|
|
||||||
@ -33,11 +34,6 @@ const INVALID_STYLES = {
|
|||||||
color: '#C60C0C',
|
color: '#C60C0C',
|
||||||
};
|
};
|
||||||
|
|
||||||
// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел,
|
|
||||||
// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634).
|
|
||||||
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
|
|
||||||
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
|
|
||||||
|
|
||||||
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
|
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
|
||||||
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
|
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
|
||||||
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
|
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
|
||||||
@ -50,10 +46,11 @@ const EDITABLE_BORDER_COLOR = '#7FA7C9';
|
|||||||
// AURORA-1890: нередактируемая ячейка строки INPUT (вычисляемая или вне
|
// AURORA-1890: нередактируемая ячейка строки INPUT (вычисляемая или вне
|
||||||
// текущего этапа) — приглушённый фон и текст, чтобы визуально отличаться от
|
// текущего этапа) — приглушённый фон и текст, чтобы визуально отличаться от
|
||||||
// редактируемой белой ячейки без отдельной палитры цветов.
|
// редактируемой белой ячейки без отдельной палитры цветов.
|
||||||
|
// Цвет текста (в т.ч. приглушённый серый) задаёт resolveCellTextColor: здесь его нет,
|
||||||
|
// чтобы стиль не перекрывал красный цвет отрицательных значений (AURORA-1892).
|
||||||
const NOT_EDITABLE_INPUT_STYLES = {
|
const NOT_EDITABLE_INPUT_STYLES = {
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
backgroundColor: '#F4F5F7',
|
backgroundColor: '#F4F5F7',
|
||||||
color: '#5F6B7A',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Строки ITEM/GROUP/ROOT: вне скоупа AURORA-1890 — сохраняем прежнее поведение.
|
// Строки ITEM/GROUP/ROOT: вне скоупа AURORA-1890 — сохраняем прежнее поведение.
|
||||||
@ -104,26 +101,6 @@ const OVER_LIMIT_CHIP_STYLES = {
|
|||||||
|
|
||||||
const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']);
|
const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']);
|
||||||
|
|
||||||
const getColorBrightness = (hexColor) => {
|
|
||||||
if (!hexColor) return 255;
|
|
||||||
const color = hexColor.replace('#', '');
|
|
||||||
let r;
|
|
||||||
let g;
|
|
||||||
let b;
|
|
||||||
if (color.length === 3) {
|
|
||||||
r = Number.parseInt(color[0] + color[0], 16);
|
|
||||||
g = Number.parseInt(color[1] + color[1], 16);
|
|
||||||
b = Number.parseInt(color[2] + color[2], 16);
|
|
||||||
} else if (color.length === 6) {
|
|
||||||
r = Number.parseInt(color.substring(0, 2), 16);
|
|
||||||
g = Number.parseInt(color.substring(2, 4), 16);
|
|
||||||
b = Number.parseInt(color.substring(4, 6), 16);
|
|
||||||
} else {
|
|
||||||
return 255;
|
|
||||||
}
|
|
||||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Оптимизированная функция подсветки
|
// Оптимизированная функция подсветки
|
||||||
const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
|
const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
|
||||||
const cleanQueries = searchQueries.filter(Boolean);
|
const cleanQueries = searchQueries.filter(Boolean);
|
||||||
@ -213,11 +190,11 @@ const CellComponent = ({
|
|||||||
|
|
||||||
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
|
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
|
||||||
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
|
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
|
||||||
const textColor = useMemo(() => {
|
const isMutedInput = isInputRow && !isEditable;
|
||||||
const brightness = getColorBrightness(backgroundColor);
|
const textColor = useMemo(
|
||||||
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
|
() => resolveCellTextColor({ backgroundColor, isNegative, isMutedInput }),
|
||||||
return brightness < 128 ? '#ffffff' : '#000000';
|
[backgroundColor, isNegative, isMutedInput],
|
||||||
}, [backgroundColor, isNegative]);
|
);
|
||||||
|
|
||||||
// AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает
|
// AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает
|
||||||
// приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений).
|
// приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений).
|
||||||
|
|||||||
@ -88,7 +88,10 @@ const bookingLessBalanceRuleScope = {
|
|||||||
// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
|
// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
|
||||||
// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
|
// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
|
||||||
// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
|
// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
|
||||||
// там минус — нормальная операция.
|
// там минус — нормальная операция. Исключение — transfer_econ (перенос в фонд экономии,
|
||||||
|
// формы 2): перечень Салмина прямо требует контроль для этих колонок (см. сверку
|
||||||
|
// AURORA-1892 от 02.11.2026, раздел 6.3); общий префикс transfer_* на этот ключ не
|
||||||
|
// распространяется.
|
||||||
export const nonNegativeColumnKeys = [
|
export const nonNegativeColumnKeys = [
|
||||||
// Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
|
// Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
|
||||||
"data.plan.q1",
|
"data.plan.q1",
|
||||||
@ -165,6 +168,95 @@ export const nonNegativeColumnKeys = [
|
|||||||
"data.amount_q4",
|
"data.amount_q4",
|
||||||
"data.limit_with_vat",
|
"data.limit_with_vat",
|
||||||
"data.limit_without_vat",
|
"data.limit_without_vat",
|
||||||
|
|
||||||
|
// Доработка по сверке AURORA-1892 от 02.11.2026 (разделы 5, 6.1): колонки из
|
||||||
|
// исходного перечня Салмина, пропущенные при первой реализации.
|
||||||
|
// Платежи следующих лет и сумма других ССП.
|
||||||
|
"data.contract_summary.future_y1",
|
||||||
|
"data.contract_summary.future_y2",
|
||||||
|
"data.contract_summary.other_ssp",
|
||||||
|
|
||||||
|
// Утверждённый план по кварталам и за год.
|
||||||
|
"data.approved.q1",
|
||||||
|
"data.approved.q2",
|
||||||
|
"data.approved.q3",
|
||||||
|
"data.approved.q4",
|
||||||
|
"data.approved.year",
|
||||||
|
|
||||||
|
// Сумма коллегиального органа.
|
||||||
|
"data.collegial.approved",
|
||||||
|
|
||||||
|
// Расходы ЦКК по кварталам и по сметам РФ.
|
||||||
|
"data.ckk.q1",
|
||||||
|
"data.ckk.q2",
|
||||||
|
"data.ckk.q3",
|
||||||
|
"data.ckk.q4",
|
||||||
|
"data.ckk.rf_schedule",
|
||||||
|
|
||||||
|
// Расходы договора по кварталам, по сметам РФ и сумма в валюте.
|
||||||
|
"data.contract_detail.q1",
|
||||||
|
"data.contract_detail.q2",
|
||||||
|
"data.contract_detail.q3",
|
||||||
|
"data.contract_detail.q4",
|
||||||
|
"data.contract_detail.rf_schedule",
|
||||||
|
"data.contract_detail.amount_foreign",
|
||||||
|
|
||||||
|
// Скорректированный план по кварталам.
|
||||||
|
"data.q1.corrected_plan",
|
||||||
|
"data.q2.corrected_plan",
|
||||||
|
"data.q3.corrected_plan",
|
||||||
|
"data.q4.corrected_plan",
|
||||||
|
|
||||||
|
// Части платежа ГО и РФ.
|
||||||
|
"data.q1.pay_ho",
|
||||||
|
"data.q1.pay_rf",
|
||||||
|
"data.q2.pay_ho",
|
||||||
|
"data.q2.pay_rf",
|
||||||
|
"data.q3.pay_ho",
|
||||||
|
"data.q3.pay_rf",
|
||||||
|
"data.q4.pay_ho",
|
||||||
|
"data.q4.pay_rf",
|
||||||
|
|
||||||
|
// Бронь квартала (отдельно от ввода брони на 2026/2027 годы выше).
|
||||||
|
"data.q1.booking",
|
||||||
|
"data.q2.booking",
|
||||||
|
"data.q3.booking",
|
||||||
|
"data.q4.booking",
|
||||||
|
|
||||||
|
// Остаток после факта (формы 1 и 2 используют разные кодовые имена).
|
||||||
|
"data.q1.rem_actual",
|
||||||
|
"data.q2.rem_actual",
|
||||||
|
"data.q3.rem_actual",
|
||||||
|
"data.q4.rem_actual",
|
||||||
|
"data.q1.residual_after_actual",
|
||||||
|
"data.q2.residual_after_actual",
|
||||||
|
"data.q3.residual_after_actual",
|
||||||
|
"data.q4.residual_after_actual",
|
||||||
|
|
||||||
|
// Перенос в фонд экономии формы 1 (data.economy — другой ключ, чем transfer_econ
|
||||||
|
// формы 2 ниже, и не входит в исключённый префикс transfer_*).
|
||||||
|
"data.q1.economy",
|
||||||
|
"data.q2.economy",
|
||||||
|
"data.q3.economy",
|
||||||
|
"data.q4.economy",
|
||||||
|
|
||||||
|
// Перенос в фонд экономии формы 2 — исключение из transfer_*, см. комментарий
|
||||||
|
// к nonNegativeColumnKeys выше.
|
||||||
|
"data.q1.transfer_econ",
|
||||||
|
"data.q2.transfer_econ",
|
||||||
|
"data.q3.transfer_econ",
|
||||||
|
"data.q4.transfer_econ",
|
||||||
|
|
||||||
|
// Всего при закрытии квартала.
|
||||||
|
"data.q1.total",
|
||||||
|
"data.q2.total",
|
||||||
|
"data.q3.total",
|
||||||
|
"data.q4.total",
|
||||||
|
|
||||||
|
// Новый план на подпись (кварталов II–IV; для I квартала поле отсутствует).
|
||||||
|
"data.q2.new_plan",
|
||||||
|
"data.q3.new_plan",
|
||||||
|
"data.q4.new_plan",
|
||||||
];
|
];
|
||||||
|
|
||||||
const isNegativeNumber = (value) =>
|
const isNegativeNumber = (value) =>
|
||||||
|
|||||||
40
web/src/components/RealtimeTable/utils/cellTextColor.js
Normal file
40
web/src/components/RealtimeTable/utils/cellTextColor.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Цвет текста ячейки таблицы (вынесено из Cell.jsx, чтобы покрыть тестами без DOM).
|
||||||
|
|
||||||
|
// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел,
|
||||||
|
// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634).
|
||||||
|
export const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
|
||||||
|
export const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
|
||||||
|
|
||||||
|
// AURORA-1890: приглушённый текст нередактируемой ячейки строки ввода (фон #F4F5F7).
|
||||||
|
export const MUTED_INPUT_TEXT_COLOR = '#5F6B7A';
|
||||||
|
|
||||||
|
export const getColorBrightness = (hexColor) => {
|
||||||
|
if (!hexColor) return 255;
|
||||||
|
const color = hexColor.replace('#', '');
|
||||||
|
let r;
|
||||||
|
let g;
|
||||||
|
let b;
|
||||||
|
if (color.length === 3) {
|
||||||
|
r = Number.parseInt(color[0] + color[0], 16);
|
||||||
|
g = Number.parseInt(color[1] + color[1], 16);
|
||||||
|
b = Number.parseInt(color[2] + color[2], 16);
|
||||||
|
} else if (color.length === 6) {
|
||||||
|
r = Number.parseInt(color.substring(0, 2), 16);
|
||||||
|
g = Number.parseInt(color.substring(2, 4), 16);
|
||||||
|
b = Number.parseInt(color.substring(4, 6), 16);
|
||||||
|
} else {
|
||||||
|
return 255;
|
||||||
|
}
|
||||||
|
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// AURORA-1892 (возврат 06.11): минус красный в любой ячейке. Раньше серый цвет
|
||||||
|
// нередактируемой ячейки строки ввода (AURORA-1890) перекрывал красный, и в расчётных
|
||||||
|
// колонках отрицательные значения оставались серыми. Фон такой ячейки всегда светлый
|
||||||
|
// (#F4F5F7), поэтому для неё — красный для светлого фона.
|
||||||
|
export const resolveCellTextColor = ({ backgroundColor, isNegative, isMutedInput }) => {
|
||||||
|
if (isMutedInput) return isNegative ? NEGATIVE_COLOR_ON_LIGHT : MUTED_INPUT_TEXT_COLOR;
|
||||||
|
const isDark = getColorBrightness(backgroundColor) < 128;
|
||||||
|
if (isNegative) return isDark ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
|
||||||
|
return isDark ? '#ffffff' : '#000000';
|
||||||
|
};
|
||||||
29
web/src/components/RealtimeTable/utils/cellTextColor.test.js
Normal file
29
web/src/components/RealtimeTable/utils/cellTextColor.test.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
MUTED_INPUT_TEXT_COLOR,
|
||||||
|
NEGATIVE_COLOR_ON_DARK,
|
||||||
|
NEGATIVE_COLOR_ON_LIGHT,
|
||||||
|
resolveCellTextColor,
|
||||||
|
} from './cellTextColor.js';
|
||||||
|
|
||||||
|
// AURORA-1892 (возврат 06.11): в расчётных (нередактируемых) ячейках строки ввода
|
||||||
|
// минус оставался серым — серый цвет AURORA-1890 перекрывал красный.
|
||||||
|
test('a negative value in a non-editable input cell is red, not muted grey', () => {
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#FFFFFF', isNegative: true, isMutedInput: true }), NEGATIVE_COLOR_ON_LIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-negative value in a non-editable input cell keeps the muted grey', () => {
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#FFFFFF', isNegative: false, isMutedInput: true }), MUTED_INPUT_TEXT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative values follow background brightness outside muted input cells', () => {
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#FFFFFF', isNegative: true, isMutedInput: false }), NEGATIVE_COLOR_ON_LIGHT);
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#933634', isNegative: true, isMutedInput: false }), NEGATIVE_COLOR_ON_DARK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regular values are black on light and white on dark backgrounds', () => {
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#FFFFFF', isNegative: false, isMutedInput: false }), '#000000');
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: '#933634', isNegative: false, isMutedInput: false }), '#ffffff');
|
||||||
|
assert.equal(resolveCellTextColor({ backgroundColor: undefined, isNegative: false, isMutedInput: false }), '#000000');
|
||||||
|
});
|
||||||
@ -57,11 +57,15 @@ test('a scoped rule is disabled outside its form/sheet scope', () => {
|
|||||||
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR' }), false);
|
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR' }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the shared nonNegativeColumnKeys list excludes correction and transfer columns', () => {
|
test('the shared nonNegativeColumnKeys list excludes correction and transfer columns, except the explicit transfer_econ carve-out', () => {
|
||||||
const excludedPrefixes = ['adj_', 'base_correction', 'revision_', 'seq_', 'target_change', 'transfer_'];
|
const excludedPrefixes = ['adj_', 'base_correction', 'revision_', 'seq_', 'target_change', 'transfer_'];
|
||||||
|
// AURORA-1892, сверка 02.11.2026 (раздел 6.3): перечень Салмина прямо требует контроль
|
||||||
|
// минуса для transfer_econ, в отличие от остальных transfer_* — это намеренное исключение.
|
||||||
|
const allowedException = 'transfer_econ';
|
||||||
|
|
||||||
for (const key of nonNegativeColumnKeys) {
|
for (const key of nonNegativeColumnKeys) {
|
||||||
const segment = key.split('.').pop();
|
const segment = key.split('.').pop();
|
||||||
|
if (segment === allowedException) continue;
|
||||||
for (const prefix of excludedPrefixes) {
|
for (const prefix of excludedPrefixes) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
segment.startsWith(prefix),
|
segment.startsWith(prefix),
|
||||||
@ -72,6 +76,54 @@ test('the shared nonNegativeColumnKeys list excludes correction and transfer col
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nonNegativeColumnKeys has no duplicate entries', () => {
|
||||||
|
assert.equal(nonNegativeColumnKeys.length, new Set(nonNegativeColumnKeys).size);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nonNegativeColumnKeys covers the gaps confirmed by the AURORA-1892 audit (02.11.2026)', () => {
|
||||||
|
const mustInclude = [
|
||||||
|
// Разделы 5/6.1 сверки: обязательные показатели, пропущенные в первой реализации.
|
||||||
|
'data.contract_summary.future_y1',
|
||||||
|
'data.contract_summary.other_ssp',
|
||||||
|
'data.approved.q1',
|
||||||
|
'data.approved.year',
|
||||||
|
'data.collegial.approved',
|
||||||
|
'data.ckk.q1',
|
||||||
|
'data.ckk.rf_schedule',
|
||||||
|
'data.contract_detail.q1',
|
||||||
|
'data.contract_detail.amount_foreign',
|
||||||
|
'data.q4.corrected_plan',
|
||||||
|
'data.q1.pay_ho',
|
||||||
|
'data.q1.pay_rf',
|
||||||
|
'data.q1.booking',
|
||||||
|
'data.q1.rem_actual',
|
||||||
|
'data.q1.residual_after_actual',
|
||||||
|
'data.q4.total',
|
||||||
|
'data.q2.new_plan',
|
||||||
|
// Раздел 6.3: исключение из transfer_* по прямому требованию перечня Салмина.
|
||||||
|
'data.q1.transfer_econ',
|
||||||
|
'data.q4.transfer_econ',
|
||||||
|
// Раздел 5: экономия формы 1 — другой ключ, чем transfer_econ, не затронут исключением.
|
||||||
|
'data.q1.economy',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of mustInclude) {
|
||||||
|
assert.ok(nonNegativeColumnKeys.includes(key), `${key} must be in nonNegativeColumnKeys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Д-статус из раздела 5/6.1: эти остатки уже покрыты правилом remaining-booking
|
||||||
|
// и не должны дублироваться здесь (иначе пользователь увидит две причины ошибки).
|
||||||
|
const mustExclude = [
|
||||||
|
'data.q1.rem_booking',
|
||||||
|
'data.q4.rem_booking',
|
||||||
|
'data.q1.residual_after_booking',
|
||||||
|
'data.q4.residual_after_booking',
|
||||||
|
];
|
||||||
|
for (const key of mustExclude) {
|
||||||
|
assert.ok(!nonNegativeColumnKeys.includes(key), `${key} is already covered by remaining-booking and must not duplicate here`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// AURORA-1893: по записи на каждую ошибочную ячейку (не по одной на колонку).
|
// AURORA-1893: по записи на каждую ошибочную ячейку (не по одной на колонку).
|
||||||
test('collectInvalidErrors produces one entry per invalid cell, with lineId and columnId', () => {
|
test('collectInvalidErrors produces one entry per invalid cell, with lineId and columnId', () => {
|
||||||
const rule = {
|
const rule = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user