move to xlsxwriter

This commit is contained in:
Raykov-MS 2026-07-29 10:28:17 +03:00
parent f8d587275b
commit a4379e5513
5 changed files with 547 additions and 42 deletions

View File

@ -11,6 +11,15 @@ class MappingRule:
column_name: str column_name: str
candidate_keys: tuple[str, ...] candidate_keys: tuple[str, ...]
source: str = "any" source: str = "any"
allow_direct_mapping: bool = True
@dataclass(frozen=True)
class StructuredGroupRule:
structured_column: str
one_line_columns: tuple[str, ...]
component_columns: tuple[str, ...]
join_with_space: bool = False
MAPPING_DATA_FILE = Path(__file__).resolve().with_name("mapping_table.json") MAPPING_DATA_FILE = Path(__file__).resolve().with_name("mapping_table.json")
@ -18,7 +27,7 @@ TARGET_REPORT_COLUMNS_COUNT = 204
_MANUAL_COLUMN_NAME_FIXES: dict[int, str] = { _MANUAL_COLUMN_NAME_FIXES: dict[int, str] = {
1: "Имя XML файла", 1: "Имя XML файла",
178: "Место государственной регистрации ЕИО/Бенефициара (структура полностью)", 178: "Место государственной регистрации ЕИО/Бенефициара (одной строкой)",
183: "Номер", 183: "Номер",
} }
@ -78,6 +87,12 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
"ИНН участника", "ИНН участника",
"Наименование участника", "Наименование участника",
"Счет участника", "Счет участника",
"Фамилия",
"Имя",
"Отчество",
"ФИО (по структуре полностью)",
"ФИО (в одной строке)",
"ФИО ( в одной строке)",
} }
for row in mapping_data: for row in mapping_data:
column = str(row.get("column_name", "")).strip() column = str(row.get("column_name", "")).strip()
@ -85,6 +100,7 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
path = str(row.get("xml_path", "")).strip() path = str(row.get("xml_path", "")).strip()
if not column or tag in _INVALID_TAGS: if not column or tag in _INVALID_TAGS:
continue continue
is_structured_target = _is_structured_column(column)
candidates = list(_extract_tag_candidate_keys(tag)) candidates = list(_extract_tag_candidate_keys(tag))
if column.startswith("ИНН"): if column.startswith("ИНН"):
candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates] candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates]
@ -94,15 +110,149 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
for path_tag in _extract_path_candidate_keys(path): for path_tag in _extract_path_candidate_keys(path):
if path_tag not in candidates: if path_tag not in candidates:
candidates.append(path_tag) candidates.append(path_tag)
non_one_line_candidates = [
candidate
for candidate in candidates
if not _is_one_line_candidate(candidate)
]
one_line_candidates = [
candidate for candidate in candidates if _is_one_line_candidate(candidate)
]
direct_candidates = candidates
allow_direct_mapping = True
if is_structured_target and one_line_candidates and not non_one_line_candidates:
allow_direct_mapping = False
if is_structured_target and non_one_line_candidates:
direct_candidates = non_one_line_candidates
rules.append( rules.append(
MappingRule( MappingRule(
column_name=column, column_name=column,
candidate_keys=tuple(candidates), candidate_keys=tuple(direct_candidates),
source=( source=(
"participant" "participant"
if column in participant_columns or column.startswith("ИНН") if column in participant_columns or column.startswith("ИНН")
else "any" else "any"
), ),
allow_direct_mapping=allow_direct_mapping,
)
)
return tuple(rules)
def _is_one_line_column(column_name: str) -> bool:
normalized = column_name.lower().replace("ё", "е")
return "одной строк" in normalized
def _is_one_line_candidate(candidate: str) -> bool:
return "строка" in candidate.lower()
def _is_structured_column(column_name: str) -> bool:
normalized = column_name.lower().replace("ё", "е")
return "структур" in normalized or "(целый)" in normalized
def _split_paths(path: str) -> tuple[str, ...]:
result: list[str] = []
for chunk in path.split("|"):
candidate = chunk.strip()
if candidate:
result.append(candidate.rstrip("/"))
return tuple(result)
def _common_prefix_depth(path_a: str, path_b: str) -> int:
parts_a = [part for part in path_a.strip("/").split("/") if part]
parts_b = [part for part in path_b.strip("/").split("/") if part]
depth = 0
for left, right in zip(parts_a, parts_b):
if left != right:
break
depth += 1
return depth
def _paths_related(
struct_paths: tuple[str, ...], candidate_paths: tuple[str, ...]
) -> bool:
if not struct_paths or not candidate_paths:
return False
for struct_path in struct_paths:
for candidate_path in candidate_paths:
if _common_prefix_depth(struct_path, candidate_path) >= 6:
return True
return False
def _build_structured_group_rules(
mapping_data: list[dict[str, str | int]],
) -> tuple[StructuredGroupRule, ...]:
by_index: dict[int, dict[str, str | int]] = {
int(item["index"]): item for item in mapping_data
}
sorted_indexes = sorted(by_index.keys())
rules: list[StructuredGroupRule] = []
for index in sorted_indexes:
item = by_index[index]
structured_name = str(item.get("column_name", "")).strip()
if not _is_structured_column(structured_name):
continue
struct_paths = _split_paths(str(item.get("xml_path", "")))
one_line_names: list[str] = []
component_names: list[str] = []
# Left-side "one line" can be immediately before structured column.
left = by_index.get(index - 1)
if left:
left_name = str(left.get("column_name", "")).strip()
if _is_one_line_column(left_name):
left_paths = _split_paths(str(left.get("xml_path", "")))
if _paths_related(struct_paths, left_paths):
one_line_names.append(left_name)
right_boundary = next(
(
candidate
for candidate in sorted_indexes
if candidate > index
and _is_structured_column(
str(by_index[candidate].get("column_name", "")).strip()
)
),
None,
)
block_end = right_boundary or (sorted_indexes[-1] + 1)
for right_index in sorted_indexes:
if right_index <= index or right_index >= block_end:
continue
right = by_index[right_index]
right_name = str(right.get("column_name", "")).strip()
right_tag = str(right.get("xml_tag", "")).strip()
right_paths = _split_paths(str(right.get("xml_path", "")))
if not _paths_related(struct_paths, right_paths):
if component_names:
break
continue
if _is_one_line_column(right_name):
one_line_names.append(right_name)
continue
if "|" in right_tag:
continue
component_names.append(right_name)
if not component_names:
continue
rules.append(
StructuredGroupRule(
structured_column=structured_name,
one_line_columns=tuple(dict.fromkeys(one_line_names)),
component_columns=tuple(dict.fromkeys(component_names)),
join_with_space="фио" in structured_name.lower(),
) )
) )
return tuple(rules) return tuple(rules)
@ -137,6 +287,9 @@ def _extract_path_candidate_keys(path: str) -> tuple[str, ...]:
_MAPPING_DATA = _load_mapping_data() _MAPPING_DATA = _load_mapping_data()
FIXED_REPORT_COLUMNS: tuple[str, ...] = _build_fixed_columns(_MAPPING_DATA) FIXED_REPORT_COLUMNS: tuple[str, ...] = _build_fixed_columns(_MAPPING_DATA)
REPORT_MAPPING_RULES: tuple[MappingRule, ...] = _build_rules(_MAPPING_DATA) REPORT_MAPPING_RULES: tuple[MappingRule, ...] = _build_rules(_MAPPING_DATA)
STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_group_rules(
_MAPPING_DATA
)
def build_fixed_row( def build_fixed_row(
@ -158,20 +311,57 @@ def build_fixed_row(
for rule in REPORT_MAPPING_RULES: for rule in REPORT_MAPPING_RULES:
if row.get(rule.column_name): if row.get(rule.column_name):
continue continue
if not rule.allow_direct_mapping:
row[rule.column_name] = ""
continue
if rule.source == "participant": if rule.source == "participant":
source_payload = participant_fields source_payload = participant_fields
suffix_index = participant_suffix_index suffix_index = participant_suffix_index
else: else:
source_payload = merged source_payload = merged
suffix_index = merged_suffix_index suffix_index = merged_suffix_index
row[rule.column_name] = _pick_value( if _is_structured_column(rule.column_name):
source_payload, suffix_index, rule.candidate_keys row[rule.column_name] = _pick_structured_value(
) source_payload=source_payload,
suffix_index=suffix_index,
candidates=rule.candidate_keys,
join_with_space="фио" in rule.column_name.lower(),
)
else:
row[rule.column_name] = _pick_value(
source_payload, suffix_index, rule.candidate_keys
)
_apply_structured_group_rules(row)
for column in FIXED_REPORT_COLUMNS: for column in FIXED_REPORT_COLUMNS:
row.setdefault(column, "") row.setdefault(column, "")
return row return row
def _apply_structured_group_rules(row: dict[str, str]) -> None:
for rule in STRUCTURED_GROUP_RULES:
one_line_has_value = any(
str(row.get(column_name, "")).strip()
for column_name in rule.one_line_columns
)
if one_line_has_value:
row[rule.structured_column] = ""
for component_column in rule.component_columns:
row[component_column] = ""
continue
values = [
str(row.get(component_column, "")).strip()
for component_column in rule.component_columns
if str(row.get(component_column, "")).strip()
]
if not values:
row[rule.structured_column] = ""
continue
separator = " " if rule.join_with_space else ", "
row[rule.structured_column] = separator.join(values)
def _merge_fields( def _merge_fields(
operation_fields: dict[str, str], operation_fields: dict[str, str],
participant_fields: dict[str, str], participant_fields: dict[str, str],
@ -206,3 +396,21 @@ def _pick_value(
if short in suffix_index: if short in suffix_index:
return suffix_index[short] return suffix_index[short]
return "" return ""
def _pick_structured_value(
*,
source_payload: dict[str, str],
suffix_index: dict[str, str],
candidates: tuple[str, ...],
join_with_space: bool,
) -> str:
values: list[str] = []
for key in candidates:
value = _pick_value(source_payload, suffix_index, (key,))
if value and value not in values:
values.append(value)
if not values:
return ""
separator = " " if join_with_space else ", "
return separator.join(values)

View File

@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Iterable from typing import Iterable
from openpyxl import Workbook import xlsxwriter
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Alignment, Font, PatternFill
from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row
@ -20,14 +20,110 @@ class ReportRow:
participant_fields: dict[str, str] participant_fields: dict[str, str]
@dataclass(frozen=True)
class GroupHeader:
start_col: int
end_col: int
title: str
# Grouped upper header from the template report for readability.
TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = (
GroupHeader(
start_col=1,
end_col=4,
title="Информация о КО, передающей сведения в уполномоченный орган",
),
GroupHeader(start_col=5, end_col=8, title="Сведения об уполномоченном лице"),
GroupHeader(
start_col=11,
end_col=13,
title="Информация о филиале КО, передающем сведения в уполномоченный орган",
),
GroupHeader(start_col=14, end_col=36, title="Информация об операции"),
GroupHeader(
start_col=37,
end_col=48,
title="Сведения о переводах денежных средств, в том числе электронных денежных средств",
),
GroupHeader(
start_col=49,
end_col=53,
title="Сведения о месте приема наличных денежных средств",
),
GroupHeader(
start_col=55,
end_col=59,
title="Сведения о месте выдачи наличных денежных средств",
),
GroupHeader(
start_col=60,
end_col=64,
title="Сведения о месте авторизации ЭСП",
),
GroupHeader(
start_col=67,
end_col=69,
title=(
"Сведения о внесении наличных денежных средств на свой банковский счет "
"или о получении наличных денежных средств со своего банковского счета "
"у одного оператора по переводу денежных средств"
),
),
GroupHeader(
start_col=70,
end_col=74,
title="Сведения о месте приема (выдачи) наличных денежных средств",
),
GroupHeader(
start_col=76,
end_col=80,
title="Сведения о месте совершения операции с денежными средствами в наличной форме",
),
GroupHeader(start_col=87, end_col=104, title="Сведения об участниках операции"),
GroupHeader(start_col=105, end_col=118, title="Сведения ЕИО"),
)
class StreamingReportWriter: class StreamingReportWriter:
def __init__(self) -> None: def __init__(self) -> None:
self.columns = list(FIXED_REPORT_COLUMNS) self.columns = list(FIXED_REPORT_COLUMNS)
temp_file = NamedTemporaryFile(suffix=".xlsx", delete=False)
self._temp_report_path = Path(temp_file.name)
temp_file.close()
self.workbook = xlsxwriter.Workbook(
str(self._temp_report_path),
{"constant_memory": True},
)
self._is_closed = False
self.sheet = self.workbook.add_worksheet("Отчет")
self._header_format = self.workbook.add_format(
{
"bold": True,
"align": "center",
"valign": "vcenter",
"text_wrap": True,
"bg_color": "#E2F0D9",
}
)
self._group_format = self.workbook.add_format(
{
"bold": True,
"align": "center",
"valign": "vcenter",
"text_wrap": True,
"bg_color": "#FFFFFF",
}
)
self._date_format = self.workbook.add_format({"num_format": "DD.MM.YYYY"})
self._sum_format = self.workbook.add_format({"num_format": "#,##0.00"})
self._column_formats = [self._resolve_format(column) for column in self.columns] self._column_formats = [self._resolve_format(column) for column in self.columns]
self.workbook = Workbook(write_only=True) self._build_group_header_row()
self.sheet = self.workbook.create_sheet(title="Отчет") self._build_header_row()
self.sheet.freeze_panes = "A2" # Freeze both header rows.
self.sheet.append(self._build_header_row()) self.sheet.freeze_panes(2, 0)
self.sheet.autofilter(1, 0, 1, len(self.columns) - 1)
self._next_data_row = 2
def append_row(self, row: ReportRow) -> None: def append_row(self, row: ReportRow) -> None:
data = build_fixed_row( data = build_fixed_row(
@ -40,41 +136,66 @@ class StreamingReportWriter:
values: list[object] = [] values: list[object] = []
for index, column_name in enumerate(self.columns): for index, column_name in enumerate(self.columns):
raw_value = data.get(column_name, "") raw_value = data.get(column_name, "")
number_format = self._column_formats[index] values.append(raw_value)
if number_format is None: for index, value in enumerate(values):
values.append(raw_value) cell_format = self._column_formats[index]
continue if cell_format is not None:
cell = WriteOnlyCell(self.sheet, value=raw_value) self.sheet.write(self._next_data_row, index, value, cell_format)
cell.number_format = number_format else:
values.append(cell) self.sheet.write(self._next_data_row, index, value)
self.sheet.append(values) self._next_data_row += 1
def save(self, destination: Path) -> None: def save(self, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
self.workbook.save(destination) try:
if not self._is_closed:
self.workbook.close()
self._is_closed = True
shutil.copyfile(self._temp_report_path, destination)
finally:
self._cleanup_temp_file()
def _build_header_row(self) -> list[WriteOnlyCell]: def _build_group_header_row(self) -> None:
fill = PatternFill(start_color="E2F0D9", end_color="E2F0D9", fill_type="solid") for header in TOP_GROUP_HEADERS:
result: list[WriteOnlyCell] = [] if header.start_col > len(self.columns):
for name in self.columns: continue
cell = WriteOnlyCell(self.sheet, value=name) first_col = header.start_col - 1
cell.font = Font(bold=True) last_col = min(header.end_col, len(self.columns)) - 1
cell.alignment = Alignment( self.sheet.merge_range(
horizontal="center", 0,
vertical="center", first_col,
wrap_text=True, 0,
last_col,
header.title,
self._group_format,
) )
cell.fill = fill
result.append(cell)
return result
def _resolve_format(self, column_name: str) -> str | None: def _build_header_row(self) -> None:
for index, name in enumerate(self.columns):
self.sheet.write(1, index, name, self._header_format)
def _resolve_format(self, column_name: str):
if "Дата" in column_name: if "Дата" in column_name:
return "DD.MM.YYYY" return self._date_format
if "Сумм" in column_name or "Сумма" in column_name: if "Сумм" in column_name or "Сумма" in column_name:
return "#,##0.00" return self._sum_format
return None return None
def _cleanup_temp_file(self) -> None:
try:
self._temp_report_path.unlink(missing_ok=True)
except Exception: # noqa: BLE001
pass
def __del__(self) -> None:
if not getattr(self, "_is_closed", True):
try:
self.workbook.close()
except Exception: # noqa: BLE001
pass
self._is_closed = True
self._cleanup_temp_file()
def write_report(rows: Iterable[ReportRow], destination: Path) -> None: def write_report(rows: Iterable[ReportRow], destination: Path) -> None:
writer = StreamingReportWriter() writer = StreamingReportWriter()

View File

@ -6,5 +6,6 @@ black==24.4.2
isort==5.13.2 isort==5.13.2
pre-commit==3.7.1 pre-commit==3.7.1
openpyxl openpyxl
xlsxwriter==3.2.9
pytest pytest
smbprotocol==1.15.0 smbprotocol==1.15.0

View File

@ -150,3 +150,147 @@ def test_build_fixed_row_skips_empty_suffix_values_in_index() -> None:
}, },
) )
assert row[_column_with("ИНН ")] == "222222222222" assert row[_column_with("ИНН ")] == "222222222222"
def test_structured_address_is_built_from_right_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"Индекс": "674500",
"КодОКСМ": "643",
"Пункт": "Москва",
"Улица": "Тверская",
"Дом": "1",
},
)
assert row["Адрес по структуре (целый)"] == "674500, 643, Москва, Тверская, 1"
def test_one_line_address_clears_structured_and_component_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"АдресСтрока": "г. Москва, Тверская, 1",
"Индекс": "674500",
"КодОКСМ": "643",
"Улица": "Тверская",
"Дом": "1",
},
)
assert row["Адрес в одной строке"] == "г. Москва, Тверская, 1"
assert row["Адрес по структуре (целый)"] == ""
assert row["Индекс"] == ""
assert row["Код страны"] == ""
assert row["Улица"] == ""
assert row["Дом"] == ""
def test_fio_columns_prefer_participant_fields_over_operation_common_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"Фам": "Кузнецов",
"Имя": "Сергей",
"Отч": "Владимирович",
},
participant_fields={
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Отч": "Каримович",
},
)
assert row["Фамилия"] == "Азимов"
assert row["Имя"] == "Ислом"
assert row["Отчество"] == "Каримович"
assert row["ФИО (по структуре полностью)"] == "Азимов Ислом Каримович"
def test_one_line_fio_clears_structured_and_component_fio_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ФИОСтрока": "Азимов Ислом Каримович",
"Фам": "Азимов",
"Имя": "Ислом",
"Отч": "Каримович",
},
)
assert row["ФИО (в одной строке)"] == "Азимов Ислом Каримович"
assert row["ФИО (по структуре полностью)"] == ""
assert row["Фамилия"] == ""
assert row["Имя"] == ""
assert row["Отчество"] == ""
assert row["Наименование ЮЛ/ФИО ( по структуре полный)"] == ""
def test_structured_column_is_not_filled_directly_from_single_line_tag() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"АдресСтрока": "г. Москва, ул. Пример, д. 1",
},
)
assert row["Адрес в одной строке"] == "г. Москва, ул. Пример, д. 1"
assert (
row[
"Адрес ЮЛ/ Адрес места жительства (места нахождения) "
"ЕИО/Бенефициара (по структуре полный)"
]
== ""
)
def test_structured_column_without_component_block_joins_candidate_values() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"Индекс": "121467",
"КодОКСМ": "643",
"Пункт": "Москва",
"Улица": "Молодогвардейская",
"Дом": "8",
"Корп": "1",
"Оф": "28",
},
)
assert (
row["Место государственной регистрации ЕИО/Бенефициара (структура полностью)"]
== "121467, 643, Москва, Молодогвардейская, 8, 1, 28"
)
def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"АдрУчредитель": "г. Москва, ул. Пушкина, д. 1",
},
)
assert row["Адрес в одной строке"] == ""
assert (
row[
"Адрес ЮЛ/ Адрес места жительства (места нахождения) "
"ЕИО/Бенефициара (по структуре полный)"
]
== "г. Москва, ул. Пушкина, д. 1"
)

