This commit is contained in:
Raykov-MS 2026-08-01 08:42:46 +03:00
parent eee68ab4e2
commit d1cb2dda71
7 changed files with 1057 additions and 144 deletions

View File

@ -8,17 +8,19 @@ from pathlib import Path
@dataclass(frozen=True) @dataclass(frozen=True)
class MappingRule: class MappingRule:
column_index: int
column_name: str column_name: str
candidate_keys: tuple[str, ...] candidate_keys: tuple[str, ...]
source: str = "any" source: str = "any"
allow_direct_mapping: bool = True allow_direct_mapping: bool = True
allow_short_lookup: bool = True
@dataclass(frozen=True) @dataclass(frozen=True)
class StructuredGroupRule: class StructuredGroupRule:
structured_column: str structured_column: int
one_line_columns: tuple[str, ...] one_line_columns: tuple[int, ...]
component_columns: tuple[str, ...] component_columns: tuple[int, ...]
join_with_space: bool = False 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]) 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, ...]: def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule, ...]:
rules: list[MappingRule] = [] rules: list[MappingRule] = []
participant_columns = { participant_columns = {
@ -93,6 +111,8 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
"ФИО (по структуре полностью)", "ФИО (по структуре полностью)",
"ФИО (в одной строке)", "ФИО (в одной строке)",
"ФИО ( в одной строке)", "ФИО ( в одной строке)",
"Код страны гражданства",
"Признак принадлежности к публичным лицам",
} }
for row in mapping_data: for row in mapping_data:
index = int(row.get("index", 0)) 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() path = str(row.get("xml_path", "")).strip()
if not column or tag in _INVALID_TAGS: if not column or tag in _INVALID_TAGS:
continue 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)) candidates = list(_extract_tag_candidate_keys(tag))
if column.startswith("ИНН"): if column.startswith("ИНН") and index < 154:
candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates] candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates]
if column == "Тип участника": if column == "Тип участника":
candidates = ["Тип", "ТипУчастника", *candidates] candidates = ["Тип", "ТипУчастника", *candidates]
if column == "Отчество": if column == "Отчество":
# В некоторых выгрузках встречается опечатка тега "Очт". # В некоторых выгрузках встречается опечатка тега "Очт".
candidates = ["Отч", "Очт", *candidates] candidates = ["Отч", "Очт", *candidates]
path_candidates = list(_extract_path_candidate_keys(path)) if path else []
if path: if path:
for path_tag in _extract_path_candidate_keys(path): for path_tag in path_candidates:
if path_tag not in candidates: if path_tag not in candidates:
candidates.append(path_tag) 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 = [ non_one_line_candidates = [
candidate candidate
for candidate in candidates for candidate in candidates
@ -128,21 +214,30 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
allow_direct_mapping = False allow_direct_mapping = False
if is_structured_target and non_one_line_candidates: if is_structured_target and non_one_line_candidates:
direct_candidates = 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( rules.append(
MappingRule( MappingRule(
column_index=index,
column_name=column, column_name=column,
candidate_keys=tuple(direct_candidates), candidate_keys=tuple(direct_candidates),
source=( source=source,
"participant"
if (
column in participant_columns
or column.startswith("ИНН")
or index in range(128, 140)
or index in range(180, 193)
)
else "any"
),
allow_direct_mapping=allow_direct_mapping, allow_direct_mapping=allow_direct_mapping,
allow_short_lookup=allow_short_lookup,
) )
) )
return tuple(rules) return tuple(rules)
@ -194,6 +289,13 @@ def _paths_related(
return False 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( def _build_structured_group_rules(
mapping_data: list[dict[str, str | int]], mapping_data: list[dict[str, str | int]],
) -> tuple[StructuredGroupRule, ...]: ) -> tuple[StructuredGroupRule, ...]:
@ -210,8 +312,12 @@ def _build_structured_group_rules(
continue continue
struct_paths = _split_paths(str(item.get("xml_path", ""))) struct_paths = _split_paths(str(item.get("xml_path", "")))
one_line_names: list[str] = [] struct_base_paths = tuple(
component_names: list[str] = [] 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) left = by_index.get(index - 1)
@ -220,7 +326,7 @@ def _build_structured_group_rules(
if _is_one_line_column(left_name): if _is_one_line_column(left_name):
left_paths = _split_paths(str(left.get("xml_path", ""))) left_paths = _split_paths(str(left.get("xml_path", "")))
if _paths_related(struct_paths, left_paths): if _paths_related(struct_paths, left_paths):
one_line_names.append(left_name) one_line_indexes.append(index - 1)
right_boundary = next( right_boundary = next(
( (
@ -242,25 +348,34 @@ def _build_structured_group_rules(
right_name = str(right.get("column_name", "")).strip() right_name = str(right.get("column_name", "")).strip()
right_tag = str(right.get("xml_tag", "")).strip() right_tag = str(right.get("xml_tag", "")).strip()
right_paths = _split_paths(str(right.get("xml_path", ""))) right_paths = _split_paths(str(right.get("xml_path", "")))
if not _paths_related(struct_paths, right_paths): if is_address_structured:
if component_names: 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 break
continue continue
if _is_one_line_column(right_name): if _is_one_line_column(right_name):
one_line_names.append(right_name) one_line_indexes.append(right_index)
if component_indexes:
break
continue continue
if "|" in right_tag: if "|" in right_tag:
continue continue
component_names.append(right_name) component_indexes.append(right_index)
if not component_names: if not component_indexes:
continue continue
rules.append( rules.append(
StructuredGroupRule( StructuredGroupRule(
structured_column=structured_name, structured_column=index,
one_line_columns=tuple(dict.fromkeys(one_line_names)), one_line_columns=tuple(dict.fromkeys(one_line_indexes)),
component_columns=tuple(dict.fromkeys(component_names)), component_columns=tuple(dict.fromkeys(component_indexes)),
join_with_space="фио" in structured_name.lower(), 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] parts = [part for part in raw_path.strip("/").split("/") if part]
if not parts: if not parts:
continue continue
dotted_path = ".".join(parts) for start_index in range(len(parts)):
if dotted_path and dotted_path not in candidates: dotted_path = ".".join(parts[start_index:])
candidates.append(dotted_path) if dotted_path and dotted_path not in candidates:
leaf_tag = parts[-1] candidates.append(dotted_path)
if leaf_tag and leaf_tag not in _INVALID_TAGS and leaf_tag not in candidates:
candidates.append(leaf_tag)
return tuple(candidates) return tuple(candidates)
@ -300,6 +413,87 @@ STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_grou
_MAPPING_DATA _MAPPING_DATA
) )
_ACCOUNT_PLACEHOLDER = "00000000000000000000" _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( def build_fixed_row(
@ -310,69 +504,42 @@ def build_fixed_row(
operation_fields: dict[str, str], operation_fields: dict[str, str],
participant_fields: dict[str, str], participant_fields: dict[str, str],
) -> dict[str, str]: ) -> dict[str, str]:
row: dict[str, str] = { by_index = build_fixed_row_by_index(
"Имя XML файла": file_name, file_name=file_name,
"Идентификатор записи": record_id, record_id=record_id,
"Уникальный номер операции": str(operation_index), operation_index=operation_index,
} operation_fields=operation_fields,
merged = _merge_fields(operation_fields, participant_fields) participant_fields=participant_fields,
merged_suffix_index = _build_suffix_index(merged) )
participant_suffix_index = _build_suffix_index(participant_fields) by_name: dict[str, str] = {}
for rule in REPORT_MAPPING_RULES: for index, column_name in enumerate(FIXED_REPORT_COLUMNS, start=1):
if row.get(rule.column_name): by_name.setdefault(column_name, by_index.get(index, ""))
continue return by_name
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
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: for rule in STRUCTURED_GROUP_RULES:
one_line_has_value = any( one_line_has_value = any(
str(row.get(column_name, "")).strip() _row_value(row, column_index).strip()
for column_name in rule.one_line_columns for column_index in rule.one_line_columns
) )
if one_line_has_value: if one_line_has_value:
row[rule.structured_column] = "" _set_row_value(row, rule.structured_column, "")
for component_column in rule.component_columns: for component_column_index in rule.component_columns:
row[component_column] = "" _set_row_value(row, component_column_index, "")
continue continue
values = [ values = [
str(row.get(component_column, "")).strip() _row_value(row, component_column_index).strip()
for component_column in rule.component_columns for component_column_index in rule.component_columns
if str(row.get(component_column, "")).strip() if _row_value(row, component_column_index).strip()
] ]
if not values: if not values:
row[rule.structured_column] = "" _set_row_value(row, rule.structured_column, "")
continue continue
separator = " " if rule.join_with_space else ", " 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( def _merge_fields(
@ -385,6 +552,58 @@ def _merge_fields(
return merged 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]: def _build_suffix_index(payload: dict[str, str]) -> dict[str, str]:
index: dict[str, str] = {} index: dict[str, str] = {}
for key, value in payload.items(): for key, value in payload.items():
@ -399,10 +618,14 @@ def _pick_value(
payload: dict[str, str], payload: dict[str, str],
suffix_index: dict[str, str], suffix_index: dict[str, str],
candidates: tuple[str, ...], candidates: tuple[str, ...],
*,
allow_short_lookup: bool = True,
) -> str: ) -> str:
for key in candidates: for key in candidates:
if key in payload and payload[key]: if key in payload and payload[key]:
return payload[key] return payload[key]
if not allow_short_lookup:
continue
short = key.rsplit(".", maxsplit=1)[-1] short = key.rsplit(".", maxsplit=1)[-1]
if short in payload and payload[short]: if short in payload and payload[short]:
return payload[short] return payload[short]
@ -417,10 +640,16 @@ def _pick_structured_value(
suffix_index: dict[str, str], suffix_index: dict[str, str],
candidates: tuple[str, ...], candidates: tuple[str, ...],
join_with_space: bool, join_with_space: bool,
allow_short_lookup: bool = True,
) -> str: ) -> str:
values: list[str] = [] values: list[str] = []
for key in candidates: 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: if value and value not in values:
values.append(value) values.append(value)
if not values: if not values:
@ -433,25 +662,32 @@ def _column(index: int) -> str:
return FIXED_REPORT_COLUMNS[index - 1] return FIXED_REPORT_COLUMNS[index - 1]
def _row_value(row: dict[str, str], index: int) -> str: def _row_value(row: dict[int, str], index: int) -> str:
return str(row.get(_column(index), "")).strip() return str(row.get(index, "")).strip()
def _set_row_value(row: dict[str, str], index: int, value: str) -> None: def _normalize_code(value: str) -> str:
row[_column(index)] = value 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]: def _extract_numeric_codes(value: str) -> set[str]:
return {match for match in re.findall(r"\d+", value)} 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) op_code = _row_value(row, 19)
extra_codes = _row_value(row, 20) extra_codes = _row_value(row, 20)
return _extract_numeric_codes(op_code) | _extract_numeric_codes(extra_codes) 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) codes = _operation_and_extra_codes(row)
op_type_code = _row_value(row, 14) op_type_code = _row_value(row, 14)
transfer_type = _row_value(row, 35) 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, 23, "")
_set_row_value(row, 24, "") _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. # 28. Идентификатор подозрительной деятельности только для 6001.
if "6001" not in codes: if "6001" not in codes:
_set_row_value(row, 28, "") _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"}: if transfer_type not in {"2", "5", "8"}:
_set_row_value(row, 62, "") _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) codes = _operation_and_extra_codes(row)
# 96. Код территории заполняется только для признака 5016 (дубль 36). # 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): if _row_value(row, 104) == "0" and not _row_value(row, 105):
_set_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) resident_flag = _row_value(row, 110)
client_flag = _row_value(row, 111) client_flag = _row_value(row, 111)
if not resident_flag and not client_flag: 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") 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: def _apply_conditional_rules_124_203(row: dict[int, str]) -> None:
participant_type = _row_value(row, 109) participant_type = _normalize_code(_row_value(row, 109))
# 124. Для ФЛ и ИП показатель отсутствует. # 124. Для ФЛ, ИП и ФЛЧП показатель отсутствует.
if participant_type in {"2", "3"}: if participant_type in {"2", "3", "4"}:
_set_row_value(row, 124, "") _set_row_value(row, 124, "")
# 125. Для ИП СНИЛС не выводится. # 125. Для ИП СНИЛС не выводится.

View File

@ -309,7 +309,7 @@
"index": 35, "index": 35,
"column_name": "Вид перевода", "column_name": "Вид перевода",
"xml_tag": "ВидПереводДС", "xml_tag": "ВидПереводДС",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ВидПереводДС", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ВидПереводДС",
"multiplies": "нет", "multiplies": "нет",
"required": "Обязательно", "required": "Обязательно",
"comment": "числовой показатель" "comment": "числовой показатель"
@ -318,7 +318,7 @@
"index": 36, "index": 36,
"column_name": "Код территории (5016)", "column_name": "Код территории (5016)",
"xml_tag": "КодТерИнГос", "xml_tag": "КодТерИнГос",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/КодТерИнГос", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/КодТерИнГос",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -327,7 +327,7 @@
"index": 37, "index": 37,
"column_name": "Тип оператора", "column_name": "Тип оператора",
"xml_tag": "ТипОператорДС", "xml_tag": "ТипОператорДС",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ТипОператорДС", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ТипОператорДС",
"multiplies": "нет", "multiplies": "нет",
"required": "Обязательно", "required": "Обязательно",
"comment": "числовой показатель" "comment": "числовой показатель"
@ -336,7 +336,7 @@
"index": 38, "index": 38,
"column_name": "Номер счета плательщика", "column_name": "Номер счета плательщика",
"xml_tag": "НомерСчетПлательщик", "xml_tag": "НомерСчетПлательщик",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/НомерСчетПлательщик", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/НомерСчетПлательщик",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -345,7 +345,7 @@
"index": 39, "index": 39,
"column_name": "Идентификатор ЭСП плательщика", "column_name": "Идентификатор ЭСП плательщика",
"xml_tag": "ИдентЭСППлательщик", "xml_tag": "ИдентЭСППлательщик",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ИдентЭСППлательщик", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ИдентЭСППлательщик",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -354,7 +354,7 @@
"index": 40, "index": 40,
"column_name": "Наименование банка плательщика", "column_name": "Наименование банка плательщика",
"xml_tag": "СведБанкПлательщик", "xml_tag": "СведБанкПлательщик",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПлательщик/НаимКО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/НаимКО",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Данные передаются в XML в виде <НаимКО> АО \"АЛЬФА-БАНК\" </НаимКО> в отчет заносятся в виде: Банк ВТБ (ПАО), филиал Филиал \"Центральный\" Банка ВТБ (ПАО)" "comment": "Данные передаются в XML в виде <НаимКО> АО \"АЛЬФА-БАНК\" </НаимКО> в отчет заносятся в виде: Банк ВТБ (ПАО), филиал Филиал \"Центральный\" Банка ВТБ (ПАО)"
@ -363,7 +363,7 @@
"index": 41, "index": 41,
"column_name": "БИК банка плательщика", "column_name": "БИК банка плательщика",
"xml_tag": "БИККО", "xml_tag": "БИККО",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПлательщик/БИККО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/БИККО",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Данные передаются в XML в виде: <БИККО> 044525593 </БИККО> в отчет заносятся в виде: 044525411" "comment": "Данные передаются в XML в виде: <БИККО> 044525593 </БИККО> в отчет заносятся в виде: 044525411"
@ -372,7 +372,7 @@
"index": 42, "index": 42,
"column_name": "Наименование банка получателя", "column_name": "Наименование банка получателя",
"xml_tag": "СведБанкПолучатель", "xml_tag": "СведБанкПолучатель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПолучатель/НаимКО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/НаимКО",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -381,7 +381,7 @@
"index": 43, "index": 43,
"column_name": "БИК банка получателя", "column_name": "БИК банка получателя",
"xml_tag": "БИККО", "xml_tag": "БИККО",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СведБанкПолучатель/БИККО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/БИККО",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -390,7 +390,7 @@
"index": 44, "index": 44,
"column_name": "Номер счета банка плательщика", "column_name": "Номер счета банка плательщика",
"xml_tag": "СчетБанкПлательщик", "xml_tag": "СчетБанкПлательщик",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СчетБанкПлательщик", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СчетБанкПлательщик",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Данные передаются в XML в виде: <СчетБанкПлательщик> 30101810300000000711 </СчетБанкПлательщик> в отчет заносятся в виде: 30101810300000000711" "comment": "Данные передаются в XML в виде: <СчетБанкПлательщик> 30101810300000000711 </СчетБанкПлательщик> в отчет заносятся в виде: 30101810300000000711"
@ -399,7 +399,7 @@
"index": 45, "index": 45,
"column_name": "Номер счета банка получателя", "column_name": "Номер счета банка получателя",
"xml_tag": "СчетБанкПолучатель", "xml_tag": "СчетБанкПолучатель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/СчетБанкПолучатель", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СчетБанкПолучатель",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Данные передаются в XML в виде: <СчетБанкПолучатель> 30101810200000000704 </СчетБанкПолучатель> в отчет заносятся в виде: 30101810200000000704" "comment": "Данные передаются в XML в виде: <СчетБанкПолучатель> 30101810200000000704 </СчетБанкПолучатель> в отчет заносятся в виде: 30101810200000000704"
@ -408,7 +408,7 @@
"index": 46, "index": 46,
"column_name": "Номер счета получателя", "column_name": "Номер счета получателя",
"xml_tag": "НомерСчетПолучатель", "xml_tag": "НомерСчетПолучатель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/НомерСчетПолучатель", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/НомерСчетПолучатель",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Данные передаются в XML в виде: <НомерСчетПолучатель> 40703810000000000000 </НомерСчетПолучатель> в отчет заносятся в виде: 40703810000000000000" "comment": "Данные передаются в XML в виде: <НомерСчетПолучатель> 40703810000000000000 </НомерСчетПолучатель> в отчет заносятся в виде: 40703810000000000000"
@ -417,7 +417,7 @@
"index": 47, "index": 47,
"column_name": "Идентификатор ЭСП получателя", "column_name": "Идентификатор ЭСП получателя",
"xml_tag": "ИдентЭСППолучателя", "xml_tag": "ИдентЭСППолучателя",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияПереводыДС/ИдентЭСППолучателя", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/ИдентЭСППолучателя",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML" "comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
@ -867,7 +867,7 @@
"index": 97, "index": 97,
"column_name": "Номер банкомата", "column_name": "Номер банкомата",
"xml_tag": "ИдТерминал", "xml_tag": "ИдТерминал",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/ИдТерминал", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/ИдТерминал",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML" "comment": "Числовой показатель, поле может остаться пустым и не передаваться в XML"
@ -876,7 +876,7 @@
"index": 98, "index": 98,
"column_name": "Код страны", "column_name": "Код страны",
"xml_tag": "КодОКСМ", "xml_tag": "КодОКСМ",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/КодОКСМ", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/КодОКСМ",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML"
@ -885,7 +885,7 @@
"index": 99, "index": 99,
"column_name": "Адрес места приема наличных денежных средств (по структуре полный)", "column_name": "Адрес места приема наличных денежных средств (по структуре полный)",
"xml_tag": "АдрМестаПриемаВыдача", "xml_tag": "АдрМестаПриемаВыдача",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/АдрМестаПриемаВыдача/АдресСтрока",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML" "comment": "текстовый показатель, поле может остаться пустым и не передаваться в XML"
@ -894,7 +894,7 @@
"index": 100, "index": 100,
"column_name": "Наименование банка, обслуживающего участника операции", "column_name": "Наименование банка, обслуживающего участника операции",
"xml_tag": "НаимКО", "xml_tag": "НаимКО",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/НаимКО", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/НаимКО",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML" "comment": "Текстовый показатель, поле может остаться пустым и не передаваться в XML"
@ -903,7 +903,7 @@
"index": 101, "index": 101,
"column_name": "БИК банка, обслуживающего участника операции", "column_name": "БИК банка, обслуживающего участника операции",
"xml_tag": "БИККО", "xml_tag": "БИККО",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведМестоОперация/БИККО", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведМестоОперация/БИККО",
"multiplies": "нет", "multiplies": "нет",
"required": "Обязательно", "required": "Обязательно",
"comment": "Числовой показатель" "comment": "Числовой показатель"
@ -912,7 +912,7 @@
"index": 102, "index": 102,
"column_name": "Номер платежной карты", "column_name": "Номер платежной карты",
"xml_tag": "НомерКарта", "xml_tag": "НомерКарта",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/НомерКарта", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НомерКарта",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Показатель в XML передается зашифрованными ***************, в отчет попадают цифры" "comment": "Показатель в XML передается зашифрованными ***************, в отчет попадают цифры"
@ -921,7 +921,7 @@
"index": 103, "index": 103,
"column_name": "Сведения о держателе платежной карты", "column_name": "Сведения о держателе платежной карты",
"xml_tag": "СведДержательКарты", "xml_tag": "СведДержательКарты",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СведДержательКарты", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СведДержательКарты",
"multiplies": "нет", "multiplies": "нет",
"required": "обязательно", "required": "обязательно",
"comment": "" "comment": ""
@ -930,7 +930,7 @@
"index": 104, "index": 104,
"column_name": "Пр-ак соверш/ опер. с уч-ем УС КО", "column_name": "Пр-ак соверш/ опер. с уч-ем УС КО",
"xml_tag": "ПризнакСотрудник", "xml_tag": "ПризнакСотрудник",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/ПризнакСотрудник", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/ПризнакСотрудник",
"multiplies": "нет", "multiplies": "нет",
"required": "обязательно", "required": "обязательно",
"comment": "Числовой показатель" "comment": "Числовой показатель"
@ -939,7 +939,7 @@
"index": 105, "index": 105,
"column_name": "Наименование иностранного банка", "column_name": "Наименование иностранного банка",
"xml_tag": "НаимИнБанк", "xml_tag": "НаимИнБанк",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/НаимИнБанк", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НаимИнБанк",
"multiplies": "нет", "multiplies": "нет",
"required": "обязательно", "required": "обязательно",
"comment": "" "comment": ""
@ -948,7 +948,7 @@
"index": 106, "index": 106,
"column_name": "СВИФТ", "column_name": "СВИФТ",
"xml_tag": "СВИФТИнБанк", "xml_tag": "СВИФТИнБанк",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СвединияКартаИнБанк/СВИФТИнБанк", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/СВИФТИнБанк",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "поле может остаться пустым и не передаваться в XML" "comment": "поле может остаться пустым и не передаваться в XML"
@ -1064,8 +1064,8 @@
{ {
"index": 119, "index": 119,
"column_name": "Идентификатор ППЦР", "column_name": "Идентификатор ППЦР",
"xml_tag": "ПризнУчастника", "xml_tag": "ИдентификацияФЛ",
"xml_path": "/СообщОперКО/нформЧасть/СведКО/Операция/УчастникОП/ПризнУчастника", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/ИдентификацияФЛ",
"multiplies": "нет", "multiplies": "нет",
"required": "обязательно", "required": "обязательно",
"comment": "числовой показатель" "comment": "числовой показатель"
@ -1101,7 +1101,7 @@
"index": 123, "index": 123,
"column_name": "Дата государственной регистрации :/ Дата рождения:", "column_name": "Дата государственной регистрации :/ Дата рождения:",
"xml_tag": "ДатаРегЮЛ|ДатаРождения", "xml_tag": "ДатаРегЮЛ|ДатаРождения",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ДатаРегЮЛ | СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СветФЛИП/ДатаРождения", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ДатаРегЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ДатаРождения",
"multiplies": "нет", "multiplies": "нет",
"required": "необязательно", "required": "необязательно",
"comment": "Показатель в формате ДД/ММ/ГГГГ." "comment": "Показатель в формате ДД/ММ/ГГГГ."

View File

@ -200,6 +200,7 @@ def _extract_common_fields(root: ET.Element) -> dict[str, str]:
def _collect_leaf_fields( def _collect_leaf_fields(
element: ET.Element, current_path: str, fields: dict[str, str] element: ET.Element, current_path: str, fields: dict[str, str]
) -> None: ) -> None:
_collect_attributes(element, current_path, fields)
children = list(element) children = list(element)
if not children: if not children:
_append_value(fields, current_path, (element.text or "").strip()) _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) _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: def _append_value(fields: dict[str, str], key: str, value: str) -> None:
if key not in fields: if key not in fields:
fields[key] = value fields[key] = value

View File

@ -4,13 +4,14 @@ import re
import shutil import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Iterable from typing import Iterable
import xlsxwriter import xlsxwriter
from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row_by_index
@dataclass @dataclass
@ -130,7 +131,7 @@ class StreamingReportWriter:
self._next_data_row = 2 self._next_data_row = 2
def append_row(self, row: ReportRow) -> None: def append_row(self, row: ReportRow) -> None:
data = build_fixed_row( data_by_index = build_fixed_row_by_index(
file_name=row.file_name, file_name=row.file_name,
record_id=row.record_id, record_id=row.record_id,
operation_index=row.operation_index, operation_index=row.operation_index,
@ -138,7 +139,7 @@ class StreamingReportWriter:
participant_fields=row.participant_fields, participant_fields=row.participant_fields,
) )
for index, column_name in enumerate(self.columns): 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] default_format = self._column_formats[index]
value, cell_format = self._normalize_cell_value( value, cell_format = self._normalize_cell_value(
column_name=column_name, column_name=column_name,
@ -153,7 +154,12 @@ class StreamingReportWriter:
self.sheet.write_string( self.sheet.write_string(
self._next_data_row, self._next_data_row,
index, 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: elif cell_format is not None:
self.sheet.write(self._next_data_row, index, value, cell_format) 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.sheet.write(self._next_data_row, index, value)
self._next_data_row += 1 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: def save(self, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
try: try:
@ -217,16 +238,54 @@ class StreamingReportWriter:
return raw_value, default_format return raw_value, default_format
def _is_text_identifier_column(self, column_name: str) -> bool: def _is_text_identifier_column(self, column_name: str) -> bool:
keywords = ("ИНН", "ОГРН", "КПП", "БИК", "СВИФТ") keywords = ("ИНН", "ОГРН", "КПП", "БИК", "СВИФТ", "Номер счета")
return any(keyword in column_name for keyword in 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: if value is None:
return "" return ""
text = str(value).strip() 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): if re.fullmatch(r"\d+\.0+", text):
return text.split(".", maxsplit=1)[0] 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: def _parse_date_value(self, value: object) -> datetime | None:
if isinstance(value, datetime): if isinstance(value, datetime):

View File

@ -4,6 +4,7 @@ from app.pipeline.mapping import (
FIXED_REPORT_COLUMNS, FIXED_REPORT_COLUMNS,
_extract_tag_candidate_keys, _extract_tag_candidate_keys,
build_fixed_row, 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_index=2,
operation_fields={ operation_fields={
"ИдФайл": "FILE_01", "ИдФайл": "FILE_01",
"НомерОперация": "OP_2",
"КодОперации": "5007", "КодОперации": "5007",
"СумОперации": "100.55", "СумОперации": "100.55",
}, },
@ -32,10 +34,10 @@ def test_build_fixed_row_maps_known_fields() -> None:
assert row["Имя XML файла"] == "f.xml" assert row["Имя XML файла"] == "f.xml"
assert row["Идентификатор записи"] == "R1" assert row["Идентификатор записи"] == "R1"
assert row["Уникальный номер операции"] == "2" assert row["Уникальный номер операции"] == "OP_2"
assert row["Код вида операции"] == "5007" assert row["Код вида операции"] == "5007"
assert row["Сумма в валюте проведения"] == "100.55" assert row["Сумма в валюте проведения"] == "100.55"
assert row[_column_with("ИНН ")] == "123456789012" assert row[_column_with("ИНН")] == "123456789012"
assert row["Тип участника"] == "01" assert row["Тип участника"] == "01"
@ -50,7 +52,7 @@ def test_build_fixed_row_keeps_missing_values_empty() -> None:
for column in FIXED_REPORT_COLUMNS: for column in FIXED_REPORT_COLUMNS:
assert column in row assert column in row
assert row["Код вида операции"] == "" assert row["Код вида операции"] == ""
assert row[_column_with("ИНН ")] == "" assert row[_column_with("ИНН")] == ""
def test_build_fixed_row_does_not_take_operation_inn_for_participant() -> None: 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", "Тип": "01",
}, },
) )
assert row[_column_with("ИНН ")] == "111111111111" assert row[_column_with("ИНН")] == "111111111111"
assert row["Тип участника"] == "01" assert row["Тип участника"] == "01"
@ -82,7 +84,7 @@ def test_build_fixed_row_uses_alternative_path_leaf_for_emitent_name() -> None:
record_id="R1", record_id="R1",
operation_index=1, operation_index=1,
operation_fields={ operation_fields={
"ФИОСтрока": "Эмитент ФЛ", "СведЦП.Эмитент.НаимЭмитентЦП.ФизЛицо.ФИОСтрока": "Эмитент ФЛ",
}, },
participant_fields={}, participant_fields={},
) )
@ -100,7 +102,7 @@ def test_build_fixed_row_maps_dotted_participant_keys() -> None:
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.НомДок": "451099", "УчастникФЛИП.СведФЛИП.СведДокУдЛичн.НомДок": "451099",
}, },
) )
assert row[_column_with("ИНН ")] == "123456789012" assert row[_column_with("ИНН")] == "123456789012"
assert row["Номер"] == "451099" assert row["Номер"] == "451099"
@ -135,7 +137,7 @@ def test_build_fixed_row_uses_first_suffix_value_from_index() -> None:
"УчастникФЛИП.Второй.ИННФЛИП": "222222222222", "УчастникФЛИП.Второй.ИННФЛИП": "222222222222",
}, },
) )
assert row[_column_with("ИНН ")] == "111111111111" assert row[_column_with("ИНН")] == "111111111111"
def test_build_fixed_row_skips_empty_suffix_values_in_index() -> None: 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", "УчастникФЛИП.Второй.ИННФЛИП": "222222222222",
}, },
) )
assert row[_column_with("ИНН ")] == "222222222222" assert row[_column_with("ИНН")] == "222222222222"
def test_structured_address_is_built_from_right_columns() -> None: 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_index=1,
operation_fields={}, operation_fields={},
participant_fields={ participant_fields={
"Индекс": "121467", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Индекс": "121467",
"КодОКСМ": "643", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.КодОКСМ": "643",
"Пункт": "Москва", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Пункт": "Москва",
"Улица": "Молодогвардейская", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Улица": "Молодогвардейская",
"Дом": "8", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Дом": "8",
"Корп": "1", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Корп": "1",
"Оф": "28", "УчастникЮЛ.СведЕИО.ЮЛЕИО.АдрРег.Оф": "28",
}, },
) )
assert ( assert (
@ -283,7 +285,9 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None
operation_index=1, operation_index=1,
operation_fields={}, operation_fields={},
participant_fields={ participant_fields={
"АдрУчредитель": "г. Москва, ул. Пушкина, д. 1", "УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.АдрРег.АдрУчредитель": (
"г. Москва, ул. Пушкина, д. 1"
),
}, },
) )
assert row["Адрес в одной строке"] == "" assert row["Адрес в одной строке"] == ""
@ -548,3 +552,394 @@ def test_dul_columns_128_134_keep_expected_positions() -> None:
assert row["Номер"] == "476635" assert row["Номер"] == "476635"
assert row["Орган, выдавший документ"] == "ОВД" assert row["Орган, выдавший документ"] == "ОВД"
assert row["КП"] == "222-022" 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] == ""

View File

@ -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) result = parse_xml_content("SKO115FZ_01_044525111_20251224_000051.xml", xml)
assert not result.is_fatal assert not result.is_fatal
assert len(result.rows) == 1 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["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р"
)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from openpyxl import load_workbook 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" 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: def _column_letter(index: int) -> str:
value = index value = index
result = "" result = ""