feat(table): плашка контроля ошибок со счётчиком, переходом к ячейке и подсказкой (AURORA-1893)

- validationUtils.collectInvalidErrors теперь собирает ошибки по ЯЧЕЙКАМ
  (id/ruleId/columnId/lineId/rowLabel/message), а не одну запись на колонку;
  новая groupValidationErrors(errors) группирует их обратно по правилу+колонке
  со счётчиком для меню плашки.
- useValidationRules: getCellErrors(columnId, value, row) отдаёт причины
  конкретной ячейки (для подсказки), isCellInvalid выражен через него;
  сравнение стабильности errors — по id (он уже кодирует правило+колонку+строку).
- RealtimeTable: handleNavigateToError({lineId, columnId}) — находит строку по
  getRowId в table.getRowModel().flatRows (не .rows, т.к. flatRows не фильтруется
  по свёрнутым группам), раскрывает родителей через setExpanded, включает
  скрытую колонку через handleSetColumnsVisibility, выделяет ячейку как
  activeSearchMatch и скроллит к ней — по образцу navigateToSearchMatch.
- ValidationErrorsAlert: красная плашка (#FDEDED/#D32F2F) со счётчиком
  «Ошибок: N», кнопками ‹›  для прохода по всем ошибкам и меню, сгруппированным
  по правилу+колонке (до 20 строк, дальше «и ещё N»); перенесена в начало
  SettingPanel, перед группами настроек.
- tableColumns/Cell: invalidMessage (из getCellErrors) доходит до Cell и
  показывается подсказкой (MUI Tooltip) при наведении на невалидную ячейку.

Тесты (node --test .../validationUtils.test.js): 12/12, включая 5 существующих
тестов nonNegativeRule из AURORA-1892 (не менялись, подтверждают отсутствие
регрессии) и 7 новых для collectInvalidErrors/groupValidationErrors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-01 15:11:26 +04:00
parent df6ec795a3
commit d7ab4d170c
8 changed files with 450 additions and 103 deletions

View File

@ -1,3 +1,4 @@
import { Tooltip } from '@mui/material';
import React, { useMemo, useCallback, useState } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext';
import { formatNumber } from '../../utils/numberFormat';
@ -147,7 +148,20 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
});
};
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => {
const CellComponent = ({
cell,
row,
column,
onClick,
globalFilter,
columnFilter,
backgroundColor,
isEditable,
isInvalid,
invalidMessage,
vspOptions,
displayDivisor = 1,
}) => {
const cellKey = `${row.id}_${column.id}`;
const isLocked = useCellLock(cellKey);
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
@ -228,7 +242,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
}
}, [isLocked, isEditable, onClick]);
return (
const cellNode = (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
type='button'
@ -242,6 +256,18 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
{lockBadge}
</div>
);
// AURORA-1893: причина ошибки по наведению — только когда ячейка действительно
// невалидна и есть текст причины (invalidMessage собран useValidationRules'ом).
if (isInvalid && invalidMessage) {
return (
<Tooltip title={invalidMessage} arrow placement='top'>
{cellNode}
</Tooltip>
);
}
return cellNode;
};
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
@ -255,6 +281,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isInvalid === nextProps.isInvalid &&
prevProps.invalidMessage === nextProps.invalidMessage &&
prevProps.isEditable === nextProps.isEditable &&
prevProps.vspOptions === nextProps.vspOptions &&
prevProps.displayDivisor === nextProps.displayDivisor

View File

