diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 8b54749..28fb277 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -204,7 +204,7 @@ def _build_structured_group_rules( 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() @@ -290,6 +290,7 @@ REPORT_MAPPING_RULES: tuple[MappingRule, ...] = _build_rules(_MAPPING_DATA) STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_group_rules( _MAPPING_DATA ) +_ACCOUNT_PLACEHOLDER = "00000000000000000000" def build_fixed_row( @@ -332,6 +333,7 @@ def build_fixed_row( source_payload, suffix_index, rule.candidate_keys ) _apply_structured_group_rules(row) + _apply_conditional_rules_1_74(row) for column in FIXED_REPORT_COLUMNS: row.setdefault(column, "") return row @@ -414,3 +416,118 @@ def _pick_structured_value( return "" separator = " " if join_with_space else ", " return separator.join(values) + + +def _column(index: int) -> str: + return FIXED_REPORT_COLUMNS[index - 1] + + +def _row_value(row: dict[str, str], index: int) -> str: + return str(row.get(_column(index), "")).strip() + + +def _set_row_value(row: dict[str, str], index: int, value: str) -> None: + row[_column(index)] = value + + +def _extract_numeric_codes(value: str) -> set[str]: + return {match for match in re.findall(r"\d+", value)} + + +def _operation_and_extra_codes(row: dict[str, str]) -> set[str]: + op_code = _row_value(row, 19) + extra_codes = _row_value(row, 20) + return _extract_numeric_codes(op_code) | _extract_numeric_codes(extra_codes) + + +def _apply_conditional_rules_1_74(row: dict[str, str]) -> None: + codes = _operation_and_extra_codes(row) + op_type_code = _row_value(row, 14) + transfer_type = _row_value(row, 35) + operator_type = _row_value(row, 37) + + # 10. Допустимые значения: 0..5, иначе очищаем. + if _row_value(row, 10) not in {"0", "1", "2", "3", "4", "5"}: + _set_row_value(row, 10, "") + + # 11. Заполняется только для приостановлений и валидных кодов основания. + if _row_value(row, 11) not in {"1", "2", "3"}: + _set_row_value(row, 11, "") + + # 15. Заполняется только для допустимых значений признака ЭСП. + if _row_value(row, 15) not in {"1", "2", "3", "4"}: + _set_row_value(row, 15, "") + + # 21. Коды необычной операции заполняются только при признаке 6001. + if "6001" not in codes: + _set_row_value(row, 21, "") + + # 22-24. Для операций с цифровыми правами (код 8) не заполняем. + if op_type_code == "8": + _set_row_value(row, 22, "") + _set_row_value(row, 23, "") + _set_row_value(row, 24, "") + + # 28. Идентификатор подозрительной деятельности только для 6001. + if "6001" not in codes: + _set_row_value(row, 28, "") + + # 30. Наименование драгметалла только для кода C99. + if _row_value(row, 29).upper() != "C99": + _set_row_value(row, 30, "") + + # 31. Предмет операции только для кодов 5020..5023. + if not {"5020", "5021", "5022", "5023"} & codes: + _set_row_value(row, 31, "") + + # 36. Код территории заполняется только для признака 5016. + if "5016" not in codes: + _set_row_value(row, 36, "") + + # 38. Счет плательщика обязателен для видов перевода 1/2/3/10 с заглушкой. + if transfer_type in {"1", "2", "3", "10"}: + if not _row_value(row, 38): + _set_row_value(row, 38, _ACCOUNT_PLACEHOLDER) + elif transfer_type in {"12", "13", "14"}: + pass + else: + _set_row_value(row, 38, "") + + # 39 / 47. Идентификаторы ЭСП отсутствуют для признака операции 9. + if op_type_code == "9": + _set_row_value(row, 39, "") + _set_row_value(row, 47, "") + + # 40. Банк плательщика заполняется только при типе оператора 2 или 4. + if operator_type not in {"2", "4"}: + _set_row_value(row, 40, "") + + # 42/43. Реквизиты банка получателя только при типе оператора 1 или 4. + if operator_type not in {"1", "4"}: + _set_row_value(row, 42, "") + _set_row_value(row, 43, "") + + # 44. Корсчет банка плательщика с заглушкой там, где это применимо. + if operator_type in {"3", "5"} or transfer_type in {"12", "13"}: + _set_row_value(row, 44, "") + elif not _row_value(row, 44): + _set_row_value(row, 44, _ACCOUNT_PLACEHOLDER) + + # 45. Корсчет банка получателя с заглушкой там, где это применимо. + if operator_type in {"3", "5"} or transfer_type in {"10", "11"}: + _set_row_value(row, 45, "") + elif not _row_value(row, 45): + _set_row_value(row, 45, _ACCOUNT_PLACEHOLDER) + + # 46. Счет получателя с заглушкой для обязательных видов перевода. + if transfer_type in {"1", "4", "7", "12"}: + if not _row_value(row, 46): + _set_row_value(row, 46, _ACCOUNT_PLACEHOLDER) + elif transfer_type in {"10", "11", "14"}: + pass + else: + _set_row_value(row, 46, "") + + # 62. Статус перевода заполняется только для видов 2/5/8. + if transfer_type not in {"2", "5", "8"}: + _set_row_value(row, 62, "") diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 7a42955..e425450 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -27,61 +27,63 @@ class GroupHeader: title: str -# Grouped upper header from the template report for readability. +# Верхняя группирующая шапка согласно согласованным логическим категориям. TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = ( + GroupHeader(start_col=1, end_col=1, title="Служебные поля"), + GroupHeader(start_col=2, end_col=6, title="Реквизиты сообщения/КО/филиала"), GroupHeader( - start_col=1, - end_col=4, - title="Информация о КО, передающей сведения в уполномоченный орган", - ), - GroupHeader(start_col=5, end_col=8, title="Сведения об уполномоченном лице"), - GroupHeader( - start_col=11, + start_col=7, end_col=13, - title="Информация о филиале КО, передающем сведения в уполномоченный орган", + title="Идентификация записи и контрольные признаки", ), - GroupHeader(start_col=14, end_col=36, title="Информация об операции"), + GroupHeader(start_col=14, end_col=36, title="Параметры операции"), GroupHeader( start_col=37, end_col=48, - title="Сведения о переводах денежных средств, в том числе электронных денежных средств", + 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="Сведения о месте авторизации ЭСП", + end_col=66, + title="Места приема/выдачи наличных и авторизация ЭСП", ), GroupHeader( start_col=67, - end_col=69, - title=( - "Сведения о внесении наличных денежных средств на свой банковский счет " - "или о получении наличных денежных средств со своего банковского счета " - "у одного оператора по переводу денежных средств" - ), - ), - GroupHeader( - start_col=70, - end_col=74, - title="Сведения о месте приема (выдачи) наличных денежных средств", + end_col=75, + title="Внесение/выдача у одного оператора (место операции)", ), GroupHeader( start_col=76, - end_col=80, - title="Сведения о месте совершения операции с денежными средствами в наличной форме", + end_col=86, + title="Наличная форма и основание операции", + ), + GroupHeader( + start_col=87, + end_col=121, + title="Участник операции (базовые сведения)", + ), + GroupHeader( + start_col=122, + end_col=154, + title="Участник операции (идентификация, документы, адреса)", + ), + GroupHeader( + start_col=155, + end_col=193, + title="ЕИО/Бенефициар (идентификация, документы, адреса)", + ), + GroupHeader( + start_col=194, + end_col=204, + title="Сведения о ценных бумагах (ЦП)", + ), + GroupHeader( + start_col=205, + end_col=205, + title=( + "Колонки вне согласованной группировки (если количество полей расширится)" + ), ), - GroupHeader(start_col=87, end_col=104, title="Сведения об участниках операции"), - GroupHeader(start_col=105, end_col=118, title="Сведения ЕИО"), ) @@ -120,7 +122,7 @@ class StreamingReportWriter: self._column_formats = [self._resolve_format(column) for column in self.columns] self._build_group_header_row() self._build_header_row() - # Freeze both header rows. + # Фиксируем обе строки шапки. self.sheet.freeze_panes(2, 0) self.sheet.autofilter(1, 0, 1, len(self.columns) - 1) self._next_data_row = 2 @@ -161,6 +163,9 @@ class StreamingReportWriter: continue first_col = header.start_col - 1 last_col = min(header.end_col, len(self.columns)) - 1 + if first_col == last_col: + self.sheet.write(0, first_col, header.title, self._group_format) + continue self.sheet.merge_range( 0, first_col, diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index c359b99..9ae0208 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -294,3 +294,86 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None ] == "г. Москва, ул. Пушкина, д. 1" ) + + +def test_digital_rights_operation_clears_currency_amount_fields() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодПризнОперации": "8", + "КодВал": "643", + "СумОперации": "100.00", + "СумРуб": "100.00", + }, + participant_fields={}, + ) + assert row["Валюта"] == "" + assert row["Сумма в валюте проведения"] == "" + assert row["Сумма в рубл. эквиваленте"] == "" + + +def test_abnormal_operation_codes_only_for_6001() -> None: + row_without_6001 = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "5007", + "ПризнНеобОперации": "1101", + }, + participant_fields={}, + ) + assert row_without_6001["Коды призн. необычной операции"] == "" + + row_with_6001 = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "6001", + "ПризнНеобОперации": "1101", + }, + participant_fields={}, + ) + assert row_with_6001["Коды призн. необычной операции"] == "1101" + + +def test_transfer_accounts_get_placeholder_when_required() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ВидПереводДС": "1", + "ТипОператорДС": "4", + }, + participant_fields={}, + ) + assert row["Номер счета плательщика"] == "00000000000000000000" + assert row["Номер счета банка плательщика"] == "00000000000000000000" + assert row["Номер счета банка получателя"] == "00000000000000000000" + assert row["Номер счета получателя"] == "00000000000000000000" + + +def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ТипОператорДС": "3", + "НаимКО": "Банк плательщика", + "СведБанкПолучатель.НаимКО": "Банк получателя", + "СведБанкПолучатель.БИККО": "044525225", + "СчетБанкПлательщик": "30101810000000000001", + "СчетБанкПолучатель": "30101810000000000002", + }, + participant_fields={}, + ) + assert row["Наименование банка плательщика"] == "" + assert row["Наименование банка получателя"] == "" + assert row["БИК банка получателя"] == "" + assert row["Номер счета банка плательщика"] == "" + assert row["Номер счета банка получателя"] == "" diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index f00f35c..db25bc9 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -62,15 +62,15 @@ def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None: workbook = load_workbook(destination) sheet = workbook["Отчет"] - assert sheet.cell(row=1, column=1).value == ( - "Информация о КО, передающей сведения в уполномоченный орган" - ) + 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)) + if header.start_col == end_col: + continue expected_ranges.add( f"{_column_letter(header.start_col)}1:{_column_letter(end_col)}1" )