From 9968d84e3b3b4e2247a2d85ad36cb105bc03bc36 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Tue, 11 Aug 2026 01:09:07 +0300 Subject: [PATCH] fix --- .pre-commit-config.yaml | 1 - app/pipeline/mapping.py | 91 +- app/pipeline/mapping_table.json | 52 +- app/pipeline/parser.py | 147 +++- app/pipeline/report.py | 2 + app/pipeline/service.py | 1 + docs/xsd/SKO115FZ_OPER_v2.xsd | 660 ++++++++++++++ docs/xsd/data_types_v2.xsd | 1440 +++++++++++++++++++++++++++++++ tests/unit/test_mapping.py | 74 ++ tests/unit/test_parser.py | 106 ++- tests/unit/test_report.py | 40 + 11 files changed, 2560 insertions(+), 54 deletions(-) create mode 100644 docs/xsd/SKO115FZ_OPER_v2.xsd create mode 100644 docs/xsd/data_types_v2.xsd diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b30b4e..1b30619 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,3 @@ repos: rev: v0.12.7 hooks: - id: ruff - - id: ruff-format diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 3cfe2ad..63c954b 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -102,6 +102,13 @@ class StructuredGroupRule: _INVALID_TAGS = {"", "-", "Источник", "путь", "подразумеваются", "тэга", "нашла"} +_INDEXED_PATH_SEGMENT = re.compile(r"\[\d+\]") +_EIO_BLOCK_NAMES = ( + "СведЕИО", + "БенефициарЮЛ", + "БенефициарФЛИП", + "БенефициарИНБОЮЛ", +) def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]: @@ -307,6 +314,7 @@ def build_fixed_row_by_index( operation_index: int, operation_fields: dict[str, str], participant_fields: dict[str, str], + is_continuation: bool = False, ) -> dict[int, str]: row: dict[int, str] = { COL_FILE_NAME: file_name, @@ -315,14 +323,17 @@ def build_fixed_row_by_index( merged = _merge_fields(operation_fields, participant_fields) merged_suffix_index = _build_suffix_index(merged) operation_suffix_index = _build_suffix_index(operation_fields) - participant_suffix_index = _build_suffix_index(participant_fields) has_eio_block = _has_any_prefixed_key( participant_fields, ( "УчастникЮЛ.СведЕИО.", "УчастникЮЛ.БенефициарЮЛ.", + "УчастникФЛИП.БенефициарФЛИП.", + "УчастникИНБОЮЛ.БенефициарИНБОЮЛ.", "СведЕИО.", "БенефициарЮЛ.", + "БенефициарФЛИП.", + "БенефициарИНБОЮЛ.", ), ) has_cp_block = _has_any_prefixed_key( @@ -339,8 +350,11 @@ def build_fixed_row_by_index( row[rule.column_index] = "" continue if rule.source == "participant": - source_payload = participant_fields - suffix_index = participant_suffix_index + source_payload = _scope_participant_payload( + participant_fields, + eio_only=rule.column_index in BLOCK_EIO, + ) + suffix_index = _build_suffix_index(source_payload) elif rule.source == "operation": source_payload = operation_fields suffix_index = operation_suffix_index @@ -362,13 +376,17 @@ def build_fixed_row_by_index( rule.candidate_keys, allow_short_lookup=rule.allow_short_lookup, ) - _apply_participant_identity_rules(row, participant_fields) + if not is_continuation: + _apply_participant_identity_rules(row, participant_fields) _apply_structured_group_rules(row) - _apply_business_rules( - row, - has_eio_block=has_eio_block, - has_cp_block=has_cp_block, - ) + if not is_continuation: + _apply_business_rules( + row, + has_eio_block=has_eio_block, + has_cp_block=has_cp_block, + ) + else: + _apply_continuation_validation_rules(row) for meta in COLUMNS.ordered_columns(): row.setdefault(meta.index, "") return row @@ -381,6 +399,7 @@ def build_fixed_row( operation_index: int, operation_fields: dict[str, str], participant_fields: dict[str, str], + is_continuation: bool = False, ) -> dict[str, str]: by_index = build_fixed_row_by_index( file_name=file_name, @@ -388,6 +407,7 @@ def build_fixed_row( operation_index=operation_index, operation_fields=operation_fields, participant_fields=participant_fields, + is_continuation=is_continuation, ) by_name: dict[str, str] = {} 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 not values: - _set(row, rule.structured_column, "") continue 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: prefix = 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: nested_suffix = f".{scoped_path}" 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 "" @@ -478,7 +502,33 @@ def _apply_participant_identity_rules( 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]: @@ -501,6 +551,14 @@ def _pick_value( for key in candidates: if value := payload.get(key): 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: continue short = key.rsplit(".", maxsplit=1)[-1] @@ -613,6 +671,13 @@ def _apply_business_rules( _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: """Правило 10: оставляем только допустимые значения признака ФТР.""" if _get(row, COL_FTR_SIGN) not in VALID_FTR_SIGNS: diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index d26b273..1cc4b13 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -3126,7 +3126,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "НаимЮЛ/ФИОФЛИП/НаимУчредитель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ЮрЛицо | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/НаимУчредитель/ФизЛицо/Отч", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/НаимЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Отч", "source_scope": "participant", "allow_short_lookup": true, "allow_direct_mapping": true, @@ -3146,7 +3146,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "ФИОФЛИП", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/ФИОСтрока", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/ФИОСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/ФИОСтрока", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3166,7 +3166,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Фам", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Фам", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Фам | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Фам", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3186,7 +3186,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Имя", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Имя", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Имя | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Имя", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3206,7 +3206,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Отч", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ФИОФЛИП/Отч", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ФИОФЛИП/Отч | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ФИОФЛИП/Отч", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3226,14 +3226,14 @@ "block": "eio", "report_group": "eio", "xml_tag": "ИННЮЛ/ИННФЛИП", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ИННЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ИННФЛИП", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/ИННЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ИННФЛИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ИННФЛИП", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, "structured_value": false, - "structured_group": "legacy_157", - "structured_role": "component", - "structured_order": 4, + "structured_group": "", + "structured_role": "", + "structured_order": 0, "join_with_space": false, "multiplies": "нет", "required": "обязательно", @@ -3246,7 +3246,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "ОГРНЮЛ|ОГРНИП|ТипФЛЧастнаяПрактика", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ТипФЛЧастнаяПрактика", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ТипФЛЧастнаяПрактика | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ТипФЛЧастнаяПрактика", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3285,10 +3285,10 @@ "column_name": "Код страны гражданства ЕИО/Бенефициара", "block": "eio", "report_group": "eio", - "xml_tag": "", - "xml_path": "", - "source_scope": "any", - "allow_short_lookup": true, + "xml_tag": "КодОКСМ", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/КодОКСМ", + "source_scope": "participant", + "allow_short_lookup": false, "allow_direct_mapping": true, "structured_value": false, "structured_group": "", @@ -3306,7 +3306,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "ПризнакПубЛицо", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ПризнакПубЛицо", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/ПризнакПубЛицо | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/ПризнакПубЛицо", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3326,7 +3326,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "АдресСтрока|АдрУчредитель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока", "source_scope": "participant", "allow_short_lookup": true, "allow_direct_mapping": true, @@ -3346,7 +3346,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф|АдрУчредитель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/", "source_scope": "participant", "allow_short_lookup": true, "allow_direct_mapping": true, @@ -3366,7 +3366,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Индекс", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Индекс", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Индекс", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3386,7 +3386,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "КодОКСМ", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/КодОКСМ", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/КодОКСМ", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3406,7 +3406,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "КодСубъектаПоОКАТО", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/КодСубъектаПоОКАТО", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/КодСубъектаПоОКАТО", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3426,7 +3426,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Район", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Район", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Район", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3446,7 +3446,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Пункт", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Пункт", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Пункт", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3466,7 +3466,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Улица", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Улица", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Улица", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3486,7 +3486,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Дом", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Дом", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Дом", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3506,7 +3506,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Корп", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Корп", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Корп", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, @@ -3526,7 +3526,7 @@ "block": "eio", "report_group": "eio", "xml_tag": "Оф", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/Оф", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/Оф", "source_scope": "participant", "allow_short_lookup": false, "allow_direct_mapping": true, diff --git a/app/pipeline/parser.py b/app/pipeline/parser.py index db49617..1729b15 100644 --- a/app/pipeline/parser.py +++ b/app/pipeline/parser.py @@ -1,11 +1,21 @@ from __future__ import annotations +import re import xml.etree.ElementTree as ET +from collections import Counter from dataclasses import dataclass, field from typing import Iterable from .config import validate_file_name as validate_pipeline_file_name +_INDEXED_SEGMENT = re.compile(r"(?P[^.]+\[(?P\d+)\])") +_EIO_SEGMENT = re.compile( + r"(?P" + r"(?:СведЕИО|БенефициарЮЛ|БенефициарФЛИП|БенефициарИНБОЮЛ)" + r"(?:\[(?P\d+)\])?" + r")" +) + @dataclass class ParticipantRow: @@ -14,6 +24,7 @@ class ParticipantRow: record_id: str operation_fields: dict[str, str] participant_fields: dict[str, str] + is_continuation: bool = False @dataclass @@ -69,28 +80,30 @@ def parse_xml_content(file_name: str, xml_content: bytes | str) -> FileParseResu ) operation_fields = {**common_fields, **operation_fields} record_id = _extract_record_id(operation_fields, index) + operation_rows = _expand_indexed_fields(operation_fields) participants = _find_children_by_name(operation, "УчастникОп") if participants: for participant in participants: participant_fields = _extract_direct_fields(participant) - result.rows.append( - ParticipantRow( + participant_rows = _expand_indexed_fields(participant_fields) + result.rows.extend( + _build_expanded_rows( file_name=file_name, operation_index=index, record_id=record_id, - operation_fields=operation_fields, - participant_fields=participant_fields, + operation_rows=operation_rows, + participant_rows=participant_rows, ) ) else: - result.rows.append( - ParticipantRow( + result.rows.extend( + _build_expanded_rows( file_name=file_name, operation_index=index, record_id=record_id, - operation_fields=operation_fields, - participant_fields={}, + operation_rows=operation_rows, + participant_rows=[{}], ) ) except Exception as exc: # noqa: BLE001 @@ -126,16 +139,117 @@ def _extract_direct_fields( ) -> dict[str, str]: excluded = set(excluded_tags or ()) 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) - if tag in excluded: - continue - _collect_leaf_fields(child, tag, fields) + position = positions[tag] + positions[tag] += 1 + path = f"{tag}[{position}]" if counts[tag] > 1 else tag + _collect_leaf_fields(child, path, 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: possible_keys = ( "ИдентификаторЗаписи", @@ -206,9 +320,16 @@ def _collect_leaf_fields( _append_value(fields, current_path, (element.text or "").strip()) return + counts = Counter(_normalize_tag(child.tag) for child in children) + positions: Counter[str] = Counter() for child in children: 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( diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 4edc803..ef8979d 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -28,6 +28,7 @@ class ReportRow: operation_index: int operation_fields: dict[str, str] participant_fields: dict[str, str] + is_continuation: bool = False @dataclass(frozen=True) @@ -93,6 +94,7 @@ class StreamingReportWriter: operation_index=row.operation_index, operation_fields=row.operation_fields, participant_fields=row.participant_fields, + is_continuation=row.is_continuation, ) for output_index, column_name in enumerate(self.columns, start=1): zero_based_index = output_index - 1 diff --git a/app/pipeline/service.py b/app/pipeline/service.py index d7b7570..fc4567c 100644 --- a/app/pipeline/service.py +++ b/app/pipeline/service.py @@ -329,6 +329,7 @@ def _process_files( operation_index=row.operation_index, operation_fields=row.operation_fields, participant_fields=row.participant_fields, + is_continuation=row.is_continuation, ) ) stage_timings["report_append_seconds"] += ( diff --git a/docs/xsd/SKO115FZ_OPER_v2.xsd b/docs/xsd/SKO115FZ_OPER_v2.xsd new file mode 100644 index 0000000..52b20dc --- /dev/null +++ b/docs/xsd/SKO115FZ_OPER_v2.xsd @@ -0,0 +1,660 @@ + + + + + + + + Сообщение об операциях, обязательному контролю, о подозрительных операциях, а также об операциях, + приостановленных в соответствии с пунктом 10 статьи 7 и (или) пунктом 8 статьи 7.5 Федерального закона № 115-ФЗ + + + + + + + Служебная часть электронного сообщения + + + + + Информационная часть электронного сообщения + + + + + + + Информация о кредитной организации (филиале кредитной организации), передающей (передающем) сведения + + + + + + + + + + Признак представления сведений в уполномоченный орган филиалом кредитной организации + + + + + + + + + + + + Информация о филиале кредитной организации, представляющем сведения + + + + + + Сведения об операции + + + + + + Номер записи в ФЭС + + + + + Тип записи + + + + + + + + + + Уникальный номер операции + + + + + + Признак операции, связанной с финансированием терроризма + или распространения оружия массового уничтожения + + + + + + + + + + + Основания приостановления операции + !!! Показатель введен, начиная с версии 2.3 формата ФЭС !!! + + + + + + + + + + Дата совершения (приостановления) операции + + + + + Дата выявления (приостановления) операции + + + + + Код признака операции (сделки) + + + + + + + + + + Признак совершения операции с использованием ЭСП + + + + + + + + + + Наименование платежной системы на стороне лица, совершающего операцию + + + + + Наименование платежной системы на стороне получателя по операции + + + + + Время совершения операции с использованием ЭСП + + + + + Код вида операции + + + + + + + + + + Дополнительный код вида операции + + + + + + + + + + Код признака необычной операции + + + + + + + + + + + Признак валютной операции.!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + + + + + + Идентификатор подозрительной деятельности.!!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + Сведения о цифровых правах + + + + + Код валюты + + + + + Сумма операции в валюте ее проведения + + + + + Сумма операции в рублевом эквиваленте + + + + + Код продаваемой валюты + + + + + Сумма продаваемой валюты + + + + + + Сведения о драгоценных металлах, драгоценных камнях, ювелирных изделиях и ломе из таких изделий + + + + + + Основание совершения операции + + + + + Назначение платежа + + + + + Характеристика операции + + + + + Сведения об участнике операции + + + + + + Код статуса участника операции (сделки) + + + + + + + + + + Тип участника операции + + + + + + + + + + Признак резидента (нерезидента) участника операции + + + + + + + + + + + Уникальный идентификатор пользователя платформы цифрового рубля + !!! Показатель введен, начиная с версии 2.2 формата ФЭС !!! + + + + + + Признак участника операции (сделки) + + + + + + + + + + + Идентификатор участника операции, включенного в Перечень, Решение, и (или) Перечень ФРОМУ + + + + + + + + + + + + Сведения о юридическом лице (филиале юридического лица, представительстве) и его единоличном исполнительном органе + + + + + + + + Сведения о юридическом лице (филиале (представительстве) юридического лица) + + + + + + + Признак идентификации бенефициарного владельца. + !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + + + + + + Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + Сведения о единоличном исполнительном органе юридического лица нерезидента + + + + + + + + + + Сведения о физическом лице, индивидуальном предпринимателе, физическом лице, + занимающемся в установленном законодательством Российской Федерации порядке частной практикой + + + + + + + Признак идентификации физического лица + + + + + + + + + + Сведения о физическом лице, индивидуальном предпринимателе + + + + + + Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + + + + Сведения об иностранной структуре без образования юридического лица + + + + + + + Сведения об иностранной структуре без образования юридического лица + + + + + + Признак идентификации бенефициарного владельца. + !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + + + + + + Сведения о бенефициарном владельце. !!! Показатель введен, начиная с версии 2.0 формата ФЭС !!! + + + + + + + + + Дополнительная информация об участнике операции (сделки) + + + + + + + + + Сведения о переводах денежных средств, в том числе электронных денежных средств + + + + + + + Вид перевода денежных средств + + + + + + + + + + Код территории + + !!!!! Показатель добавлен в версии формата 1.2 в соответствии с Федеральным законом от 28.06.2021 № 230-ФЗ !!!!! + + + + + + Тип оператора по переводу денежных средств + + + + + + + + + + Номер счета плательщика + + + + + Идентификатор ЭСП плательщика + + + + + Сведения о банке плательщика + + + + + Сведения о банке получателя + + + + + Номер счета банка плательщика + + + + + Номер счета банка получателя + + + + + Номер счета получателя + + + + + Идентификатор ЭСП получателя + + + + + Сведения о месте приема наличных денежных средств + + + + + Статус перевода + + + + + + + + + + Сведения о месте выдачи наличных денежных средств + + + + + IP-адрес сетевого оборудования плательщика + + + + + MAC-адрес сетевого оборудования плательщика + + + + + + + + + Сведения о переводах денежных средств, в том числе электронных денежных средств + + + + + + + Код характера операции + + + + + + + + + + Номер счета клиента + + + + + Номер платежной карты + + + + + Сведения о месте приема (выдачи) наличных денежных средств + + + + + + + + + Сведения об операции с использованием платежной карты, эмитированной иностранным банком, + зарегистрированным на территории иностранного государства или административно-территориальной + единицы иностранного государства, обладающей самостоятельной правоспособностью, входящих в перечень, + утвержденный уполномоченным органом (Росфинмониторингом) + + + + + + + Код территории + + + + + Сведения о месте совершения операции с денежными средствами в наличной форме + + + + + Номер платежной карты + + + + + Сведения о держателе платежной карты + + + + + Признак совершения операции с участием уполномоченного сотрудника кредитной организации (филиала кредитной организации) + + + + + + + + + + Наименование иностранного банка + + + + + СВИФТ иностранного банка + + + + + + + + + + + + + Дополнительные сведения + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/xsd/data_types_v2.xsd b/docs/xsd/data_types_v2.xsd new file mode 100644 index 0000000..3da8f73 --- /dev/null +++ b/docs/xsd/data_types_v2.xsd @@ -0,0 +1,1440 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Количество + + + + + + + + + БИК + + + + + + + + + БИК + + + + + + + + + БИК + + + + + + + + + Основной государственный регистрационный номер юридического лица + + + + + + + + + Идентификационный номер налогоплательщика + + + + + + + + + Код причины постановки на учет (КПП) + + + + + + + + + Код страны по ОКСМ + + + + + + + + + Код страны по ОКСМ + + + + + + + + + Код ОКПО + + + + + + + + + Код из Справочника субъекта Российской Федерации + + + + + + + + + Код ОКВЭД + + + + + + + + + Дата + + + + + + + + + Время + + + + + + + + + Время с учетом часового пояса относительно московского времени + + + + + + + + + + + + + + + + Код валюты ценной бумаги + + + + + + + + + Уникальный идентификатор пользователя платформы цифрового рубля + + + + + + + + + Уникальный идентификатор операции на платформе цифрового рубля + + + + + + + + + Номер счета цифрового рубля + + + + + + + + + Цена + + + + + + + + + Фамилия, имя, отчество + + + + + Фамилия + + + + + Имя + + + + + Отчество + + + + + ФИО (в одной строке) + !!!!! Показатель добавлен в версии формата 1.1 !!!!! + + + + + + + + + Сведения о документе, удостоверяющем личность гражданина Российской Федерации, + иностранного гражданина или лица без гражданства + + + + + + Код вида документа, удостоверяющего личность + + + + + + + + + + Наименование иного документа + + + + + Серия документа, удостоверяющего личность + + + + + Номер документа, удостоверяющего личность + + + + + Дата выдачи документа + + + + + Орган, выдавший документ + + + + + Код подразделения + + + + + + + + + Сведения о документе, подтверждающем право на пребывание (проживание) в Российской Федерации + + + + + + Код вида документа + + + + + + + + + + Наименование иного документа + + + + + Серия документа, подтверждающего право на пребывание (проживание) в Российской Федерации + + + + + Номер документа, подтверждающего право на пребывание (проживание) в Российской Федерации + + + + + Дата начала срока действия права пребывания (проживания) в Российской Федерации + + + + + Дата окончания срока действия права пребывания (проживания) в Российской Федерации + + + + + + + + + + Почтовый индекс + + + + + Код страны + + + + + Код субъекта Российской Федерации по ОКАТО + + + + + Район + + + + + Населенный пункт + + + + + Улица + + + + + Дом(владение) + + + + + Корпус(строение) + + + + + Офис (строение) + + + + + Адрес (в одной строке) + !!!!! Показатель добавлен в версии формата 1.1 !!!!! + + + + + + + + + Сведения о кредитной организации (филиале кредитной организации), передающей (передающем) сведения в уполномоченный орган (Росфинмониторинг) + + + + + + Регистрационный номер кредитной организации + + + + + + + + + + БИК кредитной организации (филиала кредитной организации) + + + + + Порядковый номер филиала кредитной организации + + + + + + + + + + Код ОКАТО + + + + + + + + + + + + + + Сведения о филиале кредитной организации, представляющем сведения, но не передающем самостоятельно сведения в уполномоченный орган (Росфинмониторинг) + + + + + + Регистрационный номер кредитной организации + + + + + + + + + + Порядковый номер филиала кредитной организации + + + + + + + + + + БИК филиала кредитной организации + + + + + Код ОКАТО + + + + + + + + + + + + + + Идентификатор записи сведений, представляемых кредитной организацией (филиалом кредитной организации) + + + + + + + + + + + Идентификатор записи сведений, представляемых кредитной организацией (филиалом кредитной организации) + + + + + + + + + + Реквизиты документа + + + + + Код вида документа + + + + + + + + + + Наименование иного документа + + + + + Дата документа + + + + + Номер документа + + + + + Краткое содержание документа + + + + + + + + Сведения о юридическом лице (филиале юридического лица, представительстве) + + + + + Наименование юридического лица (филиала юридического лица, представительства) + + + + + Признак филиала (представительства) юридического лица + !!!!! Показатель стал условным с версии формата 1.2 !!!!! + + + + + + + + + + ИНН (КИО) + + + + + + + + + + КПП + + + + + ОГРН + + + + + Место государственной регистрации + + + + + Дата государственной регистрации + + + + + Адрес юридического лица (филиала (представительства) юридического лица) + + + + + + + + Сведения о единоличном исполнительном органе юридического лица + + + + + Тип единоличного исполнительного органа + + + + + + + + + + Признак резидента (нерезидента) единоличного исполнительного органа + + + + + + + + + + Сведения о юридическом лице-единоличном исполнительном органе + + + + + Сведения о физическом лице-единоличном исполнительном органе + + + + + + + + Сведения о бенефициарном владельце. !!! Тип данных введен, начиная с версии 2.0 формата ФЭС !!! + + + + + Результат принятия кредитной организацией мер по идентификации бенефициарного владельца + + + + + + + + + + Признак резидента (нерезидента) бенефициарного владельца + + + + + + + + + + Сведения о бенефициарном владельце + + + + + + + + + Сведения о физическом лице, индивидуальном предпринимателе, физическом лице, занимающимся + в установленном законодательством Российской Федерации порядке частной практикой + + + + + + Фамилия, имя, отчество + + + + + ИНН + + + + + + + + + + СНИЛС + + + + + + + + + + Номер полиса обязательного медицинского страхования + + + + + + + + + + Номер телефона + + + + + ОГРН + + + + + + + + + + + Тип физического лица, занимающегося в установленном законодательством + Российской Федерации порядке частной практикой + + + + + + + + + + + + Регистрационный номер, присвоенный физическому лицу, занимающемуся в установленном + законодательством Российской Федерации порядке частной практикой + + + + + + Признак документа, удостоверяющего личность + + + + + + + + + + Сведения о документе, удостоверяющем личность + + + + + Сведения о документе, подтверждающем право на пребывание (проживание) в Российской Федерации + + + + + Дата рождения + + + + + Код страны гражданства + + + + + Признак принадлежности к публичным лицам + + + + + + + + + + Адрес места жительства (регистрации) или места пребывания + + + + + + + + Идентификатор иностранной структуры без образования юридического лица + + + + + + Регистрационный номер иностранной структуры без образования юридического лица + + + + + + + Код иностранной структуры без образования юридического лица + + + + + + + + + Сведения об иностранной структуре без образования юридического лица + + + + + Полное наименование иностранной структуры без образования юридического лица + + + + + Идентификатор (идентификаторы) иностранной структуры без образования юридического лица + + + + + + Место ведения основной деятельности иностранной структуры без образования юридического лица + + + + + + Признак, указывающий на организационную форму иностранной структуры без образования юридического лица + + + + + + + + + + + Сведения в отношении трастов и иных иностранных структур без образования юридического лица с аналогичной структурой + или функцией об их учредителях и (или) доверительных собственниках (управляющих) + + + + + + + Фамилия, имя, отчество или наименование + + + + + + Полное фирменное наименование + + + + + Фамилия, имя, отчество + + + + + + + + Адрес места жительства (места нахождения) + + + + + + + + + + + + Сведения о драгоценных металлах, драгоценных камнях, ювелирных изделиях + + + + + + Код драгоценных металлов и драгоценных камней, ювелирных изделий и лома таких изделий + + + + + + + + + + Наименование драгоценных металлов, драгоценных камней + + + + + Предмет купли-продажи + !!! Показатель введен, начиная с версии 2.3 формата ФЭС !!! + + + + + + + + + + + + + + Сведения о банке плательщика (банке получателя) + + + + + + БИК (СВИФТ) + + + + + Наименование банка + + + + + + + + + Сведения о месте приема (выдачи) наличных денежных средств + + + + + + Номер банкомата (платежного терминала) + + + + + Адрес места приема (выдачи) наличных денежных средств, авторизации ЭСП + + + + + БИК (СВИФТ) + !!!!! Показатель является обязательным с версии формата 1.1 !!!!! + + + + + Наименование кредитной организации (филиала кредитной организации) + + + + + + + + + Основание применения меры по замораживанию (блокированию) денежных средств или иного имущества + + + + + + Код основания применения мер + + + + + + + + + + Код организации или физического лица + + + + + + + + + + + + + Сведения о количестве клиентов + + + + + + Общее количество клиентов, в отношении денежных средств или иного имущества которых применены меры + (не были применены меры, подлежащие применению) в соответствии с Федеральным законом № 115-ФЗ + + + + + + + Количество клиентов, в отношении денежных средств или иного имущества которых применены меры + (не были применены меры, подлежащие применению) в соответствии с подпунктом 6 пункта 1 статьи 7 Федерального закона № 115-ФЗ + + + + + + + Количество клиентов, в отношении денежных средств или иного имущества которых применены меры (не были применены меры, подлежащие + применению) в соответствии с пунктом 5 статьи 7 Федерального закона № 115-ФЗ + + + + + + + Количество клиентов, в отношении денежных средств или иного имущества которых применены меры (не были применены меры, подлежащие применению) + в соответствии с подпунктом 6 пункта 1 статьи 7 и пунктом 5 статьи 7 Федерального закона № 115-ФЗ + + + + + + + + + + + + Номер пункта + + + + + Номер подпункта + + + + + + + + + + Номер части статьи + + + + + Пункт, подпункт + + + + + + + + + + Номер статьи + + + + + Номер части статьи + + + + + Пункт, подпункт статьи + + + + + + + + Тип данных «Реквизиты нормативного акта».!!! Введен, начиная с версии 2.0 формата ФЭС !!! + + + + + Наименование нормативного акта + + + + + Дата нормативного акта + + + + + Номер нормативного акта + + + + + + Заполняется в случае указания статей, частей статей, пунктов частей статей и подпунктов пунктов частей статей + + + + + + + Заполняется в случае указания пунктов и подпунктов нормативных актов + + + + + + + + + Сведения о цифровых правах + + + + + Вид цифрового права + + + + + + + + + + Идентификационный номер выпуска цифрового права + + + + + Код валюты + + + + + Стоимость цифрового права + + + + + Количество цифровых прав + + + + + Информация об эмитенте цифрового права + + + + + + Признак резидента (нерезидента) эмитента цифрового права + + + + + + + + + + Тип эмитента цифрового права + + + + + + + + + + Наименование эмитента цифрового права + + + + + + Gолное или сокращенное наименование юридического лица + + + + + Фамилия, имя, отчество + + + + + + + + ИНН эмитента цифрового права + + + + + + + + + + ОГРН (ОГРНИП) эмитента цифрового права + + + + + + + + Дополнительные сведения о цифровом праве + + + + + + + + Служебная часть электронного сообщения + + + + + Версия формата + + + + + + + + + + + + + Версия передающей программы + + + + + Идентификатор корреспондента + + + + + + + + + + Тип информации + + + + + + + + + + Дата ФЭС + + + + + Должность уполномоченного лица + + + + + ФИО уполномоченного лица + + + + + Телефон уполномоченного лица + + + + + Адрес электронной почты + + + + + \ No newline at end of file diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 200a38b..b10dcca 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -775,6 +775,80 @@ def test_eio_block_is_empty_when_eio_and_beneficiary_are_absent() -> None: 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: row_without_cp = build_fixed_row_by_index( file_name="f.xml", diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index a70a876..0676e9e 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -1,6 +1,8 @@ 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: @@ -120,3 +122,105 @@ def test_parse_xml_collects_legal_entity_registration_address_attributes() -> No assert ( participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р-н" ) + + +def test_extract_direct_fields_indexes_repeated_siblings_at_every_level() -> None: + participant = ET.fromstring( + """ +<УчастникОп> + <Документ><НомДок>DOC-1 + <Документ><НомДок>DOC-2 + <УчастникЮЛ> + <СведЕИО><ФЛЕИО><ИННФЛИП>111111111111 + <СведЕИО><ФЛЕИО><ИННФЛИП>222222222222 + + +""" + ) + + fields = _extract_direct_fields(participant) + + assert fields["Документ[0].НомДок"] == "DOC-1" + assert fields["Документ[1].НомДок"] == "DOC-2" + assert fields["УчастникЮЛ.СведЕИО[0].ФЛЕИО.ИННФЛИП"] == "111111111111" + assert fields["УчастникЮЛ.СведЕИО[1].ФЛЕИО.ИННФЛИП"] == "222222222222" + assert all("; " not in value for value in fields.values()) + + +def test_extract_direct_fields_keeps_single_sibling_path_unindexed() -> None: + participant = ET.fromstring( + "<УчастникОп><Документ><НомДок>DOC-1" + ) + + fields = _extract_direct_fields(participant) + + assert fields == {"Документ.НомДок": "DOC-1"} + + +def test_parse_xml_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" + } diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index f4a9595..7a1d3e1 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -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" +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: value = index result = ""