SFM/tests/unit/test_mapping.py
Raykov-MS 880fa5811d Stop dropping XML values in the transfer and cash blocks
The customer's ruling of 24.09.2026 is that every value present in the XML
must reach the report, taking into account which block holds it. Two defects
broke that for operation code 5016 with transfer types 4 and 5.

Wrong paths in mapping_table.json. Columns 48-63 spelled the block as
"СведенияПереводыДс" and columns 49-59 spelled the address as
"АдрМестоПриемаВыдача"; the schema uses "СведенияПереводыДС" and
"АдрМестаПриемаВыдача". Those columns carry allow_short_lookup=false, so they
can only match on a fully scoped path and never matched at all: the whole cash
receipt block (AV-BI) and the transfer status (BJ) stayed empty on every file.
The existing test passed only because it fed the same misspellings.

Rules that deleted data instead of filling it:
- Rules 38, 44-45 and 46 wiped the account columns for code 5016. The XML of
  those records carries СчетБанкПолучатель = 00000000000000000000, which is
  exactly the value the customer asked for. The rules now only substitute the
  placeholder into an empty column, and never for 5016.
- Rule 44 also wiped the correspondent accounts for operator types 3 and 5
  even when the XML had them.
- The cashless rule wiped columns 50, 61, 65, 76, 84, 95, 99 and 101 for
  transfer types 1-4, 7 and 10-14, although type 4 is by definition a transfer
  that accepts cash and must carry the receipt block.
- Rule 62 limited the transfer status to types 2, 5 and 8.
- Rule 30 limited the metal name to code C99, while the available exports only
  ever carry code A90.

Verified on the sample exports: all 16 records with code 5016 now carry the
zeros in column 45, the real account in column 46, and no invented
placeholders in columns 38 and 44.
2026-09-24 14:35:37 +03:00

