This commit is contained in:
Raykov-MS 2026-08-11 01:09:07 +03:00
parent d6b3e73cb7
commit 9968d84e3b
11 changed files with 2560 additions and 54 deletions

View File

@ -14,4 +14,3 @@ repos:
rev: v0.12.7 rev: v0.12.7
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format

View File

@ -102,6 +102,13 @@ class StructuredGroupRule:
_INVALID_TAGS = {"", "-", "Источник", "путь", "подразумеваются", "тэга", "нашла"} _INVALID_TAGS = {"", "-", "Источник", "путь", "подразумеваются", "тэга", "нашла"}
_INDEXED_PATH_SEGMENT = re.compile(r"\[\d+\]")
_EIO_BLOCK_NAMES = (
"СведЕИО",
"БенефициарЮЛ",
"БенефициарФЛИП",
"БенефициарИНБОЮЛ",
)
def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]: def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]:
@ -307,6 +314,7 @@ def build_fixed_row_by_index(
operation_index: int, operation_index: int,
operation_fields: dict[str, str], operation_fields: dict[str, str],
participant_fields: dict[str, str], participant_fields: dict[str, str],
is_continuation: bool = False,
) -> dict[int, str]: ) -> dict[int, str]:
row: dict[int, str] = { row: dict[int, str] = {
COL_FILE_NAME: file_name, COL_FILE_NAME: file_name,
@ -315,14 +323,17 @@ def build_fixed_row_by_index(
merged = _merge_fields(operation_fields, participant_fields) merged = _merge_fields(operation_fields, participant_fields)
merged_suffix_index = _build_suffix_index(merged) merged_suffix_index = _build_suffix_index(merged)
operation_suffix_index = _build_suffix_index(operation_fields) operation_suffix_index = _build_suffix_index(operation_fields)
participant_suffix_index = _build_suffix_index(participant_fields)
has_eio_block = _has_any_prefixed_key( has_eio_block = _has_any_prefixed_key(
participant_fields, participant_fields,
( (
"УчастникЮЛ.СведЕИО.", "УчастникЮЛ.СведЕИО.",
"УчастникЮЛ.БенефициарЮЛ.", "УчастникЮЛ.БенефициарЮЛ.",
"УчастникФЛИП.БенефициарФЛИП.",
"УчастникИНБОЮЛ.БенефициарИНБОЮЛ.",
"СведЕИО.", "СведЕИО.",
"БенефициарЮЛ.", "БенефициарЮЛ.",
"БенефициарФЛИП.",
"БенефициарИНБОЮЛ.",
), ),
) )
has_cp_block = _has_any_prefixed_key( has_cp_block = _has_any_prefixed_key(
@ -339,8 +350,11 @@ def build_fixed_row_by_index(
row[rule.column_index] = "" row[rule.column_index] = ""
continue continue
if rule.source == "participant": if rule.source == "participant":
source_payload = participant_fields source_payload = _scope_participant_payload(
suffix_index = participant_suffix_index participant_fields,
eio_only=rule.column_index in BLOCK_EIO,
)
suffix_index = _build_suffix_index(source_payload)
elif rule.source == "operation": elif rule.source == "operation":
source_payload = operation_fields source_payload = operation_fields
suffix_index = operation_suffix_index suffix_index = operation_suffix_index
@ -362,13 +376,17 @@ def build_fixed_row_by_index(
rule.candidate_keys, rule.candidate_keys,
allow_short_lookup=rule.allow_short_lookup, allow_short_lookup=rule.allow_short_lookup,
) )
if not is_continuation:
_apply_participant_identity_rules(row, participant_fields) _apply_participant_identity_rules(row, participant_fields)
_apply_structured_group_rules(row) _apply_structured_group_rules(row)
if not is_continuation:
_apply_business_rules( _apply_business_rules(
row, row,
has_eio_block=has_eio_block, has_eio_block=has_eio_block,
has_cp_block=has_cp_block, has_cp_block=has_cp_block,
) )
else:
_apply_continuation_validation_rules(row)
for meta in COLUMNS.ordered_columns(): for meta in COLUMNS.ordered_columns():
row.setdefault(meta.index, "") row.setdefault(meta.index, "")
return row return row
@ -381,6 +399,7 @@ def build_fixed_row(
operation_index: int, operation_index: int,
operation_fields: dict[str, str], operation_fields: dict[str, str],
participant_fields: dict[str, str], participant_fields: dict[str, str],
is_continuation: bool = False,
) -> dict[str, str]: ) -> dict[str, str]:
by_index = build_fixed_row_by_index( by_index = build_fixed_row_by_index(
file_name=file_name, file_name=file_name,
@ -388,6 +407,7 @@ def build_fixed_row(
operation_index=operation_index, operation_index=operation_index,
operation_fields=operation_fields, operation_fields=operation_fields,
participant_fields=participant_fields, participant_fields=participant_fields,
is_continuation=is_continuation,
) )
by_name: dict[str, str] = {} by_name: dict[str, str] = {}
for meta in COLUMNS.ordered_columns(): for meta in COLUMNS.ordered_columns():
@ -412,7 +432,6 @@ def _apply_structured_group_rules(row: dict[int, str]) -> None:
if _get(row, component_column_index).strip() if _get(row, component_column_index).strip()
] ]
if not values: if not values:
_set(row, rule.structured_column, "")
continue continue
separator = " " if rule.join_with_space else ", " separator = " " if rule.join_with_space else ", "
@ -432,13 +451,18 @@ def _merge_fields(
def _has_payload_block(payload: dict[str, str], block_name: str) -> bool: def _has_payload_block(payload: dict[str, str], block_name: str) -> bool:
prefix = f"{block_name}." prefix = f"{block_name}."
nested_marker = f".{block_name}." nested_marker = f".{block_name}."
return any(key.startswith(prefix) or nested_marker in key for key in payload) return any(
(normalized := _normalize_indexed_path(key)).startswith(prefix)
or nested_marker in normalized
for key in payload
)
def _pick_scoped_payload_value(payload: dict[str, str], scoped_path: str) -> str: def _pick_scoped_payload_value(payload: dict[str, str], scoped_path: str) -> str:
nested_suffix = f".{scoped_path}" nested_suffix = f".{scoped_path}"
for key, value in payload.items(): for key, value in payload.items():
if value and (key == scoped_path or key.endswith(nested_suffix)): normalized = _normalize_indexed_path(key)
if value and (normalized == scoped_path or normalized.endswith(nested_suffix)):
return str(value).strip() return str(value).strip()
return "" return ""
@ -478,7 +502,33 @@ def _apply_participant_identity_rules(
def _has_any_prefixed_key(payload: dict[str, str], prefixes: tuple[str, ...]) -> bool: def _has_any_prefixed_key(payload: dict[str, str], prefixes: tuple[str, ...]) -> bool:
return any(any(key.startswith(prefix) for prefix in prefixes) for key in payload) return any(
any(_normalize_indexed_path(key).startswith(prefix) for prefix in prefixes)
for key in payload
)
def _normalize_indexed_path(path: str) -> str:
"""Убирает индексы повторов, сохраняя логическую структуру XML-пути."""
return _INDEXED_PATH_SEGMENT.sub("", path)
def _is_eio_path(path: str) -> bool:
normalized = _normalize_indexed_path(path)
return any(
f".{block_name}." in f".{normalized}." for block_name in _EIO_BLOCK_NAMES
)
def _scope_participant_payload(
payload: dict[str, str],
*,
eio_only: bool,
) -> dict[str, str]:
"""Изолирует поля участника от вложенного блока ЕИО/бенефициара."""
return {
key: value for key, value in payload.items() if _is_eio_path(key) is eio_only
}
def _build_suffix_index(payload: dict[str, str]) -> dict[str, str]: def _build_suffix_index(payload: dict[str, str]) -> dict[str, str]:
@ -501,6 +551,14 @@ def _pick_value(
for key in candidates: for key in candidates:
if value := payload.get(key): if value := payload.get(key):
return value return value
normalized_key = _normalize_indexed_path(key)
for payload_key, value in payload.items():
normalized_payload_key = _normalize_indexed_path(payload_key)
if value and (
normalized_payload_key == normalized_key
or normalized_payload_key.endswith(f".{normalized_key}")
):
return value
if not allow_short_lookup: if not allow_short_lookup:
continue continue
short = key.rsplit(".", maxsplit=1)[-1] short = key.rsplit(".", maxsplit=1)[-1]
@ -613,6 +671,13 @@ def _apply_business_rules(
_clear_columns(row, BLOCK_CP) _clear_columns(row, BLOCK_CP)
def _apply_continuation_validation_rules(row: dict[int, str]) -> None:
"""Проверяет локальные коды повтора без подстановки значений основной строки."""
_rule_ftr_sign(row)
_rule_suspension_basis(row)
_rule_esp_sign(row)
def _rule_ftr_sign(row: dict[int, str]) -> None: def _rule_ftr_sign(row: dict[int, str]) -> None:
"""Правило 10: оставляем только допустимые значения признака ФТР.""" """Правило 10: оставляем только допустимые значения признака ФТР."""
if _get(row, COL_FTR_SIGN) not in VALID_FTR_SIGNS: if _get(row, COL_FTR_SIGN) not in VALID_FTR_SIGNS:

View File

@ -3126,7 +3126,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "НаимЮЛ/ФИОФЛИП/НаимУчредитель", "xml_tag": "НаимЮЛ/ФИОФЛИП/НаимУчредитель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ЮрЛицо | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Отч", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/НаимЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Отч",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": true, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3146,7 +3146,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "ФИОФЛИП", "xml_tag": "ФИОФЛИП",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/ФИОСтрока", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/ФИОСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/ФИОСтрока",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3166,7 +3166,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Фам", "xml_tag": "Фам",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Фам",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3186,7 +3186,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Имя", "xml_tag": "Имя",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Имя", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Имя",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3206,7 +3206,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Отч", "xml_tag": "Отч",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Отч", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Отч",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3226,14 +3226,14 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "ИННЮЛ/ИННФЛИП", "xml_tag": "ИННЮЛ/ИННФЛИП",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ИННЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ИННФЛИП", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/ИННЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ИННФЛИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ИННФЛИП",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "legacy_157", "structured_group": "",
"structured_role": "component", "structured_role": "",
"structured_order": 4, "structured_order": 0,
"join_with_space": false, "join_with_space": false,
"multiplies": "нет", "multiplies": "нет",
"required": "обязательно", "required": "обязательно",
@ -3246,7 +3246,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "ОГРНЮЛ|ОГРНИП|ТипФЛЧастнаяПрактика", "xml_tag": "ОГРНЮЛ|ОГРНИП|ТипФЛЧастнаяПрактика",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ТипФЛЧастнаяПрактика", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ТипФЛЧастнаяПрактика | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ТипФЛЧастнаяПрактика",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3285,10 +3285,10 @@
"column_name": "Код страны гражданства ЕИО/Бенефициара", "column_name": "Код страны гражданства ЕИО/Бенефициара",
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "", "xml_tag": "КодОКСМ",
"xml_path": "", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/КодОКСМ",
"source_scope": "any", "source_scope": "participant",
"allow_short_lookup": true, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "", "structured_group": "",
@ -3306,7 +3306,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "ПризнакПубЛицо", "xml_tag": "ПризнакПубЛицо",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ПризнакПубЛицо", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ПризнакПубЛицо | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ПризнакПубЛицо",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3326,7 +3326,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "АдресСтрока|АдрУчредитель", "xml_tag": "АдресСтрока|АдрУчредитель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": true, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3346,7 +3346,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф|АдрУчредитель", "xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф|АдрУчредитель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": true, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3366,7 +3366,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Индекс", "xml_tag": "Индекс",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Индекс", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Индекс",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3386,7 +3386,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "КодОКСМ", "xml_tag": "КодОКСМ",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/КодОКСМ", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/КодОКСМ",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3406,7 +3406,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "КодСубъектаПоОКАТО", "xml_tag": "КодСубъектаПоОКАТО",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/КодСубъектаПоОКАТО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/КодСубъектаПоОКАТО",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3426,7 +3426,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Район", "xml_tag": "Район",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Район", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Район",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3446,7 +3446,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Пункт", "xml_tag": "Пункт",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Пункт", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Пункт",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3466,7 +3466,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Улица", "xml_tag": "Улица",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Улица", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Улица",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3486,7 +3486,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Дом", "xml_tag": "Дом",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Дом", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Дом",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3506,7 +3506,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Корп", "xml_tag": "Корп",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Корп", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Корп",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3526,7 +3526,7 @@
"block": "eio", "block": "eio",
"report_group": "eio", "report_group": "eio",
"xml_tag": "Оф", "xml_tag": "Оф",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Оф", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Оф",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,

View File

@ -1,11 +1,21 @@
from __future__ import annotations from __future__ import annotations
import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Iterable from typing import Iterable
from .config import validate_file_name as validate_pipeline_file_name from .config import validate_file_name as validate_pipeline_file_name
_INDEXED_SEGMENT = re.compile(r"(?P<segment>[^.]+\[(?P<index>\d+)\])")
_EIO_SEGMENT = re.compile(
r"(?P<segment>"
r"(?:СведЕИО|БенефициарЮЛ|БенефициарФЛИП|БенефициарИНБОЮЛ)"
r"(?:\[(?P<index>\d+)\])?"
r")"
)
@dataclass @dataclass
class ParticipantRow: class ParticipantRow:
@ -14,6 +24,7 @@ class ParticipantRow:
record_id: str record_id: str
operation_fields: dict[str, str] operation_fields: dict[str, str]
participant_fields: dict[str, str] participant_fields: dict[str, str]
is_continuation: bool = False
@dataclass @dataclass
@ -69,28 +80,30 @@ def parse_xml_content(file_name: str, xml_content: bytes | str) -> FileParseResu
) )
operation_fields = {**common_fields, **operation_fields} operation_fields = {**common_fields, **operation_fields}
record_id = _extract_record_id(operation_fields, index) record_id = _extract_record_id(operation_fields, index)
operation_rows = _expand_indexed_fields(operation_fields)
participants = _find_children_by_name(operation, "УчастникОп") participants = _find_children_by_name(operation, "УчастникОп")
if participants: if participants:
for participant in participants: for participant in participants:
participant_fields = _extract_direct_fields(participant) participant_fields = _extract_direct_fields(participant)
result.rows.append( participant_rows = _expand_indexed_fields(participant_fields)
ParticipantRow( result.rows.extend(
_build_expanded_rows(
file_name=file_name, file_name=file_name,
operation_index=index, operation_index=index,
record_id=record_id, record_id=record_id,
operation_fields=operation_fields, operation_rows=operation_rows,
participant_fields=participant_fields, participant_rows=participant_rows,
) )
) )
else: else:
result.rows.append( result.rows.extend(
ParticipantRow( _build_expanded_rows(
file_name=file_name, file_name=file_name,
operation_index=index, operation_index=index,
record_id=record_id, record_id=record_id,
operation_fields=operation_fields, operation_rows=operation_rows,
participant_fields={}, participant_rows=[{}],
) )
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
@ -126,16 +139,117 @@ def _extract_direct_fields(
) -> dict[str, str]: ) -> dict[str, str]:
excluded = set(excluded_tags or ()) excluded = set(excluded_tags or ())
fields: dict[str, str] = {} fields: dict[str, str] = {}
children = [child for child in element if _normalize_tag(child.tag) not in excluded]
counts = Counter(_normalize_tag(child.tag) for child in children)
positions: Counter[str] = Counter()
for child in element: for child in children:
tag = _normalize_tag(child.tag) tag = _normalize_tag(child.tag)
if tag in excluded: position = positions[tag]
continue positions[tag] += 1
_collect_leaf_fields(child, tag, fields) path = f"{tag}[{position}]" if counts[tag] > 1 else tag
_collect_leaf_fields(child, path, fields)
return fields return fields
def _build_expanded_rows(
*,
file_name: str,
operation_index: int,
record_id: str,
operation_rows: list[dict[str, str]],
participant_rows: list[dict[str, str]],
) -> list[ParticipantRow]:
"""Строит основную и независимые continuation-строки без декартова умножения."""
rows = [
ParticipantRow(
file_name=file_name,
operation_index=operation_index,
record_id=record_id,
operation_fields=operation_rows[0],
participant_fields=participant_rows[0],
)
]
rows.extend(
ParticipantRow(
file_name=file_name,
operation_index=operation_index,
record_id=record_id,
operation_fields=operation_fields,
participant_fields={},
is_continuation=True,
)
for operation_fields in operation_rows[1:]
)
rows.extend(
ParticipantRow(
file_name=file_name,
operation_index=operation_index,
record_id=record_id,
operation_fields={},
participant_fields=participant_fields,
is_continuation=True,
)
for participant_fields in participant_rows[1:]
)
return rows
def _expand_indexed_fields(
fields: dict[str, str],
*,
group_eio: bool = True,
) -> list[dict[str, str]]:
"""Разворачивает индексированные sibling-блоки в аддитивные наборы полей."""
base_fields: dict[str, str] = {}
families: dict[str, dict[int, dict[str, str]]] = {}
eio_occurrences: dict[str, int] = {}
for key, value in fields.items():
eio_match = _EIO_SEGMENT.search(key) if group_eio else None
match = eio_match or _INDEXED_SEGMENT.search(key)
if match is None:
base_fields[key] = value
continue
indexed_segment = match.group("segment")
family_segment = re.sub(r"\[\d+\]$", "", indexed_segment)
if eio_match is not None:
occurrence_key = f"{key[: match.start()]}{indexed_segment}"
occurrence = eio_occurrences.setdefault(
occurrence_key,
len(eio_occurrences),
)
family = "__eio_or_beneficiary__"
else:
family = f"{key[: match.start()]}{family_segment}"
occurrence = int(match.group("index"))
deindexed_key = f"{key[: match.start()]}{family_segment}{key[match.end() :]}"
families.setdefault(family, {}).setdefault(occurrence, {})[
deindexed_key
] = value
if not families:
return [base_fields]
primary = dict(base_fields)
continuations: list[dict[str, str]] = []
for occurrences in families.values():
for position, occurrence in enumerate(sorted(occurrences)):
expanded_occurrence = _expand_indexed_fields(
occurrences[occurrence],
group_eio=False,
)
if position == 0:
primary.update(expanded_occurrence[0])
continuations.extend(expanded_occurrence[1:])
else:
continuations.extend(expanded_occurrence)
return [primary, *continuations]
def _extract_record_id(operation_fields: dict[str, str], operation_index: int) -> str: def _extract_record_id(operation_fields: dict[str, str], operation_index: int) -> str:
possible_keys = ( possible_keys = (
"ИдентификаторЗаписи", "ИдентификаторЗаписи",
@ -206,9 +320,16 @@ def _collect_leaf_fields(
_append_value(fields, current_path, (element.text or "").strip()) _append_value(fields, current_path, (element.text or "").strip())
return return
counts = Counter(_normalize_tag(child.tag) for child in children)
positions: Counter[str] = Counter()
for child in children: for child in children:
child_name = _normalize_tag(child.tag) child_name = _normalize_tag(child.tag)
_collect_leaf_fields(child, f"{current_path}.{child_name}", fields) position = positions[child_name]
positions[child_name] += 1
child_path = (
f"{child_name}[{position}]" if counts[child_name] > 1 else child_name
)
_collect_leaf_fields(child, f"{current_path}.{child_path}", fields)
def _collect_attributes( def _collect_attributes(

View File

@ -28,6 +28,7 @@ class ReportRow:
operation_index: int operation_index: int
operation_fields: dict[str, str] operation_fields: dict[str, str]
participant_fields: dict[str, str] participant_fields: dict[str, str]
is_continuation: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@ -93,6 +94,7 @@ class StreamingReportWriter:
operation_index=row.operation_index, operation_index=row.operation_index,
operation_fields=row.operation_fields, operation_fields=row.operation_fields,
participant_fields=row.participant_fields, participant_fields=row.participant_fields,
is_continuation=row.is_continuation,
) )
for output_index, column_name in enumerate(self.columns, start=1): for output_index, column_name in enumerate(self.columns, start=1):
zero_based_index = output_index - 1 zero_based_index = output_index - 1

View File

@ -329,6 +329,7 @@ def _process_files(
operation_index=row.operation_index, operation_index=row.operation_index,
operation_fields=row.operation_fields, operation_fields=row.operation_fields,
participant_fields=row.participant_fields, participant_fields=row.participant_fields,
is_continuation=row.is_continuation,
) )
) )
stage_timings["report_append_seconds"] += ( stage_timings["report_append_seconds"] += (

View File

@ -0,0 +1,660 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:include schemaLocation="data_types_v2.xsd"/>
<xs:element name="СообщОперКО">
<xs:annotation>
<xs:documentation>
Сообщение об операциях, обязательному контролю, о подозрительных операциях, а также об операциях,
приостановленных в соответствии с пунктом 10 статьи 7 и (или) пунктом 8 статьи 7.5 Федерального закона № 115-ФЗ
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="СлужЧасть" type="СлужЧастьТип">
<xs:annotation>
<xs:documentation>Служебная часть электронного сообщения</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ИнформЧасть">
<xs:annotation>
<xs:documentation>Информационная часть электронного сообщения</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="ИнфБанк" type="Банк">
<xs:annotation>
<xs:documentation>
Информация о кредитной организации (филиале кредитной организации), передающей (передающем) сведения
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведКО" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="ПризнакПредставСвед">
<xs:annotation>
<xs:documentation>
Признак представления сведений в уполномоченный орган филиалом кредитной организации
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ИнфФилиал" type="Филиал" minOccurs="0">
<xs:annotation>
<xs:documentation>
Информация о филиале кредитной организации, представляющем сведения
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="Операция" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Сведения об операции</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="НомерЗаписи" type="ИдентификаторЗаписи">
<xs:annotation>
<xs:documentation>Номер записи в ФЭС</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ТипЗаписи">
<xs:annotation>
<xs:documentation>Тип записи</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-4]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="НомерОперация" type="Т100" minOccurs="0">
<xs:annotation>
<xs:documentation>Уникальный номер операции</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ПризнФТр" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Признак операции, связанной с финансированием терроризма
или распространения оружия массового уничтожения
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-5]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ОснПриостановление" minOccurs="0">
<xs:annotation>
<xs:documentation>Основания приостановления операции</xs:documentation>
<xs:documentation>!!! Показатель введен, начиная с версии 2.3 формата ФЭС !!!</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-3]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ДатаОперации" type="ДатаТип">
<xs:annotation>
<xs:documentation>Дата совершения (приостановления) операции </xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ДатаВыявления" type="ДатаТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Дата выявления (приостановления) операции</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="КодПризнОперации">
<xs:annotation>
<xs:documentation>Код признака операции (сделки)</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-3]|[5-9]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ПризнакОперацияЭСП" minOccurs="0">
<xs:annotation>
<xs:documentation>Признак совершения операции с использованием ЭСП</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-4]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="НаимПлатежнаяСистема1" type="Т100" minOccurs="0">
<xs:annotation>
<xs:documentation>Наименование платежной системы на стороне лица, совершающего операцию</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="НаимПлатежнаяСистема2" type="Т100" minOccurs="0">
<xs:annotation>
<xs:documentation>Наименование платежной системы на стороне получателя по операции</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ВремяТранзакцияЭСП" type="ВЧПТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Время совершения операции с использованием ЭСП</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="КодОперации">
<xs:annotation>
<xs:documentation>Код вида операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-9]{4}"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ДопКодОперации" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Дополнительный код вида операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-9]{4}"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ПризнНеобОперации" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Код признака необычной операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-9]{4}"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ПризнВалОперации" minOccurs="0">
<xs:annotation>
<xs:documentation>
Признак валютной операции.!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="VO"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ИдентификаторПД" type="ИдентификаторЗаписи" minOccurs="0">
<xs:annotation>
<xs:documentation>
Идентификатор подозрительной деятельности.!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведЦП" type="ЦифровыеПрава" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Сведения о цифровых правах</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="КодВал" type="КодВалТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Код валюты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СумОперации" type="СуммаТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Сумма операции в валюте ее проведения</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СумРуб" type="СуммаТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Сумма операции в рублевом эквиваленте</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="КодВалКонверсия" type="КодВалТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Код продаваемой валюты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СумКонверсия" type="СуммаТип" minOccurs="0">
<xs:annotation>
<xs:documentation>Сумма продаваемой валюты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведДрагМеталл" type="ДрагМеталл" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Сведения о драгоценных металлах, драгоценных камнях, ювелирных изделиях и ломе из таких изделий
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ОснованиеОп" type="Документ" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Основание совершения операции</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="НазначениеПлатежа" type="Т2000П">
<xs:annotation>
<xs:documentation>Назначение платежа</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ХарактерОп" type="Т2000П">
<xs:annotation>
<xs:documentation>Характеристика операции</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="УчастникОп" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>Сведения об участнике операции</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="СтатусУчастника">
<xs:annotation>
<xs:documentation>Код статуса участника операции (сделки)</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-4]|6|8"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ТипУчастника">
<xs:annotation>
<xs:documentation>Тип участника операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-5]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="ПризнУчастника">
<xs:annotation>
<xs:documentation>Признак резидента (нерезидента) участника операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1|9"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="IDППЦР" type="IDППЦРТип" minOccurs="0">
<xs:annotation>
<xs:documentation>
Уникальный идентификатор пользователя платформы цифрового рубля
!!! Показатель введен, начиная с версии 2.2 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ПризнКлиент">
<xs:annotation>
<xs:documentation>Признак участника операции (сделки)</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-2]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="КодЮЛФЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>
Идентификатор участника операции, включенного в Перечень, Решение, и (или) Перечень ФРОМУ
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[0-9]{1,10}"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="УчастникЮЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения о юридическом лице (филиале юридического лица, представительстве) и его единоличном исполнительном органе
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="СведЮЛ" type="СведенияЮЛ">
<xs:annotation>
<xs:documentation>
Сведения о юридическом лице (филиале (представительстве) юридического лица)
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ПризнакИдентификацияЮЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>
Признак идентификации бенефициарного владельца.
!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="БенефициарЮЛ" type="СведенияБенефициар" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведЕИО" type="СведенияЕИО" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Сведения о единоличном исполнительном органе юридического лица нерезидента
</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="УчастникФЛИП" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения о физическом лице, индивидуальном предпринимателе, физическом лице,
занимающемся в установленном законодательством Российской Федерации порядке частной практикой
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="ИдентификацияФЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>Признак идентификации физического лица</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="1|2|3"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="СведФЛИП" type="СведенияФЛИП">
<xs:annotation>
<xs:documentation>Сведения о физическом лице, индивидуальном предпринимателе</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="БенефициарФЛИП" type="СведенияБенефициар" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="УчастникИНБОЮЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения об иностранной структуре без образования юридического лица
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="СведИНБОЮЛ" type="СведенияИНБОЮЛ">
<xs:annotation>
<xs:documentation>Сведения об иностранной структуре без образования юридического лица</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ПризнакИдентификацияИНБОЮЛ" minOccurs="0">
<xs:annotation>
<xs:documentation>
Признак идентификации бенефициарного владельца.
!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="БенефициарИНБОЮЛ" type="СведенияБенефициар" minOccurs="0" maxOccurs="unbounded">
<xs:annotation>
<xs:documentation>
Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!!
</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="КомментУчастник" type="Т2000" minOccurs="0">
<xs:annotation>
<xs:documentation>Дополнительная информация об участнике операции (сделки)</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="СведенияПереводыДС" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения о переводах денежных средств, в том числе электронных денежных средств
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="ВидПереводДС">
<xs:annotation>
<xs:documentation>Вид перевода денежных средств</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-9]|1[0-4]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="КодТерИнГос" minOccurs="0" type="Т500">
<xs:annotation>
<xs:documentation>Код территории</xs:documentation>
<xs:documentation>
!!!!! Показатель добавлен в версии формата 1.2 в соответствии с Федеральным законом от 28.06.2021 № 230-ФЗ !!!!!
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ТипОператорДС">
<xs:annotation>
<xs:documentation>Тип оператора по переводу денежных средств</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[1-5]"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="НомерСчетПлательщик" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>Номер счета плательщика</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ИдентЭСППлательщик" type="Т100" minOccurs="0">
<xs:annotation>
<xs:documentation>Идентификатор ЭСП плательщика</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведБанкПлательщик" type="БанкПлательщикПолучатель" minOccurs="0">
<xs:annotation>
<xs:documentation>Сведения о банке плательщика</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведБанкПолучатель" type="БанкПлательщикПолучатель" minOccurs="0">
<xs:annotation>
<xs:documentation>Сведения о банке получателя</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СчетБанкПлательщик" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>Номер счета банка плательщика</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СчетБанкПолучатель" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>Номер счета банка получателя</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="НомерСчетПолучатель" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>Номер счета получателя</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ИдентЭСППолучателя" type="Т100" minOccurs="0">
<xs:annotation>
<xs:documentation>Идентификатор ЭСП получателя</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведПриемНалДС" type="МестоПриемаВыдача" minOccurs="0">
<xs:annotation>
<xs:documentation>Сведения о месте приема наличных денежных средств</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СтатусПеревод" minOccurs="0">
<xs:annotation>
<xs:documentation>Статус перевода</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="СведВыдачаНалДС" type="МестоПриемаВыдача" minOccurs="0">
<xs:annotation>
<xs:documentation>Сведения о месте выдачи наличных денежных средств</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="АдресIP" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>IP-адрес сетевого оборудования плательщика</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="АдресMAC" type="Т23" minOccurs="0">
<xs:annotation>
<xs:documentation>MAC-адрес сетевого оборудования плательщика</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="СведенияВнесениеПолучениеНалДС" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения о переводах денежных средств, в том числе электронных денежных средств
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="КодХарактерОп">
<xs:annotation>
<xs:documentation>Код характера операции</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="1|2"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="НомерСчетКлиент" type="Т40">
<xs:annotation>
<xs:documentation>Номер счета клиента</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="НомерКарта" type="Т40" minOccurs="0">
<xs:annotation>
<xs:documentation>Номер платежной карты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведПриемВыдача" type="МестоПриемаВыдача" minOccurs="0">
<xs:annotation>
<xs:documentation>Сведения о месте приема (выдачи) наличных денежных средств</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="СведенияКартаИнБанк" minOccurs="0">
<xs:annotation>
<xs:documentation>
Сведения об операции с использованием платежной карты, эмитированной иностранным банком,
зарегистрированным на территории иностранного государства или административно-территориальной
единицы иностранного государства, обладающей самостоятельной правоспособностью, входящих в перечень,
утвержденный уполномоченным органом (Росфинмониторингом)
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:element name="КодТерИнБанк" type="Т500">
<xs:annotation>
<xs:documentation>Код территории</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведМестоОперация" type="МестоПриемаВыдача">
<xs:annotation>
<xs:documentation>Сведения о месте совершения операции с денежными средствами в наличной форме</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="НомерКарта" type="Т40">
<xs:annotation>
<xs:documentation>Номер платежной карты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СведДержательКарты" type="Т2000">
<xs:annotation>
<xs:documentation>Сведения о держателе платежной карты</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="ПризнакСотрудник">
<xs:annotation>
<xs:documentation>Признак совершения операции с участием уполномоченного сотрудника кредитной организации (филиала кредитной организации)</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="0|1"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="НаимИнБанк" type="Т500">
<xs:annotation>
<xs:documentation>Наименование иностранного банка</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="СВИФТИнБанк" minOccurs="0">
<xs:annotation>
<xs:documentation>СВИФТ иностранного банка</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="([A-Z]{4}[A-Z]{2}[0-9A-Z]{2}([0-9A-Z]{3}|))|НР"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="Коммент" type="Т2000" minOccurs="0">
<xs:annotation>
<xs:documentation>Дополнительные сведения</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>

