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 React, { useMemo, useCallback, useState } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext'; import { useCellLock } from '../../contexts/RealtimeContext';
import { formatNumber } from '../../utils/numberFormat'; 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 cellKey = `${row.id}_${column.id}`;
const isLocked = useCellLock(cellKey); const isLocked = useCellLock(cellKey);
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px). // AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
@ -228,7 +242,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
} }
}, [isLocked, isEditable, onClick]); }, [isLocked, isEditable, onClick]);
return ( const cellNode = (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> // biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div <div
type='button' type='button'
@ -242,6 +256,18 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
{lockBadge} {lockBadge}
</div> </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) => { const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
@ -255,6 +281,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.columnFilter === nextProps.columnFilter && prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor && prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isInvalid === nextProps.isInvalid && prevProps.isInvalid === nextProps.isInvalid &&
prevProps.invalidMessage === nextProps.invalidMessage &&
prevProps.isEditable === nextProps.isEditable && prevProps.isEditable === nextProps.isEditable &&
prevProps.vspOptions === nextProps.vspOptions && prevProps.vspOptions === nextProps.vspOptions &&
prevProps.displayDivisor === nextProps.displayDivisor prevProps.displayDivisor === nextProps.displayDivisor

View File

@ -98,7 +98,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null); const [editingCell, setEditingCell] = useState(null);
const [columnsConfig, setColumnsConfig] = 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 [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide'); const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
@ -263,14 +263,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
userRoleId, userRoleId,
onCellNumberClick: handleClickRowCell, onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError, onCellUpdateError: handleCellUpdateError,
isCellInvalid, getCellErrors,
denomination, denomination,
}); });
} catch (error) { } catch (error) {
console.error('Error creating columns:', error); console.error('Error creating columns:', error);
return []; return [];
} }
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination]);
// MRT запоминает индексы pinned-колонок до завершения асинхронной // MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет // загрузки конфигурации. Новая ссылка на columnPinning заставляет
@ -518,6 +518,57 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
[columnPinning, containerRef, table], [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 searchMatches = useMemo(() => {
const query = normalizeFilterValue(globalFilter); const query = normalizeFilterValue(globalFilter);
if (!query) return []; if (!query) return [];
@ -805,7 +856,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
canModifyRows={canModifyRows} canModifyRows={canModifyRows}
formType={formType} formType={formType}
validationErrors={validationErrors} validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn} onNavigateToError={handleNavigateToError}
headerPaths={headerPaths}
expenseItemsVisibilityAction={expenseItemsVisibilityAction} expenseItemsVisibilityAction={expenseItemsVisibilityAction}
onToggleDepthVisibility={handleToggleDepthVisibility} onToggleDepthVisibility={handleToggleDepthVisibility}
/> />

View File

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

View File

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

View File

@ -19,34 +19,36 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
const seen = new Set(); const seen = new Set();
for (const rule of activeRules) { for (const rule of activeRules) {
collectInvalidErrors(safeData, rule, headerPaths, result, seen); collectInvalidErrors(safeData, rule, result, seen);
} }
return result; return result;
}, [activeRules, headerPaths, safeData]); }, [activeRules, safeData]);
// AURORA-1893: id теперь кодирует правило+колонку+строку и уникален для каждой
// ошибочной ЯЧЕЙКИ — сравнения по нему достаточно для стабильности ссылки.
const previousErrors = stableErrorsRef.current; const previousErrors = stableErrorsRef.current;
const errorsAreEqual = const errorsAreEqual =
previousErrors.length === calculatedErrors.length && previousErrors.length === calculatedErrors.length && previousErrors.every((error, index) => error.id === calculatedErrors[index].id);
previousErrors.every(
(error, index) =>
error.id === calculatedErrors[index].id &&
error.columnId === calculatedErrors[index].columnId &&
error.message === calculatedErrors[index].message,
);
if (!errorsAreEqual) { if (!errorsAreEqual) {
stableErrorsRef.current = calculatedErrors; stableErrorsRef.current = calculatedErrors;
} }
const errors = stableErrorsRef.current; const errors = stableErrorsRef.current;
const isCellInvalid = useCallback( // AURORA-1893: причины ошибок на ячейке (для подсказки Cell) — список сообщений
// правил, которым эта ячейка не удовлетворяет.
const getCellErrors = useCallback(
(columnId, value, row) => (columnId, value, row) =>
activeRules.some( activeRules
(rule) => .filter(
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId), (rule) =>
), rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
)
.map((rule) => rule.message),
[activeRules], [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, userRoleId,
onCellUpdateError, onCellUpdateError,
onCellNumberClick, onCellNumberClick,
isCellInvalid, getCellErrors,
denomination = 'rub', denomination = 'rub',
}) => { }) => {
const displayDivisor = getDenominationDivisor(denomination); const displayDivisor = getDenominationDivisor(denomination);
@ -145,7 +145,11 @@ export const getTableColumns = ({
const getCachedCellProps = (row, column, table) => { const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`; const key = `${row.id}_${column.id}`;
const value = row.getValue(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 isEditableCell = isEditable(row, column);
const backgroundColor = getColorCell(columnColors, column, row); const backgroundColor = getColorCell(columnColors, column, row);
if (!cellPropsCache.has(row)) { if (!cellPropsCache.has(row)) {
@ -164,6 +168,7 @@ export const getTableColumns = ({
cached.value === value && cached.value === value &&
cached.displayDivisor === displayDivisor && cached.displayDivisor === displayDivisor &&
cached.isInvalid === isInvalid && cached.isInvalid === isInvalid &&
cached.invalidMessage === invalidMessage &&
cached.backgroundColor === backgroundColor cached.backgroundColor === backgroundColor
) { ) {
return cached; return cached;
@ -179,9 +184,10 @@ export const getTableColumns = ({
isEditable: isEditableCell, isEditable: isEditableCell,
backgroundColor, backgroundColor,
isInvalid, isInvalid,
invalidMessage,
value, value,
displayDivisor, displayDivisor,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`, _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayDivisor}`,
}; };
rowCache[key] = props; 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); 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; return map;
}; };
// Обходит строки и собирает ошибки по правилу валидации // AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
export const collectInvalidErrors = (rows, rule, headerPaths, errors, seen) => { // название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
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) { for (const row of rows) {
if (!rule.appliesToRow || rule.appliesToRow(row)) { if (!rule.appliesToRow || rule.appliesToRow(row)) {
for (const columnKey of rule.errorColumnKeys || rule.columnKeys) { for (const columnKey of rule.errorColumnKeys || rule.columnKeys) {
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue; 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; if (seen.has(errorId)) continue;
seen.add(errorId); seen.add(errorId);
const columnPath = headerPaths[columnKey];
const columnMessage = columnPath?.length ? `Ошибка в колонке: ${columnPath.join(' => ')}` : null;
errors.push({ errors.push({
id: errorId, id: errorId,
ruleId: rule.id,
columnId: columnKey, 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 assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js'; import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
import { isValidationRuleEnabled } from './validationUtils.js'; import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
test('nonNegativeRule flags negative numbers as invalid', () => { test('nonNegativeRule flags negative numbers as invalid', () => {
const rule = nonNegativeRule({ 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);
});