Fix column 99: assemble foreign card address from structured components

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.
This commit is contained in:
Raykov-MS 2026-09-18 11:09:31 +03:00
parent 4e11db322d
commit 34bb0f3d29
5 changed files with 134 additions and 4 deletions

View File

@ -47,6 +47,7 @@ COL_TRANSFER_STATUS = _index(62)
# Карты и место операции. # Карты и место операции.
COL_TERRITORY_CODE_CARD = _index(96) COL_TERRITORY_CODE_CARD = _index(96)
COL_FOREIGN_CARD_ADDRESS_STRUCTURED = _index(99)
COL_CARD_HOLDER_INFO = _index(103) COL_CARD_HOLDER_INFO = _index(103)
COL_EMPLOYEE_SIGN = _index(104) COL_EMPLOYEE_SIGN = _index(104)
COL_FOREIGN_BANK_NAME = _index(105) COL_FOREIGN_BANK_NAME = _index(105)

View File

@ -31,6 +31,7 @@ from .column_constants import (
COL_EXTRA_INFO, COL_EXTRA_INFO,
COL_FILE_NAME, COL_FILE_NAME,
COL_FOREIGN_BANK_NAME, COL_FOREIGN_BANK_NAME,
COL_FOREIGN_CARD_ADDRESS_STRUCTURED,
COL_FTR_SIGN, COL_FTR_SIGN,
COL_IDENT_FL, COL_IDENT_FL,
COL_INN, COL_INN,
@ -290,6 +291,32 @@ def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...
candidates = [ candidates = [
f"{base_path}.{tag}" for base_path in base_paths for tag in address_tags 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: elif index == COL_CP_ISSUER_INN:
candidates = [ candidates = [
candidate candidate

View File

@ -1965,10 +1965,10 @@
"column_name": "Адрес места приема (выдачи) наличных денежных средств (по структуре полный)", "column_name": "Адрес места приема (выдачи) наличных денежных средств (по структуре полный)",
"block": "foreign_card", "block": "foreign_card",
"report_group": "foreign_card_operation", "report_group": "foreign_card_operation",
"xml_tag": "АдрМестаПриемаВыдача", "xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": true, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": true, "structured_value": true,
"structured_group": "", "structured_group": "",
@ -1977,7 +1977,7 @@
"join_with_space": false, "join_with_space": false,
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" "comment": "У блока АдрМестаПриемаВыдача в СведенияКартаИнБанк нет единого тега АдресСтрока (в отличие от одноимённого блока в СведенияВнесениеПолучениеНалДС) — значение собирается из компонентов адреса (см. COL_FOREIGN_CARD_ADDRESS_STRUCTURED в mapping.py), поле может остаться пустым"
}, },
{ {
"index": 100, "index": 100,

View File

@ -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: def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None:
"""Регрессия: колонка 96 раньше входила в группу заголовков блока """Регрессия: колонка 96 раньше входила в группу заголовков блока
'своих' кассовых операций (cash_operation/own_account_cash), из-за чего 'своих' кассовых операций (cash_operation/own_account_cash), из-за чего

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import xml.etree.ElementTree as ET 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 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" 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: def test_repeated_scalar_leaves_are_joined_without_continuation_rows() -> None:
operation = ET.fromstring( operation = ET.fromstring(
""" """