1440
docs/xsd/data_types_v2.xsd Normal file

File diff suppressed because it is too large Load Diff

View File

@ -775,6 +775,80 @@ def test_eio_block_is_empty_when_eio_and_beneficiary_are_absent() -> None:
assert row[index] == "" assert row[index] == ""
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_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: def test_cp_block_is_empty_without_sved_cp_and_uses_emitent_inn_when_present() -> None:
row_without_cp = build_fixed_row_by_index( row_without_cp = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from app.pipeline.parser import parse_xml_content import xml.etree.ElementTree as ET
from app.pipeline.parser import _extract_direct_fields, parse_xml_content
def _base_xml(operations: str) -> str: def _base_xml(operations: str) -> str:
@ -120,3 +122,105 @@ def test_parse_xml_collects_legal_entity_registration_address_attributes() -> No
assert ( assert (
participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р" 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_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"
}

View File

@ -302,6 +302,46 @@ def test_write_report_does_not_assume_kpp_when_participant_type_is_missing(
assert sheet.cell(row=3, column=122).value == "1" assert sheet.cell(row=3, column=122).value == "1"
def test_write_report_keeps_only_repeat_fields_on_continuation_row(
tmp_path: Path,
) -> None:
destination = tmp_path / "report.xlsx"
rows = [
ReportRow(
file_name="f.xml",
record_id="RID",
operation_index=1,
operation_fields={"КодОперации": "5007"},
participant_fields={
"ТипУчастника": "1",
"УчастникЮЛ.СведЮЛ.ИННЮЛ": "2820000210",
},
),
ReportRow(
file_name="f.xml",
record_id="RID",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ИННФЛИП": "111111111111"
},
is_continuation=True,
),
]
write_report(rows, destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
assert sheet.cell(row=3, column=19).value == "5007"
assert sheet.cell(row=3, column=120).value == "2820000210"
assert sheet.cell(row=4, column=19).value is None
assert sheet.cell(row=4, column=120).value in (None, "")
assert sheet.cell(row=4, column=162).value == "111111111111"
assert sheet.cell(row=4, column=44).value in (None, "")
assert sheet.cell(row=4, column=45).value in (None, "")
def _column_letter(index: int) -> str: def _column_letter(index: int) -> str:
value = index value = index
result = "" result = ""