Apply report formatting fixes requested on 24.09.2026

- Draw a grid on the two header rows only, data cells stay borderless.
- Align column names to the top so the start of a long name stays visible.
- Narrow the 14 code columns to the length of their values; the rule matches
  by column name, so identical indicators in every block get equal widths.
- Halve both payment system name columns (32 -> 16).
- Give "Характеристика операции" the same width as "Назначение платежа".
- Drop the group title above "Статус перевода": it duplicated the column name.
  The group cell stays in place so the header grid is not broken.
- Pass the cell format to write_string. Identifier columns (БИК, ИНН, ОГРН,
  КПП, СВИФТ, account numbers) were written without one, so text wrapping and
  vertical alignment never applied to them - this is why БИК had no line break.

The width padding and the header rotation are single constants: the customer
has not confirmed yet whether "N знаков" means the value length (implemented)
or an amount to subtract, and whether narrow headers should be rotated.
This commit is contained in:
Raykov-MS 2026-09-24 12:40:47 +03:00
parent d07eedfafb
commit 5825ca2aed
2 changed files with 232 additions and 3 deletions

View File

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

View File

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