diff --git a/api/src/services/export_service/export_mappings.py b/api/src/services/export_service/export_mappings.py index 2398a18..eab2bd9 100644 --- a/api/src/services/export_service/export_mappings.py +++ b/api/src/services/export_service/export_mappings.py @@ -3386,7 +3386,12 @@ FORM4_OPER_COLORS: dict = _rename_color_paths( ) -SMETA_COLUMNS: list[tuple[str, str]] = [ +# AURORA-1903: у каждой формы на листе "Смета" свой набор колонок, в порядке +# её экрана (RealtimeTable constants/FORM_*/SMETA.js). FORM_1 показывает и +# поддержку, и развитие, и три группы ИТОГО (план/утверждено/факт); FORM_2 +# ("Смета РФ") — только поддержку; FORM_4 ("Проектная деятельность") — только +# развитие. Ключи совпадают с SMETA_VALUE_PATHS в export_normalizers.py. +SMETA_FORM1_COLUMNS: list[tuple[str, str]] = [ ("section_code", "Код раздела"), ("name", "Наименование"), ("plan_support_q1", "План поддержка I квартал"), @@ -3429,6 +3434,59 @@ SMETA_COLUMNS: list[tuple[str, str]] = [ ("corrected_development_q3", "Корректировка развитие III квартал"), ("corrected_development_q4", "Корректировка развитие IV квартал"), ] +SMETA_FORM2_COLUMNS: list[tuple[str, str]] = [ + ("section_code", "Код раздела"), + ("name", "Наименование"), + ("plan_support_q1", "План поддержка I квартал"), + ("plan_support_q2", "План поддержка II квартал"), + ("plan_support_q3", "План поддержка III квартал"), + ("plan_support_q4", "План поддержка IV квартал"), + ("plan_support_year", "План поддержка Год"), + ("approved_support_q1", "Утверждено поддержка I квартал"), + ("approved_support_q2", "Утверждено поддержка II квартал"), + ("approved_support_q3", "Утверждено поддержка III квартал"), + ("approved_support_q4", "Утверждено поддержка IV квартал"), + ("approved_support_year", "Утверждено поддержка Год"), + ("fact_support_q1", "Факт поддержка I квартал"), + ("fact_support_q2", "Факт поддержка II квартал"), + ("fact_support_q3", "Факт поддержка III квартал"), + ("fact_support_q4", "Факт поддержка IV квартал"), + ("fact_support_year", "Факт поддержка Год"), + ("corrected_support_q2", "Корректировка поддержка II квартал"), + ("corrected_support_q3", "Корректировка поддержка III квартал"), + ("corrected_support_q4", "Корректировка поддержка IV квартал"), +] +SMETA_FORM4_COLUMNS: list[tuple[str, str]] = [ + ("section_code", "Код раздела"), + ("name", "Наименование"), + ("plan_development_q1", "План развитие I квартал"), + ("plan_development_q2", "План развитие II квартал"), + ("plan_development_q3", "План развитие III квартал"), + ("plan_development_q4", "План развитие IV квартал"), + ("plan_development_year", "План развитие Год"), + ("approved_development_q1", "Утверждено развитие I квартал"), + ("approved_development_q2", "Утверждено развитие II квартал"), + ("approved_development_q3", "Утверждено развитие III квартал"), + ("approved_development_q4", "Утверждено развитие IV квартал"), + ("approved_development_year", "Утверждено развитие Год"), + ("fact_development_q1", "Факт развитие I квартал"), + ("fact_development_q2", "Факт развитие II квартал"), + ("fact_development_q3", "Факт развитие III квартал"), + ("fact_development_q4", "Факт развитие IV квартал"), + ("fact_development_year", "Факт развитие Год"), + ("corrected_development_q2", "Корректировка развитие II квартал"), + ("corrected_development_q3", "Корректировка развитие III квартал"), + ("corrected_development_q4", "Корректировка развитие IV квартал"), +] +SMETA_COLUMNS_BY_FORM: dict[str, list[tuple[str, str]]] = { + "FORM_1": SMETA_FORM1_COLUMNS, + "FORM_2": SMETA_FORM2_COLUMNS, + "FORM_4": SMETA_FORM4_COLUMNS, +} +# Алиас для обратной совместимости: часть кода (неиспользуемый openpyxl-словарь +# writer'ов в ExportService.__init__, старый тест test_sheet_column_contracts_are_stable) +# по-прежнему ссылается на единый SMETA_COLUMNS — это раскладка FORM_1. +SMETA_COLUMNS: list[tuple[str, str]] = SMETA_COLUMNS_BY_FORM["FORM_1"] FORM3_LIMIT_DEPTH_COLUMNS: dict = { "header": { "name": "Данные", diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index 00fe243..0284cab 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -45,6 +45,7 @@ from src.services.export_service.export_mappings import ( FORM3_LIMIT_DEPTH_COLUMNS, PALETTE, SMETA_COLUMNS, + SMETA_COLUMNS_BY_FORM, build_summary1_columns, build_summary2_columns, render_column_years, @@ -913,7 +914,7 @@ class ExportService: "__default__": default_writer, "SMETA": XlsxTabularSheetWriter( workbook=workbook, - columns=SMETA_COLUMNS, + columns=SMETA_COLUMNS_BY_FORM.get(form_type_code, SMETA_COLUMNS_BY_FORM["FORM_1"]), row_normalizer=self.smeta_row_normalizer.normalize, value_serializer=self.value_serializer.to_excel_value, ), diff --git a/api/src/services/export_service/export_writers.py b/api/src/services/export_service/export_writers.py index a6e7fca..fbd528a 100644 --- a/api/src/services/export_service/export_writers.py +++ b/api/src/services/export_service/export_writers.py @@ -78,6 +78,10 @@ class TabularSheetWriter(AbstractWriter): worksheet.append(headers) self._track_row_widths(column_widths, headers) for row in rows: + if row and row[0] == "META": + # v_form_view всегда отдаёт служебную строку META первой; + # на листе "Смета" ей не место (AURORA-1903). + continue raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {} normalized = self.row_normalizer(raw_data) values = [self.value_serializer(normalized.get(key)) for key, _ in self.columns] @@ -303,6 +307,10 @@ class XlsxTabularSheetWriter(AbstractWriter): output_row = 1 for row in rows: + if row and row[0] == "META": + # v_form_view всегда отдаёт служебную строку META первой; + # на листе "Смета" ей не место (AURORA-1903). + continue raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {} normalized = self.row_normalizer(raw_data) values = [self.value_serializer(normalized.get(key)) for key, _ in self.columns] diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index f0339a8..7535a9b 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -9,13 +9,47 @@ from src.services.export_service import ExportService from src.services.export_service.export_mappings import ( FORM1_FLAT_COLUMNS, SMETA_COLUMNS, + SMETA_COLUMNS_BY_FORM, ) from src.services.export_service.export_writers import ( DepthSheetWriter, TabularSheetWriter, XlsxDepthSheetWriter, + XlsxTabularSheetWriter, ) +# Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903). +SMETA_SCREEN_COLUMNS_BY_FORM = { + "FORM_1": [ + "section_code", "name", + "plan_support_q1", "plan_support_q2", "plan_support_q3", "plan_support_q4", "plan_support_year", + "plan_development_q1", "plan_development_q2", "plan_development_q3", "plan_development_q4", "plan_development_year", + "plan_total_year", + "approved_support_q1", "approved_support_q2", "approved_support_q3", "approved_support_q4", "approved_support_year", + "approved_development_q1", "approved_development_q2", "approved_development_q3", "approved_development_q4", "approved_development_year", + "approved_total_year", + "fact_support_q1", "fact_support_q2", "fact_support_q3", "fact_support_q4", "fact_support_year", + "fact_development_q1", "fact_development_q2", "fact_development_q3", "fact_development_q4", "fact_development_year", + "fact_total_year", + "corrected_support_q2", "corrected_support_q3", "corrected_support_q4", + "corrected_development_q2", "corrected_development_q3", "corrected_development_q4", + ], + "FORM_2": [ + "section_code", "name", + "plan_support_q1", "plan_support_q2", "plan_support_q3", "plan_support_q4", "plan_support_year", + "approved_support_q1", "approved_support_q2", "approved_support_q3", "approved_support_q4", "approved_support_year", + "fact_support_q1", "fact_support_q2", "fact_support_q3", "fact_support_q4", "fact_support_year", + "corrected_support_q2", "corrected_support_q3", "corrected_support_q4", + ], + "FORM_4": [ + "section_code", "name", + "plan_development_q1", "plan_development_q2", "plan_development_q3", "plan_development_q4", "plan_development_year", + "approved_development_q1", "approved_development_q2", "approved_development_q3", "approved_development_q4", "approved_development_year", + "fact_development_q1", "fact_development_q2", "fact_development_q3", "fact_development_q4", "fact_development_year", + "corrected_development_q2", "corrected_development_q3", "corrected_development_q4", + ], +} + @pytest.mark.parametrize("backend", ["openpyxl", "xlsxwriter", "xlsxwriter_columns", "xlsxwriter_rows", "xlsxwriter_palette"]) @pytest.mark.parametrize("name_key", ["name", "header.name"]) @@ -87,6 +121,91 @@ def test_sheet_column_contracts_are_stable(): assert len(SMETA_COLUMNS) == 41 assert FORM1_FLAT_COLUMNS[0] == ("section", "Код раздела") assert SMETA_COLUMNS[0] == ("section_code", "Код раздела") + # AURORA-1903: SMETA_COLUMNS остаётся алиасом на FORM_1-раскладку. + assert SMETA_COLUMNS is SMETA_COLUMNS_BY_FORM["FORM_1"] + assert [key for key, _ in SMETA_COLUMNS_BY_FORM["FORM_2"]] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_2"] + assert [key for key, _ in SMETA_COLUMNS_BY_FORM["FORM_4"]] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_4"] + + +@pytest.mark.parametrize("form_type_code", ["FORM_1", "FORM_2", "FORM_4"]) +def test_smeta_writer_columns_match_screen_order_per_form(form_type_code): + """AURORA-1903: лист "Смета" каждой формы выгружается в порядке своего экрана.""" + service = ExportService(AsyncMock()) + workbook = XlsxWorkbook(BytesIO()) + writers = service._create_xlsxwriter_sheet_writers( + workbook=workbook, + form_type_code=form_type_code, + year=2024, + ) + smeta_writer = writers["SMETA"] + assert [key for key, _ in smeta_writer.columns] == SMETA_SCREEN_COLUMNS_BY_FORM[form_type_code] + workbook.close() + + +def test_smeta_writer_falls_back_to_form1_layout_for_unknown_form_type(): + service = ExportService(AsyncMock()) + workbook = XlsxWorkbook(BytesIO()) + writers = service._create_xlsxwriter_sheet_writers( + workbook=workbook, + form_type_code=None, + year=2024, + ) + assert [key for key, _ in writers["SMETA"].columns] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_1"] + workbook.close() + + +class _FakeXlsxWorksheet: + """Фейковый worksheet, фиксирующий ровно то, что было записано.""" + + def __init__(self): + self.written_rows: dict[int, list] = {} + + def write_row(self, row, col, values): + self.written_rows[row] = list(values) + + def set_column(self, *args, **kwargs): + pass + + +def test_xlsx_tabular_sheet_writer_skips_meta_row(): + writer = XlsxTabularSheetWriter( + workbook=object(), + columns=[("section_code", "Код раздела"), ("name", "Наименование"), ("plan_support_q1", "План поддержка I квартал")], + row_normalizer=lambda row: row, + value_serializer=lambda value: value, + ) + worksheet = _FakeXlsxWorksheet() + rows = [ + ("META", -1, 0, {"ignored": True}), + ("ROOT", 0, 1, {"section_code": "1", "name": "x", "plan_support_q1": 10}), + ] + writer.write(worksheet, rows) + + assert set(worksheet.written_rows.keys()) == {0, 1} + assert worksheet.written_rows[0] == ["Код раздела", "Наименование", "План поддержка I квартал"] + assert worksheet.written_rows[1] == ["1", "x", 10] + + +def test_tabular_sheet_writer_skips_meta_row_openpyxl(): + writer = TabularSheetWriter( + columns=[("a", "A"), ("b", "B")], + row_normalizer=lambda row: row, + value_serializer=lambda value: value, + ) + wb = Workbook() + ws = wb.active + + rows = [ + ("META", -1, 0, {"a": "should-not-appear", "b": "should-not-appear"}), + ("ROOT", 0, 1, {"a": 1, "b": 2}), + ] + writer.write(ws, rows) + + assert ws.cell(row=1, column=1).value == "A" + assert ws.cell(row=1, column=2).value == "B" + assert ws.cell(row=2, column=1).value == 1 + assert ws.cell(row=2, column=2).value == 2 + assert ws.cell(row=3, column=1).value is None def test_export_service_uses_sheet_writer_strategy(): diff --git a/web/src/components/RealtimeTable/constants/FORM_1/SMETA.js b/web/src/components/RealtimeTable/constants/FORM_1/SMETA.js index 0462912..7e16929 100644 --- a/web/src/components/RealtimeTable/constants/FORM_1/SMETA.js +++ b/web/src/components/RealtimeTable/constants/FORM_1/SMETA.js @@ -569,6 +569,37 @@ export const config = { }, }, }, + { + header: "Итого: {year}", + accessorKey: "itogo_2024_utverzhdennye", + columns: [ + { + header: "", + accessorKey: "itogo_2024_utverzhdennye_field", + columns: [ + { + header: "", + accessorKey: "itogo_2024_utverzhdennye_field_field", + columns: [ + { + header: "", + accessorKey: "data.approved.total_year", + columnLetter: "Z", + size: 150, + filterFn: "contains", + }, + ], + }, + ], + }, + ], + muiTableHeadCellProps: { + sx: { + backgroundColor: "#BFBFBF", + color: "#000000", + }, + }, + }, { header: "Фактические расходы за {year} год", accessorKey: "fakticheskie_raskhody_za_2024_god", @@ -805,6 +836,37 @@ export const config = { }, }, }, + { + header: "Итого: {year}", + accessorKey: "itogo_2024_fakticheskie", + columns: [ + { + header: "", + accessorKey: "itogo_2024_fakticheskie_field", + columns: [ + { + header: "", + accessorKey: "itogo_2024_fakticheskie_field_field", + columns: [ + { + header: "", + accessorKey: "data.fact.total_year", + columnLetter: "AL", + size: 150, + filterFn: "contains", + }, + ], + }, + ], + }, + ], + muiTableHeadCellProps: { + sx: { + backgroundColor: "#BFBFBF", + color: "#000000", + }, + }, + }, { header: "Скорректированная смета расходов на {year} год", accessorKey: "skorrektirovannaya_smeta_raskhodov_na_2024_god", diff --git a/web/src/components/RealtimeTable/constants/FORM_1/SMETA.test.js b/web/src/components/RealtimeTable/constants/FORM_1/SMETA.test.js new file mode 100644 index 0000000..444d67e --- /dev/null +++ b/web/src/components/RealtimeTable/constants/FORM_1/SMETA.test.js @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +// SMETA.js is imported elsewhere via an extensionless path ("../columnColors") +// that only the project's bundler (Rsbuild) resolves, so plain `node --test` +// cannot `import` this module directly (ERR_MODULE_NOT_FOUND). Per AURORA-1903 +// and its precedent (AURORA-1901's form2SmetaConfig test), we instead match the +// file's text for its `accessorKey: "data...."` leaves and assert their order. +const sourcePath = fileURLToPath(new URL('./SMETA.js', import.meta.url)); +const source = readFileSync(sourcePath, 'utf8'); + +// AURORA-1903: экран формы 1 должен показывать "Утверждено ИТОГО Год" (Z) и +// "Факт ИТОГО Год" (AL) — раньше их теряли, хотя SQL (v_form_view) всегда их +// отдавал и выгрузка Excel их писала. Порядок ниже — источник истины для +// колонок листа "Смета" формы 1: он литерально совпадает с порядком ключей +// SMETA_COLUMNS_BY_FORM["FORM_1"] в api/src/services/export_service/export_mappings.py. +const EXPECTED_DATA_ACCESSOR_KEY_ORDER = [ + 'data.section_code', + 'data.name', + 'data.plan.support.q1', + 'data.plan.support.q2', + 'data.plan.support.q3', + 'data.plan.support.q4', + 'data.plan.support.year', + 'data.plan.development.q1', + 'data.plan.development.q2', + 'data.plan.development.q3', + 'data.plan.development.q4', + 'data.plan.development.year', + 'data.plan.total_year', + 'data.approved.support.q1', + 'data.approved.support.q2', + 'data.approved.support.q3', + 'data.approved.support.q4', + 'data.approved.support.year', + 'data.approved.development.q1', + 'data.approved.development.q2', + 'data.approved.development.q3', + 'data.approved.development.q4', + 'data.approved.development.year', + 'data.approved.total_year', + 'data.fact.support.q1', + 'data.fact.support.q2', + 'data.fact.support.q3', + 'data.fact.support.q4', + 'data.fact.support.year', + 'data.fact.development.q1', + 'data.fact.development.q2', + 'data.fact.development.q3', + 'data.fact.development.q4', + 'data.fact.development.year', + 'data.fact.total_year', + 'data.corrected.support.q2', + 'data.corrected.support.q3', + 'data.corrected.support.q4', + 'data.corrected.development.q2', + 'data.corrected.development.q3', + 'data.corrected.development.q4', +]; + +function extractDataAccessorKeysInOrder(text) { + const re = /accessorKey:\s*"(data\.[^"]+)"/g; + const keys = []; + let match; + while ((match = re.exec(text)) !== null) { + keys.push(match[1]); + } + return keys; +} + +test('FORM_1 SMETA screen leaf accessorKeys are in the ticket-mandated order (AURORA-1903)', () => { + const keys = extractDataAccessorKeysInOrder(source); + assert.deepEqual(keys, EXPECTED_DATA_ACCESSOR_KEY_ORDER); +}); + +test('FORM_1 SMETA screen has exactly one approved.total_year (Z) and one fact.total_year (AL) column', () => { + const keys = extractDataAccessorKeysInOrder(source); + assert.equal(keys.filter((key) => key === 'data.approved.total_year').length, 1); + assert.equal(keys.filter((key) => key === 'data.fact.total_year').length, 1); + assert.match(source, /accessorKey:\s*"data\.approved\.total_year"[\s\S]{0,80}columnLetter:\s*"Z"/); + assert.match(source, /accessorKey:\s*"data\.fact\.total_year"[\s\S]{0,80}columnLetter:\s*"AL"/); +});