diff --git a/api/src/services/export_service/export_mappings.py b/api/src/services/export_service/export_mappings.py index b14d8df..52210c7 100644 --- a/api/src/services/export_service/export_mappings.py +++ b/api/src/services/export_service/export_mappings.py @@ -102,6 +102,9 @@ FORM1_DEPTH_COLUMNS: dict = { "future_y1": { "name": "Платежи в {next_year} году", }, + "future_y2": { + "name": "Платежи в {year_plus_2} году", + }, "other_ssp": { "name": "Сумма в других ССП", }, @@ -182,7 +185,7 @@ FORM1_DEPTH_COLUMNS: dict = { "name": "Итоговая смета расходов", "children": { "plan": { - "name": "Резерв Финансового Комитета", + "name": "План {year}", "key": None, "children": { "q1": { @@ -217,9 +220,6 @@ FORM1_DEPTH_COLUMNS: dict = { "protocol": { "name": "Реквизиты протокола (дата, номер)", }, - "empty": { - "name": "", - }, "note": { "name": "Примечание", }, @@ -364,15 +364,6 @@ FORM1_DEPTH_COLUMNS: dict = { } } }, - "shift": { - "name": "Перенос во 2 кв {year_short}", - "key": None, - "children": { - "sum": { - "name": "Сумма, тыс. руб. ({vat})", - } - } - }, "comment_correct": { "name": "Комментарий", "key": None, @@ -607,11 +598,11 @@ FORM1_DEPTH_COLUMNS: dict = { } } }, - "shift": { - "name": "Перенос в 3 кв {year_short}", + "comment_correct": { + "name": "Комментарий", "key": None, "children": { - "sum": { + "adj_comment": { "name": "Сумма, тыс. руб. ({vat})", } } @@ -834,11 +825,11 @@ FORM1_DEPTH_COLUMNS: dict = { } } }, - "shift": { - "name": "Перенос в 4 кв {year_short}", + "comment_correct": { + "name": "Комментарий", "key": None, "children": { - "sum": { + "adj_comment": { "name": "Сумма, тыс. руб. ({vat})", } } @@ -1006,9 +997,6 @@ FORM1_DEPTH_COLUMNS: dict = { "rev_comment": { "name": "Сумма, тыс. руб. ({vat})", }, - "adj_comment": { - "name": "Сумма, тыс. руб. ({vat})", - }, } }, "new_plan_correct": { @@ -1061,6 +1049,15 @@ FORM1_DEPTH_COLUMNS: dict = { } } }, + "comment_correct": { + "name": "Комментарий", + "key": None, + "children": { + "adj_comment": { + "name": "Сумма, тыс. руб. ({vat})", + } + } + }, "plan_correct": { "name": "Скорр. план 4 кв. {year_short}", "key": None, @@ -1134,7 +1131,7 @@ FORM1_DEPTH_COLUMNS: dict = { } }, "quarter_correct": { - "name": "Факт РСБУ 3 кв {year_short}", + "name": "Факт РСБУ 4 кв {year_short}", "key": None, "children": { "actual_quarter": { @@ -3255,6 +3252,12 @@ def _rename_color_paths(colors: dict, renames: dict[str, str]) -> dict: return renamed +# На экране «Операц» формы 1 нет колонки «Платежи в {year+2} году» (она есть +# только на АХР/КВ) — отдельная раскладка без contract_summary.future_y2 (AURORA-1905). +FORM1_OPER_DEPTH_COLUMNS: dict = copy.deepcopy(FORM1_DEPTH_COLUMNS) +FORM1_OPER_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"]["children"].pop("future_y2", None) + + FORM4_DEPTH_COLUMNS: dict = _rename_depth_columns( FORM1_DEPTH_COLUMNS, FORM4_COLUMN_RENAMES, @@ -3295,26 +3298,8 @@ def _patch_form4_regular_columns() -> None: FORM4_DEPTH_COLUMNS["header"]["children"].pop(key, None) FORM4_STRUCTURE_DEPTH_COLUMNS["header"]["children"].pop("justification", None) - for quarter in ("q2", "q3"): - FORM4_DEPTH_COLUMNS[quarter]["children"].setdefault( - "comment_correct", - { - "key": None, - "name": "Комментарий", - "children": {"adj_comment": {"name": "Сумма"}}, - }, - ) - FORM4_STRUCTURE_DEPTH_COLUMNS[quarter]["children"].setdefault( - "comment_correct", - { - "key": None, - "name": "Комментарий", - "children": {"adj_comment": {"name": "Сумма"}}, - }, - ) - - for quarter in (1, 2, 3): - FORM4_DEPTH_COLUMNS.pop(f"actual_rsbu_spod_q{quarter}", None) + # comment_correct (q2/q3) теперь приходит из FORM1_DEPTH_COLUMNS уже на нужном + # месте — отдельный setdefault здесь больше не нужен (AURORA-1905). FORM4_DEPTH_COLUMNS["totals"]["children"].pop("pay_year", None) @@ -3338,7 +3323,7 @@ def _patch_form4_contract_summary_columns() -> None: summary_parent = FORM4_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"] summary = summary_parent["children"] summary["reference"] = {"name": "Реквизиты договора"} - summary["future_y2"] = {"name": "Платежи в {next_year} году"} + summary["future_y2"] = {"name": "Платежи в {year_plus_2} году"} summary["centralized_flag"] = {"name": "Централизованный договор"} summary_parent["children"] = _reorder_children( summary, @@ -3350,7 +3335,7 @@ def _patch_form4_contract_summary_columns() -> None: structure_summary_parent = FORM4_STRUCTURE_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"] structure_summary = structure_summary_parent["children"] - structure_summary["future_payments_y2"] = {"name": "Платежи в {next_year} году"} + structure_summary["future_payments_y2"] = {"name": "Платежи в {year_plus_2} году"} structure_summary_parent["children"] = _reorder_children( structure_summary, ( @@ -3361,14 +3346,17 @@ def _patch_form4_contract_summary_columns() -> None: def _patch_form4_actual_rsbu_spod_columns() -> None: + # SQL отдаёт actual_rsbu_spod только для 4 кв.; 1-3 кв. показывают то же + # значение, что и q{n}.actual_quarter внутри самого квартала (AURORA-1905). totals = FORM4_DEPTH_COLUMNS.pop("totals") for quarter in range(1, 5): + leaf_key = f"q{quarter}.actual_rsbu_spod" if quarter == 4 else f"q{quarter}.actual_quarter" FORM4_DEPTH_COLUMNS[f"actual_rsbu_spod_q{quarter}"] = { "key": None, "name": f"Факт {quarter} кв" if quarter < 4 else "Факт 4 кв + СПОД", "children": { "actual_rsbu_spod": { - "key": f"q{quarter}.actual_rsbu_spod", + "key": leaf_key, "name": "", }, }, @@ -3380,7 +3368,6 @@ _patch_form4_regular_columns() _patch_form4_header_columns() _patch_form4_contract_summary_columns() _patch_form4_actual_rsbu_spod_columns() -_patch_form4_regular_columns() FORM4_OPER_DEPTH_COLUMNS: dict = copy.deepcopy(FORM4_DEPTH_COLUMNS) FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurement_plan"] = ( @@ -3389,6 +3376,12 @@ FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurem FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurement_method"] = ( FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"].pop("proc_method") ) +# Переименование выше (pop+присвоение) уезжает в конец словаря — возвращаем +# порядок экрана: ceiling, q1-q4, rf_schedule, deadline, затем закупки, comment (AURORA-1905). +FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"] = _reorder_children( + FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"], + ("ceiling", "q1", "q2", "q3", "q4", "rf_schedule", "deadline", "procurement_plan", "procurement_method", "comment"), +) FORM4_OPER_COLORS: dict = _rename_color_paths( FORM4_COLORS["OPER"], { @@ -4080,6 +4073,7 @@ def render_column_years(columns: dict, year: int, extra: dict[str, str] | None = "year_short": f"{year % 100:02d}", "next_year": str(year + 1), "next_year_short": f"{(year + 1) % 100:02d}", + "year_plus_2": str(year + 2), } if extra: values.update(extra) diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index 70a1cd6..8fcadd7 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -34,6 +34,7 @@ from src.services.export_service.export_normalizers import ( from src.services.export_service.export_mappings import ( FORM1_COLORS, FORM1_DEPTH_COLUMNS, + FORM1_OPER_DEPTH_COLUMNS, FORM2_COLORS, FORM2_DEPTH_AHR_SUB_COLUMNS, FORM2_AHR_DEPTH_COLUMNS, @@ -1013,8 +1014,9 @@ class ExportService: } else: writer_configs = { + # AURORA-1905: на экране «Операц» формы 1 нет «Платежи в {year+2} году». "OPER": ( - FORM1_DEPTH_COLUMNS, + FORM1_OPER_DEPTH_COLUMNS, FORM1_COLORS["OPER"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["OPER"]), self.form1_row_normalizer, diff --git a/api/tests/unit/test_export_form1_form4_mapping.py b/api/tests/unit/test_export_form1_form4_mapping.py new file mode 100644 index 0000000..ad707ac --- /dev/null +++ b/api/tests/unit/test_export_form1_form4_mapping.py @@ -0,0 +1,107 @@ +import importlib + +from xlsxwriter import Workbook as XlsxWorkbook + +from src.services.export_service import export_mappings +from src.services.export_service.export_mappings import ( + FORM1_DEPTH_COLUMNS, + FORM1_OPER_DEPTH_COLUMNS, + FORM4_DEPTH_COLUMNS, + FORM4_OPER_DEPTH_COLUMNS, + FORM4_STRUCTURE_DEPTH_COLUMNS, + render_column_years, +) +from src.services.export_service.export_writers import XlsxDepthSheetWriter + + +def _flat(columns_dict): + writer = XlsxDepthSheetWriter( + workbook=XlsxWorkbook(), + columns_dict=columns_dict, + row_normalizer=lambda row: row, + value_serializer=lambda value: value, + ) + return writer.columns + + +def _tree_node(tree, path): + segments = path.split(".") + node = tree[segments[0]] + for segment in segments[1:]: + node = node["children"][segment] + return node + + +def test_no_stray_collegial_empty_column(): + for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS): + assert "collegial.empty" not in _flat(columns) + + +def test_no_shift_sum_columns_in_base_form1(): + flat = _flat(FORM1_DEPTH_COLUMNS) + assert not [key for key in flat if key.endswith(".sum")] + + +def test_form4_has_fact_q1_q2_q3_and_fact_4_plus_spod_before_totals(): + flat = _flat(FORM4_DEPTH_COLUMNS) + # 1-3 кв. показывают то же значение, что q{n}.actual_quarter внутри квартала + # (сознательный дубль ключа — влияет только на цвет, см. AURORA-1905); 4 кв. + # использует свой собственный q4.actual_rsbu_spod (SQL отдаёт только его). + assert flat.count("q1.actual_quarter") == 2 + assert flat.count("q2.actual_quarter") == 2 + assert flat.count("q3.actual_quarter") == 2 + assert flat.count("q4.actual_rsbu_spod") == 1 + totals_index = flat.index("totals.fact_year") + assert flat.index("q4.actual_rsbu_spod") < totals_index + + +def test_adj_comment_immediately_follows_adj_reserve(): + for quarter in ("q2", "q3", "q4"): + for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS): + flat = _flat(columns) + reserve_index = flat.index(f"{quarter}.adj_reserve") + comment_index = flat.index(f"{quarter}.adj_comment") + assert comment_index == reserve_index + 1, (columns is FORM1_DEPTH_COLUMNS, quarter) + + +def test_form4_oper_ckk_procurement_before_comment(): + flat = _flat(FORM4_OPER_DEPTH_COLUMNS) + assert flat.index("ckk.procurement_plan") < flat.index("ckk.comment") + assert flat.index("ckk.procurement_method") < flat.index("ckk.comment") + assert flat.index("ckk.deadline") < flat.index("ckk.procurement_plan") + + +def test_form1_oper_has_no_future_y2_but_ahr_cap_do(): + assert "contract_summary.future_y2" in _flat(FORM1_DEPTH_COLUMNS) + assert "contract_summary.future_y2" not in _flat(FORM1_OPER_DEPTH_COLUMNS) + + +def test_render_column_years_form4(): + rendered = render_column_years(FORM4_DEPTH_COLUMNS, 2026) + assert _tree_node(rendered, "upper_ssp_smeta.contract_summary.future_y2")["name"] == "Платежи в 2028 году" + assert _tree_node(rendered, "approved.plan")["name"] == "План 2026" + assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26" + + +def test_render_column_years_form4_structure(): + rendered = render_column_years(FORM4_STRUCTURE_DEPTH_COLUMNS, 2026) + assert ( + _tree_node(rendered, "upper_ssp_smeta.contract_summary.future_payments_y2")["name"] + == "Платежи в 2028 году" + ) + + +def test_render_column_years_form1(): + rendered = render_column_years(FORM1_DEPTH_COLUMNS, 2026) + assert _tree_node(rendered, "approved.plan")["name"] == "План 2026" + assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26" + + +def test_module_reload_is_idempotent(): + # Патчи FORM_4 применяются один раз при импорте модуля; повторный импорт + # не должен снова звать патчи и удваивать/ломать раскладку. + before = _flat(export_mappings.FORM4_DEPTH_COLUMNS) + importlib.reload(export_mappings) + after = _flat(export_mappings.FORM4_DEPTH_COLUMNS) + assert before == after + importlib.reload(export_mappings) diff --git a/api/tests/unit/test_export_form2_mapping.py b/api/tests/unit/test_export_form2_mapping.py index 3fc2460..b7a9d7b 100644 --- a/api/tests/unit/test_export_form2_mapping.py +++ b/api/tests/unit/test_export_form2_mapping.py @@ -6,7 +6,7 @@ from xlsxwriter import Workbook as XlsxWorkbook from src.db.models.form_type import FormTypeEnum from src.services.export_service import ExportService from src.services.export_service.export_mappings import ( - FORM1_DEPTH_COLUMNS, + FORM1_OPER_DEPTH_COLUMNS, FORM2_AHR_DEPTH_COLUMNS, FORM2_COLORS, FORM2_DEPTH_COLUMNS, @@ -144,7 +144,7 @@ def test_create_xlsxwriter_sheet_writers_form1_is_unaffected(): form1_writer = XlsxDepthSheetWriter( workbook=XlsxWorkbook(), - columns_dict=FORM1_DEPTH_COLUMNS, + columns_dict=FORM1_OPER_DEPTH_COLUMNS, # AURORA-1905: на «Операц» формы 1 нет future_y2 row_normalizer=lambda row: row, value_serializer=lambda value: value, ) diff --git a/web/src/components/RealtimeTable/constants/FORM_1/AHR.js b/web/src/components/RealtimeTable/constants/FORM_1/AHR.js index e3db978..a5756ba 100644 --- a/web/src/components/RealtimeTable/constants/FORM_1/AHR.js +++ b/web/src/components/RealtimeTable/constants/FORM_1/AHR.js @@ -3568,14 +3568,14 @@ export const config = { }, }, { - header: 'Скорректированный план 4 кв. {year:short}', - accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26', + header: 'Комментарий', + accessorKey: '4_kvartal_2024_kommentariy', columns: [ { - header: 'Сумма, {unit} (без НДС)', - accessorKey: 'data.q4.corrected_plan', - columnLetter: 'GH', - size: 250, + header: 'Сумма, {unit} (с учётом НДС)', + accessorKey: 'data.q4.adj_comment', + columnLetter: 'GG', + size: 300, filterFn: 'contains', }, ], @@ -3587,14 +3587,14 @@ export const config = { }, }, { - header: 'Комментарий', - accessorKey: '4_kvartal_2024_kommentariy', + header: 'Скорректированный план 4 кв. {year:short}', + accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26', columns: [ { - header: 'Сумма, {unit} (с учётом НДС)', - accessorKey: 'data.q4.adj_comment', - columnLetter: 'GG', - size: 300, + header: 'Сумма, {unit} (без НДС)', + accessorKey: 'data.q4.corrected_plan', + columnLetter: 'GH', + size: 250, filterFn: 'contains', }, ], diff --git a/web/src/components/RealtimeTable/constants/FORM_1/CAP.js b/web/src/components/RealtimeTable/constants/FORM_1/CAP.js index f20f23a..1670988 100644 --- a/web/src/components/RealtimeTable/constants/FORM_1/CAP.js +++ b/web/src/components/RealtimeTable/constants/FORM_1/CAP.js @@ -3582,13 +3582,13 @@ export const config = { }, }, { - header: 'Скорректированный план 4 кв. {year:short}', - accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24', + header: 'Комментарий', + accessorKey: '4_kvartal_2024_kommentariy', columns: [ { header: 'Сумма, {unit} (с учётом НДС)', - accessorKey: 'data.q4.corrected_plan', - columnLetter: 'GH', + accessorKey: 'data.q4.adj_comment', + columnLetter: 'GG', size: 300, filterFn: 'contains', }, @@ -3601,13 +3601,13 @@ export const config = { }, }, { - header: 'Комментарий', - accessorKey: '4_kvartal_2024_kommentariy', + header: 'Скорректированный план 4 кв. {year:short}', + accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24', columns: [ { header: 'Сумма, {unit} (с учётом НДС)', - accessorKey: 'data.q4.adj_comment', - columnLetter: 'GG', + accessorKey: 'data.q4.corrected_plan', + columnLetter: 'GH', size: 300, filterFn: 'contains', }, diff --git a/web/src/components/RealtimeTable/constants/FORM_1/OPER.js b/web/src/components/RealtimeTable/constants/FORM_1/OPER.js index 08b864c..eb743cc 100644 --- a/web/src/components/RealtimeTable/constants/FORM_1/OPER.js +++ b/web/src/components/RealtimeTable/constants/FORM_1/OPER.js @@ -3566,13 +3566,13 @@ export const config = { }, }, { - header: 'Скорректированный план 4 кв. {year:short}', - accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24', + header: 'Комментарий', + accessorKey: '4_kvartal_2024_kommentariy', columns: [ { header: 'Сумма, {unit} (с учётом НДС)', - accessorKey: 'data.q4.corrected_plan', - columnLetter: 'GH', + accessorKey: 'data.q4.adj_comment', + columnLetter: 'GG', size: 300, filterFn: 'contains', }, @@ -3585,13 +3585,13 @@ export const config = { }, }, { - header: 'Комментарий', - accessorKey: '4_kvartal_2024_kommentariy', + header: 'Скорректированный план 4 кв. {year:short}', + accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24', columns: [ { header: 'Сумма, {unit} (с учётом НДС)', - accessorKey: 'data.q4.adj_comment', - columnLetter: 'GG', + accessorKey: 'data.q4.corrected_plan', + columnLetter: 'GH', size: 300, filterFn: 'contains', }, diff --git a/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js b/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js new file mode 100644 index 0000000..26fb8e2 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// AURORA-1905: в 1-3 кв. "Комментарий" (adj_comment) уже шёл сразу после +// "Корректировки из резерва" (adj_reserve), перед "Скорректированный план" +// (corrected_plan); в 4 кв. порядок был нарушен (corrected_plan раньше adj_comment). +const SHEETS = ['AHR', 'CAP', 'OPER']; + +function extractDataAccessorKeys(source) { + // Пропускаем секцию "colors" (плоская карта, свой, не относящийся к + // расположению столбцов порядок) — берём только дерево "columns". + const columnsStart = source.indexOf('\n\t\tcolumns: ['); + const columnsSource = columnsStart === -1 ? source : source.slice(columnsStart); + const matches = columnsSource.matchAll(/accessorKey:\s*'(data\.[^']+)'/g); + return [...matches].map((m) => m[1]); +} + +for (const sheet of SHEETS) { + test(`FORM_1/${sheet}.js: q4 adj_reserve -> adj_comment -> corrected_plan`, () => { + const path = fileURLToPath(new URL(`../constants/FORM_1/${sheet}.js`, import.meta.url)); + const keys = extractDataAccessorKeys(readFileSync(path, 'utf8')); + + const reserveIndex = keys.indexOf('data.q4.adj_reserve'); + const commentIndex = keys.indexOf('data.q4.adj_comment'); + const planIndex = keys.indexOf('data.q4.corrected_plan'); + + assert.notEqual(reserveIndex, -1, 'data.q4.adj_reserve не найден'); + assert.notEqual(commentIndex, -1, 'data.q4.adj_comment не найден'); + assert.notEqual(planIndex, -1, 'data.q4.corrected_plan не найден'); + assert.ok(reserveIndex < commentIndex, 'adj_comment должен идти после adj_reserve'); + assert.ok(commentIndex < planIndex, 'adj_comment должен идти перед corrected_plan'); + }); +}