diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 9fbbdbb..e90ad40 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -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:
); + + // AURORA-1893: причина ошибки по наведению — только когда ячейка действительно + // невалидна и есть текст причины (invalidMessage собран useValidationRules'ом). + if (isInvalid && invalidMessage) { + return ( + + {cellNode} + + ); + } + + 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 diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index a35aec1..a2e164f 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -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} /> diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 6b4dba4..bc20e07 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -96,7 +96,8 @@ const SettingPanel = ({ canModifyRows, formType, validationErrors = [], - onNavigateToColumn, + onNavigateToError, + headerPaths, expenseItemsVisibilityAction, onToggleDepthVisibility, denomination, @@ -165,7 +166,13 @@ const SettingPanel = ({ return ( <> - + + {validationErrors.length > 0 && ( + <> + + + + )} {/* */} @@ -323,7 +330,6 @@ const SettingPanel = ({ - diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx index 18377d2..95b1a68 100644 --- a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx @@ -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 ( <> - - - Подробнее - - - + handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}> + + + { '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) => ( - handleNavigate(error.columnId)} - sx={{ - whiteSpace: 'normal', - color: warningColors.text, - cursor: 'pointer', - '&:hover': { - bgcolor: warningColors.hover, - }, - }}> - {error.message} - - ))} + {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 [ + + {group.message} · {columnTitle} ({group.count}) + , + ...visibleCells.map((cell) => ( + handleSelectCell(cell)} + sx={{ + whiteSpace: 'normal', + color: errorColors.text, + cursor: 'pointer', + pl: 3, + '&:hover': { + bgcolor: errorColors.hover, + }, + }}> + {cell.rowLabel} + + )), + ...(hiddenCount > 0 + ? [ + + и ещё {hiddenCount} + , + ] + : []), + ]; + })} ); diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js index 1758ec6..1bbe499 100644 --- a/web/src/components/RealtimeTable/hooks/useValidationRules.js +++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js @@ -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 }; }; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index baee1c2..ec1d952 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -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; diff --git a/web/src/components/RealtimeTable/utils/validationUtils.js b/web/src/components/RealtimeTable/utils/validationUtils.js index c17d470..00db287 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.js @@ -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()]; +}; diff --git a/web/src/components/RealtimeTable/utils/validationUtils.test.js b/web/src/components/RealtimeTable/utils/validationUtils.test.js index 6989933..028f79f 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.test.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js @@ -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); +});