From 8ba26427ddd0a43967170a5dfb8cf2dbe098a5be Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 16:04:33 +0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(tests):=20test=5Fform2=5Fahr=5Flimit=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20plan.qN=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20KeyError=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20null-strip=20(AURORA-1897,=20#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После AURORA-1848 GET листа вырезает null-поля, поэтому у заголовка статьи без плана в квартале ключа plan.qN нет вовсе, и чтение «до» падало KeyError до проверки самой логики. Заменено на plan.get("qN") or 0. Продуктовый код не меняется: с правкой все 7 тестов зелёные на test (после PR #295). Co-Authored-By: Claude Opus 5.5 --- api/tests/integration/test_form2_ahr_limit.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api/tests/integration/test_form2_ahr_limit.py b/api/tests/integration/test_form2_ahr_limit.py index 55812df..9fdb0e0 100644 --- a/api/tests/integration/test_form2_ahr_limit.py +++ b/api/tests/integration/test_form2_ahr_limit.py @@ -104,7 +104,7 @@ def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id): # лимитные LEAF из сидовых данных). before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10) row = _find_by_id(rows, 24) @@ -113,7 +113,7 @@ def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id): after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 assert (after - before) == pytest.approx(69.645) @@ -124,7 +124,7 @@ def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id) # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 - )["data"]["plan"]["q2"] or 0 + )["data"]["plan"].get("q2") or 0 rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1) row = _find_by_id(rows, 3) @@ -132,7 +132,7 @@ def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id) after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 - )["data"]["plan"]["q2"] or 0 + )["data"]["plan"].get("q2") or 0 assert (after - before) == pytest.approx(2.0) @@ -144,7 +144,7 @@ def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20) row = _find_by_id(rows, 7) @@ -152,7 +152,7 @@ def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 assert (after - before) == pytest.approx(7.5) @@ -165,7 +165,7 @@ def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_i # комментарий в test_annual_dms_limit_divided_by_four. before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5) row = _find_by_id(rows, 13) @@ -173,7 +173,7 @@ def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_i after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 assert (after - before) == pytest.approx(6.471875) @@ -185,7 +185,7 @@ def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, fo # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 - )["data"]["plan"]["q3"] or 0 + )["data"]["plan"].get("q3") or 0 rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2) row = _find_by_id(rows, 17) @@ -193,7 +193,7 @@ def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, fo after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 - )["data"]["plan"]["q3"] or 0 + )["data"]["plan"].get("q3") or 0 assert (after - before) == pytest.approx(7.5) @@ -232,7 +232,7 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f # быть другие записи аренды из сидовых данных). before = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 response = client.post( f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line", @@ -253,5 +253,5 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f after = _find_ahr_header_row( _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM - )["data"]["plan"]["q1"] or 0 + )["data"]["plan"].get("q1") or 0 assert (after - before) == 100 From a61c09be18003979bbfbe0ceb6fe205d69105f6f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 16:38:49 +0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(table):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=20=E2=80=94=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B0=20=D0=BA=20=D1=8F?= =?UTF-8?q?=D1=87=D0=B5=D0=B9=D0=BA=D0=B5,=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20{year}/{unit}=20(A?= =?UTF-8?q?URORA-1893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доработка по возврату тестирования 06.11: - у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет data.id, ключ ошибки был «правило:колонка:null» — все агрегаты одной колонки схлопывались в одну запись. Ключ строки теперь getValidationRowKey: line id или sort:; - переход к ошибке ищет строку по тому же ключу (раньше для агрегата прерывался, выделение оставалось на предыдущей ячейке); - прокрутка: вместо scrollToIndex(rowIndex + 5, 'auto') — центрирование и scrollIntoView самой ячейки после отрисовки (у последних строк запас +5 обрезался, шапка внутри контейнера не учитывалась — ячейка оставалась ниже видимой области); - названия колонок в плашке и в тосте EditCell — с подставленными годом и единицами (formatHeaderPaths, как в TableHead); - рамка активной ячейки дублируется псевдоэлементом ::after: inset-тень td перекрывалась красной рамкой ошибочной ячейки. Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/RealtimeTable.jsx | 42 +++++++++-- .../SettingPanel/ValidationErrorsAlert.jsx | 2 +- .../RealtimeTable/utils/activeCellCss.js | 16 ++++- .../RealtimeTable/utils/activeCellCss.test.js | 13 ++++ .../RealtimeTable/utils/validationUtils.js | 27 ++++++- .../utils/validationUtils.test.js | 71 ++++++++++++++++++- 6 files changed, 161 insertions(+), 10 deletions(-) diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index b45871d..1ac2247 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -30,6 +30,7 @@ import { isCellEditable } from './utils/cellEditability'; import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; import { canAddRowForHeader, getRowId } from './utils/rowUtils'; import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; +import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; @@ -78,6 +79,21 @@ const loadingOverlayStyle = { zIndex: 2, }; +// AURORA-1893 (возврат 06.11): после scrollToIndex строка появляется в DOM не сразу +// (виртуализатор перерисовывает диапазон по событию scroll) — ждём до нескольких кадров +// и прокручиваем к самой ячейке: scrollIntoView учитывает реальную раскладку, включая шапку. +const SCROLL_CELL_MAX_FRAMES = 10; +const scrollCellIntoViewWhenRendered = (container, rowId, columnId, framesLeft = SCROLL_CELL_MAX_FRAMES) => { + if (!container || framesLeft <= 0) return; + const escapeIdent = globalThis.CSS?.escape ?? String; + const cell = container.querySelector(`tbody tr[data-row-id="${escapeIdent(rowId)}"] > td[data-col-id="${escapeIdent(columnId)}"]`); + if (cell) { + cell.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + return; + } + requestAnimationFrame(() => scrollCellIntoViewWhenRendered(container, rowId, columnId, framesLeft - 1)); +}; + const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const budgetYear = useBudgetYear(formId, formType, year); const { user } = useAuth(); @@ -116,7 +132,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { // -> cell_edit_start следующей. const pendingEditTargetRef = useRef(null); const [columnsConfig, setColumnsConfig] = useState(null); - const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); + const { errors: validationErrors, getCellErrors, headerPaths: rawHeaderPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); const [expanded, setExpanded] = useState(true); const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide'); @@ -221,6 +237,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`; const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey); + // AURORA-1893 (возврат 06.11): названия колонок для «Контроля ошибок» и тоста EditCell — + // с подставленными годом и единицами, как в шапке таблицы. + const headerPaths = useMemo( + () => formatHeaderPaths(rawHeaderPaths, budgetYear, denomination), + [rawHeaderPaths, budgetYear, denomination], + ); useEffect(() => { rowVirtualizerRef.current?.measure?.(); @@ -686,10 +708,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { // (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows // целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть. const handleNavigateToError = useCallback( - ({ lineId, columnId } = {}) => { - if (lineId === undefined || lineId === null || !columnId) return; + ({ rowKey, columnId } = {}) => { + if (rowKey === undefined || rowKey === null || !columnId) return; - const row = table.getRowModel().flatRows.find((candidate) => getRowId(candidate) === lineId); + // AURORA-1893 (возврат 06.11): строку ищем по тому же ключу, что и ошибку + // (getValidationRowKey): у агрегатных строк нет line id, раньше переход к ним + // молча прерывался и выделение оставалось на предыдущей ячейке. + const row = table.getRowModel().flatRows.find((candidate) => getValidationRowKey(candidate.original) === rowKey); if (!row) return; const parentRows = row.getParentRows(); @@ -723,10 +748,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { 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' }); + // AURORA-1893 (возврат 06.11): раньше было scrollToIndex(rowIndex + 5, align 'auto') — + // у последних строк листа запас +5 обрезался, а шапка внутри контейнера прокрутки + // виртуализатором не учитывается, и строка оставалась за нижним краем. Центрируем + // строку, а после отрисовки доводим до видимой области саму ячейку. + rowVirtualizerRef.current?.scrollToIndex?.(rowIndex, { align: 'center' }); + scrollCellIntoViewWhenRendered(containerRef.current, row.id, columnId); }); }, - [table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn], + [table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn, containerRef], ); const searchMatches = useMemo(() => { diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx index b8025e6..0e52e57 100644 --- a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx @@ -52,7 +52,7 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToError, headerPaths }) const error = errors[index]; if (!error) return; setActiveIndex(index); - onNavigateToError?.({ lineId: error.lineId, columnId: error.columnId }); + onNavigateToError?.({ rowKey: error.rowKey, columnId: error.columnId }); }; const handleStep = (direction) => { diff --git a/web/src/components/RealtimeTable/utils/activeCellCss.js b/web/src/components/RealtimeTable/utils/activeCellCss.js index 1d11d0e..e818b69 100644 --- a/web/src/components/RealtimeTable/utils/activeCellCss.js +++ b/web/src/components/RealtimeTable/utils/activeCellCss.js @@ -36,7 +36,21 @@ const escapeCssIdentFallback = (value) => { const defaultEscape = (value) => typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value); -export const ACTIVE_CELL_STYLE = { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2' }; +// AURORA-1893 (возврат 06.11): одной inset-тени td мало — у ошибочной ячейки её перекрывает +// красная рамка вложенного div (INVALID_STYLES), и выбранная ячейка не выделялась. +// Рамка дублируется псевдоэлементом поверх содержимого; td и так position relative/sticky. +export const ACTIVE_CELL_STYLE = { + zIndex: 7, + boxShadow: 'inset 0 0 0 3px #1976d2', + '&::after': { + content: '""', + position: 'absolute', + inset: 0, + border: '3px solid #1976d2', + pointerEvents: 'none', + zIndex: 2, + }, +}; // Правило sx для контейнера таблицы: подсветка текущего совпадения поиска. // Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}. diff --git a/web/src/components/RealtimeTable/utils/activeCellCss.test.js b/web/src/components/RealtimeTable/utils/activeCellCss.test.js index af48fb6..81d7ffc 100644 --- a/web/src/components/RealtimeTable/utils/activeCellCss.test.js +++ b/web/src/components/RealtimeTable/utils/activeCellCss.test.js @@ -29,3 +29,16 @@ test('highlight keeps 3px frame and z-index 7', () => { assert.equal(ACTIVE_CELL_STYLE.zIndex, 7); assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2'); }); + +// AURORA-1893 (возврат 06.11): inset box-shadow у td перекрывается красной рамкой +// ошибочной ячейки (INVALID_STYLES на вложенном div) — выбранный агрегат с ошибкой +// не выделялся. Рамка рисуется псевдоэлементом поверх содержимого ячейки. +test('active cell frame is drawn by an ::after overlay above the cell content', () => { + const overlay = ACTIVE_CELL_STYLE['&::after']; + assert.ok(overlay, 'ACTIVE_CELL_STYLE must define an ::after overlay'); + assert.equal(overlay.content, '""'); + assert.equal(overlay.position, 'absolute'); + assert.equal(overlay.inset, 0); + assert.equal(overlay.pointerEvents, 'none'); + assert.match(overlay.border, /3px solid #1976d2/); +}); diff --git a/web/src/components/RealtimeTable/utils/validationUtils.js b/web/src/components/RealtimeTable/utils/validationUtils.js index 00db287..58b64b2 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.js @@ -1,3 +1,4 @@ +import { formatHeaderUnit, formatHeaderYear } from './headerYearUtils.js'; import { getDataRowId } from './rowUtils.js'; // Достаёт вложенное поле из объекта строки таблицы @@ -33,6 +34,28 @@ export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => { return map; }; +// AURORA-1893 (возврат 06.11): заголовки колонок в конфигах содержат плейсхолдеры +// {year}/{year:short}/{unit} — подставляем их так же, как TableHead, чтобы в «Контроле +// ошибок» и в тосте EditCell не было «План {year} ({unit})». +export const formatHeaderPaths = (headerPaths, budgetYear, denomination) => { + const formatted = {}; + for (const [columnId, path] of Object.entries(headerPaths || {})) { + formatted[columnId] = path.map((segment) => formatHeaderUnit(formatHeaderYear(segment, budgetYear), denomination)); + } + return formatted; +}; + +// AURORA-1893 (возврат 06.11): ключ строки для ошибки и перехода к ней. У строк ввода +// это line id, а у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) data.id нет — раньше ключ +// у всех агрегатов одной колонки был null, они схлопывались в одну запись и переход к ним +// не срабатывал. sort_order — глобальный ROW_NUMBER листа, уникален для каждой строки. +export const getValidationRowKey = (row) => { + const lineId = getDataRowId(row?.data); + if (lineId !== null && lineId !== undefined) return lineId; + if (row?.sort_order !== undefined && row?.sort_order !== null) return `sort:${row.sort_order}`; + return null; +}; + // AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок — // название статьи/ВСП, если есть, иначе номер строки (колонка «№»). const getRowLabel = (row) => { @@ -53,7 +76,8 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => { if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue; const lineId = getDataRowId(row.data); - const errorId = `${rule.id}:${columnKey}:${lineId}`; + const rowKey = getValidationRowKey(row); + const errorId = `${rule.id}:${columnKey}:${rowKey}`; if (seen.has(errorId)) continue; seen.add(errorId); @@ -62,6 +86,7 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => { ruleId: rule.id, columnId: columnKey, lineId, + rowKey, rowLabel: getRowLabel(row), message: rule.message, }); diff --git a/web/src/components/RealtimeTable/utils/validationUtils.test.js b/web/src/components/RealtimeTable/utils/validationUtils.test.js index 028f79f..5eaa30d 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.test.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js @@ -1,7 +1,13 @@ 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'; +import { + collectInvalidErrors, + formatHeaderPaths, + getValidationRowKey, + groupValidationErrors, + isValidationRuleEnabled, +} from './validationUtils.js'; test('nonNegativeRule flags negative numbers as invalid', () => { const rule = nonNegativeRule({ @@ -212,3 +218,66 @@ test('booking-less-balance only reports its errorColumnKeys (booking columns), n assert.equal(errors[0].columnId, 'data.q1.booking'); assert.equal(errors[0].lineId, 5); }); + +// AURORA-1893 (возврат 06.11): у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет +// data.id/line_id — ключ строки берётся из глобального sort_order. +test('getValidationRowKey uses the line id, else sort_order for aggregate rows', () => { + assert.equal(getValidationRowKey({ data: { id: 15 }, sort_order: 53 }), 15); + assert.equal(getValidationRowKey({ data: { line_id: 7 } }), 7); + assert.equal(getValidationRowKey({ row_type: 'GROUP', data: { header: { name: 'Г' } }, sort_order: 2 }), 'sort:2'); + assert.equal(getValidationRowKey({ row_type: 'ROOT', data: {}, sort_order: 0 }), 'sort:0'); + assert.equal(getValidationRowKey({ data: {} }), null); +}); + +test('collectInvalidErrors reports every invalid aggregate row, not only the first one per column', () => { + const rule = { id: 'neg', columnKeys: ['data.v'], message: 'm', isInvalid: (value) => Number(value) < 0 }; + const rows = [ + { + row_type: 'ROOT', + sort_order: 1, + data: { header: { name: 'Корень' }, v: -200 }, + subRows: [ + { + row_type: 'GROUP', + sort_order: 2, + data: { header: { name: 'Группа 1' }, v: -100 }, + subRows: [{ row_type: 'INPUT', sort_order: 3, data: { id: 9, v: -100 }, subRows: [] }], + }, + { + row_type: 'GROUP', + sort_order: 7, + data: { header: { name: 'Группа 2' }, v: -100 }, + subRows: [{ row_type: 'INPUT', sort_order: 8, data: { id: 10, v: -100 }, subRows: [] }], + }, + ], + }, + ]; + + const errors = []; + collectInvalidErrors(rows, rule, errors, new Set()); + + assert.deepEqual( + errors.map((error) => [error.rowKey, error.rowLabel]), + [ + ['sort:1', 'Корень'], + ['sort:2', 'Группа 1'], + [9, '3'], + ['sort:7', 'Группа 2'], + [10, '8'], + ], + ); + assert.equal(new Set(errors.map((error) => error.id)).size, 5); +}); + +test('formatHeaderPaths substitutes year and unit placeholders in column titles', () => { + const headerPaths = { + 'data.plan.q1': ['Планируемые показатели', 'План {year} ({unit}) с учётом НДС', '1 Кв. {year:short}'], + 'data.name': ['Наименование'], + }; + + const formatted = formatHeaderPaths(headerPaths, 2027, 'thousand'); + + assert.deepEqual(formatted['data.plan.q1'], ['Планируемые показатели', 'План 2027 (тыс. руб.) с учётом НДС', '1 Кв. 27']); + assert.deepEqual(formatted['data.name'], ['Наименование']); + assert.deepEqual(headerPaths['data.plan.q1'][1], 'План {year} ({unit}) с учётом НДС', 'исходные пути не мутируются'); +}); From d341d21ec6589f161b9f9eed5d6fd6c7639f7170 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 17:24:28 +0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(form2):=20=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B0=20=D0=90=D0=A5=D0=A0=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B3=D0=BB=D1=83=D0=B1=D0=B8=D0=BD=D1=8B=204=20(AURORA-1850,?= =?UTF-8?q?=20#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0053 пересоздала v3.get_form2_ahr_satellite_delta по телу из 0031 с сопоставлением лимитного шаблона «ei.depth = 3 AND (item_id OR num_group_id)» и откатила правку 0037. Лимиты с кодом num_group_id статьи глубины 4 (принтеры / МФУ / копиры) не попадали в target_lines, дельта была пустой, и открытый АХР не обновлял сумму до перезагрузки. Миграция 0058 пересоздаёт функцию с матчером 0037 ((depth = 3 AND item_id = code) OR num_group_id = code); остальное тело — как в 0053. Тест-защита 0037 ждал в дельте INPUT-строку, которую AURORA-1887 скрыл на листе AHR, — теперь проверяет заголовок SUB_SUB_ITEM статьи глубины 4. Co-Authored-By: Claude Opus 5.5 --- .../versions/0058_form2_ahr_delta_depth4.py | 15 ++++ .../sql/0058_form2_ahr_delta_depth4.sql | 86 +++++++++++++++++++ .../test_upd_form_cells_subtree_0037_guard.py | 15 +++- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 api/alembic/versions/0058_form2_ahr_delta_depth4.py create mode 100644 api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql diff --git a/api/alembic/versions/0058_form2_ahr_delta_depth4.py b/api/alembic/versions/0058_form2_ahr_delta_depth4.py new file mode 100644 index 0000000..9f5d5f8 --- /dev/null +++ b/api/alembic/versions/0058_form2_ahr_delta_depth4.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0058" +down_revision = "0057" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0058_form2_ahr_delta_depth4.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql b/api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql new file mode 100644 index 0000000..a81c72a --- /dev/null +++ b/api/alembic/versions/sql/0058_form2_ahr_delta_depth4.sql @@ -0,0 +1,86 @@ +-- AURORA-1850 / issue #296: дельта АХР после правки лимита пуста для статей +-- глубины 4 (принтеры / МФУ / копировальные аппараты): открытый АХР не +-- обновляет сумму до перезагрузки. +-- +-- Причина: 0053 пересоздала v3.get_form2_ahr_satellite_delta по телу из 0031 — +-- с сопоставлением лимитного шаблона «ei.depth = 3 AND (item_id = code OR +-- num_group_id = code)», то есть откатила правку 0037. По 0037 (и как во всех +-- остальных функциях синхронизации, в том числе в 0052/0053) по item_id +-- сопоставляется только статья глубины 3, а по num_group_id — статья любой +-- глубины. Лимиты с кодом num_group_id статьи глубины 4 в target_lines не +-- попадали, и дельта была пустой. +-- +-- Тело — из 0053 (с SUB_SUB_ITEM в заголовках, issue #294) + матчер 0037. +-- Позже 0053 функцию никто не переопределял (проверено по 0054–0057). +CREATE OR REPLACE FUNCTION v3.get_form2_ahr_satellite_delta( + p_form_id integer, + p_source_sheet character varying, + p_source_row_id integer +) +RETURNS TABLE( + row_type character varying, + depth integer, + sort_order bigint, + data jsonb +) +LANGUAGE sql +STABLE +AS $function$ + WITH target_lines AS ( + SELECT m.target_line_id AS line_id + FROM v3.form2_ahr_satellite_line_source m + JOIN v3.budget_line bl ON bl.id = m.target_line_id + WHERE m.source_sheet = p_source_sheet + AND m.source_id = p_source_row_id + AND bl.budget_form_id = p_form_id + AND p_source_sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + + UNION + + SELECT bl.id AS line_id + FROM v3.limit_template lt + JOIN v3.expense_item ei + ON ei.sheet = 'AHR' + AND ei.direction = 'Support' + AND ( + (ei.depth = 3 AND ei.item_id = lt.expense_item_code) + OR ei.num_group_id = lt.expense_item_code + ) + JOIN v3.budget_line bl + ON bl.budget_form_id = p_form_id + AND bl.expense_item_id = ei.id + AND bl.source_sheet = 'AHR_LIMIT' + AND bl.name = regexp_replace( + lt.name, + '^[[:space:]]*-[[:space:]]*', + '' + ) + WHERE p_source_sheet = 'AHR_LIMIT' + AND lt.id = p_source_row_id + AND lt.row_type = 'LEAF' + ), + target_path AS ( + SELECT unnest( + v3._expense_item_path_set( + COALESCE(array_agg(bl.expense_item_id), ARRAY[]::integer[]) + ) + ) AS expense_item_id + FROM target_lines tl + JOIN v3.budget_line bl ON bl.id = tl.line_id + ) + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, 'AHR', NULL, 'Support') v + WHERE ( + v.row_type IN ('ROOT', 'GROUP', 'ITEM', 'SUB_ITEM', 'SUB_SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT IN ( + SELECT expense_item_id FROM target_path + ) + ) + OR ( + v.row_type = 'INPUT' + AND (v.data->>'line_id')::INT IN ( + SELECT line_id FROM target_lines + ) + ) + ORDER BY v.sort_order; +$function$; diff --git a/api/tests/integration/test_upd_form_cells_subtree_0037_guard.py b/api/tests/integration/test_upd_form_cells_subtree_0037_guard.py index 1fec5a6..9373eba 100644 --- a/api/tests/integration/test_upd_form_cells_subtree_0037_guard.py +++ b/api/tests/integration/test_upd_form_cells_subtree_0037_guard.py @@ -31,16 +31,23 @@ OLD_DEPTH3_MATCHER = re.compile(r"ei\.depth\s*=\s*3\s+AND\s*\(") # вид до def test_ahr_delta_keeps_0037_num_group_matching(client, headers, db_exec): found = db_exec( - """SELECT lt.id FROM v3.limit_template lt + """SELECT lt.id, ei.id FROM v3.limit_template lt JOIN v3.expense_item ei ON ei.num_group_id = lt.expense_item_code AND ei.sheet = 'AHR' AND ei.direction = 'Support' AND ei.depth = 4 WHERE lt.row_type = 'LEAF' ORDER BY lt.id LIMIT 1""") if not found: pytest.skip("нет LEAF шаблона с кодом = num_group_id статьи AHR глубины 4") - lt_id = found[0][0] + lt_id, ei_id = found[0] _patch_cell(client, headers, 4, "AHR_LIMIT", lt_id, "qty_q1", 4) - delta = db_exec("SELECT row_type FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)", i=lt_id) - assert any(r[0] == "INPUT" for r in delta), "дельта АХР пуста — откат сопоставления 0037" + delta = db_exec( + "SELECT row_type, data->'header'->>'expense_item_id' FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)", + i=lt_id, + ) + # AURORA-1887 скрыл лимитные INPUT-строки на листе AHR — в дельте остаются + # заголовки статьи и её предков; для статьи глубины 4 это SUB_SUB_ITEM. + assert any(r[0] == "SUB_SUB_ITEM" and int(r[1]) == ei_id for r in delta), ( + f"в дельте АХР нет заголовка статьи глубины 4 — откат сопоставления 0037: {delta}" + ) src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))", s="v3.get_form2_ahr_satellite_delta(integer,character varying,integer)")[0][0] assert "ei.num_group_id = lt.expense_item_code" in src