diff --git a/api/src/services/export_service/export_normalizers.py b/api/src/services/export_service/export_normalizers.py index 1f67d87..5cf83f6 100644 --- a/api/src/services/export_service/export_normalizers.py +++ b/api/src/services/export_service/export_normalizers.py @@ -232,6 +232,24 @@ class Form1DepthRowNormalizer: normalized[f"{prefix}{key}"] = value +class Form4DepthRowNormalizer(Form1DepthRowNormalizer): + """AURORA-1979: подпись «программа»/«проект» вместо кода в колонке header.section_code. + + Нормализатор не получает row_type, поэтому узел определяется по line_id и id в header. + Исходные данные не меняются: раскраска читает header.section_code из исходной строки. + """ + + def normalize(self, data: dict[str, Any]) -> dict[str, Any]: + normalized = super().normalize(data) + header = data.get("header") + if data.get("line_id") is None and isinstance(header, dict): + if header.get("project_id") is not None: + normalized["header.section_code"] = "проект" + elif header.get("program_id") is not None: + normalized["header.section_code"] = "программа" + return normalized + + class SmetaRowNormalizer: def normalize(self, data: dict[str, Any]) -> dict[str, Any]: return { diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index 8fcadd7..12af8c3 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -28,6 +28,7 @@ from src.services.export_service.export_normalizers import ( ExcelValueSerializer, Form1DepthRowNormalizer, Form2DepthRowNormalizer, + Form4DepthRowNormalizer, Form3DepthRowNormalizer, SmetaRowNormalizer, ) @@ -125,6 +126,7 @@ class ExportService: self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer) self.form2_row_normalizer = Form2DepthRowNormalizer(self.value_serializer) self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer) + self.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer) self.smeta_row_normalizer = SmetaRowNormalizer() self.sheet_writers: dict[str, TabularSheetWriter] = { "__default__": DepthSheetWriter( @@ -972,19 +974,19 @@ class ExportService: FORM4_OPER_DEPTH_COLUMNS, FORM4_OPER_COLORS, _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "AHR": ( FORM4_DEPTH_COLUMNS, FORM4_COLORS["AHR"], _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "CAP": ( FORM4_DEPTH_COLUMNS, FORM4_COLORS["CAP"], _vat_placeholders(FORM4_SHEET_VAT_WORDING), - self.form1_row_normalizer, + self.form4_row_normalizer, ), "STRUCTURE": ( FORM4_STRUCTURE_DEPTH_COLUMNS, diff --git a/api/tests/unit/test_export_normalizers.py b/api/tests/unit/test_export_normalizers.py index 9d650fb..819a053 100644 --- a/api/tests/unit/test_export_normalizers.py +++ b/api/tests/unit/test_export_normalizers.py @@ -1,5 +1,8 @@ +import pytest + from src.services.export_service.export_normalizers import ( ExcelValueSerializer, + Form4DepthRowNormalizer, Form1RowNormalizer, Form3DepthRowNormalizer, SmetaRowNormalizer, @@ -78,3 +81,16 @@ def test_form3_depth_normalizer_flattens_nested_data(): assert normalized["totals.total_actual"] == 40 assert normalized["totals.economy"] == 2.1 assert "line_id" not in normalized + + +@pytest.mark.parametrize("data, expected", [ + ({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"), + ({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"), + ({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"), + ({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"), + ({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."), +]) +def test_form4_depth_normalizer_type_label(data, expected): + normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data) + assert normalized["header.section_code"] == expected + assert data["header"]["section_code"] != "программа" diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index 7535a9b..8d23f69 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -16,6 +16,7 @@ from src.services.export_service.export_writers import ( TabularSheetWriter, XlsxDepthSheetWriter, XlsxTabularSheetWriter, + _hierarchical_rows, ) # Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903). @@ -231,3 +232,10 @@ def test_export_service_uses_sheet_writer_strategy(): assert default_writer.calls == 1 assert smeta_writer.calls == 1 + + +def test_standalone_project_outline_without_group(): + rows = [("ROOT", 0, 1, {}), ("ITEM", 2, 2, {}), ("INPUT", 3, 3, {}), ("GROUP", 1, 4, {})] + assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [ + ("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1), + ] diff --git a/web/src/components/RealtimeTable/constants/FORM_4/AHR.js b/web/src/components/RealtimeTable/constants/FORM_4/AHR.js index 1422b89..e7f068b 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/AHR.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/AHR.js @@ -790,6 +790,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/constants/FORM_4/CAP.js b/web/src/components/RealtimeTable/constants/FORM_4/CAP.js index 60394de..ed4e4f9 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/CAP.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/CAP.js @@ -790,6 +790,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/constants/FORM_4/OPER.js b/web/src/components/RealtimeTable/constants/FORM_4/OPER.js index 64a2898..bd64570 100644 --- a/web/src/components/RealtimeTable/constants/FORM_4/OPER.js +++ b/web/src/components/RealtimeTable/constants/FORM_4/OPER.js @@ -791,6 +791,7 @@ export const config = { { header: "", accessorKey: "data.header.section_code", + valueLabel: "form4NodeType", columnLetter: "C", size: 150, filterFn: "contains", diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index f928581..5cbece6 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -11,7 +11,7 @@ import { isCellEditable } from './utils/cellEditability'; import { getColumnValue } from './utils/columnUtils'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; -import { getRowId } from './utils/rowUtils'; +import { getForm4TypeLabel, getRowId } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); @@ -252,7 +252,12 @@ export const getTableColumns = ({ // Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit // по строкам сами — enableEditing им не нужен, в отличие от листовых. const isLeafColumn = !col.columns?.length; - if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) { + if (isLeafColumn && !col.accessorFn && col.valueLabel === 'form4NodeType') { + // AURORA-1979: подпись «программа»/«проект»; функция назначается здесь, + // а не в конфиге — structuredClone конфига не переносит функции. + col.id = col.id ?? col.accessorKey; + col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey); + } else if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) { // MRT предупреждает об undefined для вложенных путей, отсутствующих // у отдельных типов строк. Сохраняем исходный id для настроек и прав. col.id = col.id ?? col.accessorKey; diff --git a/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js b/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js new file mode 100644 index 0000000..03afe71 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/form4TypeLabel.test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// Конфиги читаются как текст (импорт без расширений не грузится в node). +for (const sheet of ['AHR', 'CAP', 'OPER']) { + test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => { + const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8'); + assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/); + assert.doesNotMatch(source, /accessorFn/); + }); +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index d9ebe4c..3ccfdf4 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -122,3 +122,11 @@ export function isRenameResponse(msg, target) { if (!msg || !target || msg.is_self !== true) return false; return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id; } + +// AURORA-1979: подпись типа узла в колонке «Код статьи / подстатьи / позиции» формы 4. +export function getForm4TypeLabel(row) { + const header = row?.data?.header; + if (row?.row_type === 'GROUP' && header?.program_id != null) return 'программа'; + if (row?.row_type === 'ITEM' && header?.project_id != null) return 'проект'; + return null; +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index bbf6381..0cec593 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -5,6 +5,7 @@ import { canAddRowForHeader, getDataRowId, getDeleteTarget, + getForm4TypeLabel, getProjectAddTarget, getRenameTarget, isRenameResponse, @@ -124,3 +125,12 @@ test('AURORA-1980: isRenameResponse — только свой ответ на с assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false); assert.equal(isRenameResponse(msg, null), false); }); + +test('AURORA-1979: getForm4TypeLabel', () => { + assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа'); + assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект'); + assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект'); + assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null); + assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null); + assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null); +});