fix bugs
This commit is contained in:
parent
eee68ab4e2
commit
d1cb2dda71
@ -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. Для ИП СНИЛС не выводится.
|
||||
|
||||
@ -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": "Показатель в формате ДД/ММ/ГГГГ."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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] == ""
|
||||
|
||||
@ -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["УчастникЮЛ.СведЮЛ.АдрРегЮЛ.Район"] == "Благовещенский р-н"
|
||||
)
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user