From bef38dedd8bbaaccf103324ad6e46cf058b4a98e Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Tue, 11 Aug 2026 13:08:00 +0300 Subject: [PATCH] fix --- app/pipeline/column_constants.py | 1 + app/pipeline/mapping.py | 10 ++- app/pipeline/mapping_table.json | 32 ++++----- app/pipeline/parser.py | 41 ++++++++++-- tests/unit/test_mapping.py | 74 ++++++++++++++++++++- tests/unit/test_parser.py | 108 +++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 26 deletions(-) diff --git a/app/pipeline/column_constants.py b/app/pipeline/column_constants.py index 51bcf44..0d8eb18 100644 --- a/app/pipeline/column_constants.py +++ b/app/pipeline/column_constants.py @@ -65,6 +65,7 @@ COL_ADDRESS_ONE_LINE = _index(141) COL_ADDRESS_STRUCTURED = _index(142) COL_REG_PLACE_ONE_LINE = _index(152) COL_REG_PLACE_STRUCTURED = _index(153) +COL_EIO_ADDRESS_ONE_LINE = _index(167) # ЕИО и ценные бумаги. COL_EIO_INN = _index(163) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 63c954b..ff85820 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -20,6 +20,7 @@ from .column_constants import ( COL_CP_ISSUER_INN, COL_CURRENCY, COL_CURRENCY_SIGN, + COL_EIO_ADDRESS_ONE_LINE, COL_EIO_INN, COL_EIO_REG_PLACE_ONE_LINE, COL_EMPLOYEE_SIGN, @@ -330,6 +331,7 @@ def build_fixed_row_by_index( "УчастникЮЛ.БенефициарЮЛ.", "УчастникФЛИП.БенефициарФЛИП.", "УчастникИНБОЮЛ.БенефициарИНБОЮЛ.", + "УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.", "СведЕИО.", "БенефициарЮЛ.", "БенефициарФЛИП.", @@ -378,6 +380,9 @@ def build_fixed_row_by_index( ) if not is_continuation: _apply_participant_identity_rules(row, participant_fields) + # Сначала убираем запрещённые однострочные адреса, чтобы структурная + # группа не очистила их компоненты до проверки резидентства. + _rule_address_one_line(row) _apply_structured_group_rules(row) if not is_continuation: _apply_business_rules( @@ -515,7 +520,7 @@ def _normalize_indexed_path(path: str) -> str: def _is_eio_path(path: str) -> bool: normalized = _normalize_indexed_path(path) - return any( + return ".СведИНБОЮЛ.Учредитель." in f".{normalized}." or any( f".{block_name}." in f".{normalized}." for block_name in _EIO_BLOCK_NAMES ) @@ -862,10 +867,11 @@ def _allow_one_line_address(row: dict[int, str]) -> bool: def _rule_address_one_line(row: dict[int, str]) -> None: - """Правила 141, 152 и 178: однострочные адреса только для нерезидентов.""" + """Правила 141, 152, 167 и 178: однострочные адреса только для нерезидентов.""" if not _allow_one_line_address(row): _set(row, COL_ADDRESS_ONE_LINE, "") _set(row, COL_REG_PLACE_ONE_LINE, "") + _set(row, COL_EIO_ADDRESS_ONE_LINE, "") _set(row, COL_EIO_REG_PLACE_ONE_LINE, "") diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index 1cc4b13..c6bc906 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -3322,18 +3322,18 @@ { "index": 167, "column_id": "legacy_167", - "column_name": "Адрес ЮЛ/ Адрес места жительства (места нахождения) ЕИО/Бенефициара (по структуре полный)", + "column_name": "Адрес ЮЛ/ Адрес места жительства (места нахождения) ЕИО/Бенефициара (одной строкой)", "block": "eio", "report_group": "eio", "xml_tag": "АдресСтрока|АдрУчредитель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока", "source_scope": "participant", "allow_short_lookup": true, "allow_direct_mapping": true, - "structured_value": true, - "structured_group": "", - "structured_role": "", - "structured_order": 0, + "structured_value": false, + "structured_group": "legacy_168", + "structured_role": "one_line", + "structured_order": 1, "join_with_space": false, "multiplies": "нет", "required": "Обязательно", @@ -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 1729b15..70f28af 100644 --- a/app/pipeline/parser.py +++ b/app/pipeline/parser.py @@ -79,8 +79,9 @@ def parse_xml_content(file_name: str, xml_content: bytes | str) -> FileParseResu operation, excluded_tags={"УчастникОп"} ) operation_fields = {**common_fields, **operation_fields} + operation_fields = _collapse_indexed_fields(operation_fields) record_id = _extract_record_id(operation_fields, index) - operation_rows = _expand_indexed_fields(operation_fields) + operation_rows = [operation_fields] participants = _find_children_by_name(operation, "УчастникОп") if participants: @@ -92,7 +93,7 @@ def parse_xml_content(file_name: str, xml_content: bytes | str) -> FileParseResu file_name=file_name, operation_index=index, record_id=record_id, - operation_rows=operation_rows, + operation_rows=operation_rows[:1], participant_rows=participant_rows, ) ) @@ -141,13 +142,14 @@ def _extract_direct_fields( 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) + structural_repeats = _find_structural_repeat_tags(children, counts) positions: Counter[str] = Counter() for child in children: tag = _normalize_tag(child.tag) position = positions[tag] positions[tag] += 1 - path = f"{tag}[{position}]" if counts[tag] > 1 else tag + path = f"{tag}[{position}]" if tag in structural_repeats else tag _collect_leaf_fields(child, path, fields) return fields @@ -196,6 +198,15 @@ def _build_expanded_rows( return rows +def _collapse_indexed_fields(fields: dict[str, str]) -> dict[str, str]: + """Сохраняет все операционные повторы в одной строке без потери значений.""" + collapsed: dict[str, str] = {} + for key, value in fields.items(): + deindexed_key = re.sub(r"\[\d+\]", "", key) + _append_value(collapsed, deindexed_key, value) + return collapsed + + def _expand_indexed_fields( fields: dict[str, str], *, @@ -250,6 +261,21 @@ def _expand_indexed_fields( return [primary, *continuations] +def _find_structural_repeat_tags( + children: list[ET.Element], + counts: Counter[str], +) -> set[str]: + """Возвращает повторяемые теги структурных, а не скалярных XML-узлов.""" + first_by_tag: dict[str, ET.Element] = {} + for child in children: + first_by_tag.setdefault(_normalize_tag(child.tag), child) + return { + tag + for tag, first_child in first_by_tag.items() + if counts[tag] > 1 and (len(first_child) > 0 or bool(first_child.attrib)) + } + + def _extract_record_id(operation_fields: dict[str, str], operation_index: int) -> str: possible_keys = ( "ИдентификаторЗаписи", @@ -317,17 +343,22 @@ def _collect_leaf_fields( _collect_attributes(element, current_path, fields) children = list(element) if not children: - _append_value(fields, current_path, (element.text or "").strip()) + text = (element.text or "").strip() + if text or not element.attrib: + _append_value(fields, current_path, text) return counts = Counter(_normalize_tag(child.tag) for child in children) + structural_repeats = _find_structural_repeat_tags(children, counts) positions: Counter[str] = Counter() for child in children: child_name = _normalize_tag(child.tag) position = positions[child_name] positions[child_name] += 1 child_path = ( - f"{child_name}[{position}]" if counts[child_name] > 1 else child_name + f"{child_name}[{position}]" + if child_name in structural_repeats + else child_name ) _collect_leaf_fields(child, f"{current_path}.{child_path}", fields) diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index b10dcca..afdc15e 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -250,7 +250,7 @@ def test_structured_column_is_not_filled_directly_from_single_line_tag() -> None assert ( row[ "Адрес ЮЛ/ Адрес места жительства (места нахождения) " - "ЕИО/Бенефициара (по структуре полный)" + "ЕИО/Бенефициара (одной строкой)" ] == "" ) @@ -291,12 +291,16 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None }, ) assert row["Адрес в одной строке"] == "" + assert row[ + "Адрес ЮЛ/ Адрес места жительства (места нахождения) " + "ЕИО/Бенефициара (одной строкой)" + ] == ("г. Москва, ул. Пушкина, д. 1") assert ( row[ "Адрес ЮЛ/ Адрес места жительства (места нахождения) " - "ЕИО/Бенефициара (по структуре полный)" + "ЕИО/Бенефициара (структура полностью)" ] - == "г. Москва, ул. Пушкина, д. 1" + == "" ) @@ -492,6 +496,70 @@ def test_one_line_address_columns_cleared_when_participant_is_resident_client() ) +def test_eio_one_line_address_obeys_participant_residency_rule() -> None: + participant_fields = { + "УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.АдресСтрока": "г. Москва, ул. Тестовая", + "УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "101000", + "УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.КодОКСМ": "643", + } + resident_client = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"ПризнУчастника": "1", "ПризнКлиент": "1"}, + participant_fields=participant_fields, + ) + nonresident = build_fixed_row_by_index( + file_name="f.xml", + record_id="R2", + operation_index=2, + operation_fields={"ПризнУчастника": "0", "ПризнКлиент": "1"}, + participant_fields=participant_fields, + ) + + assert resident_client[167] == "" + assert resident_client[169] == "101000" + assert resident_client[170] == "643" + assert nonresident[167] == "г. Москва, ул. Тестовая" + + +def test_foreign_structure_founder_address_populates_eio_address_block() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.Индекс": "101000", + "УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.КодОКСМ": "643", + "УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.Пункт": "Москва", + }, + ) + + assert row[167] == "" + assert row[168] == "101000, 643, Москва" + assert row[169] == "101000" + assert row[170] == "643" + assert row[173] == "Москва" + + +def test_foreign_structure_founder_one_line_address_activates_eio_block() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"ПризнУчастника": "0"}, + participant_fields={ + "УчастникИНБОЮЛ.СведИНБОЮЛ.Учредитель.АдрУчредитель.АдресСтрока": ( + "г. Москва, ул. Пушкина, д. 1" + ), + }, + ) + + assert row[167] == "г. Москва, ул. Пушкина, д. 1" + assert row[168] == "" + + def test_okato_cleared_when_country_code_is_not_russia() -> None: row = build_fixed_row( file_name="f.xml", diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 0676e9e..20a41c6 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -157,6 +157,27 @@ def test_extract_direct_fields_keeps_single_sibling_path_unindexed() -> None: assert fields == {"Документ.НомДок": "DOC-1"} +def test_parse_xml_separates_repeated_attribute_only_structures() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP-ATTR + <УчастникОп> + <ТипУчастника>1 + <Документ НомДок="DOC-1"/> + <Документ НомДок="DOC-2"/> + + +""" + ) + + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + + assert len(result.rows) == 2 + assert result.rows[0].participant_fields["Документ.НомДок"] == "DOC-1" + assert result.rows[1].participant_fields == {"Документ.НомДок": "DOC-2"} + + def test_parse_xml_expands_documents_and_eio_into_additive_rows() -> None: xml = _base_xml( """ @@ -224,3 +245,90 @@ def test_parse_xml_separates_single_eio_and_single_beneficiary() -> None: assert result.rows[1].participant_fields == { "УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.ИННФЛИП": "111111111111" } + + +def test_operation_repeats_are_collapsed_once_for_two_participants() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP8 + <ОснованиеОп><НомДок>BASE-1 + <ОснованиеОп><НомДок>BASE-2 + <УчастникОп><ТипУчастника>1 + <УчастникОп><ТипУчастника>2 + +""" + ) + + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + + assert len(result.rows) == 2 + assert [row.participant_fields.get("ТипУчастника") for row in result.rows] == [ + "1", + "2", + ] + for row in result.rows: + assert row.operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2" + + +def test_operation_repeats_are_collapsed_for_single_participant() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP9 + <ОснованиеОп><НомДок>BASE-1 + <ОснованиеОп><НомДок>BASE-2 + <УчастникОп><ТипУчастника>1 + +""" + ) + + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + + assert len(result.rows) == 1 + assert result.rows[0].operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2" + + +def test_mixed_operation_and_eio_repeats_are_additive_not_cartesian() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP10 + <ОснованиеОп><НомДок>BASE-1 + <ОснованиеОп><НомДок>BASE-2 + <УчастникОп> + <ТипУчастника>1 + <УчастникЮЛ> + <СведЕИО><ФЛЕИО><ИННФЛИП>111111111111 + <СведЕИО><ФЛЕИО><ИННФЛИП>222222222222 + + + <УчастникОп><ТипУчастника>2 + +""" + ) + + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + + assert len(result.rows) == 3 + assert result.rows[0].participant_fields["ТипУчастника"] == "1" + assert result.rows[1].participant_fields == { + "УчастникЮЛ.СведЕИО.ФЛЕИО.ИННФЛИП": "222222222222" + } + assert result.rows[2].participant_fields["ТипУчастника"] == "2" + assert result.rows[0].operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2" + + +def test_repeated_scalar_leaves_are_joined_without_continuation_rows() -> None: + operation = ET.fromstring( + """ +<Операция> + <ПризнНеобОперации>1106 + <ПризнНеобОперации>1499 + +""" + ) + + fields = _extract_direct_fields(operation) + + assert fields == {"ПризнНеобОперации": "1106; 1499"}