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""" <СообщОперКО 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", " None: xml = """<СообщОперКО>""" 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_repeated_scalar_leaves_are_joined_without_continuation_rows() -> None: operation = ET.fromstring( """ <Операция> <ПризнНеобОперации>1106 <ПризнНеобОперации>1499 """ ) fields = _extract_direct_fields(operation) assert fields == {"ПризнНеобОперации": "1106; 1499"}