diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 3db3ed5..3352823 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -31,6 +31,7 @@ class GroupHeader: start_col: int end_col: int title: str + group_id: str = "" TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = tuple( @@ -38,15 +39,22 @@ TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = tuple( start_col=group.start_index, end_col=group.end_index, title=group.title, + group_id=group.group_id, ) for group in COLUMNS.report_groups() ) +# Группы верхнего уровня, у которых заказчик просил убрать наименование: над +# колонкой «Статус перевода» заголовок группы дублировал название самой +# колонки. Ячейка группы остаётся на месте (с рамкой), но без текста. +SUPPRESSED_GROUP_IDS = frozenset({"transfer_status"}) + _WIDE_TEXT_KEYWORDS = ( "Наименование", "Адрес", "Назначение платежа", + "Характеристика операции", "Доп. сведения", "Дополнительн", "Сведения", @@ -56,6 +64,46 @@ _WIDE_TEXT_KEYWORDS = ( "Орган, выдавший документ", "Сведения миграционной карты", ) +# Требования заказчика от 24.09.2026: у перечисленных показателей колонка +# сужается до ширины содержимого. Ключ — подстрока имени колонки (сравнение без +# учёта регистра), значение — длина значения в знаках: числа из списка заказчика +# совпадают с фактическими данными (ОКАТО «08», код страны «643», признак +# валютной операции «VO» и т.д.). Правило применяется ко ВСЕМ одноимённым +# колонкам во всех блоках отчёта, чтобы одинаковые показатели были одинаковой +# ширины. Порядок значим: побеждает первое совпадение, поэтому более узкие +# формулировки идут выше — «Признак валютной операции» это 2 знака, а не 3 как +# у самой валюты. +_CHARACTER_WIDTH_RULES: tuple[tuple[str, int], ...] = ( + ("признак валютной операции", 2), + ("признак операции, связанной", 1), + ("совершения оп. с испол. эсп", 1), + ("тип информации", 1), + ("номер филиала", 2), + ("окато", 2), + ("код страны", 3), + ("валют", 3), + ("вид перевода", 1), + ("код территории", 3), + ("тип оператора", 1), + ("статус перевода", 1), + ("признак дул", 1), + ("код вида документа", 2), +) +# Ширины, заданные не «в знаках», а напрямую: обе колонки наименования +# платёжной системы сужены вдвое от прежних 32. +_FIXED_WIDTH_RULES: tuple[tuple[str, float], ...] = ( + ("наименование плат. системы", 16), +) +# Excel отводит часть ширины колонки под отступы и кнопку автофильтра, поэтому +# к длине значения добавляем небольшой запас: иначе значение в колонке «на один +# знак» не видно вообще. +_WIDTH_PADDING = 2 +_MIN_COLUMN_WIDTH = 3 +# Поворот названий колонок в шапке, в градусах: 0 — перенос по словам, 90 — +# вертикальный текст. Заказчик просил перенос с выравниванием по верхнему краю, +# но в узких колонках название почти не читается, поэтому переключение на +# вертикальную шапку оставлено одной константой. +HEADER_TEXT_ROTATION = 0 _NARROW_KEYWORDS = ( "Код", "БИК", @@ -91,9 +139,11 @@ class StreamingReportWriter: { "bold": True, "align": "center", - "valign": "vcenter", + "valign": "top", "text_wrap": True, "bg_color": "#E2F0D9", + "border": 1, + "rotation": HEADER_TEXT_ROTATION, } ) self._group_format = self.workbook.add_format( @@ -103,6 +153,7 @@ class StreamingReportWriter: "valign": "vcenter", "text_wrap": True, "bg_color": "#FFFFFF", + "border": 1, } ) self._date_format = self.workbook.add_format({"num_format": "DD.MM.YYYY"}) @@ -167,6 +218,7 @@ class StreamingReportWriter: column_index=output_index, row_by_index=data_by_index, ), + default_format, ) elif cell_format is not None: self.sheet.write( @@ -199,15 +251,16 @@ class StreamingReportWriter: continue first_col = header.start_col - 1 last_col = min(header.end_col, len(self.columns)) - 1 + title = "" if header.group_id in SUPPRESSED_GROUP_IDS else header.title if first_col == last_col: - self.sheet.write(0, first_col, header.title, self._group_format) + self.sheet.write(0, first_col, title, self._group_format) continue self.sheet.merge_range( 0, first_col, 0, last_col, - header.title, + title, self._group_format, ) @@ -227,6 +280,9 @@ class StreamingReportWriter: return 12 if column_format is self._sum_format: return 14 + explicit_width = self._resolve_explicit_width(column_name) + if explicit_width is not None: + return explicit_width if "Номер счета" in column_name: return 20 if "Номер платежной карты" in column_name or "Номер банкомата" in column_name: @@ -237,6 +293,16 @@ class StreamingReportWriter: return 11 return 14 + def _resolve_explicit_width(self, column_name: str) -> float | None: + normalized = column_name.casefold() + for keyword, width in _FIXED_WIDTH_RULES: + if keyword in normalized: + return width + for keyword, characters in _CHARACTER_WIDTH_RULES: + if keyword in normalized: + return max(characters + _WIDTH_PADDING, _MIN_COLUMN_WIDTH) + return None + def _normalize_cell_value( self, *, diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index f48f81a..ab39a6c 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -401,6 +401,169 @@ def test_write_report_widens_bank_name_and_address_columns(tmp_path: Path) -> No assert sheet.column_dimensions[inn_col_letter].width < 20 +def test_write_report_draws_grid_only_in_header_rows(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + group_cell = sheet.cell(row=1, column=1) + header_cell = sheet.cell(row=2, column=1) + data_cell = sheet.cell(row=3, column=1) + + assert group_cell.border.bottom.style == "thin" + assert header_cell.border.bottom.style == "thin" + assert header_cell.border.left.style == "thin" + # Заказчик просил сетку только на двух строках шапки, данные без рамок. + assert data_cell.border.bottom.style is None + + +def test_write_report_aligns_column_names_to_top(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + header_cell = sheet.cell(row=2, column=1) + + assert header_cell.alignment.vertical == "top" + assert header_cell.alignment.wrap_text is True + + +def test_write_report_drops_group_title_above_transfer_status(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + status_col = FIXED_REPORT_COLUMNS.index("Статус перевода") + 1 + + assert sheet.cell(row=2, column=status_col).value == "Статус перевода" + assert sheet.cell(row=1, column=status_col).value in (None, "") + # Рамка у пустой ячейки группы остаётся, чтобы сетка шапки не разрывалась. + assert sheet.cell(row=1, column=status_col).border.bottom.style == "thin" + + +def test_write_report_narrows_code_columns_in_every_block(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + widths = {} + for dimension in sheet.column_dimensions.values(): + for index in range(dimension.min, dimension.max + 1): + widths[index] = dimension.width + + # Ширина = длина значения + запас под отступы и кнопку автофильтра + # (см. _WIDTH_PADDING/_MIN_COLUMN_WIDTH в app/pipeline/report.py). + expected_width_by_characters = {1: 3, 2: 4, 3: 5} + expected_characters = { + "Тип информации": 1, + "ОКАТО": 2, + "Номер филиала": 2, + "Валюта": 3, + "Признак валютной операции": 2, + "Вид перевода": 1, + "Тип оператора": 1, + "Статус перевода": 1, + "Признак ДУЛ": 1, + } + for name, characters in expected_characters.items(): + index = FIXED_REPORT_COLUMNS.index(name) + 1 + expected = expected_width_by_characters[characters] + assert expected <= widths[index] < expected + 1, name + + # Одноимённые показатели в разных блоках должны быть одинаковой ширины. + okato_indexes = [ + index + for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1) + if "ОКАТО" in name + ] + assert len(okato_indexes) > 1 + assert len({round(widths[index], 2) for index in okato_indexes}) == 1 + + document_indexes = [ + index + for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1) + if "Код вида документа" in name + ] + assert len(document_indexes) > 1 + assert len({round(widths[index], 2) for index in document_indexes}) == 1 + + +def test_write_report_halves_payment_system_columns(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + widths = {} + for dimension in sheet.column_dimensions.values(): + for index in range(dimension.min, dimension.max + 1): + widths[index] = dimension.width + + payment_system_indexes = [ + index + for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1) + if "Наименование плат. системы" in name + ] + assert len(payment_system_indexes) == 2 + for index in payment_system_indexes: + assert 16 <= widths[index] < 17 + + +def test_write_report_matches_operation_summary_columns_widths(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + widths = {} + for dimension in sheet.column_dimensions.values(): + for index in range(dimension.min, dimension.max + 1): + widths[index] = dimension.width + + names = ("Характеристика операции", "Назначение платежа", "Доп. сведения") + resolved = { + round(widths[FIXED_REPORT_COLUMNS.index(name) + 1], 2) for name in names + } + assert len(resolved) == 1 + + +def test_write_report_wraps_text_in_identifier_cells(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={"БИКФл": "040813733"}, + participant_fields={}, + ) + ] + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + bik_cell = sheet.cell(row=3, column=FIXED_REPORT_COLUMNS.index("БИК") + 1) + + assert bik_cell.value == "040813733" + # Раньше write_string вызывался без формата, поэтому у БИК, ИНН, КПП и + # номеров счетов не работал ни перенос строки, ни выравнивание. + assert bik_cell.alignment.wrap_text is True + assert bik_cell.alignment.vertical == "top" + + def _column_letter(index: int) -> str: value = index result = ""