1913 lines
77 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
from app.pipeline import mapping
from app.pipeline.mapping import (
FIXED_REPORT_COLUMNS,
_extract_tag_candidate_keys,
build_fixed_row,
build_fixed_row_by_index,
)
def _column_with(prefix: str) -> str:
for column in FIXED_REPORT_COLUMNS:
if column.startswith(prefix):
return column
raise AssertionError(f"Колонка с префиксом '{prefix}' не найдена")
def test_build_fixed_row_maps_known_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=2,
operation_fields={
"ИдФайл": "FILE_01",
"НомерОперация": "OP_2",
"КодОперации": "5007",
"СумОперации": "100.55",
},
participant_fields={
"ИННФЛ": "123456789012",
"Тип": "01",
},
)
assert row["Имя XML файла"] == "f.xml"
assert row["Идентификатор записи"] == "R1"
assert row["Уникальный номер операции"] == "OP_2"
assert row["Код вида операции"] == "5007"
assert row["Сумма в валюте проведения"] == "100.55"
assert row[_column_with("ИНН")] == "123456789012"
assert row["Тип участника"] == "01"
def test_build_fixed_row_keeps_missing_values_empty() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={},
)
for column in FIXED_REPORT_COLUMNS:
assert column in row
assert row["Код вида операции"] == ""
assert row[_column_with("ИНН")] == ""
def test_build_fixed_row_does_not_take_operation_inn_for_participant() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ИНН": "0000000000",
"Тип": "99",
},
participant_fields={
"ИННФЛ": "111111111111",
"Тип": "01",
},
)
assert row[_column_with("ИНН")] == "111111111111"
assert row["Тип участника"] == "01"
def test_fixed_report_columns_over_200() -> None:
assert len(FIXED_REPORT_COLUMNS) == 204
assert FIXED_REPORT_COLUMNS[126] == "Признак принадлежности к публичным лицам"
def test_build_fixed_row_uses_alternative_path_leaf_for_emitent_name() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведЦП.Эмитент.НаимЭмитентЦП.ФизЛицо.ФИОСтрока": "Эмитент ФЛ",
},
participant_fields={},
)
assert row["Наименование/ ФИО эмитента ЦП"] == "Эмитент ФЛ"
def test_build_fixed_row_maps_dotted_participant_keys() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.ИННФЛИП": "123456789012",
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.НомДок": "451099",
},
)
assert row[_column_with("ИНН")] == "123456789012"
assert row["Номер"] == "451099"
def test_build_fixed_row_splits_pipe_separated_xml_tag_values() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500",
},
)
assert row["Адрес по структуре (целый)"] == "674500, , , , , , , , "
def test_extract_tag_candidate_keys_supports_comma_separator() -> None:
candidates = _extract_tag_candidate_keys(
"КППЮЛ, ИдентификацияФЛ, ПризнакОргФормаИНБОЮЛ"
)
assert candidates == ("КППЮЛ", "ИдентификацияФЛ", "ПризнакОргФормаИНБОЮЛ")
def test_build_fixed_row_uses_first_suffix_value_from_index() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.Первый.ИННФЛИП": "111111111111",
"УчастникФЛИП.Второй.ИННФЛИП": "222222222222",
},
)
assert row[_column_with("ИНН")] == "111111111111"
def test_build_fixed_row_skips_empty_suffix_values_in_index() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.Первый.ИННФЛИП": "",
"УчастникФЛИП.Второй.ИННФЛИП": "222222222222",
},
)
assert row[_column_with("ИНН")] == "222222222222"
def test_mapping_normalizes_each_payload_path_only_constant_times(monkeypatch) -> None:
calls = 0
original = mapping._normalize_indexed_path
def counted(path: str) -> str:
nonlocal calls
calls += 1
return original(path)
monkeypatch.setattr(mapping, "_normalize_indexed_path", counted)
operation_fields = {
"НомерОперация": "OP1",
"КодОперации": "6001",
}
participant_fields = {
"УчастникЮЛ.СведЮЛ.НаимЮЛ": "АО СИРИУС",
"УчастникЮЛ.СведЮЛ.ИННЮЛ": "2820000210",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ФИОФЛИП.Фам": "Музалёв",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ИННФЛИП": "410116812922",
}
build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields=operation_fields,
participant_fields=participant_fields,
)
assert calls == 2 * (len(operation_fields) + len(participant_fields))
def test_structured_address_is_built_from_right_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва",
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская",
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1",
},
)
assert (
row["Адрес по структуре (целый)"] == "674500, 643, , , Москва, Тверская, 1, , "
)
def test_one_line_address_clears_structured_and_component_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"АдресСтрока": "г. Москва, Тверская, 1",
"Индекс": "674500",
"КодОКСМ": "643",
"Улица": "Тверская",
"Дом": "1",
},
)
assert row["Адрес в одной строке"] == "г. Москва, Тверская, 1"
assert row["Адрес по структуре (целый)"] == ""
assert row["Индекс"] == ""
assert row["Код страны"] == ""
assert row["Улица"] == ""
assert row["Дом"] == ""
def test_fio_columns_prefer_participant_fields_over_operation_common_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"Фам": "Кузнецов",
"Имя": "Сергей",
"Отч": "Владимирович",
},
participant_fields={
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Отч": "Каримович",
},
)
assert row["Фамилия"] == "Азимов"
assert row["Имя"] == "Ислом"
assert row["Отчество"] == "Каримович"
assert row["ФИО (по структуре полностью)"] == "Азимов Ислом Каримович"
def test_one_line_fio_clears_structured_and_component_fio_columns() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ФИОСтрока": "Азимов Ислом Каримович",
"Фам": "Азимов",
"Имя": "Ислом",
"Отч": "Каримович",
},
)
assert row["ФИО (в одной строке)"] == "Азимов Ислом Каримович"
assert row["ФИО (по структуре полностью)"] == ""
assert row["Фамилия"] == ""
assert row["Имя"] == ""
assert row["Отчество"] == ""
assert row["Наименование ЮЛ/ФИО ( по структуре полный)"] == ""
def test_structured_column_is_not_filled_directly_from_single_line_tag() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"АдресСтрока": "г. Москва, ул. Пример, д. 1",
},
)
assert row["Адрес в одной строке"] == "г. Москва, ул. Пример, д. 1"
assert (
row[
"Адрес ЮЛ/ Адрес места жительства (места нахождения) "
"ЕИО/Бенефициара (одной строкой)"
]
== ""
)
def test_structured_column_without_component_block_joins_candidate_values() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Индекс": "121467",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.КодОКСМ": "643",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Пункт": "Москва",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Улица": "Молодогвардейская",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Дом": "8",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Корп": "1",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Оф": "28",
},
)
assert (
row["Место государственной регистрации ЕИО/Бенефициара (структура полностью)"]
== "121467, 643, Москва, Молодогвардейская, 8, 1, 28"
)
def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.АдрРег.АдрУчредитель": (
"г. Москва, ул. Пушкина, д. 1"
),
},
)
assert row["Адрес в одной строке"] == ""
assert row[
"Адрес ЮЛ/ Адрес места жительства (места нахождения) "
"ЕИО/Бенефициара (одной строкой)"
] == ("г. Москва, ул. Пушкина, д. 1")
assert (
row[
"Адрес ЮЛ/ Адрес места жительства (места нахождения) "
"ЕИО/Бенефициара (структура полностью)"
]
== ""
)
def test_digital_rights_operation_clears_currency_amount_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодПризнОперации": "8",
"КодВал": "643",
"СумОперации": "100.00",
"СумРуб": "100.00",
},
participant_fields={},
)
assert row["Валюта"] == ""
assert row["Сумма в валюте проведения"] == ""
assert row["Сумма в рубл. эквиваленте"] == ""
def test_abnormal_operation_codes_only_for_6001() -> None:
row_without_6001 = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"ПризнНеобОперации": "1101",
},
participant_fields={},
)
assert row_without_6001["Коды призн. необычной операции"] == ""
row_with_6001 = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "6001",
"ПризнНеобОперации": "1101",
},
participant_fields={},
)
assert row_with_6001["Коды призн. необычной операции"] == "1101"
def test_transfer_accounts_get_placeholder_when_required() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ВидПереводДС": "1",
"ТипОператорДС": "4",
},
participant_fields={},
)
assert row["Номер счета плательщика"] == "00000000000000000000"
assert row["Номер счета банка плательщика"] == "00000000000000000000"
assert row["Номер счета банка получателя"] == "00000000000000000000"
assert row["Номер счета получателя"] == "00000000000000000000"
def test_transfer_accounts_stay_empty_without_transfer_block() -> None:
row = build_fixed_row_by_index(
file_name="SKO115FZ_01_044525111_20260810_000001.xml",
record_id="R1",
operation_index=1,
operation_fields={
"НомерОперация": "ЦФТ-БАНК_1361233603059",
"ПризнакОперации": "5",
"СведенияВнесениеПолучениеНалДС.СуммаНал": "1000.00",
},
participant_fields={},
)
assert row[38] == ""
assert row[44] == ""
assert row[45] == ""
assert row[46] == ""
def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ТипОператорДС": "3",
"НаимКО": "Банк плательщика",
"СведБанкПолучатель.НаимКО": "Банк получателя",
"СведБанкПолучатель.БИККО": "044525225",
"СчетБанкПлательщик": "30101810000000000001",
"СчетБанкПолучатель": "30101810000000000002",
},
participant_fields={},
)
assert row["Наименование банка плательщика"] == ""
assert row["Наименование банка получателя"] == ""
assert row["БИК банка получателя"] == ""
# Корсчета при типе оператора 3 заглушкой не заполняются, но и не
# затираются: заказчик 24.09.2026 потребовал выводить всё, что пришло
# в XML. Раньше эти два значения удалялись.
assert row["Номер счета банка плательщика"] == "30101810000000000001"
assert row["Номер счета банка получателя"] == "30101810000000000002"
def test_correspondent_accounts_stay_empty_for_operator_3_without_xml_values() -> None:
"""Обратная сторона предыдущего теста: если в XML корсчетов нет, при типе
оператора 3 заглушка по-прежнему не подставляется."""
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ТипОператорДС": "3", "ВидПереводДС": "1"},
participant_fields={},
)
assert row["Номер счета банка плательщика"] == ""
assert row["Номер счета банка получателя"] == ""
def test_col36_territory_code_5016_cleared_when_operation_is_not_5016() -> None:
"""Правило 36: КодТерИнГос (п.41 спецификации) — только для кода 5016."""
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"КодТерИнГос": "840",
},
participant_fields={},
)
assert row["Код территории (5016)"] == ""
def test_col96_territory_code_card_filled_for_1009_regardless_of_5016() -> None:
"""Правило 59 спецификации: КодТерИнБанк — для кода 1009 (карта ин. банка),
не зависит от того, есть ли в записи код 5016. Раньше колонка ошибочно
чистилась тем же условием, что и колонка 36 (код 5016), и всегда
оставалась пустой для 1009-записей."""
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
"СведенияКартаИнБанк.КодТерИнБанк": "410",
},
participant_fields={},
)
assert row["Код территории"] == "410"
assert row["Код территории (5016)"] == ""
def test_col103_and_105_fill_info_absent_when_no_employee_and_empty() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ПризнакСотрудник": "0",
},
participant_fields={},
)
assert row["Сведения о держателе платежной карты"] == "Информация отсутствует"
assert row["Наименование иностранного банка"] == "Информация отсутствует"
def test_col103_and_105_keep_original_values_when_present() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ПризнакСотрудник": "0",
"СведДержательКарты": "Иванов И.И.",
"НаимИнБанк": "FOREIGN BANK",
},
participant_fields={},
)
assert row["Сведения о держателе платежной карты"] == "Иванов И.И."
assert row["Наименование иностранного банка"] == "FOREIGN BANK"
def test_patronymic_supports_typo_tag_ocht() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Очт": "Каримович",
},
)
assert row["Отчество"] == "Каримович"
def test_col124_cleared_for_physical_person_and_ip() -> None:
row_fl = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ТипФЛЧастнаяПрактика": "1"},
participant_fields={"ТипУчастника": "2"},
)
assert row_fl["Признак филиала: / Тип ФЛЧП"] == ""
row_ip = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ТипФЛЧастнаяПрактика": "1"},
participant_fields={"ТипУчастника": "3"},
)
assert row_ip["Признак филиала: / Тип ФЛЧП"] == ""
def test_col125_snils_cleared_for_ip() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={"ТипУчастника": "3", "СНИЛСФЛ": "123-456-789 00"},
)
assert row["ОМС/СНИЛС/Номер телефона"] == ""
def test_one_line_address_columns_cleared_when_participant_is_resident_client() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ПризнУчастника": "1",
"ПризнКлиент": "1",
"АдресСтрока": "г. Москва, ул. Тестовая, д. 1",
},
participant_fields={},
)
assert row["Адрес в одной строке"] == ""
assert row["Место государственной регистрации ( в одной строке)"] == ""
assert (
row["Место государственной регистрации ЕИО/Бенефициара (одной строкой)"] == ""
)
def test_eio_one_line_address_obeys_participant_residency_rule() -> None:
participant_fields = {
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.АдресСтрока": "г. Москва, ул. Тестовая",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "101000",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.КодОКСМ": "643",
}
resident_client = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ПризнУчастника": "1", "ПризнКлиент": "1"},
participant_fields=participant_fields,
)
nonresident = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={"ПризнУчастника": "0", "ПризнКлиент": "1"},
participant_fields=participant_fields,
)
assert resident_client[167] == ""
assert resident_client[169] == "101000"
assert resident_client[170] == "643"
assert nonresident[167] == "г. Москва, ул. Тестовая"
def test_foreign_structure_founder_address_populates_eio_address_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.Индекс": "101000",
"УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.КодОКСМ": "643",
"УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.Пункт": "Москва",
},
)
assert row[167] == ""
assert row[168] == "101000, 643, , , Москва, , , , "
assert row[169] == "101000"
assert row[170] == "643"
assert row[173] == "Москва"
def test_foreign_structure_founder_one_line_address_activates_eio_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ПризнУчастника": "0"},
participant_fields={
"УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.АдресСтрока": (
"г. Москва, ул. Пушкина, д. 1"
),
},
)
assert row[167] == "г. Москва, ул. Пушкина, д. 1"
assert row[168] == ""
def test_okato_cleared_when_country_code_is_not_russia() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОКСМ": "840",
"КодСубъектаПоОКАТО": "45",
},
participant_fields={},
)
assert row["Код ОКАТО"] == ""
assert row["ОКАТО"] == ""
def test_col163_ogrn_eio_benef_cleared_for_physical_person() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ОГРНИП": "123456789012345"},
participant_fields={"ТипУчастника": "2"},
)
assert row["ОГРН ЕИО/Бенефициара"] == ""
def test_dul_sign_for_legal_entity_does_not_use_operation_doc_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ДокУдЛичн": "451099", "ТипУчастника": "1"},
participant_fields={},
)
assert row["Признак ДУЛ"] == ""
def test_dul_columns_128_134_keep_expected_positions() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ВидДокКод": "21",
"СерияДок": "0104",
"НомДок": "476635",
"КемВыданДок": "ОВД",
"КодПодр": "222-022",
"ДатВыдачиДок": "23/08/2005",
"ДокУдЛичн": "1",
},
)
assert row["ДУЛ (структура полностью)"] == "1, 21, 0104, 476635, ОВД, 222-022"
assert row["Признак ДУЛ"] == "1"
assert row["Код вида документа/Наименование"] == "21"
assert row["Серия"] == "0104"
assert row["Номер"] == "476635"
assert row["Орган, выдавший документ"] == "ОВД"
assert row["КП"] == "222-022"
def test_col136_does_not_use_identity_document_type_without_stay_document() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.ВидДокКод": "21",
},
)
assert row[136] == ""
def test_col136_uses_stay_document_type_instead_of_identity_document_type() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.ВидДокКод": "21",
"УчастникФЛИП.СведФЛИП.СведДокПреб.ВидДокКод": "10",
},
)
assert row[136] == "10"
def test_col136_rejects_stay_document_type_from_unrelated_participant_path() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ДругойБлок.СведДокПреб.ВидДокКод": "99",
},
)
assert row[136] == ""
def test_bank_bik_columns_use_transfer_bank_paths_instead_of_common_bikko() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"БИККО": "044525111",
"ТипОператорДС": "4",
"СвединияПереводыДС.СведБанкПлательщик.БИККО": "011012100",
"СвединияПереводыДС.СведБанкПолучатель.БИККО": "022202220",
},
participant_fields={},
)
assert row["БИК банка плательщика"] == "011012100"
assert row["БИК банка получателя"] == "022202220"
def test_transfer_bank_fields_do_not_leak_into_unrelated_bank_blocks() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ТипОператорДС": "2",
"ВидПереводДС": "1",
"СведенияПереводыДС.СведБанкПлательщик.НаимКО": "ПАО Сбербанк",
"СведенияПереводыДС.СведБанкПлательщик.БИККО": "044525225",
},
participant_fields={},
)
assert row[40] == "ПАО Сбербанк"
assert row[41] == "044525225"
for column_index in (60, 61, 75, 76, 94, 95, 100, 101):
assert row[column_index] == ""
def test_cash_bank_fields_fill_only_their_own_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.НаимКО": "АО Наличный банк",
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.БИККО": "040407777",
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.КодОКСМ": "643",
},
participant_fields={},
)
assert row[94] == "АО Наличный банк"
assert row[95] == "040407777"
for column_index in (40, 41, 42, 43, 60, 61, 75, 76, 98, 100, 101):
assert row[column_index] == ""
def test_citizenship_country_code_uses_only_direct_svedflip_value() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "762",
},
)
assert row["Код страны гражданства"] == "762"
def test_citizenship_country_code_is_empty_when_only_address_code_exists() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643"},
)
assert row["Код страны гражданства"] == ""
def test_okato_column_4_uses_branch_okatofl_and_not_bank_okatoko() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ОКАТОКО": "45",
"ОКАТОФл": "10",
"АдресСтрока": "г. Москва",
},
participant_fields={},
)
assert FIXED_REPORT_COLUMNS[3] == "ОКАТО"
assert row["ОКАТО"] == "10"
def test_col9_uses_operation_number_from_xml() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=7,
operation_fields={"НомерОперация": "ЦФТ-БАНК_1177326777530"},
participant_fields={},
)
assert row[9] == "ЦФТ-БАНК_1177326777530"
def test_operation_documents_keep_positions_and_join_multiple_documents() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ОснованиеОп[0].КодДок": "13",
"ОснованиеОп[0].ДатаДок": "29/01/2026",
"ОснованиеОп[0].НомДок": "253",
"ОснованиеОп[0].СодДок": "Размещение денежных средств",
"ОснованиеОп[1].КодДок": "99",
"ОснованиеОп[1].ИноеНаимДок": "Депозитный договор",
"ОснованиеОп[1].ДатаДок": "29/01/2026",
"ОснованиеОп[1].НомДок": "Р202307/0001/93/Э",
"ОснованиеОп[2].КодДок": "99",
"ОснованиеОп[2].ИноеНаимДок": "Общие условия размещения депозитов",
"ОснованиеОп[2].ДатаДок": "17/11/2020",
"ОснованиеОп[2].НомДок": "Р202307/0001",
},
participant_fields={},
)
assert row[107] == (
"1. 13, , , 29/01/2026, 253, Размещение денежных средств;\n"
"2. 99, , Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ;\n"
"3. 99, , Общие условия размещения депозитов, 17/11/2020, Р202307/0001, ;"
)
def test_structured_cash_address_does_not_include_bank_fields() -> None:
"""Ключи здесь записаны так, как их отдаёт парсер по схеме
SKO115FZ_OPER_v2.xsd: блок называется "СведенияПереводыДС" (обе последние
буквы заглавные), а адрес — "АдрМестаПриемаВыдача". Раньше и тест, и
mapping_table.json содержали одни и те же опечатки ("СведенияПереводыДс",
"АдрМестоПриемаВыдача"), поэтому тест проходил, а на реальных файлах весь
блок приёма наличных оставался пустым."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.Индекс": "675520",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.КодОКСМ": "643",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.КодСубъектаПоОКАТО": "10",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.Пункт": "Чигири с",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.Улица": "Зеленая ул",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.Дом": "1",
"СведенияПереводыДС.СведПриемНалДС.БИККО": "044525111",
"СведенияПереводыДС.СведПриемНалДС.НаимКО": "АО РСХБ",
},
participant_fields={},
)
assert row[50] == "675520, 643, 10, , Чигири с, Зеленая ул, 1, , "
assert "044525111" not in row[50]
assert "АО РСХБ" not in row[50]
assert row[61] == "044525111"
assert row[60] == "АО РСХБ"
def test_participant_structured_address_stays_in_142_and_components_143_plus() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "123456",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.КодСубъектаПоОКАТО": "45",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва",
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская",
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1",
},
)
assert row[141] == ""
assert row[142] == "123456, 643, 45, , Москва, Тверская, 1, , "
assert row[143] == "123456"
assert row[144] == "643"
def test_shagidova_address_keeps_missing_component_positions() -> None:
row = build_fixed_row_by_index(
file_name="SKO115FZ_01_044525111_20260810_000001.xml",
record_id="R1",
operation_index=1,
operation_fields={"НомерОперация": "ЦФТ-БАНК_1361449333033"},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "367014",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.КодСубъектаПоОКАТО": "82",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Махачкала г.",
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Гаджиева А ул",
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "12 Ж",
"УчастникФЛИП.СведФЛИП.АдрРег.Оф": "29",
},
)
assert row[142] == ("367014, 643, 82, , Махачкала г., Гаджиева А ул, 12 Ж, , 29")
assert tuple(row[index] for index in range(143, 152)) == (
"367014",
"643",
"82",
"",
"Махачкала г.",
"Гаджиева А ул",
"12 Ж",
"",
"29",
)
def test_participant_full_address_keeps_all_nine_components() -> None:
components = {
"Индекс": "367014",
"КодОКСМ": "643",
"КодСубъектаПоОКАТО": "82",
"Район": "Советский р-н",
"Пункт": "Махачкала г.",
"Улица": "Гаджиева А ул",
"Дом": "12 Ж",
"Корп": "2",
"Оф": "29",
}
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
f"УчастникФЛИП.СведФЛИП.АдрРег.{tag}": value
for tag, value in components.items()
},
)
assert row[142] == ", ".join(components.values())
assert tuple(row[index] for index in range(143, 152)) == tuple(components.values())
def test_eio_address_keeps_missing_component_positions() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "367014",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.КодОКСМ": "643",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.КодСубъектаПоОКАТО": "82",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Пункт": "Махачкала г.",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Улица": "Гаджиева А ул",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Дом": "12 Ж",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Оф": "29",
},
)
assert row[168] == ("367014, 643, 82, , Махачкала г., Гаджиева А ул, 12 Ж, , 29")
assert tuple(row[index] for index in range(169, 178)) == (
"367014",
"643",
"82",
"",
"Махачкала г.",
"Гаджиева А ул",
"12 Ж",
"",
"29",
)
def test_participant_address_does_not_use_citizenship_without_address_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "3",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "643",
},
)
assert row[126] == "643"
assert row[142] == ""
assert row[144] == ""
def test_participant_address_country_is_isolated_from_citizenship() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "2",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "762",
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "123456",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва",
},
)
assert row[126] == "762"
assert row[142] == "123456, 643, , , Москва, , , , "
assert row[144] == "643"
def test_participant_address_components_reject_unrelated_address_fields() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ДругойАдрес.Индекс": "999999",
"ДругойАдрес.Район": "Чужой район",
"ДругойАдрес.Дом": "9",
},
participant_fields={
"ТипУчастника": "3",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "643",
},
)
assert row[142] == ""
assert row[143] == ""
assert row[144] == ""
assert row[146] == ""
assert row[149] == ""
def test_col153_uses_only_legal_entity_registration_address_components() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"КодОКСМ": "643", "Индекс": "999999"},
participant_fields={
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Индекс": "675520",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодОКСМ": "643",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО": "10",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район": "Благовещенский р-н",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Пункт": "Чигири с",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Улица": "Зеленая ул",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Дом": "1",
},
)
assert row[153] == "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1"
def test_col153_is_cleared_when_registration_address_one_line_exists() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.АдресСтрока": "Москва, Тверская, 1",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Индекс": "123456",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодОКСМ": "643",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Пункт": "Москва",
},
)
assert row[152] == "Москва, Тверская, 1"
assert row[153] == ""
def test_col179_is_cleared_when_eio_registration_one_line_exists() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдресСтрока": "Москва, Арбат, 1",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.Индекс": "123456",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.КодОКСМ": "643",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.Пункт": "Москва",
},
)
assert row[178] == "Москва, Арбат, 1"
assert row[179] == ""
def test_col179_keeps_structured_eio_registration_without_one_line() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.Индекс": "123456",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.КодОКСМ": "643",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРегЮЛ.Пункт": "Москва",
},
)
assert row[178] == ""
assert row[179] == "123456, 643, Москва"
def test_cash_columns_keep_xml_values_for_any_transfer_type() -> None:
"""Раньше для видов перевода 1-4, 7 и 10-14 колонки 50, 61, 65, 76, 84,
95, 99 и 101 очищались как "безналичные". Заказчик 24.09.2026 указал, что
в отчёт должны попадать все значения из XML, а для вида перевода 4 блок
приёма наличных (включая БИК банка плательщика) обязателен."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ВидПереводДС": "4",
"КодОперации": "5016",
"СведенияПереводыДС.СведПриемНалДС.БИККО": "044525111",
"СведенияПереводыДС.СведПриемНалДС.АдрМестаПриемаВыдача.Индекс": "675520",
},
participant_fields={},
)
assert row[61] == "044525111"
assert row[51] == "675520"
assert row[50].startswith("675520")
def test_cash_columns_stay_empty_when_xml_has_no_cash_block() -> None:
"""Проверяем, что удаление правила "безналичных" не начало выдумывать
значения: без блоков наличных колонки остаются пустыми."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ВидПереводДС": "1", "КодОперации": "9003"},
participant_fields={},
)
for index in (48, 50, 61, 63, 65, 76, 84, 95, 99, 101):
assert row[index] == "", index
def test_col119_is_empty_for_legal_entity_and_uses_flip_identifier() -> None:
row_ul = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={"ТипУчастника": "1", "ИдентификацияФЛ": "1"},
)
assert row_ul[119] == ""
row_flip = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "3",
"УчастникФЛИП.ИдентификацияФЛ": "1",
},
)
assert row_flip[119] == "1"
def test_physical_person_type_2_does_not_fill_cols_119_and_122() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"СтатусУчастника": "3",
"ТипУчастника": "2",
"УчастникФЛИП.ИдентификацияФЛ": "1",
"УчастникФЛИП.СведФЛИП.ИННФЛИП": "410116812922",
},
)
assert row[119] == ""
assert row[120] == "410116812922"
assert row[122] == ""
def test_col123_uses_registration_date_for_legal_entity() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "1",
"УчастникЮЛ.СведЮЛ.ДатаРегЮЛ": "23/08/2005",
"УчастникФЛИП.СведФЛИП.ДатаРождения": "01/01/1990",
},
)
assert row[123] == "23/08/2005"
def test_eio_block_is_empty_when_eio_and_beneficiary_are_absent() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "1",
"УчастникЮЛ.СведЮЛ.ИННЮЛ": "2820000210",
"УчастникЮЛ.СведЮЛ.ОГРНЮЛ": "1022801198573",
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО": "45",
},
)
for index in (157, 162, 163, 168, 171):
assert row[index] == ""
def test_eio_other_info_combines_snils_and_phone() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ФЛЕИО.СНИЛСФЛ": "123-456-789 00",
"УчастникЮЛ.СведЕИО.ФЛЕИО.ТелефонНомер": "+7 999 123-45-67",
"УчастникЮЛ.СведЕИО.ФЛЕИО.ДатаРождения": "03/07/1984",
"УчастникЮЛ.СведЕИО.ФЛЕИО.ТипФЛЧастнаяПрактика": "2",
"УчастникЮЛ.СведЕИО.ФЛЕИО.РегнНомер": "77-12345",
},
)
assert row[164] == (
"СНИЛС: 123-456-789 00\n"
"ОМС: \n"
"Номер телефона: +7 999 123-45-67\n"
"Дата рождения: 03/07/1984\n"
"Тип ФЛ, занимающегося частной практикой: 2\n"
"Регистрационный номер: 77-12345\n"
"Дата регистрации: "
)
def test_eio_other_info_stays_empty_for_beneficiary_only_fields() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СНИЛСФЛ": "074-115-177 38",
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ДатаРождения": "18/02/1981",
},
)
assert row[164] == ""
def test_participant_and_beneficiary_inn_are_scoped_regardless_of_field_order() -> None:
own_inn = ("УчастникЮЛ.СведЮЛ.ИННЮЛ", "2820000210")
beneficiary_fields = (
("УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ФИОФЛИП.Фам", "Иванов"),
("УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ФИОФЛИП.Имя", "Иван"),
("УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ФИОФЛИП.Отч", "Иванович"),
("УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ИННФЛИП", "111111111111"),
)
for items in (
(own_inn, *beneficiary_fields),
(*reversed(beneficiary_fields), own_inn),
):
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields=dict(items),
)
assert row[120] == "2820000210"
assert row[157] == "Иванов Иван Иванович"
assert row[162] == "111111111111"
assert "111111111111" not in row[157]
def test_legal_entity_and_eio_representative_inn_remain_strictly_scoped() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЮЛ.НаимЮЛ": "АО СИРИУС",
"УчастникЮЛ.СведЮЛ.ИННЮЛ": "2820000210",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ФИОФЛИП.Фам": "Музалёв",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ФИОФЛИП.Имя": "Александр",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ФИОФЛИП.Отч": "Сергеевич",
"УчастникЮЛ.СведЕИО[0].ФЛЕИО.ИННФЛИП": "410116812922",
},
)
assert row[120] == "2820000210"
assert row[157] == "Музалёв Александр Сергеевич"
assert row[162] == "410116812922"
def test_flip_beneficiary_is_scoped_to_eio_columns() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.ИННФЛИП": "222222222222",
"УчастникФЛИП.БенефициарФЛИП.ФЛБенефициар.ИННФЛИП": "111111111111",
},
)
assert row[120] == "222222222222"
assert row[162] == "111111111111"
def test_legal_entity_eio_name_is_preserved_in_structured_column() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.СведЕИО.ЮЛЕИО.НаимЮЛ": "ООО Управляющая компания",
"УчастникЮЛ.СведЕИО.ЮЛЕИО.ИННЮЛ": "7707083893",
},
)
assert row[157] == "ООО Управляющая компания"
assert row[162] == "7707083893"
def test_continuation_row_validates_local_codes_without_filling_defaults() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ПризнФТр": "9"},
participant_fields={},
is_continuation=True,
)
assert row[10] == ""
assert row[44] == ""
assert row[45] == ""
def test_cp_block_is_empty_without_sved_cp_and_uses_emitent_inn_when_present() -> None:
row_without_cp = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={"ИННЮЛ": "2820000210"},
)
assert row_without_cp[202] == ""
row_with_cp = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"СведЦП.Эмитент.ИННЭмитентЦП": "7707083893"},
participant_fields={"ИННЮЛ": "2820000210"},
)
assert row_with_cp[202] == "7707083893"
def test_currency_sign_is_preserved_independently_of_operation_code() -> None:
non_conversion = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "6001",
"СумКонверсия": "0.00",
"ПризнВалОперации": "VO",
},
participant_fields={},
)
assert non_conversion[26] == ""
assert non_conversion[27] == "VO"
conversion = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={
"КодОперации": "6101",
"КодВалКонверсия": "978",
"СумКонверсия": "100.50",
"ПризнВалОперации": "VO",
},
participant_fields={},
)
assert conversion[26] == "100.50"
assert conversion[27] == "VO"
zero_conversion = build_fixed_row_by_index(
file_name="f.xml",
record_id="R3",
operation_index=3,
operation_fields={
"КодОперации": "6126",
"КодВалКонверсия": "840",
"СумКонверсия": "0.00",
"ПризнВалОперации": "VO",
},
participant_fields={},
)
assert zero_conversion[26] == ""
assert zero_conversion[27] == "VO"
for operation_code in ("6100", "6127"):
boundary_row = build_fixed_row_by_index(
file_name="f.xml",
record_id=operation_code,
operation_index=4,
operation_fields={
"КодОперации": operation_code,
"ПризнВалОперации": "VO",
},
participant_fields={},
)
assert boundary_row[27] == "VO"
def test_extra_info_uses_operation_comment() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"КодОперации": "9003", "Коммент": "Комментарий операции"},
participant_fields={},
)
assert row[34] == "Комментарий операции"
def test_extra_info_ignores_nested_operation_comment() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "9003",
"СведенияПереводыДС.Коммент": "Комментарий вложенного блока",
},
participant_fields={},
)
assert row[34] == ""
def test_extra_info_rejects_participant_comment_outside_allowed_codes() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"КодОперации": "9003"},
participant_fields={"КомментУчастник": "Посторонний комментарий"},
)
assert row[34] == ""
def test_extra_info_is_not_directly_mapped_on_continuation_row() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"КодОперации": "9003"},
participant_fields={"КомментУчастник": "Посторонний комментарий"},
is_continuation=True,
)
assert row[34] == ""
def test_extra_info_accepts_participant_comment_for_main_or_extra_code() -> None:
for operation_fields in (
{"КодОперации": "6102"},
{"КодОперации": "9003", "ДопКодОперации": "6126"},
):
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields=operation_fields,
participant_fields={"КомментУчастник": "Комментарий участника"},
)
assert row[34] == "Комментарий участника"
def test_extra_info_joins_distinct_allowed_comments_without_duplicates() -> None:
combined = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"КодОперации": "6102", "Коммент": "Общий комментарий"},
participant_fields={"КомментУчастник": "Комментарий участника"},
)
duplicate = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={"КодОперации": "6102", "Коммент": "Одинаковый текст"},
participant_fields={"КомментУчастник": "Одинаковый текст"},
)
assert combined[34] == "Общий комментарий; Комментарий участника"
assert duplicate[34] == "Одинаковый текст"
def test_bank_bik_columns_accept_canonical_transfer_block_spelling() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ТипОператорДС": "4",
"СведенияПереводыДС.СведБанкПлательщик.БИККО": "011012100",
"СведенияПереводыДС.СведБанкПолучатель.БИККО": "022202220",
},
participant_fields={},
)
assert row[41] == "011012100"
assert row[43] == "022202220"
def test_legal_entity_blocks_do_not_use_physical_person_identification() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "4",
"УчастникЮЛ.СведЮЛ.НаимЮЛ": "ООО Тест",
"ИдентификацияФЛ": "1",
},
)
assert row[119] == ""
assert row[122] == ""
def test_col124_is_empty_for_private_practice_participant_type_4() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "4",
"УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1",
},
)
assert row[124] == ""
padded_type_row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={},
participant_fields={
"ТипУчастника": "04",
"УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1",
},
)
assert padded_type_row[124] == ""
def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() -> None:
participant_fields = {
"ТипУчастника": "2",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Отч": "Каримович",
"УчастникФЛИП.ИдентификацияФЛ": "1",
}
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields=participant_fields,
)
assert row[114] == "Азимов Ислом Каримович"
assert row[119] == ""
assert row[122] == ""
row_with_one_line_fio = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={},
participant_fields={
**participant_fields,
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.ФИОСтрока": ("Азимов Ислом Каримович"),
},
)
assert row_with_one_line_fio[119] == ""
assert row_with_one_line_fio[122] == ""
def test_col48_atm_number_not_leaked_from_foreign_card_block() -> None:
"""Регрессия: legacy_048 (AV, cash_receipt) раньше подхватывал ИдТерминал
из совсем другого блока (СведенияКартаИнБанк, foreign_card) через
короткий поиск по голому имени тега. Реальный кейс из отчёта заказчика:
операция 1009, ИдТерминал только в СведенияКартаИнБанк.СведМестоОперация.
"""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
"СведенияКартаИнБанк.СведМестоОперация.ИдТерминал": "S1AM9302",
},
participant_fields={},
)
assert row[97] == "S1AM9302" # CS, foreign_card — должно быть тут
assert row[48] == "" # AV, cash_receipt — тут быть не должно
assert row[82] == "" # CD, cash_operation — тоже не должно
def test_col38_44_45_payer_accounts_blank_for_5016() -> None:
"""Правка п.6: для кода операции 5016 номера счетов (плательщика и
корсчетов) не подставляются заглушкой из нулей — такого блока для 5016
в спецификации нет. Номер счёта получателя (46), если он реально пришёл
в XML, не трогаем."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5016",
"ВидПереводДС": "1",
"ТипОператорДС": "2",
"НомерСчетПолучатель": "40817810106240020211",
},
participant_fields={},
)
assert row[38] == "" # Номер счета плательщика
assert row[44] == "" # Номер счета банка плательщика
assert row[45] == "" # Номер счета банка получателя
assert row[46] == "40817810106240020211" # реальные данные не трогаем
def test_col45_keeps_zero_account_from_xml_for_5016() -> None:
"""Заказчик 24.09.2026: "в эти операциях не тянутся нули в колонку счет
банка получателя". В XML операций 5016 реально лежит
СчетБанкПолучатель = 00000000000000000000, а прежняя ветка правил 44-45
затирала всю пару корсчетов по коду 5016."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5016",
"ВидПереводДС": "5",
"ТипОператорДС": "2",
"СчетБанкПолучатель": "00000000000000000000",
},
participant_fields={},
)
assert row[45] == "00000000000000000000"
# Заглушки для 5016 по-прежнему не подставляются.
assert row[38] == ""
assert row[44] == ""
assert row[46] == ""
def test_cash_receipt_and_status_filled_for_5016_transfer_type_5() -> None:
"""Заказчик 24.09.2026: при виде перевода 5 должны быть заполнены блок
приёма наличных, блок выдачи наличных и статус перевода — колонки AV-BX,
то есть 48-76, в зависимости от наличия сведений."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5016",
"ВидПереводДС": "5",
"ТипОператорДС": "2",
"СведенияПереводыДС.СведПриемНалДС.ИдТерминал": "ATM-111",
"СведенияПереводыДС.СведПриемНалДС.НаимКО": "Банк приёма",
"СведенияПереводыДС.СведПриемНалДС.БИККО": "040813733",
"СведенияПереводыДС.СтатусПеревод": "1",
"СведенияПереводыДС.СведВыдачаНалДС.ИдТерминал": "ATM-222",
"СведенияПереводыДС.СведВыдачаНалДС.НаимКО": "Банк выдачи",
"СведенияПереводыДС.СведВыдачаНалДС.БИККО": "041012731",
},
participant_fields={},
)
assert row[48] == "ATM-111"
assert row[60] == "Банк приёма"
assert row[61] == "040813733"
assert row[62] == "1"
assert row[63] == "ATM-222"
assert row[75] == "Банк выдачи"
assert row[76] == "041012731"
def test_transfer_status_is_not_limited_to_transfer_types_2_5_8() -> None:
"""Прежнее правило 62 выводило статус только для видов перевода 2, 5 и 8.
По общему правилу заказчика статус выводится всегда, когда тег есть
в XML."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ВидПереводДС": "1",
"СведенияПереводыДС.СтатусПеревод": "0",
},
participant_fields={},
)
assert row[62] == "0"
def test_metal_name_is_not_limited_to_code_c99() -> None:
"""Прежнее правило 30 выводило наименование металла только при коде C99.
В доступных выгрузках встречается код A90, и по общему правилу заказчика
наименование должно попадать в отчёт при любом коде."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведДрагМеталл.КодДрагМеталл": "A90",
"СведДрагМеталл.НаимДрагМеталл": "Золото",
},
participant_fields={},
)
assert row[29] == "A90"
assert row[30] == "Золото"
def test_col38_44_payer_accounts_still_get_placeholder_without_5016() -> None:
"""Убеждаемся, что правка п.6 не задела остальные виды перевода —
заглушка по-прежнему подставляется, если 5016 нет в кодах операции."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"ВидПереводДС": "1",
"ТипОператорДС": "2",
},
participant_fields={},
)
assert row[38] == "00000000000000000000"
assert row[44] == "00000000000000000000"
def test_col183_eio_series_column_named_correctly() -> None:
"""Регрессия: колонка 183 (СерияДок ЕИО/Бенефициара) раньше называлась
"Номер", хотя реально тянет серию документа — при этом соседняя, 184-я,
тоже называлась "Номер" (и действительно тянет номер). Проверяем и
заголовок, и что данные при этом были и остаются на своих местах."""
assert FIXED_REPORT_COLUMNS[182] == "Серия"
assert FIXED_REPORT_COLUMNS[183] == "Номер"
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.СерияДок": "4018",
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.НомДок": "044762",
},
)
assert row[183] == "4018" # Серия
assert row[184] == "044762" # Номер
def test_col99_foreign_card_address_header_renamed() -> None:
"""Заказчик попросил переименовать заголовок колонки 99 (адрес по
структуре, блок СведенияКартаИнБанк, код 1009) — было "Адрес места
приема наличных денежных средств (по структуре полный)", стало с учётом
того, что операция может быть как приёмом, так и выдачей наличных."""
assert (
FIXED_REPORT_COLUMNS[98]
== "Адрес места приема (выдачи) наличных денежных средств "
"(по структуре полный)"
)
def test_col99_foreign_card_address_assembled_from_structured_components() -> None:
"""Регрессия: у блока АдрМестаПриемаВыдача в СведенияКартаИнБанк (код
1009) нет единого тега АдресСтрока, в отличие от одноимённого блока в
СведенияВнесениеПолучениеНалДС — колонка 99 раньше мапилась напрямую на
несуществующий АдресСтрока и оставалась пустой. Значение должно
собираться из компонентов адреса, разложенных в XML по структуре."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
"СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.КодОКСМ": "840",
"СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Пункт": "Нью-Йорк",
"СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Улица": "Уолл-стрит",
"СведенияКартаИнБанк.СведМестоОперация.АдрМестаПриемаВыдача.Дом": "11",
},
participant_fields={},
)
assert row[99] == "840, Нью-Йорк, Уолл-стрит, 11"
def test_col99_foreign_card_address_not_leaked_from_participant_address() -> None:
"""Регрессия: allow_short_lookup для колонки 99 должен быть выключен —
иначе адрес участника операции (другой блок, тот же тег 'Улица') может
случайно попасть в адрес места приёма/выдачи по карте ин. банка."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
},
participant_fields={
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Улица": "Совсем другая улица",
},
)
assert row[99] == ""
def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None:
"""Регрессия: колонка 96 раньше входила в группу заголовков блока
'своих' кассовых операций (cash_operation/own_account_cash), из-за чего
визуально 'прилипала' не к своему блоку. Теперь должна входить в ту же
report_group, что и соседние колонки блока СведенияКартаИнБанк (97+)."""
from app.pipeline.column_registry import COLUMNS
columns_by_index = {meta.index: meta for meta in COLUMNS.ordered_columns()}
assert columns_by_index[96].block == "foreign_card"
assert columns_by_index[96].report_group == columns_by_index[97].report_group
groups = COLUMNS.report_groups()
foreign_card_group = next(g for g in groups if g.start_index <= 97 <= g.end_index)
assert foreign_card_group.start_index == 96