@ -98,7 +98,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, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
@ -263,14 +263,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
userRoleId,
onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError,
isCellInvalid,
getCellErrors,
denomination,
});
} catch (error) {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]);
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination]);
// MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
@ -518,6 +518,57 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
[columnPinning, containerRef, table],
);
// AURORA-1893: переход от плашки «Контроль ошибок» к конкретной ячейке (строка + колонка),
// а не только к колонке, как раньше. Строим по образцу navigateToSearchMatch ниже.
//
// Рулинг: ищем строку в table.getRowModel().flatRows, а не в table.getRowModel().rows —
// flatRows содержит все строки независимо от текущего состояния свёрнутости групп
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
const handleNavigateToError = useCallback(
({ lineId, columnId } = {}) => {
if (lineId === undefined || lineId === null || !columnId) return;
const row = table.getRowModel().flatRows.find((candidate) => getRowId(candidate) === lineId);
if (!row) return;
const parentRows = row.getParentRows();
if (parentRows.length) {
setExpanded((current) => {
if (current === true) return true;
let changed = false;
const next = { ...current };
for (const parentRow of parentRows) {
if (next[parentRow.id] !== true) {
next[parentRow.id] = true;
changed = true;
}
}
return changed ? next : current;
});
}
// Рулинг: handleToggleColumnVisibility (которую называет тикет) принимает узел
// дерева колонок, а не columnId — здесь нужнее handleSetColumnsVisibility,
// у которой сигнатура {columnId: isVisible} и которая уже доступна в этом файле.
if (columnVisibility[columnId] === false) {
handleSetColumnsVisibility({ [columnId]: true });
}
setActiveSearchMatch({ rowId: row.id, columnId });
setRowSelection({ [row.id]: true });
handleNavigateToColumn(columnId);
requestAnimationFrame(() => {
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
if (rowIndex < 0) return;
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
});
},
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn],
);
const searchMatches = useMemo(() => {
const query = normalizeFilterValue(globalFilter);
if (!query) return [];
@ -805,7 +856,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
canModifyRows={canModifyRows}
formType={formType}
validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn}
onNavigateToError={handleNavigateToError}
headerPaths={headerPaths}
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
onToggleDepthVisibility={handleToggleDepthVisibility}
/>

View File

@ -96,7 +96,8 @@ const SettingPanel = ({
canModifyRows,
formType,
validationErrors = [],
onNavigateToColumn,
onNavigateToError,
headerPaths,
expenseItemsVisibilityAction,
onToggleDepthVisibility,
denomination,
@ -165,7 +166,13 @@ const SettingPanel = ({
return (
<>
<Panel data-pin-panel>
<Stack direction='row' sx={{ gap: '1.25rem' }}>
<Stack direction='row' sx={{ gap: '1.25rem', alignItems: 'center' }}>
{validationErrors.length > 0 && (
<>
<ValidationErrorsAlert errors={validationErrors} onNavigateToError={onNavigateToError} headerPaths={headerPaths} />
<Divider orientation='vertical' flexItem />
</>
)}
<GroupByObject title='Отображение'>
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
@ -323,7 +330,6 @@ const SettingPanel = ({
</GroupByObject>
</Stack>
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
<CellLegend />
</Stack>
</Panel>

View File

@ -1,56 +1,99 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import { Button, Menu, MenuItem, Stack } from '@mui/material';
import { memo, useEffect, useState } from 'react';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { Button, IconButton, ListSubheader, Menu, MenuItem, Stack } from '@mui/material';
import { memo, useEffect, useMemo, useState } from 'react';
import { groupValidationErrors } from '../utils/validationUtils';
const warningColors = {
bg: '#fff7e6',
text: '#b54708',
border: '#FCD99C',
hover: '#fff1d6',
// AURORA-1893: «более выделенный формат контроля за ошибками» — красная плашка
// вместо прежней бледно-оранжевой.
const errorColors = {
bg: '#FDEDED',
text: '#D32F2F',
border: '#D32F2F',
hover: '#FBD5D5',
};
const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
const stepButtonSx = {
color: errorColors.text,
border: `1px solid ${errorColors.border}`,
borderRadius: '0.5rem',
'&:hover': { bgcolor: errorColors.hover },
'&.Mui-disabled': { color: errorColors.border, border: `1px solid ${errorColors.border}`, opacity: 0.5 },
};
// Внутри одной группы (правило + колонка) показываем не больше 20 строк,
// остальные — свёрнутой строкой «и ещё N».
const MAX_VISIBLE_CELLS_PER_GROUP = 20;
const getColumnTitle = (headerPaths, columnId) => headerPaths?.[columnId]?.filter(Boolean).join(' / ') || columnId;
const ValidationErrorsAlert = ({ errors = [], onNavigateToError, headerPaths }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const isOpen = Boolean(anchorEl);
const groups = useMemo(() => groupValidationErrors(errors), [errors]);
useEffect(() => {
if (errors.length === 0) {
setAnchorEl(null);
setActiveIndex(0);
return;
}
}, [errors.length]);
if (activeIndex >= errors.length) {
setActiveIndex(0);
}
}, [errors, activeIndex]);
if (errors.length === 0) return null;
const handleNavigate = (columnId) => {
onNavigateToColumn?.(columnId);
const navigateToIndex = (index) => {
const error = errors[index];
if (!error) return;
setActiveIndex(index);
onNavigateToError?.({ lineId: error.lineId, columnId: error.columnId });
};
const handleStep = (direction) => {
const nextIndex = (activeIndex + direction + errors.length) % errors.length;
navigateToIndex(nextIndex);
};
const handleSelectCell = (cell) => {
const index = errors.findIndex((candidate) => candidate.id === cell.id);
navigateToIndex(index < 0 ? 0 : index);
setAnchorEl(null);
};
return (
<>
<Button
variant='outlined'
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
ml: 2,
maxWidth: 360,
height: '2.5rem',
borderRadius: '0.5rem',
bgcolor: warningColors.bg,
color: warningColors.text,
borderColor: warningColors.border,
textTransform: 'none',
justifyContent: 'space-between',
px: 1.5,
gap: 1,
'&:hover': {
bgcolor: warningColors.hover,
borderColor: warningColors.border,
},
}}>
<Stack direction='row' sx={{ alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<WarningAmberRoundedIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
<IconButton size='small' onClick={() => handleStep(-1)} aria-label='Предыдущая ошибка' sx={stepButtonSx}>
<ChevronLeftIcon fontSize='small' />
</IconButton>
<Button
variant='outlined'
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
maxWidth: 360,
height: '2.5rem',
borderRadius: '0.5rem',
bgcolor: errorColors.bg,
color: errorColors.text,
borderColor: errorColors.border,
textTransform: 'none',
fontWeight: 600,
justifyContent: 'flex-start',
px: 1.5,
gap: 1,
'&:hover': {
bgcolor: errorColors.hover,
borderColor: errorColors.border,
},
}}>
<ErrorOutlineIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
<span
style={{
overflow: 'hidden',
@ -58,21 +101,14 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
whiteSpace: 'nowrap',
textAlign: 'left',
}}>
Контроль ошибок
Ошибок: {errors.length}
</span>
</Stack>
</Button>
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<span>Подробнее</span>
<KeyboardArrowDownIcon
sx={{
fontSize: '1.25rem',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
/>
</Stack>
</Button>
<IconButton size='small' onClick={() => handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}>
<ChevronRightIcon fontSize='small' />
</IconButton>
</Stack>
<Menu
anchorEl={anchorEl}
@ -85,31 +121,61 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
'data-validation-errors-menu': true,
sx: {
mt: 0.5,
bgcolor: warningColors.bg,
color: warningColors.text,
border: `1px solid ${warningColors.border}`,
bgcolor: errorColors.bg,
color: errorColors.text,
border: `1px solid ${errorColors.border}`,
borderRadius: '0.5rem',
boxShadow: 'none',
minWidth: anchorEl?.offsetWidth,
maxWidth: 420,
maxHeight: 420,
},
},
}}>
{errors.map((error) => (
<MenuItem
key={error.id}
onClick={() => handleNavigate(error.columnId)}
sx={{
whiteSpace: 'normal',
color: warningColors.text,
cursor: 'pointer',
'&:hover': {
bgcolor: warningColors.hover,
},
}}>
{error.message}
</MenuItem>
))}
{groups.flatMap((group) => {
const columnTitle = getColumnTitle(headerPaths, group.columnId);
const visibleCells = group.cells.slice(0, MAX_VISIBLE_CELLS_PER_GROUP);
const hiddenCount = group.count - visibleCells.length;
return [
<ListSubheader
key={`${group.id}-header`}
sx={{
bgcolor: 'inherit',
color: errorColors.text,
fontWeight: 600,
lineHeight: 1.3,
whiteSpace: 'normal',
py: 0.5,
}}>
{group.message} · {columnTitle} ({group.count})
</ListSubheader>,
...visibleCells.map((cell) => (
<MenuItem
key={cell.id}
selected={errors[activeIndex]?.id === cell.id}
onClick={() => handleSelectCell(cell)}
sx={{
whiteSpace: 'normal',
color: errorColors.text,
cursor: 'pointer',
pl: 3,
'&:hover': {
bgcolor: errorColors.hover,
},
}}>
{cell.rowLabel}
</MenuItem>
)),
...(hiddenCount > 0
? [
<MenuItem key={`${group.id}-more`} disabled sx={{ pl: 3, color: errorColors.text, opacity: 0.7 }}>
и ещё {hiddenCount}
</MenuItem>,
]
: []),
];
})}
</Menu>
</>
);

View File

@ -19,34 +19,36 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
const seen = new Set();
for (const rule of activeRules) {
collectInvalidErrors(safeData, rule, headerPaths, result, seen);
collectInvalidErrors(safeData, rule, result, seen);
}
return result;
}, [activeRules, headerPaths, safeData]);
}, [activeRules, safeData]);
// AURORA-1893: id теперь кодирует правило+колонку+строку и уникален для каждой
// ошибочной ЯЧЕЙКИ — сравнения по нему достаточно для стабильности ссылки.
const previousErrors = stableErrorsRef.current;
const errorsAreEqual =
previousErrors.length === calculatedErrors.length &&
previousErrors.every(
(error, index) =>
error.id === calculatedErrors[index].id &&
error.columnId === calculatedErrors[index].columnId &&
error.message === calculatedErrors[index].message,
);
previousErrors.length === calculatedErrors.length && previousErrors.every((error, index) => error.id === calculatedErrors[index].id);
if (!errorsAreEqual) {
stableErrorsRef.current = calculatedErrors;
}
const errors = stableErrorsRef.current;
const isCellInvalid = useCallback(
// AURORA-1893: причины ошибок на ячейке (для подсказки Cell) — список сообщений
// правил, которым эта ячейка не удовлетворяет.
const getCellErrors = useCallback(
(columnId, value, row) =>
activeRules.some(
(rule) =>
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
),
activeRules
.filter(
(rule) =>
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
)
.map((rule) => rule.message),
[activeRules],
);
return { errors, isCellInvalid, headerPaths };
const isCellInvalid = useCallback((columnId, value, row) => getCellErrors(columnId, value, row).length > 0, [getCellErrors]);
return { errors, isCellInvalid, getCellErrors, headerPaths };
};

View File

@ -119,7 +119,7 @@ export const getTableColumns = ({
userRoleId,
onCellUpdateError,
onCellNumberClick,
isCellInvalid,
getCellErrors,
denomination = 'rub',
}) => {
const displayDivisor = getDenominationDivisor(denomination);
@ -145,7 +145,11 @@ export const getTableColumns = ({
const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`;
const value = row.getValue(column.id);
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
// AURORA-1893: причины ошибки для подсказки на ячейке; isInvalid — просто
// «есть хотя бы одна причина», чтобы не держать две параллельные проверки.
const cellErrors = getCellErrors?.(column.id, value, row.original) || [];
const isInvalid = cellErrors.length > 0;
const invalidMessage = cellErrors.join('. ');
const isEditableCell = isEditable(row, column);
const backgroundColor = getColorCell(columnColors, column, row);
if (!cellPropsCache.has(row)) {
@ -164,6 +168,7 @@ export const getTableColumns = ({
cached.value === value &&
cached.displayDivisor === displayDivisor &&
cached.isInvalid === isInvalid &&
cached.invalidMessage === invalidMessage &&
cached.backgroundColor === backgroundColor
) {
return cached;
@ -179,9 +184,10 @@ export const getTableColumns = ({
isEditable: isEditableCell,
backgroundColor,
isInvalid,
invalidMessage,
value,
displayDivisor,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayDivisor}`,
};
rowCache[key] = props;

View File

@ -1,3 +1,5 @@
import { getDataRowId } from './rowUtils.js';
// Достаёт вложенное поле из объекта строки таблицы
export const getValueByPath = (row, path) => path.split('.').reduce((value, key) => value?.[key], row);
@ -31,28 +33,67 @@ export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => {
return map;
};
// Обходит строки и собирает ошибки по правилу валидации
export const collectInvalidErrors = (rows, rule, headerPaths, errors, seen) => {
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
const getRowLabel = (row) => {
const data = row?.data;
if (data?.header?.name) return data.header.name;
if (data?.name) return data.name;
if (row?.sort_order !== undefined && row?.sort_order !== null) return String(row.sort_order);
return String(getDataRowId(data) ?? '');
};
// Обходит строки и собирает ошибки по правилу валидации — по записи на каждую
// ошибочную ЯЧЕЙКУ (не одна запись на колонку, как было раньше), чтобы плашка
// могла показать счётчик и перейти к конкретной строке.
export const collectInvalidErrors = (rows, rule, errors, seen) => {
for (const row of rows) {
if (!rule.appliesToRow || rule.appliesToRow(row)) {
for (const columnKey of rule.errorColumnKeys || rule.columnKeys) {
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
const errorId = `${rule.id}:${columnKey}`;
const lineId = getDataRowId(row.data);
const errorId = `${rule.id}:${columnKey}:${lineId}`;
if (seen.has(errorId)) continue;
seen.add(errorId);
const columnPath = headerPaths[columnKey];
const columnMessage = columnPath?.length ? `Ошибка в колонке: ${columnPath.join(' => ')}` : null;
errors.push({
id: errorId,
ruleId: rule.id,
columnId: columnKey,
message: columnMessage ? `${rule.message}. ${columnMessage}` : rule.message,
lineId,
rowLabel: getRowLabel(row),
message: rule.message,
});
}
}
collectInvalidErrors(row.subRows || [], rule, headerPaths, errors, seen);
collectInvalidErrors(row.subRows || [], rule, errors, seen);
}
};
// AURORA-1893: группирует ошибки по «правило + колонка» (как раньше отображалось
// в плашке) и прикладывает к каждой группе список конкретных ячеек со счётчиком.
export const groupValidationErrors = (errors) => {
const groupsByKey = new Map();
for (const error of errors) {
const key = `${error.ruleId}:${error.columnId}`;
let group = groupsByKey.get(key);
if (!group) {
group = {
id: key,
ruleId: error.ruleId,
columnId: error.columnId,
message: error.message,
cells: [],
count: 0,
};
groupsByKey.set(key, group);
}
group.cells.push(error);
group.count += 1;
}
return [...groupsByKey.values()];
};

View File

@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js';
import { isValidationRuleEnabled } from './validationUtils.js';
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
test('nonNegativeRule flags negative numbers as invalid', () => {
const rule = nonNegativeRule({
@ -65,3 +65,150 @@ test('the shared nonNegativeColumnKeys list excludes correction and transfer col
}
}
});
// AURORA-1893: по записи на каждую ошибочную ячейку (не по одной на колонку).
test('collectInvalidErrors produces one entry per invalid cell, with lineId and columnId', () => {
const rule = {
id: 'test-rule',
columnKeys: ['data.value'],
message: 'Значение недопустимо',
isInvalid: (value) => Number(value) < 0,
};
const rows = [
{ data: { line_id: 10, value: -1 }, subRows: [] },
{ data: { line_id: 11, value: 5 }, subRows: [] },
{
data: { line_id: 12, value: 1 },
subRows: [{ data: { line_id: 13, value: -2 }, subRows: [] }],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 2);
assert.deepEqual(
errors.map((error) => ({ lineId: error.lineId, columnId: error.columnId })),
[
{ lineId: 10, columnId: 'data.value' },
{ lineId: 13, columnId: 'data.value' },
],
);
assert.equal(errors[0].id, 'test-rule:data.value:10');
assert.equal(errors[0].ruleId, 'test-rule');
assert.equal(errors[0].message, 'Значение недопустимо');
});
test('collectInvalidErrors does not duplicate the same cell when seen is reused', () => {
const rule = {
id: 'test-rule',
columnKeys: ['data.value'],
message: 'Значение недопустимо',
isInvalid: (value) => Number(value) < 0,
};
const rows = [{ data: { line_id: 10, value: -1 }, subRows: [] }];
const errors = [];
const seen = new Set();
collectInvalidErrors(rows, rule, errors, seen);
collectInvalidErrors(rows, rule, errors, seen);
assert.equal(errors.length, 1);
});
test('collectInvalidErrors fills rowLabel from data.header.name, falling back to data.name then the row number', () => {
const rule = {
id: 'label-rule',
columnKeys: ['data.value'],
message: 'msg',
isInvalid: (value) => value === 'bad',
};
const rows = [
{ data: { line_id: 1, value: 'bad', header: { name: 'Статья 1' } }, subRows: [] },
{ data: { line_id: 2, value: 'bad', name: 'ВСП 2' }, subRows: [] },
{ data: { line_id: 3, value: 'bad' }, sort_order: 7, subRows: [] },
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.deepEqual(
errors.map((error) => error.rowLabel),
['Статья 1', 'ВСП 2', '7'],
);
});
// AURORA-1893: меню «Контроль ошибок» группирует по правилу + колонке со счётчиком.
test('groupValidationErrors groups cell errors by rule and column with a count', () => {
const errors = [
{ id: 'r1:c1:1', ruleId: 'r1', columnId: 'c1', lineId: 1, rowLabel: '1', message: 'm1' },
{ id: 'r1:c1:2', ruleId: 'r1', columnId: 'c1', lineId: 2, rowLabel: '2', message: 'm1' },
{ id: 'r1:c2:1', ruleId: 'r1', columnId: 'c2', lineId: 1, rowLabel: '1', message: 'm1' },
{ id: 'r2:c1:1', ruleId: 'r2', columnId: 'c1', lineId: 1, rowLabel: '1', message: 'm2' },
];
const groups = groupValidationErrors(errors);
assert.equal(groups.length, 3);
const byKey = new Map(groups.map((group) => [`${group.ruleId}:${group.columnId}`, group]));
assert.equal(byKey.get('r1:c1').count, 2);
assert.deepEqual(
byKey.get('r1:c1').cells.map((cell) => cell.lineId),
[1, 2],
);
assert.equal(byKey.get('r1:c2').count, 1);
assert.equal(byKey.get('r2:c1').count, 1);
});
test('groupValidationErrors returns an empty list for an empty errors list', () => {
assert.deepEqual(groupValidationErrors([]), []);
});
// AURORA-1893: appliesToRow ограничивает правило «корректировки = 0» строками ROOT/depth 0.
test('current-adjustments-zero only reports rows that appliesToRow allows (ROOT / depth 0)', () => {
const rule = validationRules.find((r) => r.id === 'current-adjustments-zero');
const rows = [
{
data: { line_id: 1, q1: { adj_current: 5 } },
row_type: 'ROOT',
depth: 0,
subRows: [{ data: { line_id: 2, q1: { adj_current: 7 } }, row_type: 'ITEM', depth: 1, subRows: [] }],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 1);
assert.equal(errors[0].lineId, 1);
assert.equal(errors[0].columnId, 'data.q1.adj_current');
});
// AURORA-1893: errorColumnKeys ограничивает booking-less-balance колонками брони,
// а не парными колонками остатка, даже когда isInvalid читает обе.
test('booking-less-balance only reports its errorColumnKeys (booking columns), not the paired balance columns', () => {
const rule = validationRules.find((r) => r.id === 'booking-less-balance');
const rows = [
{
data: {
line_id: 5,
q1: { booking: 100, residual_after_booking: 50 },
q2: { booking: 10, residual_after_booking: 50 },
},
subRows: [],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 1);
assert.equal(errors[0].columnId, 'data.q1.booking');
assert.equal(errors[0].lineId, 5);
});