From ccb92fd85fced7c81d921a0ad5e58c558b8e4c84 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Wed, 16 Sep 2026 11:58:29 +0300 Subject: [PATCH] Fix Errors --- app/pipeline/mapping.py | 44 ++++++++--- app/pipeline/mapping_table.json | 18 ++--- app/pipeline/report.py | 73 ++++++++++++++++- tests/unit/test_mapping.py | 136 +++++++++++++++++++++++++++++++- tests/unit/test_report.py | 46 ++++++++++- 5 files changed, 291 insertions(+), 26 deletions(-) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index f66fdb2..2406aa7 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -64,7 +64,6 @@ from .column_constants import ( COL_SUSPENSION_BASIS, COL_SUSPICIOUS_ID, COL_TERRITORY_CODE, - COL_TERRITORY_CODE_CARD, COL_TRANSFER_STATUS, COL_TRANSFER_TYPE, COL_UNUSUAL_CODES, @@ -864,11 +863,11 @@ def _apply_business_rules( _rule_item_type(row, codes) _rule_territory_codes(row, codes) - _rule_payer_account(row, transfer_type) + _rule_payer_account(row, transfer_type, codes) _rule_esp_identifiers(row, operation_sign) _rule_payer_bank(row, operator_type) _rule_recipient_bank(row, operator_type) - _rule_correspondent_accounts(row, operator_type, transfer_type) + _rule_correspondent_accounts(row, operator_type, transfer_type, codes) _rule_recipient_account(row, transfer_type) _rule_transfer_status(row, transfer_type) @@ -978,14 +977,32 @@ def _rule_item_type(row: dict[int, str], codes: set[str]) -> None: def _rule_territory_codes(row: dict[int, str], codes: set[str]) -> None: - """Правила 36 и 96: код территории только для операции 5016.""" + """Правило 36: код территории (КодТерИнГос) — только для операции 5016. + + Колонка 96 (КодТерИнБанк, признак 96) сюда НЕ относится — это другой + одноимённый показатель официальной спецификации (п.59), обязательный + для блока СведенияКартаИнБанк (код операции 1009), а не для 5016. + Раньше обе колонки ошибочно чистились по одному условию (TERRITORY_CODE + not in codes), из-за чего колонка 96 всегда обнулялась для 1009-строк — + именно 5016-код там никогда не бывает. Для колонки 96 отдельная очистка + не нужна: движок маппинга и так подставляет в неё значение только тогда, + когда в XML реально есть СведенияКартаИнБанк/КодТерИнБанк. + """ if TERRITORY_CODE not in codes: _set(row, COL_TERRITORY_CODE, "") - _set(row, COL_TERRITORY_CODE_CARD, "") -def _rule_payer_account(row: dict[int, str], transfer_type: str) -> None: - """Правило 38: счёт плательщика зависит от вида перевода.""" +def _rule_payer_account( + row: dict[int, str], transfer_type: str, codes: set[str] +) -> None: + """Правило 38: счёт плательщика зависит от вида перевода. + + Для операций с кодом 5016 (п.6 правок) заглушку не подставляем — такого + блока (номера счетов) для 5016 в спецификации нет. + """ + if TERRITORY_CODE in codes: + _set(row, COL_PAYER_ACCOUNT, "") + return if transfer_type in {"1", "2", "3", "10"}: if not _get(row, COL_PAYER_ACCOUNT): _set(row, COL_PAYER_ACCOUNT, ACCOUNT_PLACEHOLDER) @@ -1014,9 +1031,18 @@ def _rule_recipient_bank(row: dict[int, str], operator_type: str) -> None: def _rule_correspondent_accounts( - row: dict[int, str], operator_type: str, transfer_type: str + row: dict[int, str], operator_type: str, transfer_type: str, codes: set[str] ) -> None: - """Правила 44-45: корсчета зависят от оператора и вида перевода.""" + """Правила 44-45: корсчета зависят от оператора и вида перевода. + + Для операций с кодом 5016 (п.6 правок) заглушку не подставляем — такого + блока (номера счетов) для 5016 в спецификации нет. + """ + if TERRITORY_CODE in codes: + _set(row, COL_PAYER_BANK_ACCOUNT, "") + _set(row, COL_RECIPIENT_BANK_ACCOUNT, "") + return + if not operator_type and not transfer_type: _set(row, COL_PAYER_BANK_ACCOUNT, "") _set(row, COL_RECIPIENT_BANK_ACCOUNT, "") diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index ba8479a..3436426 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -948,7 +948,7 @@ "xml_tag": "ИдТерминал", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДс/СведПриемНалДС/ИдТерминал", "source_scope": "any", - "allow_short_lookup": true, + "allow_short_lookup": false, "allow_direct_mapping": true, "structured_value": false, "structured_group": "", @@ -1903,10 +1903,10 @@ "index": 96, "column_id": "legacy_096", "column_name": "Код территории", - "block": "cash_operation", - "report_group": "own_account_cash", - "xml_tag": "КодТерИнГос", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияВнесениеПолучениеНалДС/КодТерИнГос", + "block": "foreign_card", + "report_group": "foreign_card_operation", + "xml_tag": "КодТерИнБанк", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/КодТерИнБанк", "source_scope": "any", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -1917,7 +1917,7 @@ "join_with_space": false, "multiplies": "нет", "required": "необязательно", - "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" + "comment": "Обязательный показатель для блока СведенияКартаИнБанк (код операции 1009) согласно официальной спецификации (п.59) — код территории иностранного банка, эмитировавшего карту. Ранее был ошибочно замаплен на несуществующий путь СведенияВнесениеПолучениеНалДС/КодТерИнГос (другой одноимённый показатель, п.41, применим только к коду 5016) и относился к блоку cash_operation, из-за чего колонка визуально прилипала к предыдущей группе и всегда была пустой." }, { "index": 97, @@ -1962,7 +1962,7 @@ { "index": 99, "column_id": "legacy_099", - "column_name": "Адрес места приема наличных денежных средств (по структуре полный)", + "column_name": "Адрес места приема (выдачи) наличных денежных средств (по структуре полный)", "block": "foreign_card", "report_group": "foreign_card_operation", "xml_tag": "АдрМестаПриемаВыдача", @@ -3642,7 +3642,7 @@ { "index": 183, "column_id": "legacy_183", - "column_name": "Номер", + "column_name": "Серия", "block": "eio", "report_group": "eio", "xml_tag": "СерияДок", @@ -4079,4 +4079,4 @@ "required": "обязательно", "comment": "Данные могут оставаться пустыми" } -] +] \ No newline at end of file diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 4348312..3db3ed5 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -43,6 +43,39 @@ TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = tuple( ) +_WIDE_TEXT_KEYWORDS = ( + "Наименование", + "Адрес", + "Назначение платежа", + "Доп. сведения", + "Дополнительн", + "Сведения", + "Иные сведения", + "ФИО", + "Место государственной регистрации", + "Орган, выдавший документ", + "Сведения миграционной карты", +) +_NARROW_KEYWORDS = ( + "Код", + "БИК", + "ИНН", + "ОГРН", + "КПП", + "Индекс", + "Дом", + "Корпус", + "Офис", + "Квартира", + "Серия", + "Тип", + "Признак", + "ОКАТО", + "Валюта", + "СВИФТ", +) + + class StreamingReportWriter: def __init__(self) -> None: self.columns = list(FIXED_REPORT_COLUMNS) @@ -74,12 +107,29 @@ class StreamingReportWriter: ) self._date_format = self.workbook.add_format({"num_format": "DD.MM.YYYY"}) self._sum_format = self.workbook.add_format({"num_format": "#,##0.00"}) + # Дефолтный формат для обычных текстовых ячеек — раньше такие ячейки + # писались вообще без формата (cell_format is None), поэтому перенос + # текста применялся только к шапке, а не к данным. + self._text_format = self.workbook.add_format( + {"text_wrap": True, "valign": "top"} + ) self._column_formats = [self._resolve_format(column) for column in self.columns] self._build_group_header_row() + # В режиме constant_memory строка "уходит" на диск сразу после того, + # как начата следующая — поэтому set_row для строки 0 нужно вызывать + # ДО построения строки 1, иначе вызов молча ничего не сделает. + self.sheet.set_row(0, 22) self._build_header_row() - for column_index, column_format in enumerate(self._column_formats): - if column_format is self._date_format: - self.sheet.set_column(column_index, column_index, 12) + self.sheet.set_row(1, 46) + for column_index, column_name in enumerate(self.columns): + column_format = self._column_formats[column_index] + self.sheet.set_column( + column_index, + column_index, + self._resolve_column_width(column_name, column_format), + ) + # Шапка из двух строк уже настроена выше (до перехода на следующую + # строку — см. комментарий про constant_memory). # Фиксируем обе строки шапки. self.sheet.freeze_panes(2, 0) self.sheet.autofilter(1, 0, 1, len(self.columns) - 1) @@ -170,7 +220,22 @@ class StreamingReportWriter: return self._date_format if "Сумм" in column_name or "Сумма" in column_name: return self._sum_format - return None + return self._text_format + + def _resolve_column_width(self, column_name: str, column_format) -> float: + if column_format is self._date_format: + return 12 + if column_format is self._sum_format: + return 14 + if "Номер счета" in column_name: + return 20 + if "Номер платежной карты" in column_name or "Номер банкомата" in column_name: + return 16 + if any(keyword in column_name for keyword in _WIDE_TEXT_KEYWORDS): + return 32 + if any(keyword in column_name for keyword in _NARROW_KEYWORDS): + return 11 + return 14 def _normalize_cell_value( self, diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index dc3b879..17f10e7 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -441,7 +441,8 @@ def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None: assert row["Номер счета банка получателя"] == "" -def test_col96_territory_code_cleared_when_operation_is_not_5016() -> None: +def test_col36_territory_code_5016_cleared_when_operation_is_not_5016() -> None: + """Правило 36: КодТерИнГос (п.41 спецификации) — только для кода 5016.""" row = build_fixed_row( file_name="f.xml", record_id="R1", @@ -452,7 +453,26 @@ def test_col96_territory_code_cleared_when_operation_is_not_5016() -> None: }, participant_fields={}, ) - assert row["Код территории"] == "" + assert row["Код территории (5016)"] == "" + + +def test_col96_territory_code_card_filled_for_1009_regardless_of_5016() -> None: + """Правило 59 спецификации: КодТерИнБанк — для кода 1009 (карта ин. банка), + не зависит от того, есть ли в записи код 5016. Раньше колонка ошибочно + чистилась тем же условием, что и колонка 36 (код 5016), и всегда + оставалась пустой для 1009-записей.""" + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "1009", + "СведенияКартаИнБанк.КодТерИнБанк": "410", + }, + participant_fields={}, + ) + assert row["Код территории"] == "410" + assert row["Код территории (5016)"] == "" def test_col103_and_105_fill_info_absent_when_no_employee_and_empty() -> None: @@ -1602,3 +1622,115 @@ def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() - ) assert row_with_one_line_fio[119] == "" assert row_with_one_line_fio[122] == "" + + +def test_col48_atm_number_not_leaked_from_foreign_card_block() -> None: + """Регрессия: legacy_048 (AV, cash_receipt) раньше подхватывал ИдТерминал + из совсем другого блока (СведенияКартаИнБанк, foreign_card) через + короткий поиск по голому имени тега. Реальный кейс из отчёта заказчика: + операция 1009, ИдТерминал только в СведенияКартаИнБанк.СведМестоОперация. + """ + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "1009", + "СведенияКартаИнБанк.СведМестоОперация.ИдТерминал": "S1AM9302", + }, + participant_fields={}, + ) + assert row[97] == "S1AM9302" # CS, foreign_card — должно быть тут + assert row[48] == "" # AV, cash_receipt — тут быть не должно + assert row[82] == "" # CD, cash_operation — тоже не должно + + +def test_col38_44_45_payer_accounts_blank_for_5016() -> None: + """Правка п.6: для кода операции 5016 номера счетов (плательщика и + корсчетов) не подставляются заглушкой из нулей — такого блока для 5016 + в спецификации нет. Номер счёта получателя (46), если он реально пришёл + в XML, не трогаем.""" + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "5016", + "ВидПереводДС": "1", + "ТипОператорДС": "2", + "НомерСчетПолучатель": "40817810106240020211", + }, + participant_fields={}, + ) + assert row[38] == "" # Номер счета плательщика + assert row[44] == "" # Номер счета банка плательщика + assert row[45] == "" # Номер счета банка получателя + assert row[46] == "40817810106240020211" # реальные данные не трогаем + + +def test_col38_44_payer_accounts_still_get_placeholder_without_5016() -> None: + """Убеждаемся, что правка п.6 не задела остальные виды перевода — + заглушка по-прежнему подставляется, если 5016 нет в кодах операции.""" + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "5007", + "ВидПереводДС": "1", + "ТипОператорДС": "2", + }, + participant_fields={}, + ) + assert row[38] == "00000000000000000000" + assert row[44] == "00000000000000000000" + + +def test_col183_eio_series_column_named_correctly() -> None: + """Регрессия: колонка 183 (СерияДок ЕИО/Бенефициара) раньше называлась + "Номер", хотя реально тянет серию документа — при этом соседняя, 184-я, + тоже называлась "Номер" (и действительно тянет номер). Проверяем и + заголовок, и что данные при этом были и остаются на своих местах.""" + assert FIXED_REPORT_COLUMNS[182] == "Серия" + assert FIXED_REPORT_COLUMNS[183] == "Номер" + + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.СерияДок": "4018", + "УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.НомДок": "044762", + }, + ) + assert row[183] == "4018" # Серия + assert row[184] == "044762" # Номер + + +def test_col99_foreign_card_address_header_renamed() -> None: + """Заказчик попросил переименовать заголовок колонки 99 (адрес по + структуре, блок СведенияКартаИнБанк, код 1009) — было "Адрес места + приема наличных денежных средств (по структуре полный)", стало с учётом + того, что операция может быть как приёмом, так и выдачей наличных.""" + assert ( + FIXED_REPORT_COLUMNS[98] + == "Адрес места приема (выдачи) наличных денежных средств " + "(по структуре полный)" + ) + + +def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None: + """Регрессия: колонка 96 раньше входила в группу заголовков блока + 'своих' кассовых операций (cash_operation/own_account_cash), из-за чего + визуально 'прилипала' не к своему блоку. Теперь должна входить в ту же + report_group, что и соседние колонки блока СведенияКартаИнБанк (97+).""" + from app.pipeline.column_registry import COLUMNS + + columns_by_index = {meta.index: meta for meta in COLUMNS.ordered_columns()} + assert columns_by_index[96].block == "foreign_card" + assert columns_by_index[96].report_group == columns_by_index[97].report_group + + groups = COLUMNS.report_groups() + foreign_card_group = next(g for g in groups if g.start_index <= 97 <= g.end_index) + assert foreign_card_group.start_index == 96 diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index f667549..f48f81a 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -45,7 +45,7 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) -> assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00" -def test_write_report_expands_only_date_columns(tmp_path: Path) -> None: +def test_write_report_expands_date_and_other_columns(tmp_path: Path) -> None: destination = tmp_path / "report.xlsx" write_report([], destination) @@ -56,7 +56,11 @@ def test_write_report_expands_only_date_columns(tmp_path: Path) -> None: assert date_column_letter in sheet.column_dimensions assert sheet.column_dimensions[date_column_letter].width >= 12 - assert "A" not in sheet.column_dimensions + # Теперь ширина задаётся не только у дат — у всех колонок, по группам + # (узкие коды/ИНН/БИК, широкие текстовые поля вроде наименований банков + # и адресов, остальные — среднего размера). + assert "A" in sheet.column_dimensions + assert sheet.column_dimensions["A"].width >= 12 def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None: @@ -359,6 +363,44 @@ def test_write_report_keeps_only_repeat_fields_on_continuation_row( assert sheet.cell(row=4, column=45).value in (None, "") +def test_write_report_wraps_text_in_data_cells(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + rows = [ + ReportRow( + file_name="SKO115FZ_01_123456789_20260616_X00001.xml", + record_id="RID-1", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + file_name_cell = sheet.cell(row=3, column=1) + assert file_name_cell.alignment.wrap_text is True + + +def test_write_report_widens_bank_name_and_address_columns(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + write_report([], destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + bank_col_idx = FIXED_REPORT_COLUMNS.index("Наименование банка плательщика") + 1 + bank_col_letter = get_column_letter(bank_col_idx) + inn_col_idx = next( + index + for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1) + if "ИНН" in name + ) + inn_col_letter = get_column_letter(inn_col_idx) + + assert sheet.column_dimensions[bank_col_letter].width >= 28 + assert sheet.column_dimensions[inn_col_letter].width < 20 + + def _column_letter(index: int) -> str: value = index result = ""