SFM/tests/unit/test_parser.py
Raykov-MS 7aeca7a45a .
2026-08-24 12:22:33 +03:00

372 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 == 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"
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"}