diff --git a/app/pipeline/column_constants.py b/app/pipeline/column_constants.py index b50d994..6049b06 100644 --- a/app/pipeline/column_constants.py +++ b/app/pipeline/column_constants.py @@ -47,6 +47,7 @@ COL_TRANSFER_STATUS = _index(62) # Карты и место операции. COL_TERRITORY_CODE_CARD = _index(96) +COL_FOREIGN_CARD_ADDRESS_STRUCTURED = _index(99) COL_CARD_HOLDER_INFO = _index(103) COL_EMPLOYEE_SIGN = _index(104) COL_FOREIGN_BANK_NAME = _index(105) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 2406aa7..19d04da 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -31,6 +31,7 @@ from .column_constants import ( COL_EXTRA_INFO, COL_FILE_NAME, COL_FOREIGN_BANK_NAME, + COL_FOREIGN_CARD_ADDRESS_STRUCTURED, COL_FTR_SIGN, COL_IDENT_FL, COL_INN, @@ -290,6 +291,32 @@ def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ... candidates = [ f"{base_path}.{tag}" for base_path in base_paths for tag in address_tags ] + elif index == COL_FOREIGN_CARD_ADDRESS_STRUCTURED: + # legacy_099: адрес места приёма/выдачи по карте иностранного + # банка (код 1009) приходит в XML разложенным по тем же тегам + # АдрМестаПриемаВыдача, что и другие адресные блоки (см. + # COL_REG_PLACE_STRUCTURED выше) — АдресСтрока как единого поля + # для этого блока в схеме нет, поэтому колонку нужно собирать + # из компонентов, а не мапить напрямую на несуществующий тег. + base_paths = ( + "СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача", + "СообщОперКО.ИнформЧасть.СведКО.Операция.СведенияКартаИнБанк." + "СведМестоОперация.АдрМестаПриемаВыдача", + ) + address_tags = ( + "Индекс", + "КодОКСМ", + "КодСубъектаПоОКАТО", + "Район", + "Пункт", + "Улица", + "Дом", + "Корп", + "Оф", + ) + candidates = [ + f"{base_path}.{tag}" for base_path in base_paths for tag in address_tags + ] elif index == COL_CP_ISSUER_INN: candidates = [ candidate diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index 3436426..4c8dfbb 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -1965,10 +1965,10 @@ "column_name": "Адрес места приема (выдачи) наличных денежных средств (по структуре полный)", "block": "foreign_card", "report_group": "foreign_card_operation", - "xml_tag": "АдрМестаПриемаВыдача", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока", + "xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/", "source_scope": "any", - "allow_short_lookup": true, + "allow_short_lookup": false, "allow_direct_mapping": true, "structured_value": true, "structured_group": "", @@ -1977,7 +1977,7 @@ "join_with_space": false, "multiplies": "нет", "required": "необязательно", - "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" + "comment": "У блока АдрМестаПриемаВыдача в СведенияКартаИнБанк нет единого тега АдресСтрока (в отличие от одноимённого блока в СведенияВнесениеПолучениеНалДС) — значение собирается из компонентов адреса (см. COL_FOREIGN_CARD_ADDRESS_STRUCTURED в mapping.py), поле может остаться пустым" }, { "index": 100, diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 17f10e7..1d08418 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -1720,6 +1720,48 @@ def test_col99_foreign_card_address_header_renamed() -> None: ) +def test_col99_foreign_card_address_assembled_from_structured_components() -> None: + """Регрессия: у блока АдрМестаПриемаВыдача в СведенияКартаИнБанк (код + 1009) нет единого тега АдресСтрока, в отличие от одноимённого блока в + СведенияВнесениеПолучениеНалДС — колонка 99 раньше мапилась напрямую на + несуществующий АдресСтрока и оставалась пустой. Значение должно + собираться из компонентов адреса, разложенных в XML по структуре.""" + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "1009", + "СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.КодОКСМ": "840", + "СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Пункт": "Нью-Йорк", + "СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Улица": "Уолл-стрит", + "СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Дом": "11", + }, + participant_fields={}, + ) + + assert row[99] == "840, Нью-Йорк, Уолл-стрит, 11" + + +def test_col99_foreign_card_address_not_leaked_from_participant_address() -> None: + """Регрессия: allow_short_lookup для колонки 99 должен быть выключен — + иначе адрес участника операции (другой блок, тот же тег 'Улица') может + случайно попасть в адрес места приёма/выдачи по карте ин. банка.""" + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "1009", + }, + participant_fields={ + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Улица": "Совсем другая улица", + }, + ) + + assert row[99] == "" + + def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None: """Регрессия: колонка 96 раньше входила в группу заголовков блока 'своих' кассовых операций (cash_operation/own_account_cash), из-за чего diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index c96af05..b476aad 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -2,6 +2,7 @@ from __future__ import annotations import xml.etree.ElementTree as ET +from app.pipeline.mapping import build_fixed_row_by_index from app.pipeline.parser import _extract_direct_fields, parse_xml_content @@ -358,6 +359,65 @@ def test_mixed_operation_and_eio_repeats_are_additive_not_cartesian() -> None: assert result.rows[0].operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2" +def test_1009_foreign_card_address_survives_full_pipeline() -> None: + """Сквозная проверка на синтетическом файле: реальный XML -> parse_xml_content + -> build_fixed_row_by_index. Адрес места приёма/выдачи по карте + иностранного банка (код 1009) должен собраться из компонентов + АдрМестаПриемаВыдача в колонку 99, а не потеряться и не подхватить + чужой адрес участника операции (тот же тег 'Улица' в другом блоке).""" + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP1 + <КодОперации>1009 + <СведенияКартаИнБанк> + <НомерКарта>5536********1234 + <НаимИнБанк>Bank of Testland + <СведМестоОперация> + <ИдТерминал>ATM00042 + <НаимКО>First Test Bank + <АдрМестаПриемаВыдача> + <Индекс>10001 + <КодОКСМ>840 + <Пункт>Нью-Йорк + <Улица>Уолл-стрит + <Дом>11 + + + + <УчастникОп> + <Тип>01 + <УчастникЮЛ> + <СведЮЛ> + <АдрРегЮЛ> + <Улица>Совсем другая улица участника + + + + + +""" + ) + + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert not result.is_fatal + assert len(result.rows) == 1 + + row = result.rows[0] + fixed_row = build_fixed_row_by_index( + file_name="SKO115FZ_01_123456789_20260616_X00001.xml", + record_id=row.record_id, + operation_index=1, + operation_fields=row.operation_fields, + participant_fields=row.participant_fields, + ) + + assert fixed_row[97] == "ATM00042" + assert fixed_row[98] == "840" + assert fixed_row[99] == "10001, 840, Нью-Йорк, Уолл-стрит, 11" + assert fixed_row[100] == "First Test Bank" + + def test_repeated_scalar_leaves_are_joined_without_continuation_rows() -> None: operation = ET.fromstring( """