335 lines
14 KiB
Python
335 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import xml.etree.ElementTree as ET
|
||
|
||
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 == {}
|
||
assert second_document.is_continuation
|
||
assert second_document.participant_fields == {"Документ.НомДок": "DOC-2"}
|
||
assert third_document.participant_fields == {"Документ.НомДок": "DOC-3"}
|
||
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_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"
|
||
|
||
|
||
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_repeated_scalar_leaves_are_joined_without_continuation_rows() -> None:
|
||
operation = ET.fromstring(
|
||
"""
|
||
<Операция>
|
||
<ПризнНеобОперации>1106</ПризнНеобОперации>
|
||
<ПризнНеобОперации>1499</ПризнНеобОперации>
|
||
</Операция>
|
||
"""
|
||
)
|
||
|
||
fields = _extract_direct_fields(operation)
|
||
|
||
assert fields == {"ПризнНеобОперации": "1106; 1499"}
|