AURORA-1892: доработка по сверке с исходным перечнем 02.11.2026 #293

Merged
aep merged 5 commits from feat/276-aurora-1892-negative-amounts into test 2026-11-08 17:12:37 +03:00
5 changed files with 223 additions and 33 deletions

View File

@ -1,6 +1,7 @@
import { Tooltip } from '@mui/material';
import React, { useMemo, useCallback, useState } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext';
import { resolveCellTextColor } from '../../utils/cellTextColor';
import { formatNumber } from '../../utils/numberFormat';
import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils';
@ -33,11 +34,6 @@ const INVALID_STYLES = {
color: '#C60C0C',
};
// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел,
// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634).
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
@ -50,10 +46,11 @@ const EDITABLE_BORDER_COLOR = '#7FA7C9';
// AURORA-1890: нередактируемая ячейка строки INPUT (вычисляемая или вне
// текущего этапа) — приглушённый фон и текст, чтобы визуально отличаться от
// редактируемой белой ячейки без отдельной палитры цветов.
// Цвет текста (в т.ч. приглушённый серый) задаёт resolveCellTextColor: здесь его нет,
// чтобы стиль не перекрывал красный цвет отрицательных значений (AURORA-1892).
const NOT_EDITABLE_INPUT_STYLES = {
cursor: 'default',
backgroundColor: '#F4F5F7',
color: '#5F6B7A',
};
// Строки 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 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 cleanQueries = searchQueries.filter(Boolean);
@ -213,11 +190,11 @@ const CellComponent = ({
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
const textColor = useMemo(() => {
const brightness = getColorBrightness(backgroundColor);
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isNegative]);
const isMutedInput = isInputRow && !isEditable;
const textColor = useMemo(
() => resolveCellTextColor({ backgroundColor, isNegative, isMutedInput }),
[backgroundColor, isNegative, isMutedInput],
);
// AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает
// приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений).

View File

@ -88,7 +88,10 @@ const bookingLessBalanceRuleScope = {
// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
// там минус — нормальная операция.
// там минус — нормальная операция. Исключение — transfer_econ (перенос в фонд экономии,
// формы 2): перечень Салмина прямо требует контроль для этих колонок (см. сверку
// AURORA-1892 от 02.11.2026, раздел 6.3); общий префикс transfer_* на этот ключ не
// распространяется.
export const nonNegativeColumnKeys = [
// Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
"data.plan.q1",
@ -165,6 +168,95 @@ export const nonNegativeColumnKeys = [
"data.amount_q4",
"data.limit_with_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) =>

View 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';
};

View 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');
});

View File

@ -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);
});
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_'];
// AURORA-1892, сверка 02.11.2026 (раздел 6.3): перечень Салмина прямо требует контроль
// минуса для transfer_econ, в отличие от остальных transfer_* — это намеренное исключение.
const allowedException = 'transfer_econ';
for (const key of nonNegativeColumnKeys) {
const segment = key.split('.').pop();
if (segment === allowedException) continue;
for (const prefix of excludedPrefixes) {
assert.equal(
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: по записи на каждую ошибочную ячейку (не по одной на колонку).
test('collectInvalidErrors produces one entry per invalid cell, with lineId and columnId', () => {
const rule = {