+ // AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx)
+ // — там же проверяется редактируемость/блокировка и открывается редактор одним
+ // кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда
+ // не передавал onClick в Cell).
+ const cellNode = (
+
{highlightedContent}
{isOverLimit && сверх лимита }
{lockBadge}
);
+
+ // AURORA-1893: причина ошибки по наведению — только когда ячейка действительно
+ // невалидна и есть текст причины (invalidMessage собран useValidationRules'ом).
+ if (isInvalid && invalidMessage) {
+ return (
+
+ {cellNode}
+
+ );
+ }
+
+ return cellNode;
};
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
@@ -229,6 +280,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.displayFactor === nextProps.displayFactor &&
diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
index acdf758..619bfe2 100644
--- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
+++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx
@@ -1,6 +1,8 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { toast } from 'react-toastify';
+import { nonNegativeColumnKeys } from '../../constants/validation';
import { useRealtimeActions } from '../../contexts/RealtimeContext';
-import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils';
+import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput } from '../../utils/formulaUtils';
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
const EDITOR_STYLES = {
@@ -29,19 +31,33 @@ const TEXTAREA_STYLES = {
fontSize: 'inherit',
};
-const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
+const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
const storageKey = `formula_${tableId}_${cellId}`;
- const [state, setState] = useState(() => ({
- currentValue: value,
- displayValue: value,
- isFormulaMode: false,
- }));
+ // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
+ // редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
+ // подставляют этот символ (или пустую строку) вместо текущего значения —
+ // RealtimeTable хранит его в своём state и отдаёт через
+ // table.options.meta.getEditorInitialInput(), тот же приём, что уже
+ // применяется для getSelectedColumnId/getColumnTitle в этом файле.
+ // Инициализатор useState вызывается один раз при монтировании — ровно
+ // тогда, когда этот редактор открылся именно так.
+ const [state, setState] = useState(() => {
+ const initialInput = table?.options?.meta?.getEditorInitialInput?.();
+ if (initialInput !== null && initialInput !== undefined) {
+ return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
+ }
+ return { currentValue: value, displayValue: value, isFormulaMode: false };
+ });
// Позиция ячейки
const [position, setPosition] = useState();
const textareaRef = useRef(null);
+ // AURORA-1891:
этой ячейки — чтобы после закрытия редактора вернуть
+ // туда фокус (иначе стрелки/клавиатурная навигация отваливаются после
+ // первого же редактирования, см. muiTableBodyCellProps.onKeyDown).
+ const tdRef = useRef(null);
const refFinished = useRef(false);
@@ -75,6 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const tr = td?.offsetParent;
if (td && tr) {
+ tdRef.current = td;
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
@@ -83,6 +100,15 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}
}, [refCell]);
+ // AURORA-1891: при закрытии редактора (размонтирование) фокус возвращается
+ // в этой ячейки — без этого фокус терялся бы на удалённом textarea,
+ // и стрелки/Tab/Enter перестают работать сразу после первого редактирования.
+ useEffect(() => {
+ return () => {
+ tdRef.current?.focus?.();
+ };
+ }, []);
+
// Auto-resize
useLayoutEffect(() => {
const el = textareaRef.current;
@@ -103,17 +129,34 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}, [state.currentValue]);
useEffect(() => {
- // Небольшая задержка для гарантии, что DOM полностью готов
- const timeoutId = setTimeout(() => {
+ // AURORA-1891: setTimeout(50) был произвольной задержкой "на всякий
+ // случай"; requestAnimationFrame ждёт ровно до следующего кадра
+ // отрисовки — DOM (textarea) к этому моменту уже готов, фокус приходит
+ // на кадр раньше и без угадывания длительности.
+ const frameId = requestAnimationFrame(() => {
if (textareaRef.current && !disabled) {
textareaRef.current.focus();
textareaRef.current.select();
}
- }, 50);
+ });
- return () => clearTimeout(timeoutId);
+ return () => cancelAnimationFrame(frameId);
}, [disabled]);
+ // AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем,
+ // но значение всё равно сохраняется (жёсткий запрет в этой задаче не делается).
+ const warnIfNegativeNotAllowed = useCallback(
+ (newValue) => {
+ const columnId = cell?.column?.id;
+ if (!columnId || !nonNegativeColumnKeys.includes(columnId)) return;
+ if (!Number.isFinite(Number(newValue)) || Number(newValue) >= 0) return;
+
+ const columnTitle = table?.options?.meta?.getColumnTitle?.(columnId) ?? columnId;
+ toast.warning(`В колонке «${columnTitle}» отрицательные суммы запрещены`);
+ },
+ [cell, table],
+ );
+
const handleSave = useCallback(() => {
const { currentValue } = state;
@@ -128,6 +171,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: formattedResult,
isFormulaMode: true,
}));
+ warnIfNegativeNotAllowed(formattedResult);
onChange?.(formattedResult);
} catch {
setState((prev) => ({
@@ -144,9 +188,29 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: currentValue,
isFormulaMode: false,
}));
- onChange?.(normalizeNumericInput(currentValue));
+ const normalizedValue = normalizeNumericInput(currentValue);
+ warnIfNegativeNotAllowed(normalizedValue);
+ // AURORA-1891: значение не изменилось — не шлём onChange (а значит и
+ // cell_updated): открытие и закрытие ячейки без правки не должно
+ // создавать запись в журнале аудита.
+ if (isSameCellValue(normalizedValue, value)) return;
+ onChange?.(normalizedValue);
}
- }, [state, storageKey, onChange]);
+ }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
+
+ // Завершает редактирование текущей ячейки (cell_edit_end) — читает
+ // table.getState().editingCell, а не cell.row/cell.column: к этому моменту
+ // handleSave (через onChange -> EditCellPortal.handleChange) уже мог
+ // вызвать table.setEditingCell(null), но реальным состоянием владеет
+ // RealtimeTable.jsx (контролируемый state.editingCell), так что
+ // table.getState().editingCell всё ещё возвращает ячейку, которую мы
+ // только что редактировали.
+ const endCurrentEditing = useCallback(() => {
+ const editingCell = table.getState().editingCell;
+ if (editingCell) {
+ contextEndEditing?.(editingCell.row, editingCell.column);
+ }
+ }, [table, contextEndEditing]);
const finishEditing = useCallback(() => {
if (refFinished.current) return;
@@ -154,11 +218,16 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
if (!disabled) {
handleSave();
}
- const editingCell = table.getState().editingCell;
- if (editingCell) {
- contextEndEditing?.(editingCell.row, editingCell.column);
- }
- }, [disabled, handleSave, table, contextEndEditing]);
+ endCurrentEditing();
+ }, [disabled, handleSave, endCurrentEditing]);
+
+ // AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого
+ // cell_updated), только завершение редактирования текущей ячейки.
+ const cancelEditing = useCallback(() => {
+ if (refFinished.current) return;
+ refFinished.current = true;
+ endCurrentEditing();
+ }, [endCurrentEditing]);
useEffect(() => {
const handleClickOutside = (event) => {
@@ -182,12 +251,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const handleKeyDown = useCallback(
(e) => {
+ if (refFinished.current) return;
+
+ // AURORA-1891: отмена — значение не сохраняется, cell_updated не шлётся.
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ cancelEditing();
+ return;
+ }
+
+ // AURORA-1891: Tab/Shift+Tab — сохранить и перейти к следующей/предыдущей
+ // редактируемой ячейке в строке. Переход резолвит и прокручивает
+ // table.options.meta.openNextEditableCell (RealtimeTable.jsx); сама
+ // ячейка открывается позже, в эффекте, который ждёт cell_edit_end
+ // текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем.
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right');
+ finishEditing();
+ return;
+ }
+
+ // Shift+Enter — перенос строки в textarea (стандартное поведение,
+ // e.preventDefault() не вызывается).
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
+ table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
finishEditing();
}
},
- [finishEditing],
+ [cancelEditing, finishEditing, table, cell],
);
const editorStyles = useMemo(
diff --git a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx
index 7af48c7..beafa61 100644
--- a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx
+++ b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx
@@ -8,7 +8,7 @@ const EDITOR_STYLES = {
top: 0,
};
-const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => {
+const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
const vspOptions = useVspOptions();
@@ -17,6 +17,9 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
const [open, setOpen] = useState(false);
const refFinished = useRef(false);
+ // AURORA-1891: см. тот же приём в EditCell.jsx — фокус после закрытия
+ // редактора возвращается в , чтобы клавиатурная навигация не отваливалась.
+ const tdRef = useRef(null);
useEffect(() => {
const timer = setTimeout(() => {
@@ -32,6 +35,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
const tr = td?.offsetParent;
if (td && tr) {
+ tdRef.current = td;
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
@@ -40,6 +44,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
}
}, [refCell]);
+ useEffect(() => {
+ return () => {
+ tdRef.current?.focus?.();
+ };
+ }, []);
+
const finishEditing = useCallback(() => {
if (refFinished.current) return;
refFinished.current = true;
@@ -50,6 +60,32 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
}
}, [table, contextEndEditing]);
+ // AURORA-1891: та же клавиатурная логика, что в EditCell.jsx — Esc уже
+ // работает через onClose MUI Select (reason !== 'selectOption' вызывает
+ // finishEditing без onChange, т.е. без cell_updated — отмена). Здесь
+ // добираем Tab/Shift+Tab и Enter: сохранить (для dropdown «сохранить» —
+ // это просто закрыть без выбора, onChange уже случился в handleSelectChange,
+ // если пользователь кликнул опцию) и перейти к следующей редактируемой ячейке.
+ const handleKeyDown = useCallback(
+ (event) => {
+ if (refFinished.current) return;
+
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ table.options.meta?.openNextEditableCell?.(cell.row, cell.column, event.shiftKey ? 'left' : 'right');
+ finishEditing();
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
+ finishEditing();
+ }
+ },
+ [cell, finishEditing, table],
+ );
+
const handleSelectChange = useCallback(
(e) => {
const val = e.target.value;
@@ -92,6 +128,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
{
const {
data,
columnsCurStage,
+ stageColumnDeadlines,
isLoading: isTableLoading,
editingCells: dataEditingCells,
} = useRealtimeData(formId, sheetName, direction, formType, year);
@@ -97,8 +100,22 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
rowSelectionRef.current = rowSelection;
const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null);
+ // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной ячейке,
+ // открывают редактор и подставляют этот символ (или '') вместо текущего
+ // значения — EditCell читает его через table.options.meta.getEditorInitialInput
+ // (ref-обёртка ниже — тот же приём, что у selectedColumnIdRef, чтобь не
+ // пересоздавать tableMeta на каждое нажатие клавиши).
+ const [editorInitialInput, setEditorInitialInput] = useState(null);
+ const editorInitialInputRef = useRef(editorInitialInput);
+ editorInitialInputRef.current = editorInitialInput;
+ // AURORA-1891: «отложенная цель» для Tab/Enter — следующая ячейка
+ // открывается не сразу (сервер ещё не закончил cell_edit_end текущей), а
+ // в эффекте ниже, когда dataEditingCells (и вместе с ним editingCell)
+ // станет null. См. ruling в PR про порядок cell_updated -> cell_edit_end
+ // -> cell_edit_start следующей.
+ const pendingEditTargetRef = useRef(null);
const [columnsConfig, setColumnsConfig] = useState(null);
- const { errors: validationErrors, isCellInvalid } = 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');
@@ -143,6 +160,45 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
addProject: contextAddProject,
setVspOptions,
} = useRealtimeActions();
+ // AURORA-1891: нужен сам стор (не подписка на конкретную ячейку, как
+ // useCellLock) — для имперактивной проверки «не заблокирована ли эта
+ // ячейка» внутри onClick/onKeyDown на td (muiTableBodyCellProps ниже).
+ const lockedCellsStore = useLockedCellsStore();
+
+ const isAdminUser = userRoleId === ROLES_NAME_ID.admin;
+ const stageColumnsSet = useMemo(() => new Set(columnsCurStage), [columnsCurStage]);
+
+ // AURORA-1891: та же проверка, что isCellEditable/isEditable в
+ // tableColumns.jsx, нужна здесь отдельно — у клика/клавиатуры на ячейке
+ // (и у Tab/Enter перехода) нет доступа к замыканию processColumns.
+ const cellIsEditable = useCallback(
+ (row, columnId) =>
+ isCellEditable({
+ rowType: row.original?.row_type,
+ columnId,
+ isAdmin: isAdminUser,
+ stageColumns: stageColumnsSet,
+ }),
+ [isAdminUser, stageColumnsSet],
+ );
+
+ // AURORA-1891: cellIsEditable(row, columnId) не знает про сам column.def —
+ // для номера строки (accessorKey 'sort_order') enableEditing жёстко false
+ // (tableColumns.jsx), а cellIsEditable для INPUT-строки у админа сказала
+ // бы true. Без этой сверки клик/Tab у админа мог бы попытаться открыть
+ // (несуществующий) редактор для номера строки вместо выделения строки
+ // или перехода дальше. Используется и в muiTableBodyCellProps, и в
+ // openNextEditableCell (Tab/Enter не должны переходить на такую колонку).
+ const isColumnActuallyEditable = useCallback(
+ (row, column) => {
+ if (!cellIsEditable(row, column.id)) return false;
+ const enableEditing = column.columnDef?.enableEditing;
+ if (typeof enableEditing === 'function') return enableEditing(row);
+ if (typeof enableEditing === 'boolean') return enableEditing;
+ return true;
+ },
+ [cellIsEditable],
+ );
const {
columnSizing,
@@ -263,7 +319,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
userRoleId,
onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError,
- isCellInvalid,
+ getCellErrors,
denomination,
sheetName,
});
@@ -271,7 +327,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
console.error('Error creating columns:', error);
return [];
}
- }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination, sheetName]);
+ }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination, sheetName]);
// MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
@@ -298,12 +354,25 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const selectedColumnIdRef = useRef(selectedColumnId);
selectedColumnIdRef.current = selectedColumnId;
+ // AURORA-1891: сама функция объявляется ниже (после создания table — ей
+ // нужны getOrderedVisibleColumns(table)/table.getRowModel().flatRows), а
+ // tableMeta собирается до table. Тот же приём, что у selectedColumnIdRef:
+ // стабильная обёртка в tableMeta, реальная реализация — в ref, который
+ // обновляется на каждом рендере ниже.
+ const openNextEditableCellRef = useRef(null);
+
const tableMeta = useMemo(
() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
+ // AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса.
+ getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId,
+ // AURORA-1891: «начальный ввод» для EditCell при открытии редактора цифрой/Backspace/Delete.
+ getEditorInitialInput: () => editorInitialInputRef.current,
+ // AURORA-1891: Tab/Shift+Tab/Enter из EditCell.jsx/VspDropdownEditCell.jsx.
+ openNextEditableCell: (row, column, direction) => openNextEditableCellRef.current?.(row, column, direction),
}),
- [handleUpdateCell],
+ [handleUpdateCell, headerPaths],
);
const vspRegistrationById = useMemo(
@@ -342,6 +411,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
+ isColumnActuallyEditable,
+ lockedCellsStore,
+ setEditorInitialInput,
}) => ({
...BASE_TABLE_CONFIG,
globalFilterFn,
@@ -353,7 +425,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
columnVirtualizerInstanceRef: columnVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
onEditingCellChange: (cell) => {
- if (cell && !editingCell) {
+ // AURORA-1891: защита на случай, если что-то кроме нашего собственного
+ // onClick/onKeyDown откроет редактор для нередактируемой ячейки —
+ // cell_edit_start на сервер не уйдёт, блокировка (🔒 у коллег) не возьмётся.
+ if (cell && !editingCell && isColumnActuallyEditable(cell.row, cell.column)) {
contextStartEditing?.(cell.row, cell.column);
}
},
@@ -387,7 +462,54 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
- muiTableBodyCellProps: ({ column }) => ({ 'data-col-id': column.id }),
+ muiTableBodyCellProps: ({ cell, column, row, table }) => {
+ const cellKey = `${row.id}_${column.id}`;
+
+ // AURORA-1891: открыть редактор можно только если ячейка редактируема
+ // (isColumnActuallyEditable — см. определение выше про колонку
+ // номера строки), не занята коллегой (lockedCellsStore) и сейчас
+ // вообще ничего не редактируется. Иначе клик/клавиша не трогают td —
+ // клик всплывает на строку (её onClick выделяет строку — п.7
+ // тикета), клавиша просто игнорируется.
+ const canOpenEditor = () => !editingCell && isColumnActuallyEditable(row, column) && !lockedCellsStore.hasCell(cellKey);
+
+ return {
+ 'data-col-id': column.id,
+ onClick: (event) => {
+ if (!canOpenEditor()) return;
+ // Не даём клику дойти до muiTableBodyRowProps.onClick — иначе
+ // открытие редактора попутно выделило/сняло бы выделение строки.
+ event.stopPropagation();
+ table.setEditingCell(cell);
+ },
+ onKeyDown: (event) => {
+ if (!canOpenEditor()) return;
+
+ // Enter/F2 — открыть редактор с текущим значением.
+ if (event.key === 'Enter' || event.key === 'F2') {
+ event.preventDefault();
+ table.setEditingCell(cell);
+ return;
+ }
+
+ // Delete/Backspace — открыть редактор с пустым значением.
+ if (event.key === 'Delete' || event.key === 'Backspace') {
+ event.preventDefault();
+ setEditorInitialInput('');
+ table.setEditingCell(cell);
+ return;
+ }
+
+ // Печатный символ без модификаторов — «начальный ввод»: открыть
+ // редактор и подставить его вместо текущего значения.
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ setEditorInitialInput(event.key);
+ table.setEditingCell(cell);
+ }
+ },
+ };
+ },
muiTableHeadProps: {
sx: {
display: 'table-header-group',
@@ -435,6 +557,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
+ isColumnActuallyEditable,
+ lockedCellsStore,
+ setEditorInitialInput,
}),
[
columns,
@@ -453,6 +578,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
expanded,
globalFilterFn,
activeSearchMatch,
+ isColumnActuallyEditable,
+ lockedCellsStore,
+ setEditorInitialInput,
],
);
@@ -489,6 +617,111 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
[columnPinning, containerRef, table],
);
+ // AURORA-1891: Tab/Shift+Tab (direction 'right'/'left', в строке) и Enter
+ // ('down', по столбцу) — вызывается из EditCell.jsx/VspDropdownEditCell.jsx
+ // через table.options.meta.openNextEditableCell. Сам обход уже готового
+ // списка — в чистых findNextEditableColumnId/findNextEditableRowId
+ // (utils/cellNavigation.js); здесь — только резолв реальных MRT
+ // row/column (flatRows, не .rows — см. рулинг AURORA-1893 выше в
+ // handleNavigateToError) и прокрутка к цели, по образцу navigateToSearchMatch.
+ //
+ // Открытие целевой ячейки происходит не здесь: сервер должен сначала
+ // закончить cell_edit_end текущей, поэтому мы только помечаем цель в
+ // pendingEditTargetRef — реальный table.setEditingCell для неё вызывается
+ // в эффекте ниже, когда dataEditingCells (а с ним editingCell) станет null.
+ const openNextEditableCell = useCallback(
+ (row, column, direction) => {
+ let targetRowId = row.id;
+ let targetColumnId = column.id;
+
+ if (direction === 'right' || direction === 'left') {
+ const columns = getOrderedVisibleColumns(table);
+ const nextColumnId = findNextEditableColumnId({
+ columns,
+ currentColumnId: column.id,
+ step: direction === 'right' ? 1 : -1,
+ isEditable: (candidateColumn) => isColumnActuallyEditable(row, candidateColumn),
+ });
+ if (!nextColumnId) return;
+ targetColumnId = nextColumnId;
+ } else if (direction === 'down' || direction === 'up') {
+ const rows = table.getRowModel().flatRows;
+ const nextRowId = findNextEditableRowId({
+ rows,
+ currentRowId: row.id,
+ step: direction === 'down' ? 1 : -1,
+ isEditable: (candidateRow) => isColumnActuallyEditable(candidateRow, column),
+ });
+ if (!nextRowId) return;
+ targetRowId = nextRowId;
+ } else {
+ return;
+ }
+
+ pendingEditTargetRef.current = { rowId: targetRowId, columnId: targetColumnId };
+
+ handleNavigateToColumn(targetColumnId);
+ requestAnimationFrame(() => {
+ const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === targetRowId);
+ if (rowIndex < 0) return;
+ rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
+ });
+ },
+ [table, isColumnActuallyEditable, handleNavigateToColumn],
+ );
+ openNextEditableCellRef.current = openNextEditableCell;
+
+ // 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 [];
@@ -566,6 +799,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
useEffect(() => {
if (!dataEditingCells?.line_id) {
setEditingCell(null);
+
+ // AURORA-1891: Tab/Enter из EditCell.jsx пометили следующую ячейку в
+ // pendingEditTargetRef (openNextEditableCell выше) — сервер только
+ // что закончил cell_edit_end текущей (editingCell/dataEditingCells
+ // стали null), теперь можно открыть следующую, тем же путём, что и
+ // обычное открытие по клику/double-click (table.setEditingCell
+ // триггерит onEditingCellChange -> contextStartEditing -> cell_edit_start).
+ const pendingTarget = pendingEditTargetRef.current;
+ if (pendingTarget) {
+ pendingEditTargetRef.current = null;
+ const animationFrameId = requestAnimationFrame(() => {
+ try {
+ const row = table.getRow(pendingTarget.rowId);
+ const cell = row?.getVisibleCells().find((c) => c.column.id === pendingTarget.columnId);
+ if (cell) table.setEditingCell(cell);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+ return () => cancelAnimationFrame(animationFrameId);
+ }
return;
}
if (editingCell) return;
@@ -583,6 +837,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
return () => cancelAnimationFrame(animationFrameId);
}, [dataEditingCells, editingCell, table]);
+ // AURORA-1891: «начальный ввод» (цифра/Backspace/Delete) нужен только для
+ // самого первого монтирования EditCell этой ячейки (useState-инициализатор
+ // там читает его один раз) — как только редактор открылся, очищаем, чтобы
+ // следующее открытие той же ячейки другим способом не подхватило старое
+ // значение.
+ useEffect(() => {
+ if (editingCell && editorInitialInputRef.current !== null) {
+ setEditorInitialInput(null);
+ }
+ }, [editingCell]);
+
const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]);
const handleAddRow = useCallback(() => {
@@ -783,7 +1048,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
canModifyRows={canModifyRows}
formType={formType}
validationErrors={validationErrors}
- onNavigateToColumn={handleNavigateToColumn}
+ onNavigateToError={handleNavigateToError}
+ headerPaths={headerPaths}
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
onToggleDepthVisibility={handleToggleDepthVisibility}
/>
@@ -824,6 +1090,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
+ stageColumnDeadlines={stageColumnDeadlines}
+ userRoleId={userRoleId}
/>,
headerPortalRef.current,
)}
diff --git a/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
new file mode 100644
index 0000000..11f6601
--- /dev/null
+++ b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
@@ -0,0 +1,55 @@
+import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
+import { IconButton, Stack, Tooltip } from '@mui/material';
+import { memo } from 'react';
+
+// AURORA-1890: легенда рядом с плашкой "Контроль ошибок" — те же цвета,
+// что в Cell.jsx (EDITABLE_STYLES / NOT_EDITABLE_INPUT_STYLES), без новых
+// палитр колонок (columnColors.js не меняется).
+const controlSx = {
+ minHeight: '2.5rem',
+ flex: '0 0 2.5rem',
+};
+
+const sampleBaseSx = {
+ display: 'inline-block',
+ width: '0.875rem',
+ height: '0.875rem',
+ borderRadius: '0.1875rem',
+ flexShrink: 0,
+};
+
+const editableSampleSx = {
+ ...sampleBaseSx,
+ backgroundColor: '#FFFFFF',
+ border: '1px solid #7FA7C9',
+};
+
+const notEditableSampleSx = {
+ ...sampleBaseSx,
+ backgroundColor: '#F4F5F7',
+ border: '1px solid rgba(0, 0, 0, 0.12)',
+};
+
+const legendRowSx = { alignItems: 'center', gap: 0.75 };
+
+const CellLegend = () => (
+
+
+
+ можно вводить
+
+
+
+ расчёт или закрыто на этапе
+
+
+ }>
+
+
+
+
+);
+
+export default memo(CellLegend);
diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
index ebacf0a..0ffc992 100644
--- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
+++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
@@ -22,6 +22,7 @@ import {
VisibleExpenseItems,
} from '../../common/icons/icons';
import { collectLeafColumns } from '../utils/columnUtils';
+import CellLegend from './CellLegend';
import { AHR_LIMIT_SHEET } from '../utils/moneyUtils';
import { GroupByObject } from './GroupByObject/GroupByObject';
import { Panel } from './SettingPanel.style';
@@ -96,7 +97,8 @@ const SettingPanel = ({
canModifyRows,
formType,
validationErrors = [],
- onNavigateToColumn,
+ onNavigateToError,
+ headerPaths,
expenseItemsVisibilityAction,
onToggleDepthVisibility,
denomination,
@@ -165,7 +167,13 @@ const SettingPanel = ({
return (
<>
-
+
+ {validationErrors.length > 0 && (
+ <>
+
+
+ >
+ )}
{/* */}
@@ -329,7 +337,9 @@ const SettingPanel = ({
/>
-
+
+
+
>
);
diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx
index 18377d2..b8025e6 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/ErrorOutlineOutlined';
+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 (
<>
- 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,
- },
- }}>
-
-
+
+ handleStep(-1)} aria-label='Предыдущая ошибка' sx={stepButtonSx}>
+
+
+
+ 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,
+ },
+ }}>
+
{
whiteSpace: 'nowrap',
textAlign: 'left',
}}>
- Контроль ошибок
+ Ошибок: {errors.length}
-
+
-
- Подробнее
-
-
-
+ 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/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx
index 61d7ed5..af59797 100644
--- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx
+++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx
@@ -1,8 +1,23 @@
import React, { useMemo } from 'react';
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
+import { formatDeadline } from '../utils/deadlineFormat';
+import { ROLES_NAME_ID } from '../../../constants/constants';
import { formatHeaderUnit, formatHeaderYear } from '../utils/headerYearUtils';
import ColumnResizer from './ColumnResizer';
+// AURORA-1890: срок ввода в заголовке колонки показывается только
+// исполнителю (у админа колонка открыта всегда объединением всех фаз,
+// метка ему ничего не даёт — см. тикет, п.5).
+const DEADLINE_LABEL_STYLES = {
+ display: 'block',
+ fontSize: '0.5625rem',
+ fontWeight: 400,
+ lineHeight: 1.2,
+ marginTop: '2px',
+ whiteSpace: 'nowrap',
+ opacity: 0.8,
+};
+
const getColumnPinnedStyles = (column) => {
const pinningPosition = column.getIsPinned();
@@ -102,7 +117,7 @@ const getColorBrightness = (hexColor) => {
return 0.299 * r + 0.587 * g + 0.114 * b;
};
-const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, denomination }) => {
+const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, stageColumnDeadlines, userRoleId, denomination }) => {
const column = header.column;
const headerSize = header.getSize();
const isPinned = Boolean(pinningPosition);
@@ -120,6 +135,15 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor]);
+ // AURORA-1890: срок ввода колонки на текущем этапе исполнителя.
+ const deadlineLabel = useMemo(() => {
+ if (userRoleId === ROLES_NAME_ID.admin) return null;
+ const closesAt = stageColumnDeadlines?.get?.(column.id);
+ if (!closesAt) return null;
+ const formatted = formatDeadline(closesAt);
+ return formatted ? `✎ ${formatted}` : null;
+ }, [stageColumnDeadlines, column.id, userRoleId]);
+
const handleChangeWidth = (deltaWidth) => {
const size = header.getSize();
onChangeWidth(header, size + deltaWidth);
@@ -173,6 +197,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
boxSizing: 'border-box',
}}>
{header.isPlaceholder ? null : formatHeaderUnit(formatHeaderYear(column.columnDef.header, budgetYear), denomination)}
+ {deadlineLabel && {deadlineLabel} }
@@ -317,7 +342,17 @@ const FilterCell = ({ column }) => {
);
};
-const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear, denomination }) => {
+const TableHeadComponent = ({
+ table,
+ selectedColumnId,
+ onColumnSelect,
+ onChangeWidth,
+ isLoadingData,
+ budgetYear,
+ stageColumnDeadlines,
+ userRoleId,
+ denomination,
+}) => {
const handleHeaderClick = (header) => {
if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id);
@@ -362,6 +397,8 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
+ stageColumnDeadlines={stageColumnDeadlines}
+ userRoleId={userRoleId}
/>
)),
)}
diff --git a/web/src/components/RealtimeTable/constants/validation.js b/web/src/components/RealtimeTable/constants/validation.js
index 0abee24..197f6f9 100644
--- a/web/src/components/RealtimeTable/constants/validation.js
+++ b/web/src/components/RealtimeTable/constants/validation.js
@@ -84,6 +84,103 @@ const bookingLessBalanceRuleScope = {
includedScopes: [{ formType: "FORM_2" }],
};
+// Требование AURORA-1892. Колонки, в которых отрицательное значение считается ошибкой.
+// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
+// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
+// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
+// там минус — нормальная операция.
+export const nonNegativeColumnKeys = [
+ // Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
+ "data.plan.q1",
+ "data.plan.q2",
+ "data.plan.q3",
+ "data.plan.q4",
+ "data.plan.year",
+
+ // Ввод брони на 2026 и 2027 годы (форма 2).
+ "data.booking.y2026.q1",
+ "data.booking.y2026.q2",
+ "data.booking.y2026.q3",
+ "data.booking.y2026.q4",
+ "data.booking.y2027.q1",
+ "data.booking.y2027.q2",
+ "data.booking.y2027.q3",
+ "data.booking.y2027.q4",
+
+ // Факт по месяцам и СПОД.
+ "data.q1.actual_m1",
+ "data.q1.actual_m2",
+ "data.q1.actual_m3",
+ "data.q2.actual_m1",
+ "data.q2.actual_m2",
+ "data.q2.actual_m3",
+ "data.q3.actual_m1",
+ "data.q3.actual_m2",
+ "data.q3.actual_m3",
+ "data.q4.actual_m1",
+ "data.q4.actual_m2",
+ "data.q4.actual_m3",
+ "data.q4.actual_spod",
+
+ // Сумма платежа.
+ "data.q1.pay_amount",
+ "data.q2.pay_amount",
+ "data.q3.pay_amount",
+ "data.q4.pay_amount",
+
+ // Предельная стоимость договора / ЦКК.
+ "data.contract.ceiling",
+ "data.contract_detail.ceiling",
+ "data.ckk.ceiling",
+ "data.contract_summary.total",
+
+ // Сателлиты формы 2 — АХР Аренда/Коммунальные услуги/Безопасность: факт по месяцам
+ // и квартальный итог (data.plan.qN уже учтён выше — ключ общий с основными листами).
+ "data.fact_q1.jan",
+ "data.fact_q1.feb",
+ "data.fact_q1.mar",
+ "data.fact_q1.total",
+ "data.fact_q2.apr",
+ "data.fact_q2.may",
+ "data.fact_q2.jun",
+ "data.fact_q2.total",
+ "data.fact_q3.jul",
+ "data.fact_q3.aug",
+ "data.fact_q3.sep",
+ "data.fact_q3.total",
+ "data.fact_q4.oct",
+ "data.fact_q4.nov",
+ "data.fact_q4.dec",
+ "data.fact_q4.total",
+
+ // АХР Лимиты — по вложению Салмина минусов не должно быть вообще во всех
+ // числовых показателях листа (шире, чем исходный набор qty_qN/amount_qN).
+ "data.qty_q1",
+ "data.qty_q2",
+ "data.qty_q3",
+ "data.qty_q4",
+ "data.amount_q1",
+ "data.amount_q2",
+ "data.amount_q3",
+ "data.amount_q4",
+ "data.limit_with_vat",
+ "data.limit_without_vat",
+];
+
+const isNegativeNumber = (value) =>
+ value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0;
+
+// Фабрика правила «минус запрещён» для заданного перечня колонок.
+// appliesToRow намеренно не ограничивается — правило проверяет все строки.
+export const nonNegativeRule = ({ id, columnKeys, message, includedScopes, excludedScopes } = {}) => ({
+ id,
+ columnKeys,
+ message,
+ ...(includedScopes ? { includedScopes } : {}),
+ ...(excludedScopes ? { excludedScopes } : {}),
+ isInvalid: isNegativeNumber,
+});
+
export const validationRules = [
{
...remainingBookingRuleScope,
@@ -210,4 +307,9 @@ export const validationRules = [
);
},
},
+ nonNegativeRule({
+ id: "non-negative",
+ columnKeys: nonNegativeColumnKeys,
+ message: "Отрицательное значение в этой колонке недопустимо",
+ }),
];
diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
index 1adaaed..7de8ac8 100644
--- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js
+++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
@@ -79,6 +79,13 @@ export const useCellLock = (cellKey) => {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
};
+// AURORA-1891: имперактивная проверка lockedCellsStore.hasCell(key) из
+// обработчика клика/клавиатуры на ячейке (muiTableBodyCellProps в
+// RealtimeTable.jsx) — там нужен сам стор (не подписка на конкретную ячейку,
+// как useCellLock выше), чтобы в момент события синхронно узнать, занята ли
+// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
+export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
+
export const useRealtime = () => {
const actions = useRealtimeActions();
const connection = useRealtimeConnection();
diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
index df45906..2b4973b 100644
--- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js
+++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
@@ -9,6 +9,10 @@ import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = useState([]);
const [columnsCurStage, setColumnsCurStage] = useState([]);
+ // AURORA-1890: срок ввода (closes_at) для колонок текущего этапа —
+ // чтобы показать его в заголовке колонки. Ключи совпадают с columnsCurStage
+ // ("data."), closes_at отбрасывался раньше (useRealtimeData.js:48-51).
+ const [stageColumnDeadlines, setStageColumnDeadlines] = useState(new Map());
const [isLoading, setIsLoading] = useState(true);
const [editingCells, setEditingCells] = useState({});
@@ -47,8 +51,10 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const data = res.result.filter((d) => d.depth > -1);
const curStageColumns = res.result.filter((d) => d.depth === -1)[0];
- const stageColumns = (curStageColumns?.data?.editable || []).map((column) => `data.${column.column}`);
+ const editableColumns = curStageColumns?.data?.editable || [];
+ const stageColumns = editableColumns.map((column) => `data.${column.column}`);
setColumnsCurStage(stageColumns);
+ setStageColumnDeadlines(new Map(editableColumns.map((column) => [`data.${column.column}`, column.closes_at])));
if (!data || data.length === 0) {
setData([]);
@@ -233,6 +239,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return {
data,
columnsCurStage,
+ stageColumnDeadlines,
isLoading,
editingCells,
};
diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js
index b234f12..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 };
+ 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 e25ccd6..7bb4454 100644
--- a/web/src/components/RealtimeTable/tableColumns.jsx
+++ b/web/src/components/RealtimeTable/tableColumns.jsx
@@ -7,6 +7,7 @@ import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUt
import { ROLES_NAME_ID } from '../../constants/constants';
import { blueColumn } from './constants/columnColors';
import { sectionCodeColor } from './constants/columnConfig';
+import { isCellEditable } from './utils/cellEditability';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
@@ -118,7 +119,7 @@ export const getTableColumns = ({
userRoleId,
onCellUpdateError,
onCellNumberClick,
- isCellInvalid,
+ getCellErrors,
denomination = 'thousand',
sheetName,
}) => {
@@ -128,28 +129,52 @@ export const getTableColumns = ({
const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap();
+ // AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable),
+ // columnsCurStage из useRealtimeData — массив; строим Set один раз на
+ // пересборку колонок, а не на каждую ячейку.
+ const stageColumns = new Set(columnsCurStage);
+
// factor === 1 означает «как в базе»: либо колонка не денежная, либо разрядность уже совпадает с базовой.
// На листе AHR_LIMIT нет селектора — значения всегда показываются как есть, независимо от сохранённой
// разрядности (там она по умолчанию 'thousand', а база листа — 'rub').
- const getCellDisplayFactor = (column, moneyUnit = getMoneyUnit(column.columnDef, sheetName)) =>
+ const getCellDisplayFactor = (columnDef, moneyUnit = getMoneyUnit(columnDef, sheetName)) =>
moneyUnit && sheetName !== AHR_LIMIT_SHEET ? getDisplayFactor(moneyUnit, denomination) : 1;
+ // AURORA-1890/1891: принимает либо настоящий MRT column (column.id +
+ // column.columnDef), либо голый конфиг колонки из processColumns (col,
+ // у которого своего column.id ещё нет — только col.accessorKey, а сам col
+ // это и есть будущий columnDef). columnId уходит в isCellEditable
+ // (вычисляемые поля/этап/админ), columnDef — в getCellDisplayFactor
+ // (AURORA-1899: нельзя редактировать ячейку, когда выбранная разрядность
+ // искажает число относительно базовой единицы).
const isEditable = (row, column) => {
- if (row.original?.row_type !== 'INPUT') return false;
- const moneyUnit = getMoneyUnit(column.columnDef, sheetName);
+ const columnId = column.id ?? column.accessorKey;
+ if (
+ !isCellEditable({
+ rowType: row.original?.row_type,
+ columnId,
+ isAdmin: userRoleId === ROLES_NAME_ID.admin,
+ stageColumns,
+ })
+ ) {
+ return false;
+ }
+ const columnDef = column.columnDef ?? column;
+ const moneyUnit = getMoneyUnit(columnDef, sheetName);
// Разрядность, отличная от базовой «тыс. руб.», искажает значение — редактирование запрещено,
// чтобы случайно не записать домноженное/разделённое число.
- if (moneyUnit !== null && getCellDisplayFactor(column, moneyUnit) !== 1) return false;
- if (userRoleId === ROLES_NAME_ID.admin) return true;
- const keyCol = column.id;
- if (columnsCurStage.includes(keyCol)) return true;
- return false;
+ if (moneyUnit !== null && getCellDisplayFactor(columnDef, moneyUnit) !== 1) return false;
+ return true;
};
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);
const displayFactor = getCellDisplayFactor(column);
@@ -169,6 +194,7 @@ export const getTableColumns = ({
cached.value === value &&
cached.displayFactor === displayFactor &&
cached.isInvalid === isInvalid &&
+ cached.invalidMessage === invalidMessage &&
cached.backgroundColor === backgroundColor
) {
return cached;
@@ -184,9 +210,10 @@ export const getTableColumns = ({
isEditable: isEditableCell,
backgroundColor,
isInvalid,
+ invalidMessage,
value,
displayFactor,
- _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayFactor}`,
+ _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`,
};
rowCache[key] = props;
@@ -218,6 +245,9 @@ export const getTableColumns = ({
for (const col of columns) {
col.header = normalizeMonthHeader(col.header);
const usesVspOptions = col.editType === 'vsp_dropdown';
+ // Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
+ // по строкам сами — enableEditing им не нужен, в отличие от листовых.
+ const isLeafColumn = !col.columns?.length;
col.Cell = ({ cell, table, column, row }) => {
const props = getCachedCellProps(row, column, table);
@@ -246,8 +276,15 @@ export const getTableColumns = ({
return editComponent;
};
- if (col.editType === 'vsp_dropdown') {
- col.enableEditing = (row) => row?.original?.row_type === 'INPUT';
+ // AURORA-1891: раньше enableEditing выставлялся только для editType
+ // 'vsp_dropdown' (частный случай) — для остальных листовых колонок MRT
+ // считал строку INPUT редактируемой всегда, даже вычисляемые поля и
+ // колонки вне текущего этапа; от этого двойной клик открывал редактор
+ // (пусть и disabled) и сервер всё равно брал блокировку (🔒 у коллег).
+ // Теперь единая проверка isCellEditable для каждой листовой колонки —
+ // MRT не откроет редактор ни по двойному клику, ни программно.
+ if (isLeafColumn) {
+ col.enableEditing = (row) => isEditable(row, col);
}
if (col.columns?.length) {
diff --git a/web/src/components/RealtimeTable/utils/cellEditability.js b/web/src/components/RealtimeTable/utils/cellEditability.js
new file mode 100644
index 0000000..36efce4
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/cellEditability.js
@@ -0,0 +1,107 @@
+// AURORA-1890: редактируемость ячейки — чистая функция, вынесенная из
+// tableColumns.jsx. Используется tableColumns.jsx (isEditable внутри
+// getTableColumns) и будет использоваться AURORA-1891 — сигнатуру без
+// необходимости не менять.
+//
+// isComputedColumn повторяет часть серверной проверки из
+// v3._apply_form_cell и v3._apply_form3_cell
+// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql; функция
+// переопределяется CREATE OR REPLACE в более новых миграциях, но список
+// computed_field/normative_field исключений не менялся): какие колонки
+// сервер отвергает при попытке правки через _apply_form_cell/_apply_form3_cell,
+// те же колонки фронт не должен показывать как редактируемые — даже
+// администратору, у которого isEditable иначе был бы всегда true на
+// строках INPUT.
+//
+// Это фронтовый список, зашитый в коде (а не синхронизированный с БД
+// автоматически) — умышленно, см. AURORA-1890. Расхождения чинятся отдельным
+// PR, а не здесь.
+
+// 0023:416-420 — IF v_field IN (...) THEN RAISE EXCEPTION 'computed_field: %'
+// применяется к полям scope q1..q4, плюс FORM_3 (_apply_form3_cell, 0023:515)
+// называет те же по смыслу поля иначе (quarter_actual вместо actual_quarter,
+// total_corr — отдельное суммарное поле без аналога в FORM_1/2/4).
+const QUARTER_COMPUTED_FIELDS = new Set([
+ 'corrected_plan',
+ 'new_plan',
+ 'actual_quarter',
+ 'booking',
+ 'rem_booking',
+ 'rem_actual',
+ 'residual_after_booking',
+ 'residual_after_actual',
+ 'economy',
+ 'total',
+ // FORM_3 / PROJECT CURRENT_EXPENSES (_apply_form3_cell, 0023:515-517)
+ 'quarter_actual',
+ 'total_corr',
+]);
+
+// 0023:200-219 — header.* редактируется через v3.budget_line, остальные
+// поля header.* (year/section/item_id/num_group/vsp_address) — computed_field.
+const HEADER_EDITABLE_FIELDS = new Set(['name', 'internal_order', 'justification', 'vsp_id']);
+
+// 0023:154-155 — атрибуты ВСП на сателлитах AHR_RENT/AHR_UTILITY/AHR_SECURITY
+// правятся через справочник ВСП, не через эту ячейку.
+const VSP_ATTRIBUTE_FIELDS = new Set(['address', 'object_type', 'rented_area']);
+
+// 0023:115-127 — лист лимитов AHR_LIMIT: amount_qN вычисляется из
+// qty × limit × period_factor, limit_with_vat/limit_without_vat — нормативный
+// справочник (правится админом отдельно, не через ячейку формы).
+const LIMIT_SHEET_COMPUTED_FIELDS = new Set(['amount_q1', 'amount_q2', 'amount_q3', 'amount_q4', 'limit_with_vat', 'limit_without_vat']);
+
+const QUARTER_SCOPE_PATTERN = /^q[1-4]$/;
+const FACT_QUARTER_SCOPE_PATTERN = /^fact_q[1-4]$/;
+
+const DATA_PREFIX = 'data.';
+
+/**
+ * Повторяет серверный список computed_field/normative_field исключений
+ * v3._apply_form_cell / v3._apply_form3_cell для набора колонок, который
+ * знает фронт (FORM_1/2/4, PROJECT, сателлиты AHR_LIMIT/RENT/UTILITY/SECURITY,
+ * OTCH9F). columnId — это React Table column.id в формате accessorKey, т.е.
+ * всегда с префиксом "data." для полей строки (например "data.q1.booking").
+ */
+export const isComputedColumn = (columnId) => {
+ if (typeof columnId !== 'string' || !columnId.startsWith(DATA_PREFIX)) return false;
+
+ const parts = columnId.slice(DATA_PREFIX.length).split('.');
+ const [scope, field] = parts;
+
+ // 0023:221-223 (FORM_1/2/4), 0023:515 (FORM_3) — approved.*/totals.* всегда вычисляемые.
+ if (scope === 'approved' || scope === 'totals') return true;
+
+ // 0023:200-219 — header.* кроме разрешённого списка.
+ if (scope === 'header') return !HEADER_EDITABLE_FIELDS.has(field);
+
+ if (parts.length === 2) {
+ // *.year — plan.year, seq_dfip.year, seq_ssp.year, reserve.year и т.п.
+ if (field === 'year') return true;
+
+ // qN. из списка вычисляемых полей квартала.
+ if (QUARTER_SCOPE_PATTERN.test(scope) && QUARTER_COMPUTED_FIELDS.has(field)) return true;
+
+ // fact_qN.total — сателлиты AHR_RENT/AHR_UTILITY/AHR_SECURITY.
+ if (FACT_QUARTER_SCOPE_PATTERN.test(scope) && field === 'total') return true;
+
+ return false;
+ }
+
+ if (parts.length === 1) {
+ if (VSP_ATTRIBUTE_FIELDS.has(scope)) return true;
+ if (LIMIT_SHEET_COMPUTED_FIELDS.has(scope)) return true;
+ }
+
+ return false;
+};
+
+/**
+ * isCellEditable({ rowType, columnId, isAdmin, stageColumns })
+ *
+ * rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId))
+ *
+ * stageColumns — Set с ключами вида "data." (columnsCurStage из
+ * useRealtimeData, отражающий окно текущего этапа пользователя).
+ */
+export const isCellEditable = ({ rowType, columnId, isAdmin, stageColumns }) =>
+ rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId));
diff --git a/web/src/components/RealtimeTable/utils/cellEditability.test.js b/web/src/components/RealtimeTable/utils/cellEditability.test.js
new file mode 100644
index 0000000..618a710
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/cellEditability.test.js
@@ -0,0 +1,146 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { isCellEditable, isComputedColumn } from './cellEditability.js';
+
+// Контрольные примеры из тикета AURORA-1890.
+
+test('INPUT row with a column open on the user stage is editable', () => {
+ const editable = isCellEditable({
+ rowType: 'INPUT',
+ columnId: 'data.plan.q1',
+ isAdmin: false,
+ stageColumns: new Set(['data.plan.q1']),
+ });
+ assert.equal(editable, true);
+});
+
+test('ITEM row is never editable, even for a stage column', () => {
+ const editable = isCellEditable({
+ rowType: 'ITEM',
+ columnId: 'data.plan.q1',
+ isAdmin: false,
+ stageColumns: new Set(['data.plan.q1']),
+ });
+ assert.equal(editable, false);
+});
+
+test('admin cannot edit computed columns on an INPUT row', () => {
+ const computedColumnIds = ['data.q1.corrected_plan', 'data.approved.q1', 'data.totals.fact_year', 'data.header.vsp_address'];
+
+ for (const columnId of computedColumnIds) {
+ const editable = isCellEditable({
+ rowType: 'INPUT',
+ columnId,
+ isAdmin: true,
+ stageColumns: new Set(),
+ });
+ assert.equal(editable, false, `${columnId} must not be editable even for admin`);
+ }
+});
+
+test('admin can edit a regular (non-computed) INPUT cell outside their own stage', () => {
+ const editable = isCellEditable({
+ rowType: 'INPUT',
+ columnId: 'data.plan.q1',
+ isAdmin: true,
+ stageColumns: new Set(),
+ });
+ assert.equal(editable, true);
+});
+
+test('executor cannot edit a column outside their current stage', () => {
+ const editable = isCellEditable({
+ rowType: 'INPUT',
+ columnId: 'data.plan.q1',
+ isAdmin: false,
+ stageColumns: new Set(['data.plan.q2']),
+ });
+ assert.equal(editable, false);
+});
+
+// isComputedColumn — повторяет v3._apply_form_cell / v3._apply_form3_cell
+// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql).
+
+test('approved.* and totals.* are always computed (0023:221-223, 0023:515)', () => {
+ assert.equal(isComputedColumn('data.approved.q1'), true);
+ assert.equal(isComputedColumn('data.approved.year'), true);
+ assert.equal(isComputedColumn('data.totals.fact_year'), true);
+ assert.equal(isComputedColumn('data.totals.go_balance_only'), true);
+});
+
+test('header.* is computed except name/internal_order/justification/vsp_id (0023:200-219)', () => {
+ assert.equal(isComputedColumn('data.header.name'), false);
+ assert.equal(isComputedColumn('data.header.internal_order'), false);
+ assert.equal(isComputedColumn('data.header.justification'), false);
+ assert.equal(isComputedColumn('data.header.vsp_id'), false);
+
+ assert.equal(isComputedColumn('data.header.vsp_address'), true);
+ assert.equal(isComputedColumn('data.header.section'), true);
+ assert.equal(isComputedColumn('data.header.item_id'), true);
+ assert.equal(isComputedColumn('data.header.num_group'), true);
+ assert.equal(isComputedColumn('data.header.year'), true);
+});
+
+test('qN computed fields match the SQL exception list (0023:416-420)', () => {
+ const computedQuarterFields = [
+ 'corrected_plan',
+ 'new_plan',
+ 'actual_quarter',
+ 'booking',
+ 'rem_booking',
+ 'rem_actual',
+ 'residual_after_booking',
+ 'residual_after_actual',
+ 'economy',
+ 'total',
+ ];
+ for (const field of computedQuarterFields) {
+ assert.equal(isComputedColumn(`data.q1.${field}`), true, `data.q1.${field} must be computed`);
+ assert.equal(isComputedColumn(`data.q4.${field}`), true, `data.q4.${field} must be computed`);
+ }
+
+ // обычные ненулевые поля квартала остаются редактируемыми
+ assert.equal(isComputedColumn('data.q1.adj_current'), false);
+ assert.equal(isComputedColumn('data.q1.pay_amount'), false);
+});
+
+test('FORM_3 (PROJECT/CURRENT_EXPENSES) uses different names for the same computed fields (_apply_form3_cell, 0023:515)', () => {
+ assert.equal(isComputedColumn('data.q1.quarter_actual'), true);
+ assert.equal(isComputedColumn('data.q2.total_corr'), true);
+});
+
+test('*.year columns are computed regardless of scope (plan.year, seq_dfip.year, seq_ssp.year, reserve.year)', () => {
+ assert.equal(isComputedColumn('data.plan.year'), true);
+ assert.equal(isComputedColumn('data.seq_dfip.year'), true);
+ assert.equal(isComputedColumn('data.seq_ssp.year'), true);
+ assert.equal(isComputedColumn('data.reserve.year'), true);
+ assert.equal(isComputedColumn('data.sequestration.year'), true);
+});
+
+test('satellite fact_qN.total is computed, other fact_qN fields are editable (0023:163-164)', () => {
+ assert.equal(isComputedColumn('data.fact_q1.total'), true);
+ assert.equal(isComputedColumn('data.fact_q1.jan'), false);
+ assert.equal(isComputedColumn('data.fact_q2.total'), true);
+});
+
+test('VSP attributes on AHR_RENT/UTILITY/SECURITY satellites are computed (0023:154-155)', () => {
+ assert.equal(isComputedColumn('data.address'), true);
+ assert.equal(isComputedColumn('data.object_type'), true);
+ assert.equal(isComputedColumn('data.rented_area'), true);
+});
+
+test('AHR_LIMIT sheet: amount_qN and limit_with/without_vat are computed (0023:120-124)', () => {
+ assert.equal(isComputedColumn('data.amount_q1'), true);
+ assert.equal(isComputedColumn('data.amount_q4'), true);
+ assert.equal(isComputedColumn('data.limit_with_vat'), true);
+ assert.equal(isComputedColumn('data.limit_without_vat'), true);
+
+ // qty_qN остаётся редактируемым полем листа лимитов
+ assert.equal(isComputedColumn('data.qty_q1'), false);
+});
+
+test('plain non-"data." column ids (row number, grouping headers) are never computed', () => {
+ assert.equal(isComputedColumn('sort_order'), false);
+ assert.equal(isComputedColumn(undefined), false);
+ assert.equal(isComputedColumn(null), false);
+});
diff --git a/web/src/components/RealtimeTable/utils/cellNavigation.js b/web/src/components/RealtimeTable/utils/cellNavigation.js
new file mode 100644
index 0000000..bef4d0b
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/cellNavigation.js
@@ -0,0 +1,46 @@
+/**
+ * AURORA-1891: Tab/Shift+Tab и Enter в EditCell.jsx/VspDropdownEditCell.jsx
+ * вызывают table.options.meta.openNextEditableCell(row, column, direction)
+ * (RealtimeTable.jsx), который резолвит реальные MRT-строки/колонки
+ * (getOrderedVisibleColumns, table.getRowModel().flatRows — не .rows, см.
+ * рулинг AURORA-1893 про expandRows()/paginateExpandedRows: .rows уже
+ * отфильтрован состоянием свёрнутости групп, и строка в свёрнутой группе
+ * там попросту не нашлась бы) и предикат isCellEditable, а эти две функции
+ * делают только сам обход уже готового списка — без знания про MRT.
+ */
+
+/**
+ * Следующая (step: 1, Tab) или предыдущая (step: -1, Shift+Tab) редактируемая
+ * колонка в той же строке. columns — видимые листовые колонки в порядке
+ * getOrderedVisibleColumns (скрытые туда не попадают — для этой функции они
+ * просто не существуют). isEditable(column) — редактируема ли эта колонка
+ * ИМЕННО в текущей строке (вызывающая сторона уже знает строку).
+ * Возвращает id следующей редактируемой колонки или null, если дошли до края.
+ */
+export const findNextEditableColumnId = ({ columns, currentColumnId, step, isEditable }) => {
+ const startIndex = columns.findIndex((column) => column.id === currentColumnId);
+ if (startIndex < 0) return null;
+
+ for (let index = startIndex + step; index >= 0 && index < columns.length; index += step) {
+ if (isEditable(columns[index])) return columns[index].id;
+ }
+ return null;
+};
+
+/**
+ * Следующая (step: 1, Enter) или предыдущая (step: -1) строка, где заданная
+ * колонка редактируема. rows — table.getRowModel().flatRows (весь порядок
+ * строк независимо от текущего состояния свёрнутости групп). isEditable(row)
+ * — редактируема ли заданная колонка в этой строке (пропускает итоговые
+ * строки ROOT/GROUP/ITEM, у которых row_type !== 'INPUT').
+ * Возвращает id следующей строки или null, если дошли до края таблицы.
+ */
+export const findNextEditableRowId = ({ rows, currentRowId, step, isEditable }) => {
+ const startIndex = rows.findIndex((row) => row.id === currentRowId);
+ if (startIndex < 0) return null;
+
+ for (let index = startIndex + step; index >= 0 && index < rows.length; index += step) {
+ if (isEditable(rows[index])) return rows[index].id;
+ }
+ return null;
+};
diff --git a/web/src/components/RealtimeTable/utils/cellNavigation.test.js b/web/src/components/RealtimeTable/utils/cellNavigation.test.js
new file mode 100644
index 0000000..9ce3927
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/cellNavigation.test.js
@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { findNextEditableColumnId, findNextEditableRowId } from './cellNavigation.js';
+
+// AURORA-1891: Tab/Shift+Tab (в строке) и Enter (по столбцу, вниз/вверх) —
+// EditCell.jsx/VspDropdownEditCell.jsx знают только текущую строку/колонку,
+// а сам обход видимых листовых колонок (getOrderedVisibleColumns) и строк
+// (table.getRowModel().flatRows) и предикат редактируемости (isCellEditable)
+// собирает вызывающая сторона (RealtimeTable.jsx meta.openNextEditableCell).
+// Здесь — только чистый обход уже готового списка.
+
+// columns — то, что вернул бы getOrderedVisibleColumns: скрытая колонка
+// конфига сюда вообще не попала бы, поэтому «пропуск скрытых» проверяется
+// самим фактом, что функция работает только с тем, что ей передали, и про
+// скрытые колонки ничего не знает.
+const COLUMNS = [
+ { id: 'a', editable: true },
+ { id: 'b', editable: false }, // не редактируется в этой строке (напр. вычисляемая)
+ { id: 'c', editable: true },
+ { id: 'd', editable: false },
+];
+const isColumnEditable = (column) => column.editable;
+
+test('Tab: moves right to the next editable column, skipping non-editable ones', () => {
+ const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: 1, isEditable: isColumnEditable });
+ assert.equal(next, 'c');
+});
+
+test('Shift+Tab: moves left to the previous editable column', () => {
+ const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: -1, isEditable: isColumnEditable });
+ assert.equal(prev, 'a');
+});
+
+test('Tab at the end of the row returns null (конец строки)', () => {
+ const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: 1, isEditable: isColumnEditable });
+ assert.equal(next, null);
+});
+
+test('Shift+Tab at the start of the row returns null', () => {
+ const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: -1, isEditable: isColumnEditable });
+ assert.equal(prev, null);
+});
+
+test('an unknown current column id returns null instead of throwing', () => {
+ const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'missing', step: 1, isEditable: isColumnEditable });
+ assert.equal(next, null);
+});
+
+// rows — table.getRowModel().flatRows: включают итоговые строки (GROUP/ITEM/
+// ROOT), которые isCellEditable делает нередактируемыми независимо от
+// колонки (rowType !== 'INPUT'); здесь это просто row.editable === false.
+const ROWS = [
+ { id: 'row-1', editable: true },
+ { id: 'row-2', editable: false }, // итоговая строка (GROUP)
+ { id: 'row-3', editable: true },
+ { id: 'row-4', editable: false }, // итоговая строка (ROOT)
+];
+const isRowEditable = (row) => row.editable;
+
+test('Enter: moves down to the next row where the column is editable, skipping summary rows', () => {
+ const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-1', step: 1, isEditable: isRowEditable });
+ assert.equal(next, 'row-3');
+});
+
+test('Enter at the last row returns null (конец таблицы)', () => {
+ const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: 1, isEditable: isRowEditable });
+ assert.equal(next, null);
+});
+
+test('moving up (step: -1) finds the previous editable row', () => {
+ const prev = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: -1, isEditable: isRowEditable });
+ assert.equal(prev, 'row-1');
+});
+
+test('an unknown current row id returns null instead of throwing', () => {
+ const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'missing', step: 1, isEditable: isRowEditable });
+ assert.equal(next, null);
+});
diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.js b/web/src/components/RealtimeTable/utils/deadlineFormat.js
new file mode 100644
index 0000000..e539bef
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/deadlineFormat.js
@@ -0,0 +1,17 @@
+// AURORA-1890: срок ввода в заголовке колонки — closes_at показывается в
+// локальном времени браузера пользователя (ISO-строка/Date уже несёт
+// смещение часового пояса, Date сам пересчитывает его в локальное при
+// обращении к getDate/getHours и т.п.), без года: «до 31.10 18:00».
+export const formatDeadline = (closesAt) => {
+ if (!closesAt) return null;
+
+ const date = closesAt instanceof Date ? closesAt : new Date(closesAt);
+ if (Number.isNaN(date.getTime())) return null;
+
+ const day = String(date.getDate()).padStart(2, '0');
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+
+ return `до ${day}.${month} ${hours}:${minutes}`;
+};
diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.test.js b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js
new file mode 100644
index 0000000..6ef1e27
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { formatDeadline } from './deadlineFormat.js';
+
+test('ISO closes_at is formatted as "до DD.MM HH:mm" in local time', () => {
+ const closesAt = new Date(2026, 9, 31, 18, 0, 0); // 31 октября, 18:00, локальное время
+ assert.equal(formatDeadline(closesAt.toISOString()), 'до 31.10 18:00');
+});
+
+test('single-digit day/month/hour/minute are zero-padded', () => {
+ const closesAt = new Date(2026, 0, 5, 9, 3, 0); // 5 января, 09:03
+ assert.equal(formatDeadline(closesAt.toISOString()), 'до 05.01 09:03');
+});
+
+test('a Date instance is accepted directly, not only an ISO string', () => {
+ const closesAt = new Date(2026, 11, 1, 0, 0, 0);
+ assert.equal(formatDeadline(closesAt), 'до 01.12 00:00');
+});
+
+test('falsy or invalid input returns null instead of throwing', () => {
+ assert.equal(formatDeadline(null), null);
+ assert.equal(formatDeadline(undefined), null);
+ assert.equal(formatDeadline(''), null);
+ assert.equal(formatDeadline('not-a-date'), null);
+});
diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js
index b36151c..ca73d3b 100644
--- a/web/src/components/RealtimeTable/utils/formulaUtils.js
+++ b/web/src/components/RealtimeTable/utils/formulaUtils.js
@@ -81,6 +81,48 @@ export const evaluateCellValue = (value) => {
}
};
+// AURORA-1891: сравнение "было/стало" для handleSave (EditCell.jsx) — если
+// значение фактически не изменилось, cell_updated не шлём (иначе лишняя
+// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
+// Формула — особый случай: даже текстуально совпадающая формула всегда
+// считается «изменённой», чтобы handleSave по-прежнему всегда её
+// пересчитывал и писал в localStorage (так было до этой задачи).
+const normalizeComparableValue = (value) => {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'number') return String(value);
+ if (typeof value !== 'string') return String(value);
+
+ const trimmed = value.trim();
+ if (trimmed === '') return '';
+
+ // "1 500,0" -> "1500,0": обычный и неразрывный пробел между цифрами —
+ // разделитель тысяч. normalizeNumericInput сам такие пробелы не убирает,
+ // он только меняет десятичную запятую на точку.
+ const withoutThousandsSeparators = trimmed.replace(/(\d)[ ](?=\d)/g, '$1');
+ const normalized = normalizeNumericInput(withoutThousandsSeparators);
+
+ if (typeof normalized === 'string' && /^[+-]?\d+(\.\d+)?$/.test(normalized)) {
+ const asNumber = Number(normalized);
+ if (Number.isFinite(asNumber)) return String(asNumber);
+ }
+
+ return trimmed;
+};
+
+/**
+ * Сравнивает старое и новое значение ячейки «по смыслу», а не строго: число
+ * и его строковое представление (в т.ч. с разделителем тысяч и запятой вместо
+ * точки) считаются равными, как и пустая строка с null/undefined. Формула
+ * (строка, начинающаяся с "=") никогда не считается неизменной.
+ * @param {string|number|null|undefined} a
+ * @param {string|number|null|undefined} b
+ * @returns {boolean}
+ */
+export const isSameCellValue = (a, b) => {
+ if (isFormula(a) || isFormula(b)) return false;
+ return normalizeComparableValue(a) === normalizeComparableValue(b);
+};
+
/**
* Форматирует число для отображения
* @param {number} value - Число для форматирования
diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.test.js b/web/src/components/RealtimeTable/utils/formulaUtils.test.js
new file mode 100644
index 0000000..9325d24
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/formulaUtils.test.js
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { isSameCellValue } from './formulaUtils.js';
+
+// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
+// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
+// открытии и закрытии ячейки без правки.
+
+test('a numeric string equals the same number', () => {
+ assert.equal(isSameCellValue('1500', 1500), true);
+});
+
+test('a thousands-separated, comma-decimal value equals the plain number', () => {
+ assert.equal(isSameCellValue('1 500,0', 1500), true);
+});
+
+test('empty string and null both mean "no value"', () => {
+ assert.equal(isSameCellValue('', null), true);
+ assert.equal(isSameCellValue(null, ''), true);
+ assert.equal(isSameCellValue(undefined, ''), true);
+ assert.equal(isSameCellValue(undefined, null), true);
+});
+
+test('a formula is never treated as unchanged, even against identical formula text', () => {
+ assert.equal(isSameCellValue('=1+2', '=1+2'), false);
+ assert.equal(isSameCellValue('=1+1', 2), false);
+ assert.equal(isSameCellValue(2, '=1+1'), false);
+});
+
+test('different values (numeric or text) are not the same', () => {
+ assert.equal(isSameCellValue('1500', 1501), false);
+ assert.equal(isSameCellValue('abc', 'abd'), false);
+ assert.equal(isSameCellValue('1500', ''), false);
+});
+
+test('non-numeric text compares as-is after trimming surrounding whitespace', () => {
+ assert.equal(isSameCellValue(' текст ', 'текст'), true);
+});
diff --git a/web/src/components/RealtimeTable/utils/numberFormat.js b/web/src/components/RealtimeTable/utils/numberFormat.js
new file mode 100644
index 0000000..9e8db1b
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/numberFormat.js
@@ -0,0 +1,17 @@
+// Форматирует число ячейки таблицы для отображения.
+// Ноль по-прежнему выводится как «-» (привычный формат), а isNegative
+// позволяет вызывающей стороне (Cell.jsx) подобрать более заметный цвет
+// для отрицательных значений, не путая их с нулём.
+export const formatNumber = (value, asInteger = false) => {
+ const num = Number(value);
+ if (Number.isNaN(num)) return { text: String(value), isNegative: false };
+ if (num === 0) return { text: '-', isNegative: false };
+
+ const text = num.toLocaleString('ru-RU', {
+ minimumFractionDigits: asInteger ? 0 : 1,
+ maximumFractionDigits: asInteger ? 0 : 1,
+ useGrouping: true,
+ });
+
+ return { text, isNegative: num < 0 };
+};
diff --git a/web/src/components/RealtimeTable/utils/numberFormat.test.js b/web/src/components/RealtimeTable/utils/numberFormat.test.js
new file mode 100644
index 0000000..02757e8
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/numberFormat.test.js
@@ -0,0 +1,37 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { formatNumber } from './numberFormat.js';
+
+test('negative numbers are formatted ru-RU and flagged as negative', () => {
+ const result = formatNumber(-1234.5);
+ assert.equal(result.text, '-1 234,5');
+ assert.equal(result.isNegative, true);
+});
+
+test('zero is rendered as a dash and is never negative', () => {
+ const result = formatNumber(0);
+ assert.equal(result.text, '-');
+ assert.equal(result.isNegative, false);
+});
+
+test('positive numbers keep the familiar ru-RU grouping and are not negative', () => {
+ const result = formatNumber(1234.5);
+ assert.equal(result.text, '1 234,5');
+ assert.equal(result.isNegative, false);
+});
+
+test('asInteger drops the fractional digit', () => {
+ const integer = formatNumber(1234, true);
+ assert.equal(integer.text, '1 234');
+ assert.equal(integer.isNegative, false);
+
+ const negativeInteger = formatNumber(-1234, true);
+ assert.equal(negativeInteger.text, '-1 234');
+ assert.equal(negativeInteger.isNegative, true);
+});
+
+test('non-numeric input is returned as a string without crashing', () => {
+ const result = formatNumber('не число');
+ assert.equal(result.text, 'не число');
+ assert.equal(result.isNegative, false);
+});
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
new file mode 100644
index 0000000..028f79f
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js
@@ -0,0 +1,214 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+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({
+ id: 'non-negative-test',
+ columnKeys: ['data.plan.q1'],
+ message: 'Отрицательное значение в этой колонке недопустимо',
+ });
+
+ assert.equal(rule.isInvalid(-1), true);
+ assert.equal(rule.isInvalid(-0.01), true);
+});
+
+test('nonNegativeRule does not flag zero, empty or positive values', () => {
+ const rule = nonNegativeRule({
+ id: 'non-negative-test',
+ columnKeys: ['data.plan.q1'],
+ message: 'Отрицательное значение в этой колонке недопустимо',
+ });
+
+ assert.equal(rule.isInvalid(0), false);
+ assert.equal(rule.isInvalid(''), false);
+ assert.equal(rule.isInvalid(null), false);
+ assert.equal(rule.isInvalid(undefined), false);
+ assert.equal(rule.isInvalid(100), false);
+});
+
+test('nonNegativeRule does not restrict which rows it applies to', () => {
+ const rule = nonNegativeRule({
+ id: 'non-negative-test',
+ columnKeys: ['data.plan.q1'],
+ message: 'Отрицательное значение в этой колонке недопустимо',
+ });
+
+ assert.equal(rule.appliesToRow, undefined);
+});
+
+test('a scoped rule is disabled outside its form/sheet scope', () => {
+ const rule = nonNegativeRule({
+ id: 'non-negative-scoped-test',
+ columnKeys: ['data.qty_q1'],
+ message: 'Отрицательное значение в этой колонке недопустимо',
+ includedScopes: [{ formType: 'FORM_2', sheetName: 'AHR_LIMIT' }],
+ });
+
+ assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR_LIMIT' }), true);
+ assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_1', sheetName: 'AHR' }), false);
+ assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR' }), false);
+});
+
+test('the shared nonNegativeColumnKeys list excludes correction and transfer columns', () => {
+ const excludedPrefixes = ['adj_', 'base_correction', 'revision_', 'seq_', 'target_change', 'transfer_'];
+
+ for (const key of nonNegativeColumnKeys) {
+ const segment = key.split('.').pop();
+ for (const prefix of excludedPrefixes) {
+ assert.equal(
+ segment.startsWith(prefix),
+ false,
+ `${key} looks like a correction/transfer column and must not be in nonNegativeColumnKeys`,
+ );
+ }
+ }
+});
+
+// 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);
+});