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.
434 lines
18 KiB
Python
434 lines
18 KiB
Python
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
|
||
|
||
|
||
def _base_xml(operations: str) -> str:
|
||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<СообщОперКО xmlns="urn:test">
|
||
<СлужЧасть><ИдФайл>ID1</ИдФайл></СлужЧасть>
|
||
<ИнформЧасть>
|
||
<ИнфБанк><БИК>123</БИК></ИнфБанк>
|
||
<СведКО>
|
||
{operations}
|
||
</СведКО>
|
||
</ИнформЧасть>
|
||
</СообщОперКО>
|
||
"""
|
||
|
||
|
||
def test_parse_xml_with_invalid_name_returns_fatal() -> None:
|
||
result = parse_xml_content("broken.xml", "<СообщОперКО/>")
|
||
assert result.is_fatal
|
||
assert "Некорректное имя XML-файла" in (result.fatal_error or "")
|
||
|
||
|
||
def test_parse_xml_with_malformed_xml_returns_fatal() -> None:
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", "<broken")
|
||
assert result.is_fatal
|
||
assert "Ошибка XML" in (result.fatal_error or "")
|
||
|
||
|
||
def test_parse_xml_without_inform_part_returns_fatal() -> None:
|
||
xml = """<?xml version="1.0" encoding="UTF-8"?><СообщОперКО></СообщОперКО>"""
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
assert result.is_fatal
|
||
assert "ИнформЧасть" in (result.fatal_error or "")
|
||
|
||
|
||
def test_parse_xml_without_participants_creates_single_row() -> None:
|
||
xml = _base_xml(
|
||
"<Операция><ИдентификаторЗаписи>OP1</ИдентификаторЗаписи><СуммаОпер>100</СуммаОпер></Операция>"
|
||
)
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
assert not result.is_fatal
|
||
assert len(result.rows) == 1
|
||
assert result.rows[0].record_id == "OP1"
|
||
|
||
|
||
def test_parse_xml_with_namespace_and_two_participants() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP2</ИдентификаторЗаписи>
|
||
<УчастникОп><Тип>01</Тип></УчастникОп>
|
||
<УчастникОп><Тип>02</Тип></УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
assert not result.is_fatal
|
||
assert len(result.rows) == 2
|
||
assert result.rows[0].participant_fields["Тип"] == "01"
|
||
assert result.rows[1].participant_fields["Тип"] == "02"
|
||
|
||
|
||
def test_parse_xml_operation_error_marks_partial(monkeypatch) -> None:
|
||
xml = _base_xml(
|
||
"<Операция><ИдентификаторЗаписи>OP3</ИдентификаторЗаписи><УчастникОп><Тип>01</Тип></УчастникОп></Операция>"
|
||
)
|
||
from app.pipeline import parser
|
||
|
||
original = parser._extract_direct_fields
|
||
|
||
def _boom(element, excluded_tags=None): # noqa: ANN001
|
||
tag_name = parser._normalize_tag(element.tag)
|
||
if tag_name == "УчастникОп":
|
||
raise ValueError("bad participant")
|
||
return original(element, excluded_tags=excluded_tags)
|
||
|
||
monkeypatch.setattr(parser, "_extract_direct_fields", _boom)
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
assert not result.is_fatal
|
||
assert result.operation_errors
|
||
assert result.operation_errors[0].record_id == "OP3"
|
||
|
||
|
||
def test_parse_xml_with_numeric_suffix_file_name_is_valid() -> None:
|
||
xml = _base_xml(
|
||
"<Операция><ИдентификаторЗаписи>OP4</ИдентификаторЗаписи><СуммаОпер>100</СуммаОпер></Операция>"
|
||
)
|
||
result = parse_xml_content("SKO115FZ_01_044525111_20251224_000051.xml", xml)
|
||
assert not result.is_fatal
|
||
assert len(result.rows) == 1
|
||
|
||
|
||
def test_parse_xml_collects_legal_entity_registration_address_attributes() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP5</ИдентификаторЗаписи>
|
||
<УчастникОп>
|
||
<УчастникЮЛ>
|
||
<СведЮЛ>
|
||
<АдрРегЮЛ Индекс="675520" КодОКСМ="643" КодСубъектаПоОКАТО="10"
|
||
Район="Благовещенский р-н" Пункт="Чигири с"
|
||
Улица="Зеленая ул" Дом="1" />
|
||
</СведЮЛ>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
assert not result.is_fatal
|
||
assert len(result.rows) == 1
|
||
participant_fields = result.rows[0].participant_fields
|
||
assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Индекс"] == "675520"
|
||
assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодОКСМ"] == "643"
|
||
assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО"] == "10"
|
||
assert (
|
||
participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р-н"
|
||
)
|
||
|
||
|
||
def test_extract_direct_fields_indexes_repeated_siblings_at_every_level() -> None:
|
||
participant = ET.fromstring(
|
||
"""
|
||
<УчастникОп>
|
||
<Документ><НомДок>DOC-1</НомДок></Документ>
|
||
<Документ><НомДок>DOC-2</НомДок></Документ>
|
||
<УчастникЮЛ>
|
||
<СведЕИО><ФЛЕИО><ИННФЛИП>111111111111</ИННФЛИП></ФЛЕИО></СведЕИО>
|
||
<СведЕИО><ФЛЕИО><ИННФЛИП>222222222222</ИННФЛИП></ФЛЕИО></СведЕИО>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
"""
|
||
)
|
||
|
||
fields = _extract_direct_fields(participant)
|
||
|
||
assert fields["Документ[0].НомДок"] == "DOC-1"
|
||
assert fields["Документ[1].НомДок"] == "DOC-2"
|
||
assert fields["УчастникЮЛ.СведЕИО[0].ФЛЕИО.ИННФЛИП"] == "111111111111"
|
||
assert fields["УчастникЮЛ.СведЕИО[1].ФЛЕИО.ИННФЛИП"] == "222222222222"
|
||
assert all("; " not in value for value in fields.values())
|
||
|
||
|
||
def test_extract_direct_fields_keeps_single_sibling_path_unindexed() -> None:
|
||
participant = ET.fromstring(
|
||
"<УчастникОп><Документ><НомДок>DOC-1</НомДок></Документ></УчастникОп>"
|
||
)
|
||
|
||
fields = _extract_direct_fields(participant)
|
||
|
||
assert fields == {"Документ.НомДок": "DOC-1"}
|
||
|
||
|
||
def test_parse_xml_separates_repeated_attribute_only_structures() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP-ATTR</ИдентификаторЗаписи>
|
||
<УчастникОп>
|
||
<ТипУчастника>1</ТипУчастника>
|
||
<Документ НомДок="DOC-1"/>
|
||
<Документ НомДок="DOC-2"/>
|
||
</УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 2
|
||
assert result.rows[0].participant_fields["Документ.НомДок"] == "DOC-1"
|
||
assert result.rows[1].participant_fields == {"Документ.НомДок": "DOC-2"}
|
||
|
||
|
||
def test_parse_xml_expands_documents_and_eio_into_additive_rows() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP6</ИдентификаторЗаписи>
|
||
<УчастникОп>
|
||
<ТипУчастника>1</ТипУчастника>
|
||
<Документ><НомДок>DOC-1</НомДок></Документ>
|
||
<Документ><НомДок>DOC-2</НомДок></Документ>
|
||
<Документ><НомДок>DOC-3</НомДок></Документ>
|
||
<УчастникЮЛ>
|
||
<СведЕИО><ФЛЕИО><ИННФЛИП>111111111111</ИННФЛИП></ФЛЕИО></СведЕИО>
|
||
<СведЕИО><ЮЛЕИО><ИННЮЛ>2222222222</ИННЮЛ></ЮЛЕИО></СведЕИО>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert not result.is_fatal
|
||
assert len(result.rows) == 4
|
||
base_row, second_document, third_document, second_eio = result.rows
|
||
assert base_row.participant_fields["ТипУчастника"] == "1"
|
||
assert base_row.participant_fields["Документ.НомДок"] == "DOC-1"
|
||
assert (
|
||
base_row.participant_fields["УчастникЮЛ.СведЕИО.ФЛЕИО.ИННФЛИП"]
|
||
== "111111111111"
|
||
)
|
||
assert second_document.operation_fields == base_row.operation_fields
|
||
assert second_document.is_continuation
|
||
assert second_document.participant_fields == {"Документ.НомДок": "DOC-2"}
|
||
assert third_document.operation_fields == base_row.operation_fields
|
||
assert third_document.participant_fields == {"Документ.НомДок": "DOC-3"}
|
||
assert second_eio.operation_fields == base_row.operation_fields
|
||
assert second_eio.participant_fields == {
|
||
"УчастникЮЛ.СведЕИО.ЮЛЕИО.ИННЮЛ": "2222222222"
|
||
}
|
||
|
||
|
||
def test_parse_xml_separates_single_eio_and_single_beneficiary() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP7</ИдентификаторЗаписи>
|
||
<УчастникОп>
|
||
<ТипУчастника>1</ТипУчастника>
|
||
<УчастникЮЛ>
|
||
<СведЕИО><ЮЛЕИО><ИННЮЛ>2222222222</ИННЮЛ></ЮЛЕИО></СведЕИО>
|
||
<БенефициарЮЛ>
|
||
<ФЛБенефициар><ИННФЛИП>111111111111</ИННФЛИП></ФЛБенефициар>
|
||
</БенефициарЮЛ>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 2
|
||
assert (
|
||
result.rows[0].participant_fields["УчастникЮЛ.СведЕИО.ЮЛЕИО.ИННЮЛ"]
|
||
== "2222222222"
|
||
)
|
||
assert result.rows[1].participant_fields == {
|
||
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ИННФЛИП": "111111111111"
|
||
}
|
||
|
||
|
||
def test_beneficiary_continuation_keeps_full_operation_context() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP-BENEFICIARY</ИдентификаторЗаписи>
|
||
<НомерОперация>ЦФТ-БАНК_1361473945637</НомерОперация>
|
||
<СумОперации>1500.00</СумОперации>
|
||
<УчастникОп>
|
||
<ТипУчастника>1</ТипУчастника>
|
||
<УчастникЮЛ>
|
||
<БенефициарЮЛ>
|
||
<ФЛБенефициар><ИННФЛИП>111111111111</ИННФЛИП></ФЛБенефициар>
|
||
</БенефициарЮЛ>
|
||
<БенефициарЮЛ>
|
||
<ФЛБенефициар><ИННФЛИП>222222222222</ИННФЛИП></ФЛБенефициар>
|
||
</БенефициарЮЛ>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 2
|
||
main_row, beneficiary_continuation = result.rows
|
||
assert beneficiary_continuation.is_continuation
|
||
assert beneficiary_continuation.operation_fields == main_row.operation_fields
|
||
assert (
|
||
beneficiary_continuation.operation_fields["НомерОперация"]
|
||
== "ЦФТ-БАНК_1361473945637"
|
||
)
|
||
assert beneficiary_continuation.operation_fields["СумОперации"] == "1500.00"
|
||
|
||
|
||
def test_operation_repeats_are_collapsed_once_for_two_participants() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP8</ИдентификаторЗаписи>
|
||
<ОснованиеОп><НомДок>BASE-1</НомДок></ОснованиеОп>
|
||
<ОснованиеОп><НомДок>BASE-2</НомДок></ОснованиеОп>
|
||
<УчастникОп><ТипУчастника>1</ТипУчастника></УчастникОп>
|
||
<УчастникОп><ТипУчастника>2</ТипУчастника></УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 2
|
||
assert [row.participant_fields.get("ТипУчастника") for row in result.rows] == [
|
||
"1",
|
||
"2",
|
||
]
|
||
for row in result.rows:
|
||
assert row.operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2"
|
||
|
||
|
||
def test_operation_repeats_are_collapsed_for_single_participant() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP9</ИдентификаторЗаписи>
|
||
<ОснованиеОп><НомДок>BASE-1</НомДок></ОснованиеОп>
|
||
<ОснованиеОп><НомДок>BASE-2</НомДок></ОснованиеОп>
|
||
<УчастникОп><ТипУчастника>1</ТипУчастника></УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 1
|
||
assert result.rows[0].operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2"
|
||
assert result.rows[0].operation_fields["ОснованиеОп[0].НомДок"] == "BASE-1"
|
||
assert result.rows[0].operation_fields["ОснованиеОп[1].НомДок"] == "BASE-2"
|
||
|
||
|
||
def test_mixed_operation_and_eio_repeats_are_additive_not_cartesian() -> None:
|
||
xml = _base_xml(
|
||
"""
|
||
<Операция>
|
||
<ИдентификаторЗаписи>OP10</ИдентификаторЗаписи>
|
||
<ОснованиеОп><НомДок>BASE-1</НомДок></ОснованиеОп>
|
||
<ОснованиеОп><НомДок>BASE-2</НомДок></ОснованиеОп>
|
||
<УчастникОп>
|
||
<ТипУчастника>1</ТипУчастника>
|
||
<УчастникЮЛ>
|
||
<СведЕИО><ФЛЕИО><ИННФЛИП>111111111111</ИННФЛИП></ФЛЕИО></СведЕИО>
|
||
<СведЕИО><ФЛЕИО><ИННФЛИП>222222222222</ИННФЛИП></ФЛЕИО></СведЕИО>
|
||
</УчастникЮЛ>
|
||
</УчастникОп>
|
||
<УчастникОп><ТипУчастника>2</ТипУчастника></УчастникОп>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml)
|
||
|
||
assert len(result.rows) == 3
|
||
assert result.rows[0].participant_fields["ТипУчастника"] == "1"
|
||
assert result.rows[1].participant_fields == {
|
||
"УчастникЮЛ.СведЕИО.ФЛЕИО.ИННФЛИП": "222222222222"
|
||
}
|
||
assert result.rows[2].participant_fields["ТипУчастника"] == "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:
|
||
operation = ET.fromstring(
|
||
"""
|
||
<Операция>
|
||
<ПризнНеобОперации>1106</ПризнНеобОперации>
|
||
<ПризнНеобОперации>1499</ПризнНеобОперации>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
fields = _extract_direct_fields(operation)
|
||
|
||
assert fields == {"ПризнНеобОперации": "1106; 1499"}
|