AURORA-1903: лист «Смета» — своя раскладка колонок для каждой формы #287
@ -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": "Данные",
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"/);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user