DFiP_Budget_planing/api/tests/unit/test_export_writers.py
Andrey Epifancev 72f48cc559 fix(export): лист "Смета" выгружается по раскладке экрана каждой формы (AURORA-1903)
До этой правки лист "Смета" всех форм выгружался одной плоской
раскладкой SMETA_COLUMNS (41 колонка) — для FORM_2/FORM_4 в выгрузке
были лишние колонки другого направления (поддержка/развитие), которых
нет на экране. Добавлена SMETA_COLUMNS_BY_FORM{"FORM_1","FORM_2","FORM_4"}
с раскладкой колонок строго в порядке экрана каждой формы;
_create_xlsxwriter_sheet_writers теперь выбирает нужный список по
form_type_code. SMETA_COLUMNS остаётся алиасом на FORM_1-раскладку для
обратной совместимости (неиспользуемый openpyxl-словарь, старый тест).

Также TabularSheetWriter.write и XlsxTabularSheetWriter.write теперь
пропускают служебную строку META (как уже делают depth-писатели) —
раньше она давала пустую вторую строку на листе "Смета".

На экране FORM_1/SMETA.js добавлены "Утверждено ИТОГО Год" (Z) и
"Факт ИТОГО Год" (AL) — SQL (v_form_view) всегда их отдавал и
выгрузка Excel их писала, но экран их терял.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-01 16:01:25 +04:00

234 lines
10 KiB
Python

from unittest.mock import AsyncMock
from io import BytesIO
import pytest
from openpyxl import Workbook, load_workbook
from xlsxwriter import Workbook as XlsxWorkbook
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"])
def test_depth_writer_preserves_collapsible_row_hierarchy(backend, name_key):
stream = BytesIO()
workbook = Workbook() if backend == "openpyxl" else XlsxWorkbook(stream)
worksheet = workbook.active if backend == "openpyxl" else workbook.add_worksheet()
options = {}
if backend == "xlsxwriter_columns":
options["column_format_resolver"] = lambda key, column: {"bold": True}
if backend == "xlsxwriter_rows":
options["row_format_resolver"] = lambda row, key, column: {"bold": True}
if backend == "xlsxwriter_palette":
options["colors_config"] = {"header": {"palette": "green"}}
writer_class = DepthSheetWriter if backend == "openpyxl" else XlsxDepthSheetWriter
if backend != "openpyxl":
options["workbook"] = workbook
writer = writer_class(
columns_dict={"header": {"name": "Header", "key": "", "children": {
"name": {"name": "Name", "key": name_key},
"code": {"name": "Code"},
"contract.name": {"name": "Contract name"},
}}},
row_normalizer=lambda row: row,
value_serializer=lambda value: value,
**options,
)
# Missing GROUP/SUB_ITEM levels and misleading numeric depths match frontend cases.
types = ["ROOT", "ITEM", "INPUT", "META", "INPUT", "ITEM", "SUB_ITEM", "INPUT", "ROOT"]
writer.write(worksheet, [(kind, 42, index, {name_key: str(index), "code": index, "contract.name": "Contract"}) for index, kind in enumerate(types)])
if backend == "openpyxl":
workbook.save(stream)
else:
workbook.close()
stream.seek(0)
sheet = load_workbook(stream).active
assert sheet.sheet_properties.outlinePr.summaryBelow is False
assert [sheet.row_dimensions[index].outlineLevel for index in range(3, 11)] == [0, 1, 2, 2, 1, 2, 3, 0]
assert [sheet.cell(index, 1).value for index in range(3, 11)] == ["0", "1", "2", "4", "5", "6", "7", "8"]
assert all(not sheet.row_dimensions[index].hidden for index in range(3, 11))
assert [sheet.cell(index, 1).alignment.indent for index in range(3, 11)] == [0, 1, 2, 2, 1, 2, 3, 0]
assert all(sheet.cell(index, column).alignment.indent == 0 for index in range(3, 11) for column in (2, 3))
if backend in ("xlsxwriter_columns", "xlsxwriter_rows"):
assert sheet.cell(4, 1).font.bold is True
if backend == "xlsxwriter_palette":
assert sheet.cell(4, 1).fill.fgColor == sheet.cell(4, 2).fill.fgColor
def test_tabular_sheet_writer_writes_headers_and_rows():
writer = TabularSheetWriter(
columns=[("a", "A"), ("b", "B")],
row_normalizer=lambda row: row,
value_serializer=lambda value: value,
)
wb = Workbook()
ws = wb.active
rows = [(None, None, None, {"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
def test_sheet_column_contracts_are_stable():
assert len(FORM1_FLAT_COLUMNS) == 17
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():
service = ExportService(AsyncMock())
class Recorder:
def __init__(self):
self.calls = 0
def write(self, worksheet, rows):
self.calls += 1
worksheet.append(["ok"])
default_writer = Recorder()
smeta_writer = Recorder()
service.sheet_writers = {"__default__": default_writer, "SMETA": smeta_writer}
wb = Workbook()
ws_default = wb.active
ws_smeta = wb.create_sheet("smeta")
service._write_sheet_rows(ws_default, [], "AHR")
service._write_sheet_rows(ws_smeta, [], "SMETA")
assert default_writer.calls == 1
assert smeta_writer.calls == 1