SFM/tests/unit/test_parser.py
Raykov-MS 6cc68862c1 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.
2026-09-18 11:20:07 +03:00

434 lines
18 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.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"}