From d07eedfafb3efa1f5be723c90e107386f51889db Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 18 Sep 2026 11:09:31 +0300 Subject: [PATCH] Fix column 99: assemble foreign card address from structured components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The АдрМестаПриемаВыдача block under СведенияКартаИнБанк (code 1009) has no single АдресСтрока tag, unlike the same-named block under СведенияВнесениеПолучениеНалДС — column 99 was mapped directly onto that nonexistent tag and stayed empty. It now assembles the value from the individual address components (Индекс, КодОКСМ, КодСубъектаПоОКАТО, Район, Пункт, Улица, Дом, Корп, Оф), the same way column 153 already does for registration place. Also disabled allow_short_lookup for this column, since it was previously able to pick up a same-named tag (e.g. Улица) from an unrelated address elsewhere in the file. Added regression tests at both the mapping level and a full parse_xml_content -> build_fixed_row_by_index pipeline level with a synthetic code-1009 XML. --- app/pipeline/column_constants.py | 1 + app/pipeline/mapping.py | 27 ++++++++++++++ app/pipeline/mapping_table.json | 8 ++--- tests/unit/test_mapping.py | 42 ++++++++++++++++++++++ tests/unit/test_parser.py | 60 ++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 4 deletions(-) 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( """