View File

@ -5,7 +5,7 @@ from pathlib import Path
from openpyxl import load_workbook from openpyxl import load_workbook
from app.pipeline.mapping import FIXED_REPORT_COLUMNS from app.pipeline.mapping import FIXED_REPORT_COLUMNS
from app.pipeline.report import ReportRow, write_report from app.pipeline.report import TOP_GROUP_HEADERS, ReportRow, write_report
def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) -> None: def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) -> None:
@ -24,10 +24,11 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
workbook = load_workbook(destination) workbook = load_workbook(destination)
sheet = workbook["Отчет"] sheet = workbook["Отчет"]
headers = [cell.value for cell in sheet[1]] headers = [cell.value for cell in sheet[2]]
assert headers == list(FIXED_REPORT_COLUMNS) assert headers == list(FIXED_REPORT_COLUMNS)
assert sheet.freeze_panes == "A3"
row_values = [ row_values = [
sheet.cell(row=2, column=index).value sheet.cell(row=3, column=index).value
for index in range(1, len(FIXED_REPORT_COLUMNS) + 1) for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)
] ]
assert "SKO115FZ_01_123456789_20260616_X00001.xml" in row_values assert "SKO115FZ_01_123456789_20260616_X00001.xml" in row_values
@ -41,7 +42,7 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
None, None,
) )
if date_col_idx is not None: if date_col_idx is not None:
assert sheet.cell(row=2, column=date_col_idx).number_format == "DD.MM.YYYY" assert sheet.cell(row=3, column=date_col_idx).number_format == "DD.MM.YYYY"
sum_col_idx = next( sum_col_idx = next(
( (
@ -52,4 +53,34 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
None, None,
) )
if sum_col_idx is not None: if sum_col_idx is not None:
assert sheet.cell(row=2, column=sum_col_idx).number_format == "#,##0.00" assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00"
def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
write_report([], destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
assert sheet.cell(row=1, column=1).value == (
"Информация о КО, передающей сведения в уполномоченный орган"
)
merged_ranges = {str(rng) for rng in sheet.merged_cells.ranges}
expected_ranges = set()
for header in TOP_GROUP_HEADERS:
if header.start_col > len(FIXED_REPORT_COLUMNS):
continue
end_col = min(header.end_col, len(FIXED_REPORT_COLUMNS))
expected_ranges.add(
f"{_column_letter(header.start_col)}1:{_column_letter(end_col)}1"
)
assert merged_ranges == expected_ranges
def _column_letter(index: int) -> str:
value = index
result = ""
while value:
value, remainder = divmod(value - 1, 26)
result = chr(65 + remainder) + result
return result