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:
parent
d07eedfafb
commit
5825ca2aed
@ -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,
|
||||
*,
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user