AURORA-1903: лист «Смета» — своя раскладка колонок для каждой формы #287

Merged
aep merged 1 commits from feat/286-aurora-1903-smeta-export-layout into test 2026-11-02 20:37:03 +03:00
6 changed files with 334 additions and 2 deletions

View File

@ -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": "Данные",

View File

@ -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,
),

View File

@ -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]

View File

@ -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():

View File

@ -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",

View File

@ -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"/);
});