diff --git a/app/pipeline/column_constants.py b/app/pipeline/column_constants.py index 2cc77b4..9271b04 100644 --- a/app/pipeline/column_constants.py +++ b/app/pipeline/column_constants.py @@ -50,6 +50,7 @@ COL_TERRITORY_CODE_CARD = _index(96) COL_CARD_HOLDER_INFO = _index(103) COL_EMPLOYEE_SIGN = _index(104) COL_FOREIGN_BANK_NAME = _index(105) +COL_OPERATION_DOCUMENTS = _index(107) # Участник. COL_PARTICIPANT_TYPE = _index(109) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 1eaf5b0..29639b8 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -39,6 +39,7 @@ from .column_constants import ( COL_METAL_CODE, COL_METAL_NAME, COL_OPERATION_CODE, + COL_OPERATION_DOCUMENTS, COL_OPERATION_SIGN, COL_OPERATOR_TYPE, COL_PARTICIPANT_TYPE, @@ -133,6 +134,17 @@ class StructuredGroupRule: _INVALID_TAGS = {"", "-", "Источник", "путь", "подразумеваются", "тэга", "нашла"} _INDEXED_PATH_SEGMENT = re.compile(r"\[\d+\]") +_OPERATION_DOCUMENT_FIELDS = ( + "КодДок", + "ИноеНаимДок", + "ДатаДок", + "НомДок", + "СодДок", +) +_OPERATION_DOCUMENT_FIELD = re.compile( + r"(?:^|\.)ОснованиеОп(?:\[(?P\d+)\])?\." + r"(?PКодДок|ИноеНаимДок|ДатаДок|НомДок|СодДок)$" +) _EIO_BLOCK_NAMES = ( "СведЕИО", "БенефициарЮЛ", @@ -453,6 +465,11 @@ def build_fixed_row_by_index( rule.compiled_candidates, allow_short_lookup=allow_short_lookup, ) + _set( + row, + COL_OPERATION_DOCUMENTS, + _format_operation_documents(operation_lookup), + ) if not is_continuation: _apply_participant_identity_rules(row, participant_lookup) # Сначала убираем запрещённые однострочные адреса, чтобы структурная @@ -629,6 +646,35 @@ def _lookup_pick_direct_value(lookup: _PayloadLookup, field_name: str) -> str: return str(value).strip() if value else "" +def _format_operation_documents(lookup: _PayloadLookup) -> str: + indexed_documents: dict[int, dict[str, str]] = {} + single_document: dict[str, str] = {} + for entry in lookup.entries: + match = _OPERATION_DOCUMENT_FIELD.search(entry.raw_path) + if match is None: + continue + field_name = match.group("field") + value = str(entry.value).strip() + document_index = match.group("index") + if document_index is None: + single_document[field_name] = value + continue + indexed_documents.setdefault(int(document_index), {})[field_name] = value + + documents = ( + [indexed_documents[index] for index in sorted(indexed_documents)] + if indexed_documents + else [single_document] if single_document else [] + ) + formatted_documents = [] + for position, document in enumerate(documents, start=1): + fields = [ + document.get(field_name, "") for field_name in _OPERATION_DOCUMENT_FIELDS + ] + formatted_documents.append(f"{position}. {', '.join(fields)}") + return "; ".join(formatted_documents) + + def _apply_participant_identity_rules( row: dict[int, str], participant_lookup: _PayloadLookup ) -> None: diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index 514e070..1bf7871 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -2125,12 +2125,12 @@ "column_name": "Сведения о документе, подтверждающем совершение операции", "block": "operation_basis", "report_group": "participant_base", - "xml_tag": "СодДок", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/ОснованиеОп/СодДок", - "source_scope": "any", - "allow_short_lookup": true, + "xml_tag": "КодДок|ИноеНаимДок|ДатаДок|НомДок|СодДок", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/ОснованиеОп/", + "source_scope": "operation", + "allow_short_lookup": false, "allow_direct_mapping": true, - "structured_value": false, + "structured_value": true, "structured_group": "", "structured_role": "", "structured_order": 0, @@ -3265,12 +3265,12 @@ "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_value": true, "structured_group": "", "structured_role": "", "structured_order": 0, diff --git a/app/pipeline/parser.py b/app/pipeline/parser.py index b7fc2ae..4245af3 100644 --- a/app/pipeline/parser.py +++ b/app/pipeline/parser.py @@ -201,9 +201,13 @@ def _build_expanded_rows( def _collapse_indexed_fields(fields: dict[str, str]) -> dict[str, str]: """Сохраняет все операционные повторы в одной строке без потери значений.""" collapsed: dict[str, str] = {} + indexed_operation_documents: dict[str, str] = {} for key, value in fields.items(): + if re.match(r"^ОснованиеОп\[\d+\]\.", key): + indexed_operation_documents[key] = value deindexed_key = re.sub(r"\[\d+\]", "", key) _append_value(collapsed, deindexed_key, value) + collapsed.update(indexed_operation_documents) return collapsed diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 71583d3..4a7760e 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -825,6 +825,35 @@ def test_col9_uses_operation_number_from_xml() -> None: assert row[9] == "ЦФТ-БАНК_1177326777530" +def test_operation_documents_keep_positions_and_join_multiple_documents() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ОснованиеОп[0].КодДок": "13", + "ОснованиеОп[0].ДатаДок": "29/01/2026", + "ОснованиеОп[0].НомДок": "253", + "ОснованиеОп[0].СодДок": "Размещение денежных средств", + "ОснованиеОп[1].КодДок": "99", + "ОснованиеОп[1].ИноеНаимДок": "Депозитный договор", + "ОснованиеОп[1].ДатаДок": "29/01/2026", + "ОснованиеОп[1].НомДок": "Р202307/0001/93/Э", + "ОснованиеОп[2].КодДок": "99", + "ОснованиеОп[2].ИноеНаимДок": "Общие условия размещения депозитов", + "ОснованиеОп[2].ДатаДок": "17/11/2020", + "ОснованиеОп[2].НомДок": "Р202307/0001", + }, + participant_fields={}, + ) + + assert row[107] == ( + "1. 13, , 29/01/2026, 253, Размещение денежных средств; " + "2. 99, Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ; " + "3. 99, Общие условия размещения депозитов, 17/11/2020, Р202307/0001, " + ) + + def test_structured_cash_address_does_not_include_bank_fields() -> None: row = build_fixed_row_by_index( file_name="f.xml", @@ -1090,6 +1119,21 @@ def test_eio_block_is_empty_when_eio_and_beneficiary_are_absent() -> None: assert row[index] == "" +def test_eio_other_info_combines_snils_and_phone() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "УчастникЮЛ.СведЕИО.ФЛЕИО.СНИЛСФЛ": "123-456-789 00", + "УчастникЮЛ.СведЕИО.ФЛЕИО.ТелефонНомер": "+7 999 123-45-67", + }, + ) + + assert row[164] == "123-456-789 00, +7 999 123-45-67" + + def test_participant_and_beneficiary_inn_are_scoped_regardless_of_field_order() -> None: own_inn = ("УчастникЮЛ.СведЮЛ.ИННЮЛ", "2820000210") beneficiary_fields = ( diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 912268b..c96af05 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -324,6 +324,8 @@ def test_operation_repeats_are_collapsed_for_single_participant() -> None: assert len(result.rows) == 1 assert result.rows[0].operation_fields["ОснованиеОп.НомДок"] == "BASE-1; BASE-2" + assert result.rows[0].operation_fields["ОснованиеОп[0].НомДок"] == "BASE-1" + assert result.rows[0].operation_fields["ОснованиеОп[1].НомДок"] == "BASE-2" def test_mixed_operation_and_eio_repeats_are_additive_not_cartesian() -> None: