diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index a23311b..99eea6e 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -8,17 +8,19 @@ from pathlib import Path @dataclass(frozen=True) class MappingRule: + column_index: int column_name: str candidate_keys: tuple[str, ...] source: str = "any" allow_direct_mapping: bool = True + allow_short_lookup: bool = True @dataclass(frozen=True) class StructuredGroupRule: - structured_column: str - one_line_columns: tuple[str, ...] - component_columns: tuple[str, ...] + structured_column: int + one_line_columns: tuple[int, ...] + component_columns: tuple[int, ...] join_with_space: bool = False @@ -79,6 +81,22 @@ def _build_fixed_columns(mapping_data: list[dict[str, str | int]]) -> tuple[str, return tuple(columns[:TARGET_REPORT_COLUMNS_COUNT]) +def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]: + candidates: list[str] = [] + for transfers_block in ("СведенияПереводыДС", "СвединияПереводыДС"): + path = ( + "/СообщОперКО/ИнформЧасть/СведКО/Операция/" + f"{transfers_block}/{bank_block}/БИККО" + ) + for candidate in _extract_path_candidate_keys(path): + if ( + candidate.endswith(f"{bank_block}.БИККО") + and candidate not in candidates + ): + candidates.append(candidate) + return tuple(candidates) + + def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, ...]: rules: list[MappingRule] = [] participant_columns = { @@ -93,6 +111,8 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, "ФИО (по структуре полностью)", "ФИО (в одной строке)", "ФИО ( в одной строке)", + "Код страны гражданства", + "Признак принадлежности к публичным лицам", } for row in mapping_data: index = int(row.get("index", 0)) @@ -101,19 +121,85 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, path = str(row.get("xml_path", "")).strip() if not column or tag in _INVALID_TAGS: continue - is_structured_target = _is_structured_column(column) + is_structured_target = _is_structured_column(column) or index == 153 candidates = list(_extract_tag_candidate_keys(tag)) - if column.startswith("ИНН"): + if column.startswith("ИНН") and index < 154: candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates] if column == "Тип участника": candidates = ["Тип", "ТипУчастника", *candidates] if column == "Отчество": # В некоторых выгрузках встречается опечатка тега "Очт". candidates = ["Отч", "Очт", *candidates] + path_candidates = list(_extract_path_candidate_keys(path)) if path else [] if path: - for path_tag in _extract_path_candidate_keys(path): + for path_tag in path_candidates: if path_tag not in candidates: candidates.append(path_tag) + allow_short_lookup = True + if column == "БИК банка плательщика": + candidates = list(_bank_bik_candidate_keys("СведБанкПлательщик")) + allow_short_lookup = False + elif column == "БИК банка получателя": + candidates = list(_bank_bik_candidate_keys("СведБанкПолучатель")) + allow_short_lookup = False + elif column == "Код страны гражданства": + candidates = [ + candidate + for candidate in [*path_candidates, *candidates] + if candidate.endswith("СведФЛИП.КодОКСМ") + ] + allow_short_lookup = False + elif index == 119: + candidates = [ + candidate + for candidate in [*path_candidates, *candidates] + if candidate.endswith("ИдентификацияФЛ") + ] + allow_short_lookup = False + elif index == 123: + candidates = [ + candidate + for candidate in [*path_candidates, *candidates] + if candidate.endswith("ДатаРегЮЛ") or candidate.endswith("ДатаРождения") + ] + allow_short_lookup = False + elif index == 152: + candidates = [ + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.АдресСтрока", + "СообщОперКО.ИнформЧасть.СведКО.Операция.УчастникОП.УчастникЮЛ.СведЮЛ.АдрРегЮЛ.АдресСтрока", + ] + allow_short_lookup = False + elif index == 153: + base_paths = ( + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ", + "СообщОперКО.ИнформЧасть.СведКО.Операция.УчастникОП.УчастникЮЛ.СведЮЛ.АдрРегЮЛ", + ) + address_tags = ( + "Индекс", + "КодОКСМ", + "КодСубъектаПоОКАТО", + "Район", + "Пункт", + "Улица", + "Дом", + "Корп", + "Оф", + ) + candidates = [ + f"{base_path}.{tag}" for base_path in base_paths for tag in address_tags + ] + allow_short_lookup = False + elif index == 202: + candidates = [ + candidate + for candidate in [*path_candidates, *candidates] + if candidate.endswith("ИННЭмитентЦП") + ] + allow_short_lookup = False + elif 49 <= index <= 101 or 155 <= index <= 204: + allow_short_lookup = False + if not candidates: + candidates = list(_extract_tag_candidate_keys(tag)) non_one_line_candidates = [ candidate for candidate in candidates @@ -128,21 +214,30 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, allow_direct_mapping = False if is_structured_target and non_one_line_candidates: direct_candidates = non_one_line_candidates + if 194 <= index <= 204 or index in {41, 43}: + source = "operation" + elif 155 <= index <= 193: + source = "participant" + elif index in {119, 122, 124}: + source = "participant" + else: + source = ( + "participant" + if ( + column in participant_columns + or column.startswith("ИНН") + or index in range(128, 140) + ) + else "any" + ) rules.append( MappingRule( + column_index=index, column_name=column, candidate_keys=tuple(direct_candidates), - source=( - "participant" - if ( - column in participant_columns - or column.startswith("ИНН") - or index in range(128, 140) - or index in range(180, 193) - ) - else "any" - ), + source=source, allow_direct_mapping=allow_direct_mapping, + allow_short_lookup=allow_short_lookup, ) ) return tuple(rules) @@ -194,6 +289,13 @@ def _paths_related( return False +def _is_path_under_any_base(path: str, base_paths: tuple[str, ...]) -> bool: + for base_path in base_paths: + if path == base_path or path.startswith(f"{base_path}/"): + return True + return False + + def _build_structured_group_rules( mapping_data: list[dict[str, str | int]], ) -> tuple[StructuredGroupRule, ...]: @@ -210,8 +312,12 @@ def _build_structured_group_rules( continue struct_paths = _split_paths(str(item.get("xml_path", ""))) - one_line_names: list[str] = [] - component_names: list[str] = [] + struct_base_paths = tuple( + path.rsplit("/", maxsplit=1)[0] for path in struct_paths if "/" in path + ) + is_address_structured = "адрес" in structured_name.lower().replace("ё", "е") + one_line_indexes: list[int] = [] + component_indexes: list[int] = [] # Поле "в одной строке" может располагаться слева от структурного. left = by_index.get(index - 1) @@ -220,7 +326,7 @@ def _build_structured_group_rules( if _is_one_line_column(left_name): left_paths = _split_paths(str(left.get("xml_path", ""))) if _paths_related(struct_paths, left_paths): - one_line_names.append(left_name) + one_line_indexes.append(index - 1) right_boundary = next( ( @@ -242,25 +348,34 @@ def _build_structured_group_rules( right_name = str(right.get("column_name", "")).strip() right_tag = str(right.get("xml_tag", "")).strip() right_paths = _split_paths(str(right.get("xml_path", ""))) - if not _paths_related(struct_paths, right_paths): - if component_names: + if is_address_structured: + is_related = any( + _is_path_under_any_base(candidate_path, struct_base_paths) + for candidate_path in right_paths + ) + else: + is_related = _paths_related(struct_paths, right_paths) + if not is_related: + if component_indexes: break continue if _is_one_line_column(right_name): - one_line_names.append(right_name) + one_line_indexes.append(right_index) + if component_indexes: + break continue if "|" in right_tag: continue - component_names.append(right_name) + component_indexes.append(right_index) - if not component_names: + if not component_indexes: continue rules.append( StructuredGroupRule( - structured_column=structured_name, - one_line_columns=tuple(dict.fromkeys(one_line_names)), - component_columns=tuple(dict.fromkeys(component_names)), + structured_column=index, + one_line_columns=tuple(dict.fromkeys(one_line_indexes)), + component_columns=tuple(dict.fromkeys(component_indexes)), join_with_space="фио" in structured_name.lower(), ) ) @@ -284,12 +399,10 @@ def _extract_path_candidate_keys(path: str) -> tuple[str, ...]: parts = [part for part in raw_path.strip("/").split("/") if part] if not parts: continue - dotted_path = ".".join(parts) - if dotted_path and dotted_path not in candidates: - candidates.append(dotted_path) - leaf_tag = parts[-1] - if leaf_tag and leaf_tag not in _INVALID_TAGS and leaf_tag not in candidates: - candidates.append(leaf_tag) + for start_index in range(len(parts)): + dotted_path = ".".join(parts[start_index:]) + if dotted_path and dotted_path not in candidates: + candidates.append(dotted_path) return tuple(candidates) @@ -300,6 +413,87 @@ STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_grou _MAPPING_DATA ) _ACCOUNT_PLACEHOLDER = "00000000000000000000" +_CURRENCY_OPERATION_CODES = frozenset(str(code) for code in range(6101, 6127)) + + +def build_fixed_row_by_index( + *, + file_name: str, + record_id: str, + operation_index: int, + operation_fields: dict[str, str], + participant_fields: dict[str, str], +) -> dict[int, str]: + row: dict[int, str] = { + 1: file_name, + 7: record_id, + } + 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( + operation_fields, + ( + "СведЦП.", + "СообщОперКО.ИнформЧасть.СведКО.Операция.СведЦП.", + ), + ) + for rule in REPORT_MAPPING_RULES: + if rule.column_index in row: + continue + if not rule.allow_direct_mapping: + row[rule.column_index] = "" + continue + if rule.source == "participant": + source_payload = participant_fields + suffix_index = participant_suffix_index + elif rule.source == "operation": + source_payload = operation_fields + suffix_index = operation_suffix_index + else: + source_payload = merged + suffix_index = merged_suffix_index + if _is_structured_column(rule.column_name) or rule.column_index == 153: + row[rule.column_index] = _pick_structured_value( + source_payload=source_payload, + suffix_index=suffix_index, + candidates=rule.candidate_keys, + join_with_space="фио" in rule.column_name.lower(), + allow_short_lookup=( + rule.allow_short_lookup if rule.column_index == 153 else True + ), + ) + else: + row[rule.column_index] = _pick_value( + source_payload, + suffix_index, + rule.candidate_keys, + allow_short_lookup=rule.allow_short_lookup, + ) + _apply_participant_identity_rules(row, participant_fields) + _apply_structured_group_rules(row) + _apply_conditional_rules_1_74(row) + _apply_conditional_rules_75_123(row) + _apply_conditional_rules_124_203(row) + if not has_eio_block: + for index in range(155, 194): + _set_row_value(row, index, "") + if not has_cp_block: + for index in range(194, 205): + _set_row_value(row, index, "") + for index in range(1, TARGET_REPORT_COLUMNS_COUNT + 1): + row.setdefault(index, "") + return row def build_fixed_row( @@ -310,69 +504,42 @@ def build_fixed_row( operation_fields: dict[str, str], participant_fields: dict[str, str], ) -> dict[str, str]: - row: dict[str, str] = { - "Имя XML файла": file_name, - "Идентификатор записи": record_id, - "Уникальный номер операции": str(operation_index), - } - merged = _merge_fields(operation_fields, participant_fields) - merged_suffix_index = _build_suffix_index(merged) - participant_suffix_index = _build_suffix_index(participant_fields) - for rule in REPORT_MAPPING_RULES: - if row.get(rule.column_name): - continue - if not rule.allow_direct_mapping: - row[rule.column_name] = "" - continue - if rule.source == "participant": - source_payload = participant_fields - suffix_index = participant_suffix_index - else: - source_payload = merged - suffix_index = merged_suffix_index - if _is_structured_column(rule.column_name): - row[rule.column_name] = _pick_structured_value( - source_payload=source_payload, - suffix_index=suffix_index, - candidates=rule.candidate_keys, - join_with_space="фио" in rule.column_name.lower(), - ) - else: - row[rule.column_name] = _pick_value( - source_payload, suffix_index, rule.candidate_keys - ) - _apply_structured_group_rules(row) - _apply_conditional_rules_1_74(row) - _apply_conditional_rules_75_123(row) - _apply_conditional_rules_124_203(row) - for column in FIXED_REPORT_COLUMNS: - row.setdefault(column, "") - return row + by_index = build_fixed_row_by_index( + file_name=file_name, + record_id=record_id, + operation_index=operation_index, + operation_fields=operation_fields, + participant_fields=participant_fields, + ) + by_name: dict[str, str] = {} + for index, column_name in enumerate(FIXED_REPORT_COLUMNS, start=1): + by_name.setdefault(column_name, by_index.get(index, "")) + return by_name -def _apply_structured_group_rules(row: dict[str, str]) -> None: +def _apply_structured_group_rules(row: dict[int, str]) -> None: for rule in STRUCTURED_GROUP_RULES: one_line_has_value = any( - str(row.get(column_name, "")).strip() - for column_name in rule.one_line_columns + _row_value(row, column_index).strip() + for column_index in rule.one_line_columns ) if one_line_has_value: - row[rule.structured_column] = "" - for component_column in rule.component_columns: - row[component_column] = "" + _set_row_value(row, rule.structured_column, "") + for component_column_index in rule.component_columns: + _set_row_value(row, component_column_index, "") continue values = [ - str(row.get(component_column, "")).strip() - for component_column in rule.component_columns - if str(row.get(component_column, "")).strip() + _row_value(row, component_column_index).strip() + for component_column_index in rule.component_columns + if _row_value(row, component_column_index).strip() ] if not values: - row[rule.structured_column] = "" + _set_row_value(row, rule.structured_column, "") continue separator = " " if rule.join_with_space else ", " - row[rule.structured_column] = separator.join(values) + _set_row_value(row, rule.structured_column, separator.join(values)) def _merge_fields( @@ -385,6 +552,58 @@ def _merge_fields( return merged +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) + + +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)): + return str(value).strip() + return "" + + +def _apply_participant_identity_rules( + row: dict[int, str], participant_fields: dict[str, str] +) -> None: + participant_type = _normalize_code(_row_value(row, 109)) + has_legal_entity = _has_payload_block(participant_fields, "УчастникЮЛ") + has_physical_person = _has_payload_block(participant_fields, "УчастникФЛИП") + has_foreign_structure = _has_payload_block(participant_fields, "УчастникИНБОЮЛ") + + if has_physical_person and not has_legal_entity: + identification = _pick_scoped_payload_value( + participant_fields, "УчастникФЛИП.ИдентификацияФЛ" + ) + _set_row_value(row, 119, identification) + else: + _set_row_value(row, 119, "") + + if has_legal_entity or participant_type == "1": + value = _pick_scoped_payload_value( + participant_fields, "УчастникЮЛ.СведЮЛ.КППЮЛ" + ) + elif has_foreign_structure: + value = _pick_scoped_payload_value( + participant_fields, + "УчастникИНБОЮЛ.СведИНБОЮЛ.ПризнакОргФормаИНБОЮЛ", + ) + elif has_physical_person: + value = _pick_scoped_payload_value( + participant_fields, "УчастникФЛИП.ИдентификацияФЛ" + ) + else: + value = "" + _set_row_value(row, 122, value) + + +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) + + def _build_suffix_index(payload: dict[str, str]) -> dict[str, str]: index: dict[str, str] = {} for key, value in payload.items(): @@ -399,10 +618,14 @@ def _pick_value( payload: dict[str, str], suffix_index: dict[str, str], candidates: tuple[str, ...], + *, + allow_short_lookup: bool = True, ) -> str: for key in candidates: if key in payload and payload[key]: return payload[key] + if not allow_short_lookup: + continue short = key.rsplit(".", maxsplit=1)[-1] if short in payload and payload[short]: return payload[short] @@ -417,10 +640,16 @@ def _pick_structured_value( suffix_index: dict[str, str], candidates: tuple[str, ...], join_with_space: bool, + allow_short_lookup: bool = True, ) -> str: values: list[str] = [] for key in candidates: - value = _pick_value(source_payload, suffix_index, (key,)) + value = _pick_value( + source_payload, + suffix_index, + (key,), + allow_short_lookup=allow_short_lookup, + ) if value and value not in values: values.append(value) if not values: @@ -433,25 +662,32 @@ def _column(index: int) -> str: return FIXED_REPORT_COLUMNS[index - 1] -def _row_value(row: dict[str, str], index: int) -> str: - return str(row.get(_column(index), "")).strip() +def _row_value(row: dict[int, str], index: int) -> str: + return str(row.get(index, "")).strip() -def _set_row_value(row: dict[str, str], index: int, value: str) -> None: - row[_column(index)] = value +def _normalize_code(value: str) -> str: + normalized = value.strip() + if re.fullmatch(r"\d+", normalized): + return normalized.lstrip("0") or "0" + return normalized + + +def _set_row_value(row: dict[int, str], index: int, value: str) -> None: + row[index] = value def _extract_numeric_codes(value: str) -> set[str]: return {match for match in re.findall(r"\d+", value)} -def _operation_and_extra_codes(row: dict[str, str]) -> set[str]: +def _operation_and_extra_codes(row: dict[int, str]) -> set[str]: op_code = _row_value(row, 19) extra_codes = _row_value(row, 20) return _extract_numeric_codes(op_code) | _extract_numeric_codes(extra_codes) -def _apply_conditional_rules_1_74(row: dict[str, str]) -> None: +def _apply_conditional_rules_1_74(row: dict[int, str]) -> None: codes = _operation_and_extra_codes(row) op_type_code = _row_value(row, 14) transfer_type = _row_value(row, 35) @@ -479,6 +715,16 @@ def _apply_conditional_rules_1_74(row: dict[str, str]) -> None: _set_row_value(row, 23, "") _set_row_value(row, 24, "") + # 26. Сумма продаваемой валюты выводится только при конверсии и не равна нулю. + conversion_amount = _row_value(row, 26) + normalized_amount = conversion_amount.replace(",", ".") + if not _row_value(row, 25) or re.fullmatch(r"[+-]?0+(?:\.0+)?", normalized_amount): + _set_row_value(row, 26, "") + + # 27. Признак VO относится только к валютным операциям 6101-6126. + if not codes & _CURRENCY_OPERATION_CODES: + _set_row_value(row, 27, "") + # 28. Идентификатор подозрительной деятельности только для 6001. if "6001" not in codes: _set_row_value(row, 28, "") @@ -543,8 +789,13 @@ def _apply_conditional_rules_1_74(row: dict[str, str]) -> None: if transfer_type not in {"2", "5", "8"}: _set_row_value(row, 62, "") + # Для безналичных операций не заполняем блоки приема/выдачи наличных. + if transfer_type in {"1", "2", "3", "4", "7", "10", "11", "12", "13", "14"}: + for column_index in (50, 61, 65): + _set_row_value(row, column_index, "") -def _apply_conditional_rules_75_123(row: dict[str, str]) -> None: + +def _apply_conditional_rules_75_123(row: dict[int, str]) -> None: codes = _operation_and_extra_codes(row) # 96. Код территории заполняется только для признака 5016 (дубль 36). @@ -559,8 +810,23 @@ def _apply_conditional_rules_75_123(row: dict[str, str]) -> None: if _row_value(row, 104) == "0" and not _row_value(row, 105): _set_row_value(row, 105, "Информация отсутствует") + transfer_type = _row_value(row, 35) + if transfer_type in {"1", "2", "3", "4", "7", "10", "11", "12", "13", "14"}: + for column_index in (76, 84, 95, 99, 101): + _set_row_value(row, column_index, "") -def _allow_one_line_address(row: dict[str, str]) -> bool: + participant_type = _normalize_code(_row_value(row, 109)) + + # 119. Для ЮЛ и ФЛ поле ППЦР не заполняется. + if participant_type in {"1", "2"}: + _set_row_value(row, 119, "") + + # 122. Для ФЛ идентификация не подставляется вместо отсутствующего КПП. + if participant_type == "2": + _set_row_value(row, 122, "") + + +def _allow_one_line_address(row: dict[int, str]) -> bool: resident_flag = _row_value(row, 110) client_flag = _row_value(row, 111) if not resident_flag and not client_flag: @@ -568,11 +834,11 @@ def _allow_one_line_address(row: dict[str, str]) -> bool: return resident_flag in {"0", "9"} or (resident_flag == "1" and client_flag == "0") -def _apply_conditional_rules_124_203(row: dict[str, str]) -> None: - participant_type = _row_value(row, 109) +def _apply_conditional_rules_124_203(row: dict[int, str]) -> None: + participant_type = _normalize_code(_row_value(row, 109)) - # 124. Для ФЛ и ИП показатель отсутствует. - if participant_type in {"2", "3"}: + # 124. Для ФЛ, ИП и ФЛЧП показатель отсутствует. + if participant_type in {"2", "3", "4"}: _set_row_value(row, 124, "") # 125. Для ИП СНИЛС не выводится. diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index 33cbf30..f93955b 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -309,7 +309,7 @@ "index": 35, "column_name": "Вид перевода", "xml_tag": "ВидПереводДС", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ВидПереводДС", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ВидПереводДС", "multiplies": "нет", "required": "Обязательно", "comment": "числовой показатель" @@ -318,7 +318,7 @@ "index": 36, "column_name": "Код территории (5016)", "xml_tag": "КодТерИнГос", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/КодТерИнГос", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/КодТерИнГос", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -327,7 +327,7 @@ "index": 37, "column_name": "Тип оператора", "xml_tag": "ТипОператорДС", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ТипОператорДС", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ТипОператорДС", "multiplies": "нет", "required": "Обязательно", "comment": "числовой показатель" @@ -336,7 +336,7 @@ "index": 38, "column_name": "Номер счета плательщика", "xml_tag": "НомерСчетПлательщик", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/НомерСчетПлательщик", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/НомерСчетПлательщик", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -345,7 +345,7 @@ "index": 39, "column_name": "Идентификатор ЭСП плательщика", "xml_tag": "ИдентЭСППлательщик", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ИдентЭСППлательщик", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ИдентЭСППлательщик", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -354,7 +354,7 @@ "index": 40, "column_name": "Наименование банка плательщика", "xml_tag": "СведБанкПлательщик", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПлательщик/НаимКО", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/НаимКО", "multiplies": "нет", "required": "необязательно", "comment": "Данные передаются в XML в виде <НаимКО> АО \"АЛЬФА-БАНК\" в отчет заносятся в виде: Банк ВТБ (ПАО), филиал Филиал \"Центральный\" Банка ВТБ (ПАО)" @@ -363,7 +363,7 @@ "index": 41, "column_name": "БИК банка плательщика", "xml_tag": "БИККО", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПлательщик/БИККО", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/БИККО", "multiplies": "нет", "required": "необязательно", "comment": "Данные передаются в XML в виде: <БИККО> 044525593 в отчет заносятся в виде: 044525411" @@ -372,7 +372,7 @@ "index": 42, "column_name": "Наименование банка получателя", "xml_tag": "СведБанкПолучатель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПолучатель/НаимКО", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/НаимКО", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -381,7 +381,7 @@ "index": 43, "column_name": "БИК банка получателя", "xml_tag": "БИККО", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПолучатель/БИККО", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/БИККО", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -390,7 +390,7 @@ "index": 44, "column_name": "Номер счета банка плательщика", "xml_tag": "СчетБанкПлательщик", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СчетБанкПлательщик", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СчетБанкПлательщик", "multiplies": "нет", "required": "необязательно", "comment": "Данные передаются в XML в виде: <СчетБанкПлательщик> 30101810300000000711 в отчет заносятся в виде: 30101810300000000711" @@ -399,7 +399,7 @@ "index": 45, "column_name": "Номер счета банка получателя", "xml_tag": "СчетБанкПолучатель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СчетБанкПолучатель", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СчетБанкПолучатель", "multiplies": "нет", "required": "необязательно", "comment": "Данные передаются в XML в виде: <СчетБанкПолучатель> 30101810200000000704 в отчет заносятся в виде: 30101810200000000704" @@ -408,7 +408,7 @@ "index": 46, "column_name": "Номер счета получателя", "xml_tag": "НомерСчетПолучатель", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/НомерСчетПолучатель", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/НомерСчетПолучатель", "multiplies": "нет", "required": "необязательно", "comment": "Данные передаются в XML в виде: <НомерСчетПолучатель> 40703810000000000000 в отчет заносятся в виде: 40703810000000000000" @@ -417,7 +417,7 @@ "index": 47, "column_name": "Идентификатор ЭСП получателя", "xml_tag": "ИдентЭСППолучателя", - "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ИдентЭСППолучателя", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ИдентЭСППолучателя", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" @@ -867,7 +867,7 @@ "index": 97, "column_name": "Номер банкомата", "xml_tag": "ИдТерминал", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/ИдТерминал", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/ИдТерминал", "multiplies": "нет", "required": "необязательно", "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" @@ -876,7 +876,7 @@ "index": 98, "column_name": "Код страны", "xml_tag": "КодОКСМ", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/КодОКСМ", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/КодОКСМ", "multiplies": "нет", "required": "необязательно", "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" @@ -885,7 +885,7 @@ "index": 99, "column_name": "Адрес места приема наличных денежных средств (по структуре полный)", "xml_tag": "АдрМестаПриемаВыдача", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока", "multiplies": "нет", "required": "необязательно", "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" @@ -894,7 +894,7 @@ "index": 100, "column_name": "Наименование банка, обслуживающего участника операции", "xml_tag": "НаимКО", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/НаимКО", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/НаимКО", "multiplies": "нет", "required": "необязательно", "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" @@ -903,7 +903,7 @@ "index": 101, "column_name": "БИК банка, обслуживающего участника операции", "xml_tag": "БИККО", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/БИККО", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/БИККО", "multiplies": "нет", "required": "Обязательно", "comment": "Числовой показатель" @@ -912,7 +912,7 @@ "index": 102, "column_name": "Номер платежной карты", "xml_tag": "НомерКарта", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/НомерКарта", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НомерКарта", "multiplies": "нет", "required": "необязательно", "comment": "Показатель в XML передается зашифрованными ***************, в отчет попадают цифры" @@ -921,7 +921,7 @@ "index": 103, "column_name": "Сведения о держателе платежной карты", "xml_tag": "СведДержательКарты", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведДержательКарты", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведДержательКарты", "multiplies": "нет", "required": "обязательно", "comment": "" @@ -930,7 +930,7 @@ "index": 104, "column_name": "Пр-ак соверш/ опер. с уч-ем УС КО", "xml_tag": "ПризнакСотрудник", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/ПризнакСотрудник", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/ПризнакСотрудник", "multiplies": "нет", "required": "обязательно", "comment": "Числовой показатель" @@ -939,7 +939,7 @@ "index": 105, "column_name": "Наименование иностранного банка", "xml_tag": "НаимИнБанк", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/НаимИнБанк", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НаимИнБанк", "multiplies": "нет", "required": "обязательно", "comment": "" @@ -948,7 +948,7 @@ "index": 106, "column_name": "СВИФТ", "xml_tag": "СВИФТИнБанк", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СВИФТИнБанк", + "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СВИФТИнБанк", "multiplies": "нет", "required": "необязательно", "comment": "поле может остаться пустым и не передаваться в XML" @@ -1064,8 +1064,8 @@ { "index": 119, "column_name": "Идентификатор ППЦР", - "xml_tag": "ПризнУчастника", - "xml_path": "/СообщОперКО/нформЧасть/СведКО/Операция/УчастникОП/ПризнУчастника", + "xml_tag": "ИдентификацияФЛ", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/ИдентификацияФЛ", "multiplies": "нет", "required": "обязательно", "comment": "числовой показатель" @@ -1101,7 +1101,7 @@ "index": 123, "column_name": "Дата государственной регистрации :/ Дата рождения:", "xml_tag": "ДатаРегЮЛ|ДатаРождения", - "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ДатаРегЮЛ | СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СветФЛИП/ДатаРождения", + "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ДатаРегЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ДатаРождения", "multiplies": "нет", "required": "необязательно", "comment": "Показатель в формате ДД/ММ/ГГГГ." diff --git a/app/pipeline/parser.py b/app/pipeline/parser.py index 3ba7f99..db49617 100644 --- a/app/pipeline/parser.py +++ b/app/pipeline/parser.py @@ -200,6 +200,7 @@ def _extract_common_fields(root: ET.Element) -> dict[str, str]: def _collect_leaf_fields( element: ET.Element, current_path: str, fields: dict[str, str] ) -> None: + _collect_attributes(element, current_path, fields) children = list(element) if not children: _append_value(fields, current_path, (element.text or "").strip()) @@ -210,6 +211,14 @@ def _collect_leaf_fields( _collect_leaf_fields(child, f"{current_path}.{child_name}", fields) +def _collect_attributes( + element: ET.Element, current_path: str, fields: dict[str, str] +) -> None: + for raw_name, raw_value in element.attrib.items(): + attr_name = _normalize_tag(raw_name) + _append_value(fields, f"{current_path}.{attr_name}", raw_value.strip()) + + def _append_value(fields: dict[str, str], key: str, value: str) -> None: if key not in fields: fields[key] = value diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 8acc0db..f6fdf06 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -4,13 +4,14 @@ import re import shutil from dataclasses import dataclass from datetime import date, datetime +from decimal import Decimal, InvalidOperation from pathlib import Path from tempfile import NamedTemporaryFile from typing import Iterable import xlsxwriter -from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row +from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row_by_index @dataclass @@ -130,7 +131,7 @@ class StreamingReportWriter: self._next_data_row = 2 def append_row(self, row: ReportRow) -> None: - data = build_fixed_row( + data_by_index = build_fixed_row_by_index( file_name=row.file_name, record_id=row.record_id, operation_index=row.operation_index, @@ -138,7 +139,7 @@ class StreamingReportWriter: participant_fields=row.participant_fields, ) for index, column_name in enumerate(self.columns): - raw_value = data.get(column_name, "") + raw_value = self._resolve_output_value(index + 1, data_by_index) default_format = self._column_formats[index] value, cell_format = self._normalize_cell_value( column_name=column_name, @@ -153,7 +154,12 @@ class StreamingReportWriter: self.sheet.write_string( self._next_data_row, index, - self._normalize_identifier_string(value), + self._normalize_identifier_string( + column_name, + value, + column_index=index + 1, + row_by_index=data_by_index, + ), ) elif cell_format is not None: self.sheet.write(self._next_data_row, index, value, cell_format) @@ -161,6 +167,21 @@ class StreamingReportWriter: self.sheet.write(self._next_data_row, index, value) self._next_data_row += 1 + def _resolve_output_value( + self, + column_index: int, + row_by_index: dict[int, str], + ) -> object: + # В целевом шаблоне адресный блок участника ожидается со структурным + # адресом в колонке 141, поэтому на этапе выгрузки делаем сдвиг. + if column_index == 141: + return row_by_index.get(142, "") + if 142 <= column_index <= 150: + return row_by_index.get(column_index + 1, "") + if column_index == 151: + return row_by_index.get(141, "") + return row_by_index.get(column_index, "") + def save(self, destination: Path) -> None: destination.parent.mkdir(parents=True, exist_ok=True) try: @@ -217,16 +238,54 @@ class StreamingReportWriter: return raw_value, default_format def _is_text_identifier_column(self, column_name: str) -> bool: - keywords = ("ИНН", "ОГРН", "КПП", "БИК", "СВИФТ") + keywords = ("ИНН", "ОГРН", "КПП", "БИК", "СВИФТ", "Номер счета") return any(keyword in column_name for keyword in keywords) - def _normalize_identifier_string(self, value: object) -> str: + def _normalize_identifier_string( + self, + column_name: str, + value: object, + *, + column_index: int, + row_by_index: dict[int, str], + ) -> str: if value is None: return "" text = str(value).strip() + normalized = self._to_plain_integer_text(text) + if normalized is None: + return text + + if "Номер счета" in column_name: + return normalized.zfill(20) + if "БИК" in column_name: + return normalized.zfill(9) + if "КПП" in column_name: + participant_type = str(row_by_index.get(109, "")).strip() + normalized_type = participant_type.lstrip("0") or "0" + if column_index == 122 and normalized_type != "1": + return normalized + return normalized.zfill(9) + if "ИНН" in column_name: + if len(normalized) in {9, 11, 4}: + return normalized.zfill(len(normalized) + 1) + return normalized + return normalized + + def _to_plain_integer_text(self, text: str) -> str | None: + if re.fullmatch(r"\d+", text): + return text if re.fullmatch(r"\d+\.0+", text): return text.split(".", maxsplit=1)[0] - return text + if re.fullmatch(r"\d+(?:\.\d+)?[eE][+-]?\d+", text): + try: + numeric = Decimal(text) + except InvalidOperation: + return None + if numeric != numeric.to_integral_value(): + return None + return format(numeric, "f").split(".", maxsplit=1)[0] + return None def _parse_date_value(self, value: object) -> datetime | None: if isinstance(value, datetime): diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 1097fe3..200a38b 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -4,6 +4,7 @@ from app.pipeline.mapping import ( FIXED_REPORT_COLUMNS, _extract_tag_candidate_keys, build_fixed_row, + build_fixed_row_by_index, ) @@ -21,6 +22,7 @@ def test_build_fixed_row_maps_known_fields() -> None: operation_index=2, operation_fields={ "ИдФайл": "FILE_01", + "НомерОперация": "OP_2", "КодОперации": "5007", "СумОперации": "100.55", }, @@ -32,10 +34,10 @@ def test_build_fixed_row_maps_known_fields() -> None: assert row["Имя XML файла"] == "f.xml" assert row["Идентификатор записи"] == "R1" - assert row["Уникальный номер операции"] == "2" + assert row["Уникальный номер операции"] == "OP_2" assert row["Код вида операции"] == "5007" assert row["Сумма в валюте проведения"] == "100.55" - assert row[_column_with("ИНН ")] == "123456789012" + assert row[_column_with("ИНН")] == "123456789012" assert row["Тип участника"] == "01" @@ -50,7 +52,7 @@ def test_build_fixed_row_keeps_missing_values_empty() -> None: for column in FIXED_REPORT_COLUMNS: assert column in row assert row["Код вида операции"] == "" - assert row[_column_with("ИНН ")] == "" + assert row[_column_with("ИНН")] == "" def test_build_fixed_row_does_not_take_operation_inn_for_participant() -> None: @@ -67,7 +69,7 @@ def test_build_fixed_row_does_not_take_operation_inn_for_participant() -> None: "Тип": "01", }, ) - assert row[_column_with("ИНН ")] == "111111111111" + assert row[_column_with("ИНН")] == "111111111111" assert row["Тип участника"] == "01" @@ -82,7 +84,7 @@ def test_build_fixed_row_uses_alternative_path_leaf_for_emitent_name() -> None: record_id="R1", operation_index=1, operation_fields={ - "ФИОСтрока": "Эмитент ФЛ", + "СведЦП.Эмитент.НаимЭмитентЦП.ФизЛицо.ФИОСтрока": "Эмитент ФЛ", }, participant_fields={}, ) @@ -100,7 +102,7 @@ def test_build_fixed_row_maps_dotted_participant_keys() -> None: "УчастникФЛИП.СведФЛИП.СведДокУдЛичн.НомДок": "451099", }, ) - assert row[_column_with("ИНН ")] == "123456789012" + assert row[_column_with("ИНН")] == "123456789012" assert row["Номер"] == "451099" @@ -135,7 +137,7 @@ def test_build_fixed_row_uses_first_suffix_value_from_index() -> None: "УчастникФЛИП.Второй.ИННФЛИП": "222222222222", }, ) - assert row[_column_with("ИНН ")] == "111111111111" + assert row[_column_with("ИНН")] == "111111111111" def test_build_fixed_row_skips_empty_suffix_values_in_index() -> None: @@ -149,7 +151,7 @@ def test_build_fixed_row_skips_empty_suffix_values_in_index() -> None: "УчастникФЛИП.Второй.ИННФЛИП": "222222222222", }, ) - assert row[_column_with("ИНН ")] == "222222222222" + assert row[_column_with("ИНН")] == "222222222222" def test_structured_address_is_built_from_right_columns() -> None: @@ -261,13 +263,13 @@ def test_structured_column_without_component_block_joins_candidate_values() -> N operation_index=1, operation_fields={}, participant_fields={ - "Индекс": "121467", - "КодОКСМ": "643", - "Пункт": "Москва", - "Улица": "Молодогвардейская", - "Дом": "8", - "Корп": "1", - "Оф": "28", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Индекс": "121467", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.КодОКСМ": "643", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Пункт": "Москва", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Улица": "Молодогвардейская", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Дом": "8", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Корп": "1", + "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Оф": "28", }, ) assert ( @@ -283,7 +285,9 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None operation_index=1, operation_fields={}, participant_fields={ - "АдрУчредитель": "г. Москва, ул. Пушкина, д. 1", + "УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.АдрРег.АдрУчредитель": ( + "г. Москва, ул. Пушкина, д. 1" + ), }, ) assert row["Адрес в одной строке"] == "" @@ -548,3 +552,394 @@ def test_dul_columns_128_134_keep_expected_positions() -> None: assert row["Номер"] == "476635" assert row["Орган, выдавший документ"] == "ОВД" assert row["КП"] == "222-022" + + +def test_bank_bik_columns_use_transfer_bank_paths_instead_of_common_bikko() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "БИККО": "044525111", + "ТипОператорДС": "4", + "СвединияПереводыДС.СведБанкПлательщик.БИККО": "011012100", + "СвединияПереводыДС.СведБанкПолучатель.БИККО": "022202220", + }, + participant_fields={}, + ) + assert row["БИК банка плательщика"] == "011012100" + assert row["БИК банка получателя"] == "022202220" + + +def test_citizenship_country_code_uses_only_direct_svedflip_value() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643", + "УчастникФЛИП.СведФЛИП.КодОКСМ": "762", + }, + ) + assert row["Код страны гражданства"] == "762" + + +def test_citizenship_country_code_is_empty_when_only_address_code_exists() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643"}, + ) + assert row["Код страны гражданства"] == "" + + +def test_okato_column_4_uses_branch_okatofl_and_not_bank_okatoko() -> None: + row = build_fixed_row( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ОКАТОКО": "45", + "ОКАТОФл": "10", + "АдресСтрока": "г. Москва", + }, + participant_fields={}, + ) + assert FIXED_REPORT_COLUMNS[3] == "ОКАТО" + assert row["ОКАТО"] == "10" + + +def test_col9_uses_operation_number_from_xml() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=7, + operation_fields={"НомерОперация": "ЦФТ-БАНК_1177326777530"}, + participant_fields={}, + ) + assert row[9] == "ЦФТ-БАНК_1177326777530" + + +def test_structured_cash_address_does_not_include_bank_fields() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.Индекс": "675520", + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.КодОКСМ": "643", + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.КодСубъектаПоОКАТО": "10", + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.Пункт": "Чигири с", + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.Улица": "Зеленая ул", + "СведенияПереводыДс.СведПриемНалДС.АдрМестоПриемаВыдача.Дом": "1", + "СведенияПереводыДс.СведПриемНалДС.БИККО": "044525111", + "СведенияПереводыДс.СведПриемНалДС.НаимКО": "АО РСХБ", + }, + participant_fields={}, + ) + assert row[50] == "675520, 643, 10, Чигири с, Зеленая ул, 1" + assert "044525111" not in row[50] + assert "АО РСХБ" not in row[50] + + +def test_participant_structured_address_stays_in_142_and_components_143_plus() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "123456", + "УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643", + "УчастникФЛИП.СведФЛИП.АдрРег.КодСубъектаПоОКАТО": "45", + "УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва", + "УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская", + "УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1", + }, + ) + assert row[141] == "" + assert row[142] == "123456, 643, 45, Москва, Тверская, 1" + assert row[143] == "123456" + assert row[144] == "643" + + +def test_col153_uses_only_legal_entity_registration_address_components() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"КодОКСМ": "643", "Индекс": "999999"}, + participant_fields={ + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Индекс": "675520", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодОКСМ": "643", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО": "10", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район": "Благовещенский р-н", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Пункт": "Чигири с", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Улица": "Зеленая ул", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Дом": "1", + }, + ) + assert row[153] == "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1" + + +def test_cash_columns_cleared_for_cashless_transfer_type_1() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ВидПереводДС": "1", + "КодОперации": "4006", + "БИККО": "044525111", + "СведенияПереводыДс.СведПриемНалДС.БИККО": "044525111", + }, + participant_fields={}, + ) + for index in (50, 61, 65, 76, 84, 95, 99, 101): + assert row[index] == "" + + +def test_col119_is_empty_for_legal_entity_and_uses_flip_identifier() -> None: + row_ul = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={"ТипУчастника": "1", "ИдентификацияФЛ": "1"}, + ) + assert row_ul[119] == "" + + row_flip = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "ТипУчастника": "3", + "УчастникФЛИП.ИдентификацияФЛ": "1", + }, + ) + assert row_flip[119] == "1" + + +def test_physical_person_type_2_does_not_fill_cols_119_and_122() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "СтатусУчастника": "3", + "ТипУчастника": "2", + "УчастникФЛИП.ИдентификацияФЛ": "1", + "УчастникФЛИП.СведФЛИП.ИННФЛИП": "410116812922", + }, + ) + assert row[119] == "" + assert row[120] == "410116812922" + assert row[122] == "" + + +def test_col123_uses_registration_date_for_legal_entity() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "ТипУчастника": "1", + "УчастникЮЛ.СведЮЛ.ДатаРегЮЛ": "23/08/2005", + "УчастникФЛИП.СведФЛИП.ДатаРождения": "01/01/1990", + }, + ) + assert row[123] == "23/08/2005" + + +def test_eio_block_is_empty_when_eio_and_beneficiary_are_absent() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "ТипУчастника": "1", + "УчастникЮЛ.СведЮЛ.ИННЮЛ": "2820000210", + "УчастникЮЛ.СведЮЛ.ОГРНЮЛ": "1022801198573", + "УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО": "45", + }, + ) + for index in (157, 162, 163, 168, 171): + assert row[index] == "" + + +def test_cp_block_is_empty_without_sved_cp_and_uses_emitent_inn_when_present() -> None: + row_without_cp = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={"ИННЮЛ": "2820000210"}, + ) + assert row_without_cp[202] == "" + + row_with_cp = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={"СведЦП.Эмитент.ИННЭмитентЦП": "7707083893"}, + participant_fields={"ИННЮЛ": "2820000210"}, + ) + assert row_with_cp[202] == "7707083893" + + +def test_currency_columns_apply_only_to_conversion_and_currency_operations() -> None: + non_conversion = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "КодОперации": "6001", + "СумКонверсия": "0.00", + "ПризнВалОперации": "VO", + }, + participant_fields={}, + ) + assert non_conversion[26] == "" + assert non_conversion[27] == "" + + conversion = build_fixed_row_by_index( + file_name="f.xml", + record_id="R2", + operation_index=2, + operation_fields={ + "КодОперации": "6101", + "КодВалКонверсия": "978", + "СумКонверсия": "100.50", + "ПризнВалОперации": "VO", + }, + participant_fields={}, + ) + assert conversion[26] == "100.50" + assert conversion[27] == "VO" + + zero_conversion = build_fixed_row_by_index( + file_name="f.xml", + record_id="R3", + operation_index=3, + operation_fields={ + "КодОперации": "6126", + "КодВалКонверсия": "840", + "СумКонверсия": "0.00", + "ПризнВалОперации": "VO", + }, + participant_fields={}, + ) + assert zero_conversion[26] == "" + assert zero_conversion[27] == "VO" + + for operation_code in ("6100", "6127"): + boundary_row = build_fixed_row_by_index( + file_name="f.xml", + record_id=operation_code, + operation_index=4, + operation_fields={ + "КодОперации": operation_code, + "ПризнВалОперации": "VO", + }, + participant_fields={}, + ) + assert boundary_row[27] == "" + + +def test_bank_bik_columns_accept_canonical_transfer_block_spelling() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={ + "ТипОператорДС": "4", + "СведенияПереводыДС.СведБанкПлательщик.БИККО": "011012100", + "СведенияПереводыДС.СведБанкПолучатель.БИККО": "022202220", + }, + participant_fields={}, + ) + assert row[41] == "011012100" + assert row[43] == "022202220" + + +def test_legal_entity_blocks_do_not_use_physical_person_identification() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "ТипУчастника": "4", + "УчастникЮЛ.СведЮЛ.НаимЮЛ": "ООО Тест", + "ИдентификацияФЛ": "1", + }, + ) + assert row[119] == "" + assert row[122] == "" + + +def test_col124_is_empty_for_private_practice_participant_type_4() -> None: + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields={ + "ТипУчастника": "4", + "УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1", + }, + ) + assert row[124] == "" + + padded_type_row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R2", + operation_index=2, + operation_fields={}, + participant_fields={ + "ТипУчастника": "04", + "УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1", + }, + ) + assert padded_type_row[124] == "" + + +def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() -> None: + participant_fields = { + "ТипУчастника": "2", + "УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов", + "УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом", + "УчастникФЛИП.СведФЛИП.ФИОФЛИП.Отч": "Каримович", + "УчастникФЛИП.ИдентификацияФЛ": "1", + } + row = build_fixed_row_by_index( + file_name="f.xml", + record_id="R1", + operation_index=1, + operation_fields={}, + participant_fields=participant_fields, + ) + assert row[114] == "Азимов Ислом Каримович" + assert row[119] == "" + assert row[122] == "" + + row_with_one_line_fio = build_fixed_row_by_index( + file_name="f.xml", + record_id="R2", + operation_index=2, + operation_fields={}, + participant_fields={ + **participant_fields, + "УчастникФЛИП.СведФЛИП.ФИОФЛИП.ФИОСтрока": ("Азимов Ислом Каримович"), + }, + ) + assert row_with_one_line_fio[119] == "" + assert row_with_one_line_fio[122] == "" diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 186b555..a70a876 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -91,3 +91,32 @@ def test_parse_xml_with_numeric_suffix_file_name_is_valid() -> None: result = parse_xml_content("SKO115FZ_01_044525111_20251224_000051.xml", xml) assert not result.is_fatal assert len(result.rows) == 1 + + +def test_parse_xml_collects_legal_entity_registration_address_attributes() -> None: + xml = _base_xml( + """ +<Операция> + <ИдентификаторЗаписи>OP5 + <УчастникОп> + <УчастникЮЛ> + <СведЮЛ> + <АдрРегЮЛ Индекс="675520" КодОКСМ="643" КодСубъектаПоОКАТО="10" + Район="Благовещенский р-н" Пункт="Чигири с" + Улица="Зеленая ул" Дом="1" /> + + + + +""" + ) + result = parse_xml_content("SKO115FZ_01_123456789_20260616_X00001.xml", xml) + assert not result.is_fatal + assert len(result.rows) == 1 + participant_fields = result.rows[0].participant_fields + assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Индекс"] == "675520" + assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодОКСМ"] == "643" + assert participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.КодСубъектаПоОКАТО"] == "10" + assert ( + participant_fields["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р-н" + ) diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index d1aa911..6170a1a 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from pathlib import Path +from unittest.mock import patch from openpyxl import load_workbook @@ -135,6 +136,160 @@ def test_write_report_writes_inn_like_values_as_text(tmp_path: Path) -> None: assert cell.value == "2820000210" +def test_write_report_preserves_account_number_as_text(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + fixed_row = {index: "" for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)} + fixed_row[FIXED_REPORT_COLUMNS.index("Номер счета плательщика") + 1] = ( + "40702156767000000008" + ) + fixed_row[FIXED_REPORT_COLUMNS.index("Номер счета получателя") + 1] = ( + 4.0702156767e19 + ) + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + + with patch("app.pipeline.report.build_fixed_row_by_index", return_value=fixed_row): + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + payer_col = FIXED_REPORT_COLUMNS.index("Номер счета плательщика") + 1 + receiver_col = FIXED_REPORT_COLUMNS.index("Номер счета получателя") + 1 + assert sheet.cell(row=3, column=payer_col).value == "40702156767000000008" + assert sheet.cell(row=3, column=receiver_col).value == "40702156767000000000" + + +def test_write_report_restores_leading_zeros_for_bik_inn_kpp(tmp_path: Path) -> None: + destination = tmp_path / "report.xlsx" + fixed_row = {index: "" for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)} + fixed_row[109] = "1" + fixed_row[FIXED_REPORT_COLUMNS.index("БИК") + 1] = 41012731 + fixed_row[FIXED_REPORT_COLUMNS.index("ИНН") + 1] = 323086051 + fixed_row[FIXED_REPORT_COLUMNS.index("КПП (ПРИЗНАК ИСБОЮЛ) /Пр ид-ии ФЛ:") + 1] = ( + 32601001 + ) + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + + with patch("app.pipeline.report.build_fixed_row_by_index", return_value=fixed_row): + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + bik_col = FIXED_REPORT_COLUMNS.index("БИК") + 1 + inn_col = next( + index + for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1) + if "ИНН" in name + ) + kpp_col = FIXED_REPORT_COLUMNS.index("КПП (ПРИЗНАК ИСБОЮЛ) /Пр ид-ии ФЛ:") + 1 + assert sheet.cell(row=3, column=bik_col).value == "041012731" + assert sheet.cell(row=3, column=inn_col).value == "0323086051" + assert sheet.cell(row=3, column=kpp_col).value == "032601001" + + +def test_write_report_shifts_participant_address_block_to_expected_columns( + tmp_path: Path, +) -> None: + destination = tmp_path / "report.xlsx" + fixed_row = {index: "" for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)} + fixed_row[141] = "г. Благовещенск" + fixed_row[142] = "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1" + fixed_row[143] = "675520" + fixed_row[144] = "643" + fixed_row[145] = "10" + fixed_row[146] = "Благовещенский р-н" + fixed_row[147] = "Чигири с" + fixed_row[148] = "Зеленая ул" + fixed_row[149] = "1" + fixed_row[150] = "" + fixed_row[151] = "" + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + + with patch("app.pipeline.report.build_fixed_row_by_index", return_value=fixed_row): + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + assert sheet.cell(row=3, column=141).value == ( + "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1" + ) + assert sheet.cell(row=3, column=142).value == "675520" + assert sheet.cell(row=3, column=143).value == "643" + assert sheet.cell(row=3, column=144).value == "10" + + +def test_write_report_does_not_pad_physical_person_identification_as_kpp( + tmp_path: Path, +) -> None: + destination = tmp_path / "report.xlsx" + fixed_row = {index: "" for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)} + fixed_row[109] = "2" + fixed_row[122] = "1" + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + + with patch("app.pipeline.report.build_fixed_row_by_index", return_value=fixed_row): + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + assert sheet.cell(row=3, column=122).value == "1" + + +def test_write_report_does_not_assume_kpp_when_participant_type_is_missing( + tmp_path: Path, +) -> None: + destination = tmp_path / "report.xlsx" + fixed_row = {index: "" for index in range(1, len(FIXED_REPORT_COLUMNS) + 1)} + fixed_row[122] = "1" + rows = [ + ReportRow( + file_name="f.xml", + record_id="RID", + operation_index=1, + operation_fields={}, + participant_fields={}, + ) + ] + + with patch("app.pipeline.report.build_fixed_row_by_index", return_value=fixed_row): + write_report(rows, destination) + + workbook = load_workbook(destination) + sheet = workbook["Отчет"] + assert sheet.cell(row=3, column=122).value == "1" + + def _column_letter(index: int) -> str: value = index result = ""