diff --git a/app/pipeline/column_constants.py b/app/pipeline/column_constants.py index 0d8eb18..6c2f869 100644 --- a/app/pipeline/column_constants.py +++ b/app/pipeline/column_constants.py @@ -27,6 +27,7 @@ COL_SUSPICIOUS_ID = _index(28) COL_METAL_CODE = _index(29) COL_METAL_NAME = _index(30) COL_ITEM_TYPE = _index(31) +COL_EXTRA_INFO = _index(34) # Перевод. COL_TRANSFER_TYPE = _index(35) @@ -101,7 +102,7 @@ PARTICIPANT_ADDRESS_OUTPUT_SOURCES: dict[int, int] = { # Бизнес-константы. ACCOUNT_PLACEHOLDER = "00000000000000000000" -CURRENCY_OPERATION_CODES = frozenset(str(code) for code in range(6101, 6127)) +EXPORT_SUBSIDIARY_COMMENT_CODES = frozenset(str(code) for code in range(6102, 6127)) CASHLESS_TRANSFER_TYPES = frozenset( {"1", "2", "3", "4", "7", "10", "11", "12", "13", "14"} ) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index ff85820..6458d23 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -19,13 +19,13 @@ from .column_constants import ( COL_COUNTRY_CODE, COL_CP_ISSUER_INN, COL_CURRENCY, - COL_CURRENCY_SIGN, COL_EIO_ADDRESS_ONE_LINE, COL_EIO_INN, COL_EIO_REG_PLACE_ONE_LINE, COL_EMPLOYEE_SIGN, COL_ESP_SIGN, COL_EXTRA_CODES, + COL_EXTRA_INFO, COL_FILE_NAME, COL_FOREIGN_BANK_NAME, COL_FTR_SIGN, @@ -63,8 +63,8 @@ from .column_constants import ( COL_TRANSFER_STATUS, COL_TRANSFER_TYPE, COL_UNUSUAL_CODES, - CURRENCY_OPERATION_CODES, EMPLOYEE_ABSENT_CODE, + EXPORT_SUBSIDIARY_COMMENT_CODES, OKATO_COUNTRY_PAIRS, PARTICIPANT_TYPE_FL, PARTICIPANT_TYPE_FLCHP, @@ -389,6 +389,8 @@ def build_fixed_row_by_index( row, has_eio_block=has_eio_block, has_cp_block=has_cp_block, + operation_fields=operation_fields, + participant_fields=participant_fields, ) else: _apply_continuation_validation_rules(row) @@ -472,6 +474,14 @@ def _pick_scoped_payload_value(payload: dict[str, str], scoped_path: str) -> str return "" +def _pick_direct_payload_value(payload: dict[str, str], field_name: str) -> str: + """Возвращает только прямое поле текущего XML-блока без suffix-поиска.""" + for key, value in payload.items(): + if value and _normalize_indexed_path(key) == field_name: + return str(value).strip() + return "" + + def _apply_participant_identity_rules( row: dict[int, str], participant_fields: dict[str, str] ) -> None: @@ -633,6 +643,8 @@ def _apply_business_rules( *, has_eio_block: bool, has_cp_block: bool, + operation_fields: dict[str, str], + participant_fields: dict[str, str], ) -> None: codes = _operation_codes(row) operation_sign = _get(row, COL_OPERATION_SIGN) @@ -646,7 +658,7 @@ def _apply_business_rules( _rule_unusual_codes(row, codes) _rule_digital_rights_currency(row, operation_sign) _rule_sale_currency(row) - _rule_currency_sign(row, codes) + _rule_extra_info(row, codes, operation_fields, participant_fields) _rule_suspicious_activity(row, codes) _rule_metal_name(row) _rule_item_type(row, codes) @@ -725,10 +737,26 @@ def _rule_sale_currency(row: dict[int, str]) -> None: _set(row, COL_SALE_AMOUNT, "") -def _rule_currency_sign(row: dict[int, str], codes: set[str]) -> None: - """Правило 27: признак VO только для валютных операций 6101-6126.""" - if not codes & CURRENCY_OPERATION_CODES: - _set(row, COL_CURRENCY_SIGN, "") +def _rule_extra_info( + row: dict[int, str], + codes: set[str], + operation_fields: dict[str, str], + participant_fields: dict[str, str], +) -> None: + """Правило 34: объединяет допустимые комментарии операции и участника.""" + operation_comment = _pick_direct_payload_value(operation_fields, "Коммент") + participant_comment = "" + if codes & EXPORT_SUBSIDIARY_COMMENT_CODES: + participant_comment = _pick_direct_payload_value( + participant_fields, "КомментУчастник" + ) + + values = list( + dict.fromkeys( + value for value in (operation_comment, participant_comment) if value + ) + ) + _set(row, COL_EXTRA_INFO, "; ".join(values)) def _rule_suspicious_activity(row: dict[int, str], codes: set[str]) -> None: diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index c6bc906..9d327b8 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -9,7 +9,7 @@ "xml_path": "/ММ/ГГГГ", "source_scope": "any", "allow_short_lookup": true, - "allow_direct_mapping": true, + "allow_direct_mapping": false, "structured_value": false, "structured_group": "", "structured_role": "", @@ -665,11 +665,11 @@ "column_name": "Доп. сведения", "block": "operation_base", "report_group": "operation_parameters", - "xml_tag": "КомментУчастник", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОп/КомментУчастник", + "xml_tag": "Коммент | КомментУчастник", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/Коммент | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОп/КомментУчастник", "source_scope": "any", "allow_short_lookup": true, - "allow_direct_mapping": true, + "allow_direct_mapping": false, "structured_value": false, "structured_group": "", "structured_role": "", diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index afdc15e..4e0177f 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -937,7 +937,7 @@ def test_cp_block_is_empty_without_sved_cp_and_uses_emitent_inn_when_present() - assert row_with_cp[202] == "7707083893" -def test_currency_columns_apply_only_to_conversion_and_currency_operations() -> None: +def test_currency_sign_is_preserved_independently_of_operation_code() -> None: non_conversion = build_fixed_row_by_index( file_name="f.xml", record_id="R1", @@ -950,7 +950,7 @@ def test_currency_columns_apply_only_to_conversion_and_currency_operations() -> participant_fields={}, ) assert non_conversion[26] == "" - assert non_conversion[27] == "" + assert non_conversion[27] == "VO" conversion = build_fixed_row_by_index( file_name="f.xml", @@ -993,7 +993,95 @@ def test_currency_columns_apply_only_to_conversion_and_currency_operations() -> }, participant_fields={}, ) - assert boundary_row[27] == "" + assert boundary_row[27] == "VO" + + +def test_extra_info_uses_operation_comment() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"КодОперации": "9003", "Коммент": "Комментарий операции"}, + participant_fields={}, + ) + + assert row[34] == "Комментарий операции" + + +def test_extra_info_ignores_nested_operation_comment() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "9003", + "СведенияПереводыДС.Коммент": "Комментарий вложенного блока", + }, + participant_fields={}, + ) + + assert row[34] == "" + + +def test_extra_info_rejects_participant_comment_outside_allowed_codes() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"КодОперации": "9003"}, + participant_fields={"КомментУчастник": "Посторонний комментарий"}, + ) + + assert row[34] == "" + + +def test_extra_info_is_not_directly_mapped_on_continuation_row() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"КодОперации": "9003"}, + participant_fields={"КомментУчастник": "Посторонний комментарий"}, + is_continuation=True, + ) + + assert row[34] == "" + + +def test_extra_info_accepts_participant_comment_for_main_or_extra_code() -> None: + for operation_fields in ( + {"КодОперации": "6102"}, + {"КодОперации": "9003", "ДопКодОперации": "6126"}, + ): + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields=operation_fields, + participant_fields={"КомментУчастник": "Комментарий участника"}, + ) + + assert row[34] == "Комментарий участника" + + +def test_extra_info_joins_distinct_allowed_comments_without_duplicates() -> None: + combined = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"КодОперации": "6102", "Коммент": "Общий комментарий"}, + participant_fields={"КомментУчастник": "Комментарий участника"}, + ) + duplicate = build_fixed_row_by_index( + file_name="f.xml", + record_id="R2", + operation_index=2, + operation_fields={"КодОперации": "6102", "Коммент": "Одинаковый текст"}, + participant_fields={"КомментУчастник": "Одинаковый текст"}, + ) + + assert combined[34] == "Общий комментарий; Комментарий участника" + assert duplicate[34] == "Одинаковый текст" def test_bank_bik_columns_accept_canonical_transfer_block_spelling() -> None: