Kinda fix

This commit is contained in:
Raykov-MS 2026-10-09 17:35:36 +03:00
parent 6229fe7814
commit d1daecdb31
7 changed files with 620 additions and 369 deletions

View File

@ -5,17 +5,35 @@ from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings from app.core.config import settings
# Без явных таймаутов зависший/протухший сокет к Postgres (например, тихо
# оборванное NAT/LB соединение после долгого простоя — ровно то, что могло
# случиться во время получасовой обработки батча из 230 файлов) блокирует
# asyncpg НАВСЕГДА: ни ошибки, ни лога, просто вечное ожидание чтения
# сокета. Это тот же класс проблемы с asyncio.run()-за-asyncio.run() вокруг
# общего пула соединений, что уже ловили раньше (см. комментарий про
# engine.dispose() в opencloud_token_store.py) — тут решаем его с другой
# стороны: даём зависшему соединению упасть с понятной ошибкой вместо
# бесконечного ожидания.
_CONNECT_TIMEOUT_SECONDS = 10
_COMMAND_TIMEOUT_SECONDS = 15
connect_args: dict = {} connect_args: dict = {}
if settings.DATABASE_SCHEMA: if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL: if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
else:
# timeout/command_timeout — параметры asyncpg.connect(), не SQLAlchemy;
# aiosqlite их не понимает, поэтому только для не-sqlite URL.
connect_args["timeout"] = _CONNECT_TIMEOUT_SECONDS
connect_args["command_timeout"] = _COMMAND_TIMEOUT_SECONDS
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=5, pool_size=5,
max_overflow=10, max_overflow=10,
pool_timeout=_CONNECT_TIMEOUT_SECONDS,
connect_args=connect_args, connect_args=connect_args,
) )

View File

@ -7,46 +7,77 @@ from .column_constants import (
ACCOUNT_PLACEHOLDER, ACCOUNT_PLACEHOLDER,
BLOCK_CP, BLOCK_CP,
BLOCK_EIO, BLOCK_EIO,
COL_ADDRESS_ONE_LINE,
COL_ADDRESS_STRUCTURED, COL_ADDRESS_STRUCTURED,
COL_AMOUNT_CURRENCY,
COL_AMOUNT_RUB,
COL_BIRTH_REG_DATE, COL_BIRTH_REG_DATE,
COL_BRANCH_SIGN,
COL_CARD_HOLDER_INFO, COL_CARD_HOLDER_INFO,
COL_CLIENT_SIGN, COL_CLIENT_SIGN,
COL_COUNTRY_CODE, COL_COUNTRY_CODE,
COL_CP_ISSUER_INN, COL_CP_ISSUER_INN,
COL_CURRENCY,
COL_EIO_ADDRESS_ONE_LINE,
COL_EIO_INN,
COL_EIO_OTHER_INFO, COL_EIO_OTHER_INFO,
COL_EIO_REG_PLACE_ONE_LINE, COL_EIO_REG_PLACE_ONE_LINE,
COL_EIO_REG_PLACE_STRUCTURED, COL_EIO_REG_PLACE_STRUCTURED,
COL_EMPLOYEE_SIGN, COL_EMPLOYEE_SIGN,
COL_ESP_SIGN,
COL_EXTRA_CODES, COL_EXTRA_CODES,
COL_EXTRA_INFO, COL_EXTRA_INFO,
COL_FILE_NAME, COL_FILE_NAME,
COL_FOREIGN_BANK_NAME, COL_FOREIGN_BANK_NAME,
COL_FOREIGN_CARD_ADDRESS_STRUCTURED, COL_FOREIGN_CARD_ADDRESS_STRUCTURED,
COL_FTR_SIGN,
COL_IDENT_FL, COL_IDENT_FL,
COL_INN, COL_INN,
COL_INSURANCE_NUMBER,
COL_ITEM_TYPE,
COL_KPP, COL_KPP,
COL_OPERATION_CODE, COL_OPERATION_CODE,
COL_OPERATION_DOCUMENTS, COL_OPERATION_DOCUMENTS,
COL_OPERATION_SIGN,
COL_OPERATOR_TYPE, COL_OPERATOR_TYPE,
COL_PARTICIPANT_TYPE, COL_PARTICIPANT_TYPE,
COL_PAYER_ACCOUNT, COL_PAYER_ACCOUNT,
COL_PAYER_BANK_ACCOUNT, COL_PAYER_BANK_ACCOUNT,
COL_PAYER_BANK_BIK, COL_PAYER_BANK_BIK,
COL_PAYER_BANK_NAME,
COL_PAYER_ESP,
COL_RECIPIENT_ACCOUNT, COL_RECIPIENT_ACCOUNT,
COL_RECIPIENT_BANK_ACCOUNT, COL_RECIPIENT_BANK_ACCOUNT,
COL_RECIPIENT_BANK_BIK, COL_RECIPIENT_BANK_BIK,
COL_RECIPIENT_BANK_NAME,
COL_RECIPIENT_ESP,
COL_RECORD_ID, COL_RECORD_ID,
COL_REG_PLACE_ONE_LINE, COL_REG_PLACE_ONE_LINE,
COL_REG_PLACE_STRUCTURED, COL_REG_PLACE_STRUCTURED,
COL_RESIDENT_SIGN, COL_RESIDENT_SIGN,
COL_SALE_AMOUNT,
COL_SALE_CURRENCY,
COL_STAY_DOCUMENT_TYPE, COL_STAY_DOCUMENT_TYPE,
COL_SUSPENSION_BASIS,
COL_SUSPICIOUS_ID,
COL_TERRITORY_CODE,
COL_TRANSFER_TYPE, COL_TRANSFER_TYPE,
COL_UNUSUAL_CODES, COL_UNUSUAL_CODES,
EMPLOYEE_ABSENT_CODE, EMPLOYEE_ABSENT_CODE,
EXPORT_SUBSIDIARY_COMMENT_CODES, EXPORT_SUBSIDIARY_COMMENT_CODES,
OKATO_COUNTRY_PAIRS,
PARTICIPANT_ADDRESS_COMPONENT_COLUMNS, PARTICIPANT_ADDRESS_COMPONENT_COLUMNS,
PARTICIPANT_TYPE_FL,
PARTICIPANT_TYPE_FLCHP,
PARTICIPANT_TYPE_IP,
PARTICIPANT_TYPE_UL, PARTICIPANT_TYPE_UL,
PRECIOUS_ITEM_CODES,
RUSSIA_COUNTRY_CODE,
SUSPICIOUS_CODE,
TERRITORY_CODE, TERRITORY_CODE,
VALID_ESP_SIGNS,
VALID_FTR_SIGNS,
VALID_SUSPENSION_BASES,
) )
from .column_registry import COLUMNS, ColumnMeta from .column_registry import COLUMNS, ColumnMeta
@ -57,6 +88,10 @@ class _CompiledCandidate:
normalized: str normalized: str
short: str short: str
strict_scoped: bool strict_scoped: bool
# Собственные суффиксы normalized по границе ".", из двух и более
# сегментов: "a.b.c.d" -> ("b.c.d", "c.d"). Нужны _pick_value для поиска
# через словарь вместо прохода по всем полям строки (см. там).
dotted_suffixes: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@ -86,6 +121,10 @@ class _PayloadLookup:
normalized_exact_first: dict[str, str] normalized_exact_first: dict[str, str]
normalized_suffix_first: dict[str, str] normalized_suffix_first: dict[str, str]
raw_short_first: dict[str, str] raw_short_first: dict[str, str]
# normalized_path (только составные, с ".") -> (позиция первого поля с
# непустым значением, значение). Позиция нужна, чтобы при нескольких
# совпадениях вернуть то же поле, что и прежний линейный проход.
dotted_path_first: dict[str, tuple[int, str]]
@dataclass(frozen=True) @dataclass(frozen=True)
@ -125,15 +164,15 @@ _EIO_BLOCK_NAMES = (
"БенефициарФЛИП", "БенефициарФЛИП",
"БенефициарИНБОЮЛ", "БенефициарИНБОЮЛ",
) )
_ONE_LINE_STRUCTURED_PAIRS = (
(COL_REG_PLACE_ONE_LINE, COL_REG_PLACE_STRUCTURED),
(COL_EIO_REG_PLACE_ONE_LINE, COL_EIO_REG_PLACE_STRUCTURED),
)
_PARTICIPANT_ADDRESS_BASE_PATHS = ( _PARTICIPANT_ADDRESS_BASE_PATHS = (
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ", "УчастникЮЛ.СведЮЛ.АдрРегЮЛ",
"УчастникФЛИП.СведФЛИП.АдрРег", "УчастникФЛИП.СведФЛИП.АдрРег",
"УчастникИНБОЮЛ.СведИНБОЮЛ.МестоДеятельностьИНБОЮЛ", "УчастникИНБОЮЛ.СведИНБОЮЛ.МестоДеятельностьИНБОЮЛ",
) )
_ONE_LINE_STRUCTURED_PAIRS = (
(COL_REG_PLACE_ONE_LINE, COL_REG_PLACE_STRUCTURED),
(COL_EIO_REG_PLACE_ONE_LINE, COL_EIO_REG_PLACE_STRUCTURED),
)
def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]: def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]:
@ -166,11 +205,17 @@ def _compile_candidates(candidates: tuple[str, ...]) -> tuple[_CompiledCandidate
other != normalized and other.endswith(f".{normalized}") other != normalized and other.endswith(f".{normalized}")
for other in normalized_candidates for other in normalized_candidates
), ),
dotted_suffixes=_dotted_suffixes(normalized),
) )
for candidate, normalized in zip(candidates, normalized_candidates) for candidate, normalized in zip(candidates, normalized_candidates)
) )
def _dotted_suffixes(normalized: str) -> tuple[str, ...]:
parts = normalized.split(".")
return tuple(".".join(parts[start:]) for start in range(1, len(parts) - 1))
def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...]: def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...]:
rules: list[MappingRule] = [] rules: list[MappingRule] = []
for meta in mapping_data: for meta in mapping_data:
@ -478,8 +523,11 @@ def build_fixed_row_by_index(
) )
if not is_continuation: if not is_continuation:
_apply_participant_identity_rules(row, participant_lookup) _apply_participant_identity_rules(row, participant_lookup)
# Сначала убираем запрещённые однострочные адреса, чтобы структурная
# группа не очистила их компоненты до проверки резидентства.
_rule_address_one_line(row)
_apply_structured_group_rules(row) _apply_structured_group_rules(row)
_drop_structured_duplicates_of_one_line(row) _apply_one_line_structured_pairs(row)
if not is_continuation: if not is_continuation:
_apply_business_rules( _apply_business_rules(
row, row,
@ -488,6 +536,8 @@ def build_fixed_row_by_index(
operation_lookup=operation_lookup, operation_lookup=operation_lookup,
participant_lookup=participant_lookup, participant_lookup=participant_lookup,
) )
else:
_apply_continuation_validation_rules(row)
for meta in COLUMNS.ordered_columns(): for meta in COLUMNS.ordered_columns():
row.setdefault(meta.index, "") row.setdefault(meta.index, "")
return row return row
@ -517,15 +567,16 @@ def build_fixed_row(
def _apply_structured_group_rules(row: dict[int, str]) -> None: def _apply_structured_group_rules(row: dict[int, str]) -> None:
"""Собирает структурную колонку адреса из её компонентов.
Однострочная версия адреса больше не отменяет структурную: раньше при
заполненной одной строке структурная колонка и все её компоненты
обнулялись, то есть отчёт показывал либо одно, либо другое. Заказчик
24.09.2026 потребовал выводить все значения из XML, а в файлах бывают
заполнены обе версии сразу.
"""
for rule in STRUCTURED_GROUP_RULES: for rule in STRUCTURED_GROUP_RULES:
one_line_has_value = any(
_get(row, column_index).strip() for column_index in rule.one_line_columns
)
if one_line_has_value:
_set(row, rule.structured_column, "")
for component_column_index in rule.component_columns:
_set(row, component_column_index, "")
continue
component_values = [ component_values = [
_get(row, component_column_index).strip() _get(row, component_column_index).strip()
for component_column_index in rule.component_columns for component_column_index in rule.component_columns
@ -542,27 +593,10 @@ def _apply_structured_group_rules(row: dict[int, str]) -> None:
_set(row, rule.structured_column, separator.join(values)) _set(row, rule.structured_column, separator.join(values))
def _drop_structured_duplicates_of_one_line(row: dict[int, str]) -> None: def _apply_one_line_structured_pairs(row: dict[int, str]) -> None:
"""Убирает структурную колонку, если она дословно повторяет однострочную. """Оставляет только однострочное значение, если заполнены обе версии поля."""
Такое бывает, когда один и тот же тег XML подходит под кандидатов обеих
колонок: значение одно, а колонок две. Именно это, а не взаимное
исключение версий адреса, и должно отсекаться — если структура собрана из
своих компонентов и отличается от одной строки, обе колонки остаются
заполненными, потому что оба значения есть в файле.
"""
for rule in STRUCTURED_GROUP_RULES:
structured_value = _get(row, rule.structured_column).strip()
if not structured_value:
continue
if any(
_get(row, column_index).strip() == structured_value
for column_index in rule.one_line_columns
):
_set(row, rule.structured_column, "")
for one_line_column, structured_column in _ONE_LINE_STRUCTURED_PAIRS: for one_line_column, structured_column in _ONE_LINE_STRUCTURED_PAIRS:
structured_value = _get(row, structured_column).strip() if _get(row, one_line_column):
if structured_value and _get(row, one_line_column).strip() == structured_value:
_set(row, structured_column, "") _set(row, structured_column, "")
@ -600,10 +634,13 @@ def _build_payload_lookup_from_entries(
normalized_exact_first: dict[str, str] = {} normalized_exact_first: dict[str, str] = {}
normalized_suffix_first: dict[str, str] = {} normalized_suffix_first: dict[str, str] = {}
raw_short_first: dict[str, str] = {} raw_short_first: dict[str, str] = {}
dotted_path_first: dict[str, tuple[int, str]] = {}
for entry in entries: for position, entry in enumerate(entries):
if not entry.value: if not entry.value:
continue continue
if "." in entry.normalized_path:
dotted_path_first.setdefault(entry.normalized_path, (position, entry.value))
normalized_exact_first.setdefault(entry.normalized_path, entry.value) normalized_exact_first.setdefault(entry.normalized_path, entry.value)
path_parts = entry.normalized_path.split(".") path_parts = entry.normalized_path.split(".")
for start_index in range(len(path_parts)): for start_index in range(len(path_parts)):
@ -619,6 +656,7 @@ def _build_payload_lookup_from_entries(
normalized_exact_first=normalized_exact_first, normalized_exact_first=normalized_exact_first,
normalized_suffix_first=normalized_suffix_first, normalized_suffix_first=normalized_suffix_first,
raw_short_first=raw_short_first, raw_short_first=raw_short_first,
dotted_path_first=dotted_path_first,
) )
@ -693,10 +731,7 @@ def _format_operation_documents(lookup: _PayloadLookup) -> str:
document.get(field_name, "") for field_name in _OPERATION_DOCUMENT_FIELDS document.get(field_name, "") for field_name in _OPERATION_DOCUMENT_FIELDS
] ]
fields.insert(1, "") fields.insert(1, "")
# Эталонный отчёт отделяет номер документа запятой, а не точкой: formatted_documents.append(f"{position}. {', '.join(fields)};")
# "1, 13, , , 05/08/2026, ...". Расхождение затрагивало почти каждую
# строку отчёта, поэтому приводим к виду эталона.
formatted_documents.append(f"{position}, {', '.join(fields)};")
return "\n".join(formatted_documents) return "\n".join(formatted_documents)
@ -774,13 +809,18 @@ def _pick_value(
if value := lookup.normalized_suffix_first.get(candidate.normalized): if value := lookup.normalized_suffix_first.get(candidate.normalized):
return value return value
if not allow_short_lookup: if not allow_short_lookup:
for entry in lookup.entries: # Ищем первое (в порядке полей) непустое составное поле, путь
if ( # которого является собственным суффиксом кандидата. Раньше это
entry.value # был проход по ВСЕМ полям строки с endswith на каждый кандидат
and "." in entry.normalized_path # каждой колонки — ~40% времени формирования отчёта (профиль на
and candidate.normalized.endswith(f".{entry.normalized_path}") # 123 реальных файлах). Теперь — несколько обращений к словарю.
): best: tuple[int, str] | None = None
return entry.value for suffix in candidate.dotted_suffixes:
found = lookup.dotted_path_first.get(suffix)
if found is not None and (best is None or found[0] < best[0]):
best = found
if best is not None:
return best[1]
continue continue
if value := lookup.values.get(candidate.short): if value := lookup.values.get(candidate.short):
return value return value
@ -850,46 +890,91 @@ def _apply_business_rules(
participant_lookup: _PayloadLookup, participant_lookup: _PayloadLookup,
) -> None: ) -> None:
codes = _operation_codes(row) codes = _operation_codes(row)
operation_sign = _get(row, COL_OPERATION_SIGN)
transfer_type = _get(row, COL_TRANSFER_TYPE) transfer_type = _get(row, COL_TRANSFER_TYPE)
operator_type = _get(row, COL_OPERATOR_TYPE) operator_type = _get(row, COL_OPERATOR_TYPE)
participant_type = _normalize_code(_get(row, COL_PARTICIPANT_TYPE))
# Заказчик 24.09.2026: в отчёт попадают все значения из XML. Поэтому здесь _rule_ftr_sign(row)
# остались только правила, которые ничего не удаляют: сборка колонки из _rule_suspension_basis(row)
# нескольких тегов и подстановка заглушек в пустые колонки. Правила, _rule_esp_sign(row)
# которые обнуляли колонку, если код операции, вид перевода или тип _rule_unusual_codes(row, codes)
# участника не входил в разрешённый список, убраны — они выбрасывали _rule_digital_rights_currency(row, operation_sign)
# данные, реально присутствующие в файле. _rule_sale_currency(row)
_rule_code_list_separator(row)
_rule_extra_info(row, codes, operation_lookup, participant_lookup) _rule_extra_info(row, codes, operation_lookup, participant_lookup)
_rule_suspicious_activity(row, codes)
_rule_item_type(row, codes)
_rule_territory_codes(row, codes)
_rule_payer_account(row, transfer_type, codes) _rule_payer_account(row, transfer_type, codes)
_rule_esp_identifiers(row, operation_sign)
_rule_payer_bank(row, operator_type)
_rule_recipient_bank(row, operator_type)
_rule_correspondent_accounts(row, operator_type, transfer_type, codes) _rule_correspondent_accounts(row, operator_type, transfer_type, codes)
_rule_recipient_account(row, transfer_type, codes) _rule_recipient_account(row, transfer_type, codes)
_rule_card_without_employee(row) _rule_card_without_employee(row)
# Это не фильтр по бизнес-логике, а обратная сторона требования "принимая _rule_participant_identity(row, participant_type)
# во внимание, в каком блоке содержатся": без блока ЕИО/ЦП в записи _rule_branch_sign(row, participant_type)
# значения в этих колонках могли появиться только из чужого блока. _rule_snils_for_ip(row, participant_type)
_rule_address_one_line(row)
_rule_okato_by_country(row)
_rule_eio_inn_for_physical_person(row, participant_type)
if not has_eio_block: if not has_eio_block:
_clear_columns(row, BLOCK_EIO) _clear_columns(row, BLOCK_EIO)
if not has_cp_block: if not has_cp_block:
_clear_columns(row, BLOCK_CP) _clear_columns(row, BLOCK_CP)
# Перечисления кодов эталонный отчёт АРМ «Комита» печатает через запятую без def _apply_continuation_validation_rules(row: dict[int, str]) -> None:
# пробела ("1101,1106,1499"), а парсер склеивает повторяющиеся теги через """Проверяет локальные коды повтора без подстановки значений основной строки."""
# "; " (см. _append_value в parser.py). Приводим к виду эталона только _rule_ftr_sign(row)
# колонки-перечисления кодов: в текстовых полях "; " читается лучше. _rule_suspension_basis(row)
_CODE_LIST_COLUMNS = (COL_EXTRA_CODES, COL_UNUSUAL_CODES) _rule_esp_sign(row)
_CODE_LIST_SEPARATOR = ","
def _rule_code_list_separator(row: dict[int, str]) -> None: def _rule_ftr_sign(row: dict[int, str]) -> None:
for column in _CODE_LIST_COLUMNS: """Правило 10: оставляем только допустимые значения признака ФТР."""
value = _get(row, column) if _get(row, COL_FTR_SIGN) not in VALID_FTR_SIGNS:
if "; " not in value: _set(row, COL_FTR_SIGN, "")
continue
codes = [part.strip() for part in value.split(";") if part.strip()]
_set(row, column, _CODE_LIST_SEPARATOR.join(codes)) def _rule_suspension_basis(row: dict[int, str]) -> None:
"""Правило 11: основание приостановления должно быть допустимым."""
if _get(row, COL_SUSPENSION_BASIS) not in VALID_SUSPENSION_BASES:
_set(row, COL_SUSPENSION_BASIS, "")
def _rule_esp_sign(row: dict[int, str]) -> None:
"""Правило 15: признак ЭСП должен быть допустимым."""
if _get(row, COL_ESP_SIGN) not in VALID_ESP_SIGNS:
_set(row, COL_ESP_SIGN, "")
def _rule_unusual_codes(row: dict[int, str], codes: set[str]) -> None:
"""Правило 21: коды необычной операции относятся только к 6001."""
if SUSPICIOUS_CODE not in codes:
_set(row, COL_UNUSUAL_CODES, "")
def _rule_digital_rights_currency(row: dict[int, str], operation_sign: str) -> None:
"""Правила 22-24: для цифровых прав валютные суммы не выводятся."""
if operation_sign == "8":
_set(row, COL_CURRENCY, "")
_set(row, COL_AMOUNT_CURRENCY, "")
_set(row, COL_AMOUNT_RUB, "")
def _rule_sale_currency(row: dict[int, str]) -> None:
"""Правило 26: нулевая сумма или отсутствие конверсии дают пустое поле."""
conversion_amount = _get(row, COL_SALE_AMOUNT)
normalized_amount = conversion_amount.replace(",", ".")
if not _get(row, COL_SALE_CURRENCY) or re.fullmatch(
r"[+-]?0+(?:\.0+)?", normalized_amount
):
_set(row, COL_SALE_AMOUNT, "")
def _rule_extra_info( def _rule_extra_info(
@ -914,6 +999,34 @@ def _rule_extra_info(
_set(row, COL_EXTRA_INFO, "; ".join(values)) _set(row, COL_EXTRA_INFO, "; ".join(values))
def _rule_suspicious_activity(row: dict[int, str], codes: set[str]) -> None:
"""Правило 28: идентификатор подозрительной деятельности только для 6001."""
if SUSPICIOUS_CODE not in codes:
_set(row, COL_SUSPICIOUS_ID, "")
def _rule_item_type(row: dict[int, str], codes: set[str]) -> None:
"""Правило 31: предмет операции только для кодов 5020-5023."""
if not codes & PRECIOUS_ITEM_CODES:
_set(row, COL_ITEM_TYPE, "")
def _rule_territory_codes(row: dict[int, str], codes: set[str]) -> None:
"""Правило 36: код территории (КодТерИнГос) — только для операции 5016.
Колонка 96 (КодТерИнБанк, признак 96) сюда НЕ относится — это другой
одноимённый показатель официальной спецификации (п.59), обязательный
для блока СведенияКартаИнБанк (код операции 1009), а не для 5016.
Раньше обе колонки ошибочно чистились по одному условию (TERRITORY_CODE
not in codes), из-за чего колонка 96 всегда обнулялась для 1009-строк —
именно 5016-код там никогда не бывает. Для колонки 96 отдельная очистка
не нужна: движок маппинга и так подставляет в неё значение только тогда,
когда в XML реально есть СведенияКартаИнБанк/КодТерИнБанк.
"""
if TERRITORY_CODE not in codes:
_set(row, COL_TERRITORY_CODE, "")
def _rule_payer_account( def _rule_payer_account(
row: dict[int, str], transfer_type: str, codes: set[str] row: dict[int, str], transfer_type: str, codes: set[str]
) -> None: ) -> None:
@ -930,6 +1043,26 @@ def _rule_payer_account(
_set(row, COL_PAYER_ACCOUNT, ACCOUNT_PLACEHOLDER) _set(row, COL_PAYER_ACCOUNT, ACCOUNT_PLACEHOLDER)
def _rule_esp_identifiers(row: dict[int, str], operation_sign: str) -> None:
"""Правила 39 и 47: идентификаторы ЭСП отсутствуют для признака 9."""
if operation_sign == "9":
_set(row, COL_PAYER_ESP, "")
_set(row, COL_RECIPIENT_ESP, "")
def _rule_payer_bank(row: dict[int, str], operator_type: str) -> None:
"""Правило 40: банк плательщика только для типов оператора 2 и 4."""
if operator_type not in {"2", "4"}:
_set(row, COL_PAYER_BANK_NAME, "")
def _rule_recipient_bank(row: dict[int, str], operator_type: str) -> None:
"""Правила 42-43: банк получателя только для типов оператора 1 и 4."""
if operator_type not in {"1", "4"}:
_set(row, COL_RECIPIENT_BANK_NAME, "")
_set(row, COL_RECIPIENT_BANK_BIK, "")
def _rule_correspondent_accounts( def _rule_correspondent_accounts(
row: dict[int, str], operator_type: str, transfer_type: str, codes: set[str] row: dict[int, str], operator_type: str, transfer_type: str, codes: set[str]
) -> None: ) -> None:
@ -985,9 +1118,60 @@ def _rule_card_without_employee(row: dict[int, str]) -> None:
_set(row, COL_FOREIGN_BANK_NAME, "Информация отсутствует") _set(row, COL_FOREIGN_BANK_NAME, "Информация отсутствует")
def _rule_participant_identity(row: dict[int, str], participant_type: str) -> None:
"""Правила 119 и 122: идентификация и КПП зависят от типа участника."""
if participant_type in {PARTICIPANT_TYPE_UL, PARTICIPANT_TYPE_FL}:
_set(row, COL_IDENT_FL, "")
if participant_type == PARTICIPANT_TYPE_FL:
_set(row, COL_KPP, "")
def _rule_branch_sign(row: dict[int, str], participant_type: str) -> None:
"""Правило 124: показатель отсутствует для ФЛ, ИП и ФЛЧП."""
if participant_type in {
PARTICIPANT_TYPE_FL,
PARTICIPANT_TYPE_IP,
PARTICIPANT_TYPE_FLCHP,
}:
_set(row, COL_BRANCH_SIGN, "")
def _rule_snils_for_ip(row: dict[int, str], participant_type: str) -> None:
"""Правило 125: СНИЛС не выводится для ИП."""
if participant_type != PARTICIPANT_TYPE_IP:
return
insurance_value = _get(row, COL_INSURANCE_NUMBER)
if re.fullmatch(r"\d{3}-?\d{3}-?\d{3}\s?\d{2}", insurance_value):
_set(row, COL_INSURANCE_NUMBER, "")
def _allow_one_line_address(row: dict[int, str]) -> bool: def _allow_one_line_address(row: dict[int, str]) -> bool:
resident_flag = _get(row, COL_RESIDENT_SIGN) resident_flag = _get(row, COL_RESIDENT_SIGN)
client_flag = _get(row, COL_CLIENT_SIGN) client_flag = _get(row, COL_CLIENT_SIGN)
if not resident_flag and not client_flag: if not resident_flag and not client_flag:
return True return True
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 _rule_address_one_line(row: dict[int, str]) -> None:
"""Правила 141, 152, 167 и 178: однострочные адреса только для нерезидентов."""
if not _allow_one_line_address(row):
_set(row, COL_ADDRESS_ONE_LINE, "")
_set(row, COL_REG_PLACE_ONE_LINE, "")
_set(row, COL_EIO_ADDRESS_ONE_LINE, "")
_set(row, COL_EIO_REG_PLACE_ONE_LINE, "")
def _rule_okato_by_country(row: dict[int, str]) -> None:
"""ОКАТО адресного блока заполняется только для России."""
for country_column, okato_column in OKATO_COUNTRY_PAIRS:
if _get(row, country_column) != RUSSIA_COUNTRY_CODE:
_set(row, okato_column, "")
def _rule_eio_inn_for_physical_person(
row: dict[int, str], participant_type: str
) -> None:
"""Правило 163: показатель ЕИО отсутствует для ФЛ."""
if participant_type == PARTICIPANT_TYPE_FL:
_set(row, COL_EIO_INN, "")

View File

@ -788,7 +788,7 @@
"xml_tag": "СведБанкПлательщик", "xml_tag": "СведБанкПлательщик",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/НаимКО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/НаимКО",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "", "structured_group": "",
@ -828,7 +828,7 @@
"xml_tag": "СведБанкПолучатель", "xml_tag": "СведБанкПолучатель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/НаимКО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/НаимКО",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "", "structured_group": "",
@ -2028,7 +2028,7 @@
"xml_tag": "НомерКарта", "xml_tag": "НомерКарта",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НомерКарта", "xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НомерКарта",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "", "structured_group": "",
@ -2246,7 +2246,7 @@
"block": "participant_base", "block": "participant_base",
"report_group": "participant_base", "report_group": "participant_base",
"xml_tag": "НаимЮЛ|ПолноеНаимИНБОЮЛ", "xml_tag": "НаимЮЛ|ПолноеНаимИНБОЮЛ",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/ПолноеНаимИНБОЮЛ", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/полноеНаимИНБОЮЛ",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": true, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2406,7 +2406,7 @@
"block": "participant_identity", "block": "participant_identity",
"report_group": "participant_identifiers", "report_group": "participant_identifiers",
"xml_tag": "ОГРНЮЛ|ОГРНИП|РегнНомер|РегИНБОЮЛ", "xml_tag": "ОГРНЮЛ|ОГРНИП|РегнНомер|РегИНБОЮЛ",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/РегнНомер | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/РегИНБОЮЛ", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/РегнНомер | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СвебИНБОЮЛ/РегИНБОЮЛ",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": true, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2806,9 +2806,9 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "АдресСтрока", "xml_tag": "АдресСтрока",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/АдресСтрока", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/АдресСтрока",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "legacy_142", "structured_group": "legacy_142",
@ -2826,7 +2826,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф", "xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2846,7 +2846,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Индекс", "xml_tag": "Индекс",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Индекс", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Индекс",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2866,7 +2866,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "КодОКСМ", "xml_tag": "КодОКСМ",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодОКСМ", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодОКСМ",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2886,7 +2886,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "КодСубъектаПоОКАТО", "xml_tag": "КодСубъектаПоОКАТО",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодСубъектаПоОКАТО", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодСубъектаПоОКАТО",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2906,7 +2906,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Район", "xml_tag": "Район",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Район", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Район",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2926,7 +2926,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Пункт", "xml_tag": "Пункт",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Пункт", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Пункт",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2946,7 +2946,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Улица", "xml_tag": "Улица",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Улица", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Улица",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2966,7 +2966,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Дом", "xml_tag": "Дом",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Дом", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Дом",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -2986,7 +2986,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Корп", "xml_tag": "Корп",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Корп", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Корп",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3006,7 +3006,7 @@
"block": "participant_address", "block": "participant_address",
"report_group": "participant_address", "report_group": "participant_address",
"xml_tag": "Оф", "xml_tag": "Оф",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Оф", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Оф",
"source_scope": "any", "source_scope": "any",
"allow_short_lookup": false, "allow_short_lookup": false,
"allow_direct_mapping": true, "allow_direct_mapping": true,
@ -3328,7 +3328,7 @@
"xml_tag": "АдресСтрока|АдрУчредитель", "xml_tag": "АдресСтрока|АдрУчредитель",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока", "xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока",
"source_scope": "participant", "source_scope": "participant",
"allow_short_lookup": false, "allow_short_lookup": true,
"allow_direct_mapping": true, "allow_direct_mapping": true,
"structured_value": false, "structured_value": false,
"structured_group": "legacy_168", "structured_group": "legacy_168",

View File

@ -164,7 +164,16 @@ class StreamingReportWriter:
self._text_format = self.workbook.add_format( self._text_format = self.workbook.add_format(
{"text_wrap": True, "valign": "top"} {"text_wrap": True, "valign": "top"}
) )
# Явный «обычный» формат для ячеек, которые раньше писались вообще
# без формата (например, невалидная дата в колонке дат): без него
# xlsxwriter подставил бы формат колонки (DD.MM.YYYY) из set_column.
self._plain_format = self.workbook.add_format()
self._column_formats = [self._resolve_format(column) for column in self.columns] self._column_formats = [self._resolve_format(column) for column in self.columns]
# Признак зависит только от имени колонки — считаем один раз, а не
# на каждую ячейку каждой строки.
self._text_identifier_flags = [
self._is_text_identifier_column(column) for column in self.columns
]
self._build_group_header_row() self._build_group_header_row()
# В режиме constant_memory строка "уходит" на диск сразу после того, # В режиме constant_memory строка "уходит" на диск сразу после того,
# как начата следующая — поэтому set_row для строки 0 нужно вызывать # как начата следующая — поэтому set_row для строки 0 нужно вызывать
@ -174,10 +183,14 @@ class StreamingReportWriter:
self.sheet.set_row(1, 46) self.sheet.set_row(1, 46)
for column_index, column_name in enumerate(self.columns): for column_index, column_name in enumerate(self.columns):
column_format = self._column_formats[column_index] column_format = self._column_formats[column_index]
# Формат колонки задаём на уровне колонки: пустые ячейки данных
# больше не пишутся (см. append_row), и вводимое вручную в пустую
# ячейку значение получит тот же формат, что и раньше.
self.sheet.set_column( self.sheet.set_column(
column_index, column_index,
column_index, column_index,
self._resolve_column_width(column_name, column_format), self._resolve_column_width(column_name, column_format),
column_format,
) )
# Шапка из двух строк уже настроена выше (до перехода на следующую # Шапка из двух строк уже настроена выше (до перехода на следующую
# строку — см. комментарий про constant_memory). # строку — см. комментарий про constant_memory).
@ -204,20 +217,29 @@ class StreamingReportWriter:
raw_value=raw_value, raw_value=raw_value,
default_format=default_format, default_format=default_format,
) )
# Пустые ячейки не пишем: в отчёте их ~70%, и каждая со стилем
# раздувала XML листа (15 МБ xlsx против 5 МБ на 123 файлах) и
# съедала ~40% времени записи. Внешне ничего не меняется — формат
# колонки задан в __init__ через set_column.
if value is None or value == "":
continue
if isinstance(value, datetime) and cell_format is self._date_format: if isinstance(value, datetime) and cell_format is self._date_format:
self.sheet.write_datetime( self.sheet.write_datetime(
self._next_data_row, zero_based_index, value, cell_format self._next_data_row, zero_based_index, value, cell_format
) )
elif self._is_text_identifier_column(column_name): elif self._text_identifier_flags[zero_based_index]:
self.sheet.write_string( identifier = self._normalize_identifier_string(
self._next_data_row,
zero_based_index,
self._normalize_identifier_string(
column_name, column_name,
value, value,
column_index=output_index, column_index=output_index,
row_by_index=data_by_index, row_by_index=data_by_index,
), )
if identifier == "":
continue
self.sheet.write_string(
self._next_data_row,
zero_based_index,
identifier,
default_format, default_format,
) )
elif cell_format is not None: elif cell_format is not None:
@ -225,7 +247,9 @@ class StreamingReportWriter:
self._next_data_row, zero_based_index, value, cell_format self._next_data_row, zero_based_index, value, cell_format
) )
else: else:
self.sheet.write(self._next_data_row, zero_based_index, value) self.sheet.write(
self._next_data_row, zero_based_index, value, self._plain_format
)
self._next_data_row += 1 self._next_data_row += 1
def _resolve_output_value( def _resolve_output_value(

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import tempfile import tempfile
import time import time
from collections.abc import Sequence from collections.abc import Callable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -28,6 +28,13 @@ from .report import ReportRow, StreamingReportWriter
EMPTY_BATCH_STATUS = "info" EMPTY_BATCH_STATUS = "info"
# Вызывается после каждого обработанного XML-файла: (номер файла, всего
# файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать
# прогресс длинной обработки вместо одного блокирующего спиннера — см.
# комментарий в run_batch про причину (жалоба СФМ на зависание RAISA при
# батче 230 файлов).
ProgressCallback = Callable[[int, int, str], None]
@dataclass @dataclass
class BatchResult: class BatchResult:
@ -40,6 +47,11 @@ class BatchResult:
log_path: str log_path: str
messages: list[str] = field(default_factory=list) messages: list[str] = field(default_factory=list)
duration_seconds: float = 0.0 duration_seconds: float = 0.0
# Заполняются, только если файлы обработаны, но загрузка в OpenCloud не
# удалась: раньше в этом случае отчёт удалялся вместе с временной
# папкой, и 40 минут обработки большого батча пропадали впустую.
# main.py отдаёт их пользователю через st.download_button.
fallback_files: dict[str, bytes] = field(default_factory=dict)
def run_batch( def run_batch(
@ -47,6 +59,7 @@ def run_batch(
launcher: str, launcher: str,
opencloud_username: str | None, opencloud_username: str | None,
opencloud_access_token: str | None, opencloud_access_token: str | None,
progress_callback: ProgressCallback | None = None,
) -> BatchResult: ) -> BatchResult:
"""Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud. """Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud.
@ -62,6 +75,14 @@ def run_batch(
XML просто попадают в протокол со статусом "error" — никуда не XML просто попадают в протокол со статусом "error" — никуда не
перемещаются (перемещать их и некуда, это временная распакованная копия перемещаются (перемещать их и некуда, это временная распакованная копия
вложения). вложения).
progress_callback, если передан, вызывается после разбора каждого
XML-файла с (обработано_файлов, всего_файлов, имя_файла). Весь батч
по-прежнему выполняется синхронно в одном вызове (отдельный воркер/очередь
— отдельная задача), но колбэк даёт вызывающему коду возможность
обновлять прогресс по ходу обработки, а не показывать один спиннер на
всё время — именно отсутствие видимого прогресса на большом батче (230
файлов, тестирование СФМ) навело на мысль, что приложение "зависло".
""" """
started_at = time.perf_counter() started_at = time.perf_counter()
if not opencloud_username: if not opencloud_username:
@ -102,6 +123,26 @@ def run_batch(
messages=["Не прикреплено ни одного файла."], messages=["Не прикреплено ни одного файла."],
) )
# Доступ к OpenCloud проверяем ДО обработки, а не после: на батче из 230
# файлов СФМ получили HTTP 401 от OpenCloud только через ~40 минут
# обработки, когда дело дошло до загрузки. Неверный/отозванный App Token
# или несовпадение username должны всплывать сразу.
try:
opencloud_space_id = resolve_personal_drive_id(
opencloud_username, opencloud_access_token
)
except OpenCloudUploadError as exc:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[_describe_opencloud_access_error(exc, opencloud_username)],
)
protocol_rows: list[ProtocolRow] = [] protocol_rows: list[ProtocolRow] = []
attachment_failures: list[str] = [] attachment_failures: list[str] = []
attachment_names = ", ".join(path.name for path in input_paths) attachment_names = ", ".join(path.name for path in input_paths)
@ -109,12 +150,15 @@ def run_batch(
with tempfile.TemporaryDirectory() as extract_dir: with tempfile.TemporaryDirectory() as extract_dir:
extract_dir_path = Path(extract_dir) extract_dir_path = Path(extract_dir)
stage_started = time.perf_counter()
collected = collect_xml_attachments(input_paths, extract_dir_path) collected = collect_xml_attachments(input_paths, extract_dir_path)
extract_seconds = time.perf_counter() - stage_started
log_lines.append( log_lines.append(
f"{_now()} | вложений: {len(input_paths)} " f"{_now()} | вложений: {len(input_paths)} "
f"(архивов: {collected.archives}, " f"(архивов: {collected.archives}, "
f"отдельных XML: {collected.loose_xml_files}), " f"отдельных XML: {collected.loose_xml_files}), "
f"найдено XML-файлов: {len(collected.files)}" f"найдено XML-файлов: {len(collected.files)}, "
f"распаковка: {extract_seconds:.1f} с"
) )
for problem in collected.problems: for problem in collected.problems:
protocol_rows.append( protocol_rows.append(
@ -152,7 +196,12 @@ def run_batch(
) )
report_writer = StreamingReportWriter() report_writer = StreamingReportWriter()
for collected_xml in collected.files: total_files = len(collected.files)
total_rows_written = 0
parse_total_seconds = 0.0
write_total_seconds = 0.0
files_loop_started = time.perf_counter()
for file_number, collected_xml in enumerate(collected.files, start=1):
file_name = collected_xml.file_name file_name = collected_xml.file_name
try: try:
content = collected_xml.path.read_bytes() content = collected_xml.path.read_bytes()
@ -170,12 +219,17 @@ def run_batch(
) )
) )
log_lines.append(f"{_now()} | read failed: {file_name}. {exc}") log_lines.append(f"{_now()} | read failed: {file_name}. {exc}")
if progress_callback is not None:
progress_callback(file_number, total_files, file_name)
continue continue
# parse_xml_content сам проверяет имя файла и проставляет # parse_xml_content сам проверяет имя файла и проставляет
# fatal_error, если оно не соответствует шаблону — отдельная # fatal_error, если оно не соответствует шаблону — отдельная
# валидация здесь не нужна. # валидация здесь не нужна.
parse_started = time.perf_counter()
parse_result = parse_xml_content(file_name=file_name, xml_content=content) parse_result = parse_xml_content(file_name=file_name, xml_content=content)
parse_seconds = time.perf_counter() - parse_started
write_started = time.perf_counter()
for row in parse_result.rows: for row in parse_result.rows:
report_writer.append_row( report_writer.append_row(
ReportRow( ReportRow(
@ -187,6 +241,10 @@ def run_batch(
is_continuation=row.is_continuation, is_continuation=row.is_continuation,
) )
) )
write_seconds = time.perf_counter() - write_started
parse_total_seconds += parse_seconds
write_total_seconds += write_seconds
total_rows_written += len(parse_result.rows)
status, message = _resolve_parse_status(parse_result) status, message = _resolve_parse_status(parse_result)
protocol_rows.append( protocol_rows.append(
ProtocolRow( ProtocolRow(
@ -200,7 +258,23 @@ def run_batch(
processed_at=datetime.now(), processed_at=datetime.now(),
) )
) )
log_lines.append(f"{_now()} | parsed {file_name}: {status}") # Замер на каждый файл — чтобы по логу было видно, растёт ли
# время на файл по ходу батча (накопление) или его съедает
# конкретный тяжёлый файл (много операций/участников).
# 100 файлов = 6 мин, 230 файлов = 45 мин — нелинейно, причина
# пока не локализована.
log_lines.append(
f"{_now()} | parsed {file_name}: {status} | "
f"{file_number}/{total_files} | "
f"размер={len(content) // 1024} КБ, "
f"операций={parse_result.total_operations}, "
f"строк={len(parse_result.rows)}, "
f"разбор={parse_seconds:.2f} с, "
f"запись={write_seconds:.2f} с, "
f"всего строк в отчёте={total_rows_written}"
)
if progress_callback is not None:
progress_callback(file_number, total_files, file_name)
if not protocol_rows: if not protocol_rows:
protocol_rows.append( protocol_rows.append(
@ -216,6 +290,14 @@ def run_batch(
) )
log_lines.append(f"{_now()} | empty batch") log_lines.append(f"{_now()} | empty batch")
files_loop_seconds = time.perf_counter() - files_loop_started
log_lines.append(
f"{_now()} | обработка файлов: {files_loop_seconds:.1f} с "
f"(разбор XML: {parse_total_seconds:.1f} с, "
f"запись строк отчёта: {write_total_seconds:.1f} с), "
f"строк в отчёте: {total_rows_written}"
)
year, month, day, date_key = build_date_parts() year, month, day, date_key = build_date_parts()
with tempfile.TemporaryDirectory() as artifacts_dir: with tempfile.TemporaryDirectory() as artifacts_dir:
@ -224,16 +306,24 @@ def run_batch(
local_protocol = artifacts_dir_path / "protocol.xlsx" local_protocol = artifacts_dir_path / "protocol.xlsx"
local_log = artifacts_dir_path / "process.log" local_log = artifacts_dir_path / "process.log"
stage_started = time.perf_counter()
report_writer.save(local_report) report_writer.save(local_report)
report_save_seconds = time.perf_counter() - stage_started
stage_started = time.perf_counter()
write_protocol(protocol_rows, local_protocol) write_protocol(protocol_rows, local_protocol)
protocol_seconds = time.perf_counter() - stage_started
log_lines.append(
f"{_now()} | сохранение отчёта: {report_save_seconds:.1f} с "
f"({local_report.stat().st_size // 1024} КБ), "
f"протокол: {protocol_seconds:.1f} с"
)
local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8") local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8")
try: try:
# Номер запуска для папки дня в OpenCloud определяем по # Номер запуска для папки дня в OpenCloud определяем по
# самому OpenCloud — см. next_opencloud_run_number. # самому OpenCloud — см. next_opencloud_run_number.
opencloud_space_id = resolve_personal_drive_id( # opencloud_space_id получен в начале run_batch (проверка
opencloud_username, opencloud_access_token # доступа до обработки).
)
run_number = next_opencloud_run_number( run_number = next_opencloud_run_number(
opencloud_space_id, opencloud_space_id,
(DEFAULT_OUTPUT_DIR_NAME, year, month, day), (DEFAULT_OUTPUT_DIR_NAME, year, month, day),
@ -259,6 +349,14 @@ def run_batch(
run_number=run_number, run_number=run_number,
) )
except OpenCloudUploadError as exc: except OpenCloudUploadError as exc:
# Файлы ещё лежат во временной папке — забираем их в память,
# чтобы пользователь мог скачать результат из интерфейса.
fallback_name_suffix = date_key
fallback_files = {
f"report_{fallback_name_suffix}.xlsx": local_report.read_bytes(),
f"protocol_{fallback_name_suffix}.xlsx": local_protocol.read_bytes(),
f"process_{fallback_name_suffix}.log": local_log.read_bytes(),
}
return BatchResult( return BatchResult(
status="error", status="error",
processed=0, processed=0,
@ -268,10 +366,13 @@ def run_batch(
protocol_path="", protocol_path="",
log_path="", log_path="",
messages=[ messages=[
*log_lines,
f"Файлы обработаны, но не удалось загрузить результат " f"Файлы обработаны, но не удалось загрузить результат "
f"в OpenCloud: {exc}", f"в OpenCloud: {_describe_opencloud_access_error(exc, opencloud_username)} "
f"Отчёт можно скачать кнопками ниже.",
*log_lines[-5:],
], ],
duration_seconds=round(time.perf_counter() - started_at, 2),
fallback_files=fallback_files,
) )
processed = sum(1 for row in protocol_rows if row.status == "success") processed = sum(1 for row in protocol_rows if row.status == "success")
@ -376,6 +477,18 @@ def _upload_artifacts(
return report_url, protocol_url, log_url return report_url, protocol_url, log_url
def _describe_opencloud_access_error(exc: Exception, username: str) -> str:
text = str(exc)
if "HTTP 401" in text:
return (
f"OpenCloud отклонил App Token для пользователя «{username}» (HTTP 401). "
"Вероятно, токен истёк, отозван, введён с ошибкой или выпущен под "
"другой учётной записью. Создайте новый App Token в OpenCloud и "
"сохраните его через «Добавить токен»."
)
return f"Нет доступа к OpenCloud: {text}"
def _now() -> str: def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S") return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

30
main.py
View File

@ -48,14 +48,32 @@ def main() -> None:
): ):
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
input_paths = _save_attachments(uploaded_files, Path(tmp_dir)) input_paths = _save_attachments(uploaded_files, Path(tmp_dir))
with st.spinner("Выполняется обработка..."): progress_bar = st.progress(0.0, text="Подготовка файлов...")
status_placeholder = st.empty()
def _on_progress(done: int, total: int, file_name: str) -> None:
# Прогресс по файлам, а не один спиннер на весь батч —
# иначе при большом количестве файлов (были жалобы на
# батч в 230 штук) непонятно, идёт обработка или
# приложение зависло, и пользователь закрывает вкладку
# посреди обработки.
fraction = done / total if total else 1.0
progress_bar.progress(
fraction, text=f"Обработано {done} из {total}: {file_name}"
)
status_placeholder.caption(
f"Последний обработанный файл: {file_name}"
)
launcher_name = resolve_opencloud_username() or resolve_user_id() launcher_name = resolve_opencloud_username() or resolve_user_id()
batch_result = run_batch( batch_result = run_batch(
input_paths=input_paths, input_paths=input_paths,
launcher=launcher_name, launcher=launcher_name,
opencloud_username=resolve_opencloud_username(), opencloud_username=resolve_opencloud_username(),
opencloud_access_token=resolve_opencloud_access_token(), opencloud_access_token=resolve_opencloud_access_token(),
progress_callback=_on_progress,
) )
progress_bar.progress(1.0, text="Готово")
if batch_result.status == "success": if batch_result.status == "success":
st.success("Обработка завершена успешно.") st.success("Обработка завершена успешно.")
elif batch_result.status == "partial": elif batch_result.status == "partial":
@ -76,6 +94,16 @@ def main() -> None:
st.caption("Последние сообщения обработки:") st.caption("Последние сообщения обработки:")
for message in batch_result.messages: for message in batch_result.messages:
st.write(f"- {message}") st.write(f"- {message}")
# Если обработка прошла, а загрузка в OpenCloud — нет, отдаём
# результат напрямую, чтобы не терять долгую обработку.
for file_name, payload in batch_result.fallback_files.items():
st.download_button(
f"Скачать {file_name}",
data=payload,
file_name=file_name,
use_container_width=True,
key=f"fallback_{file_name}",
)
if st.button("Выйти", type="secondary"): if st.button("Выйти", type="secondary"):
clear_session() clear_session()

View File

@ -206,34 +206,26 @@ def test_structured_address_is_built_from_right_columns() -> None:
) )
def test_one_line_and_structured_address_coexist() -> None: def test_one_line_address_clears_structured_and_component_columns() -> None:
"""Раньше заполненная однострочная версия обнуляла и структурную колонку, row = build_fixed_row(
и все её компоненты. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML. Если в файле заполнены обе версии
адреса, обе попадают в отчёт."""
# Колонки проверяем по индексу: имена "Индекс", "Код страны", "Улица" и
# "Дом" встречаются в отчёте по нескольку раз (в блоках наличных, адреса
# участника и адреса ЕИО), поэтому обращение по имени попадает не в ту.
row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
operation_index=1, operation_index=1,
operation_fields={}, operation_fields={},
participant_fields={ participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.АдресСтрока": "г. Москва, Тверская, 1", "АдресСтрока": "г. Москва, Тверская, 1",
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500", "Индекс": "674500",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643", "КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская", "Улица": "Тверская",
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1", "Дом": "1",
}, },
) )
assert row[141] == "г. Москва, Тверская, 1" assert row["Адрес в одной строке"] == "г. Москва, Тверская, 1"
# Структурная колонка сохраняет пустые позиции компонентов. assert row["Адрес по структуре (целый)"] == ""
assert row[142] == "674500, 643, , , , Тверская, 1, , " assert row["Индекс"] == ""
assert row[143] == "674500" assert row["Код страны"] == ""
assert row[144] == "643" assert row["Улица"] == ""
assert row[148] == "Тверская" assert row["Дом"] == ""
assert row[149] == "1"
def test_fio_columns_prefer_participant_fields_over_operation_common_fields() -> None: def test_fio_columns_prefer_participant_fields_over_operation_common_fields() -> None:
@ -258,15 +250,7 @@ def test_fio_columns_prefer_participant_fields_over_operation_common_fields() ->
assert row["ФИО (по структуре полностью)"] == "Азимов Ислом Каримович" assert row["ФИО (по структуре полностью)"] == "Азимов Ислом Каримович"
def test_one_line_fio_keeps_component_columns() -> None: def test_one_line_fio_clears_structured_and_component_fio_columns() -> None:
"""Компоненты ФИО больше не обнуляются из-за однострочной версии.
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML.
Собранная колонка "ФИО (по структуре полностью)" остаётся пустой только
потому, что дословно совпала с однострочной версией: дублировать одно и
то же значение в двух колонках смысла нет.
"""
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -281,26 +265,20 @@ def test_one_line_fio_keeps_component_columns() -> None:
) )
assert row["ФИО (в одной строке)"] == "Азимов Ислом Каримович" assert row["ФИО (в одной строке)"] == "Азимов Ислом Каримович"
assert row["ФИО (по структуре полностью)"] == "" assert row["ФИО (по структуре полностью)"] == ""
assert row["Фамилия"] == "Азимов" assert row["Фамилия"] == ""
assert row["Имя"] == "Ислом" assert row["Имя"] == ""
assert row["Отчество"] == "Каримович" assert row["Отчество"] == ""
assert row["Наименование ЮЛ/ФИО ( по структуре полный)"] == ""
def test_participant_address_is_taken_only_from_participant_block() -> None: def test_structured_column_is_not_filled_directly_from_single_line_tag() -> None:
"""Тег АдресСтрока входит в общий тип «Адрес» и встречается в шести блоках
схемы, поэтому колонке 141 запрещён короткий поиск по имени тега: иначе
адрес места приёма наличных попадал в адрес участника. Значение берётся
только из адресного блока самого участника."""
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
operation_index=1, operation_index=1,
operation_fields={ operation_fields={},
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача."
"АдрМестаПриемаВыдача.АдресСтрока": "VLADIVOSTOK, RUS",
},
participant_fields={ participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.АдресСтрока": "г. Москва, ул. Пример, д. 1", "АдресСтрока": "г. Москва, ул. Пример, д. 1",
}, },
) )
assert row["Адрес в одной строке"] == "г. Москва, ул. Пример, д. 1" assert row["Адрес в одной строке"] == "г. Москва, ул. Пример, д. 1"
@ -313,22 +291,6 @@ def test_participant_address_is_taken_only_from_participant_block() -> None:
) )
def test_cash_place_address_does_not_leak_into_participant_address() -> None:
"""Обратная проверка: если у участника адреса нет, а адрес места приёма
наличных есть, колонка 141 остаётся пустой."""
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача."
"АдрМестаПриемаВыдача.АдресСтрока": "VLADIVOSTOK, RUS",
},
participant_fields={},
)
assert row["Адрес в одной строке"] == ""
def test_structured_column_without_component_block_joins_candidate_values() -> None: def test_structured_column_without_component_block_joins_candidate_values() -> None:
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
@ -377,10 +339,7 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None
) )
def test_digital_rights_operation_keeps_currency_amount_fields() -> None: def test_digital_rights_operation_clears_currency_amount_fields() -> None:
"""Прежнее правило 22-24 обнуляло валюту и суммы при признаке операции 8.
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -393,27 +352,35 @@ def test_digital_rights_operation_keeps_currency_amount_fields() -> None:
}, },
participant_fields={}, participant_fields={},
) )
assert row["Валюта"] == "643" assert row["Валюта"] == ""
assert row["Сумма в валюте проведения"] == "100.00" assert row["Сумма в валюте проведения"] == ""
assert row["Сумма в рубл. эквиваленте"] == "100.00" assert row["Сумма в рубл. эквиваленте"] == ""
def test_abnormal_operation_codes_are_kept_for_any_operation_code() -> None: def test_abnormal_operation_codes_only_for_6001() -> None:
"""Прежнее правило 21 выводило коды признака необычной операции только row_without_6001 = build_fixed_row(
для кода операции 6001. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
for operation_code in ("5007", "6001"):
row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
operation_index=1, operation_index=1,
operation_fields={ operation_fields={
"КодОперации": operation_code, "КодОперации": "5007",
"ПризнНеобОперации": "1101", "ПризнНеобОперации": "1101",
}, },
participant_fields={}, participant_fields={},
) )
assert row["Коды призн. необычной операции"] == "1101", operation_code assert row_without_6001["Коды призн. необычной операции"] == ""
row_with_6001 = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "6001",
"ПризнНеобОперации": "1101",
},
participant_fields={},
)
assert row_with_6001["Коды призн. необычной операции"] == "1101"
def test_transfer_accounts_get_placeholder_when_required() -> None: def test_transfer_accounts_get_placeholder_when_required() -> None:
@ -467,14 +434,9 @@ def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
}, },
participant_fields={}, participant_fields={},
) )
# Голый тег НаимКО (без блока) в колонку плательщика больше не попадает:
# колонке 40 запрещён короткий поиск, иначе наименование подтягивалось из
# чужого банковского блока.
assert row["Наименование банка плательщика"] == "" assert row["Наименование банка плательщика"] == ""
# А то, что лежит в своём блоке, выводится независимо от типа оператора: assert row["Наименование банка получателя"] == ""
# прежние правила 42-43 обнуляли эти колонки при типе оператора 3. assert row["БИК банка получателя"] == ""
assert row["Наименование банка получателя"] == "Банк получателя"
assert row["БИК банка получателя"] == "044525225"
# Корсчета при типе оператора 3 заглушкой не заполняются, но и не # Корсчета при типе оператора 3 заглушкой не заполняются, но и не
# затираются: заказчик 24.09.2026 потребовал выводить всё, что пришло # затираются: заказчик 24.09.2026 потребовал выводить всё, что пришло
# в XML. Раньше эти два значения удалялись. # в XML. Раньше эти два значения удалялись.
@ -496,11 +458,8 @@ def test_correspondent_accounts_stay_empty_for_operator_3_without_xml_values() -
assert row["Номер счета банка получателя"] == "" assert row["Номер счета банка получателя"] == ""
def test_col36_territory_code_is_kept_for_any_operation_code() -> None: def test_col36_territory_code_5016_cleared_when_operation_is_not_5016() -> None:
"""Прежнее правило 36 выводило КодТерИнГос только для кода 5016. """Правило 36: КодТерИнГос (п.41 спецификации) — только для кода 5016."""
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML. Тег приходит в XML только там, где он предусмотрен схемой,
поэтому отдельное условие по коду операции не нужно."""
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -511,7 +470,7 @@ def test_col36_territory_code_is_kept_for_any_operation_code() -> None:
}, },
participant_fields={}, participant_fields={},
) )
assert row["Код территории (5016)"] == "840" assert row["Код территории (5016)"] == ""
def test_col96_territory_code_card_filled_for_1009_regardless_of_5016() -> None: def test_col96_territory_code_card_filled_for_1009_regardless_of_5016() -> None:
@ -598,9 +557,7 @@ def test_col124_cleared_for_physical_person_and_ip() -> None:
assert row_ip["Признак филиала: / Тип ФЛЧП"] == "" assert row_ip["Признак филиала: / Тип ФЛЧП"] == ""
def test_col125_snils_is_kept_for_ip() -> None: def test_col125_snils_cleared_for_ip() -> None:
"""Прежнее правило 125 не выводило СНИЛС для ИП. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
row = build_fixed_row( row = build_fixed_row(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -608,7 +565,7 @@ def test_col125_snils_is_kept_for_ip() -> None:
operation_fields={}, operation_fields={},
participant_fields={"ТипУчастника": "3", "СНИЛСФЛ": "123-456-789 00"}, participant_fields={"ТипУчастника": "3", "СНИЛСФЛ": "123-456-789 00"},
) )
assert row["ОМС/СНИЛС/Номер телефона"] == "123-456-789 00" assert row["ОМС/СНИЛС/Номер телефона"] == ""
def test_one_line_address_columns_cleared_when_participant_is_resident_client() -> None: def test_one_line_address_columns_cleared_when_participant_is_resident_client() -> None:
@ -630,10 +587,7 @@ def test_one_line_address_columns_cleared_when_participant_is_resident_client()
) )
def test_eio_one_line_address_is_kept_regardless_of_residency() -> None: def test_eio_one_line_address_obeys_participant_residency_rule() -> None:
"""Прежнее правило 167 выводило однострочный адрес только у нерезидентов.
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
participant_fields = { participant_fields = {
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.АдресСтрока": "г. Москва, ул. Тестовая", "УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.АдресСтрока": "г. Москва, ул. Тестовая",
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "101000", "УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "101000",
@ -654,7 +608,7 @@ def test_eio_one_line_address_is_kept_regardless_of_residency() -> None:
participant_fields=participant_fields, participant_fields=participant_fields,
) )
assert resident_client[167] == "г. Москва, ул. Тестовая" assert resident_client[167] == ""
assert resident_client[169] == "101000" assert resident_client[169] == "101000"
assert resident_client[170] == "643" assert resident_client[170] == "643"
assert nonresident[167] == "г. Москва, ул. Тестовая" assert nonresident[167] == "г. Москва, ул. Тестовая"
@ -933,9 +887,9 @@ def test_operation_documents_keep_positions_and_join_multiple_documents() -> Non
) )
assert row[107] == ( assert row[107] == (
"1, 13, , , 29/01/2026, 253, Размещение денежных средств;\n" "1. 13, , , 29/01/2026, 253, Размещение денежных средств;\n"
"2, 99, , Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ;\n" "2. 99, , Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ;\n"
"3, 99, , Общие условия размещения депозитов, 17/11/2020, Р202307/0001, ;" "3. 99, , Общие условия размещения депозитов, 17/11/2020, Р202307/0001, ;"
) )
@ -1158,10 +1112,7 @@ def test_col153_uses_only_legal_entity_registration_address_components() -> None
assert row[153] == "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1" assert row[153] == "675520, 643, 10, Благовещенский р-н, Чигири с, Зеленая ул, 1"
def test_col152_and_col153_registration_place_coexist() -> None: def test_col153_is_cleared_when_registration_address_one_line_exists() -> None:
"""Место регистрации в одной строке больше не обнуляет структурную
версию. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
row = build_fixed_row_by_index( row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -1176,12 +1127,10 @@ def test_col152_and_col153_registration_place_coexist() -> None:
) )
assert row[152] == "Москва, Тверская, 1" assert row[152] == "Москва, Тверская, 1"
assert row[153] == "123456, 643, Москва" assert row[153] == ""
def test_col178_and_col179_eio_registration_place_coexist() -> None: def test_col179_is_cleared_when_eio_registration_one_line_exists() -> None:
"""То же для места регистрации ЕИО/Бенефициара. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
row = build_fixed_row_by_index( row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -1196,7 +1145,7 @@ def test_col178_and_col179_eio_registration_place_coexist() -> None:
) )
assert row[178] == "Москва, Арбат, 1" assert row[178] == "Москва, Арбат, 1"
assert row[179] == "123456, 643, Москва" assert row[179] == ""
def test_col179_keeps_structured_eio_registration_without_one_line() -> None: def test_col179_keeps_structured_eio_registration_without_one_line() -> None:
@ -1275,16 +1224,7 @@ def test_col119_is_empty_for_legal_entity_and_uses_flip_identifier() -> None:
assert row_flip[119] == "1" assert row_flip[119] == "1"
def test_physical_person_identification_is_kept_in_cols_119_and_122() -> None: def test_physical_person_type_2_does_not_fill_cols_119_and_122() -> None:
"""Прежнее правило 119/122 обнуляло идентификацию ФЛ по типу участника.
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML.
По таблице маппинга тег УчастникФЛИП.ИдентификацияФЛ — источник и для
колонки 119, и для колонки 122 (у неё три источника: КППЮЛ для ЮЛ,
ИдентификацияФЛ для ФЛИП, ПризнакОргФормаИНБОЮЛ для ИНБОЮЛ), поэтому без
прежнего правила значение попадает в обе колонки.
"""
row = build_fixed_row_by_index( row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -1297,9 +1237,9 @@ def test_physical_person_identification_is_kept_in_cols_119_and_122() -> None:
"УчастникФЛИП.СведФЛИП.ИННФЛИП": "410116812922", "УчастникФЛИП.СведФЛИП.ИННФЛИП": "410116812922",
}, },
) )
assert row[119] == "1" assert row[119] == ""
assert row[120] == "410116812922" assert row[120] == "410116812922"
assert row[122] == "1" assert row[122] == ""
def test_col123_uses_registration_date_for_legal_entity() -> None: def test_col123_uses_registration_date_for_legal_entity() -> None:
@ -1455,11 +1395,7 @@ def test_legal_entity_eio_name_is_preserved_in_structured_column() -> None:
assert row[162] == "7707083893" assert row[162] == "7707083893"
def test_continuation_row_does_not_fill_defaults() -> None: def test_continuation_row_validates_local_codes_without_filling_defaults() -> None:
"""У повторной строки заглушки счетов не подставляются. Локальные коды
больше не проверяются на допустимость: прежние правила 10, 11 и 15
обнуляли код, если он не входил в разрешённый набор, а заказчик 24.09.2026 потребовал выводить всё из XML.
"""
row = build_fixed_row_by_index( row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id="R1", record_id="R1",
@ -1469,6 +1405,7 @@ def test_continuation_row_does_not_fill_defaults() -> None:
is_continuation=True, is_continuation=True,
) )
assert row[10] == ""
assert row[44] == "" assert row[44] == ""
assert row[45] == "" assert row[45] == ""
@ -1493,46 +1430,50 @@ def test_cp_block_is_empty_without_sved_cp_and_uses_emitent_inn_when_present() -
assert row_with_cp[202] == "7707083893" assert row_with_cp[202] == "7707083893"
def test_conversion_amount_and_currency_sign_come_from_xml() -> None: def test_currency_sign_is_preserved_independently_of_operation_code() -> None:
"""Прежнее правило 26 обнуляло сумму продаваемой валюты, если валюта не non_conversion = build_fixed_row_by_index(
указана или сумма нулевая. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие file_name="f.xml",
в XML.""" record_id="R1",
for record_id, fields, expected_amount in ( operation_index=1,
( operation_fields={
"R1", "КодОперации": "6001",
{"КодОперации": "6001", "СумКонверсия": "0.00", "ПризнВалОперации": "VO"}, "СумКонверсия": "0.00",
"0.00", "ПризнВалОперации": "VO",
), },
( participant_fields={},
"R2", )
{ assert non_conversion[26] == ""
assert non_conversion[27] == "VO"
conversion = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=2,
operation_fields={
"КодОперации": "6101", "КодОперации": "6101",
"КодВалКонверсия": "978", "КодВалКонверсия": "978",
"СумКонверсия": "100.50", "СумКонверсия": "100.50",
"ПризнВалОперации": "VO", "ПризнВалОперации": "VO",
}, },
"100.50", participant_fields={},
), )
( assert conversion[26] == "100.50"
"R3", assert conversion[27] == "VO"
{
zero_conversion = build_fixed_row_by_index(
file_name="f.xml",
record_id="R3",
operation_index=3,
operation_fields={
"КодОперации": "6126", "КодОперации": "6126",
"КодВалКонверсия": "840", "КодВалКонверсия": "840",
"СумКонверсия": "0.00", "СумКонверсия": "0.00",
"ПризнВалОперации": "VO", "ПризнВалОперации": "VO",
}, },
"0.00",
),
):
row = build_fixed_row_by_index(
file_name="f.xml",
record_id=record_id,
operation_index=1,
operation_fields=fields,
participant_fields={}, participant_fields={},
) )
assert row[26] == expected_amount, record_id assert zero_conversion[26] == ""
assert row[27] == "VO", record_id assert zero_conversion[27] == "VO"
for operation_code in ("6100", "6127"): for operation_code in ("6100", "6127"):
boundary_row = build_fixed_row_by_index( boundary_row = build_fixed_row_by_index(
@ -1668,26 +1609,33 @@ def test_legal_entity_blocks_do_not_use_physical_person_identification() -> None
assert row[122] == "" assert row[122] == ""
def test_col124_keeps_private_practice_type() -> None: def test_col124_is_empty_for_private_practice_participant_type_4() -> None:
"""Прежнее правило 124 обнуляло колонку для ФЛ, ИП и ФЛЧП, хотя для ФЛЧП
она и предназначена (её второй источник — ТипФЛЧастнаяПрактика).
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
в XML."""
for record_id, participant_type in (("R1", "4"), ("R2", "04")):
row = build_fixed_row_by_index( row = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
record_id=record_id, record_id="R1",
operation_index=1, operation_index=1,
operation_fields={}, operation_fields={},
participant_fields={ participant_fields={
"ТипУчастника": participant_type, "ТипУчастника": "4",
"УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1", "УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1",
}, },
) )
assert row[124] == "1", participant_type 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() -> None: def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() -> None:
participant_fields = { participant_fields = {
"ТипУчастника": "2", "ТипУчастника": "2",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов", "УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
@ -1703,12 +1651,8 @@ def test_col119_is_not_included_in_structured_fio() -> None:
participant_fields=participant_fields, participant_fields=participant_fields,
) )
assert row[114] == "Азимов Ислом Каримович" assert row[114] == "Азимов Ислом Каримович"
# Прежнее правило 119 обнуляло колонку для ЮЛ и ФЛ; теперь значение assert row[119] == ""
# выводится, потому что оно есть в XML. assert row[122] == ""
assert row[119] == "1"
# Тот же тег ИдентификацияФЛ — второй источник колонки 122, поэтому
# значение попадает и туда.
assert row[122] == "1"
row_with_one_line_fio = build_fixed_row_by_index( row_with_one_line_fio = build_fixed_row_by_index(
file_name="f.xml", file_name="f.xml",
@ -1720,10 +1664,8 @@ def test_col119_is_not_included_in_structured_fio() -> None:
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.ФИОСтрока": ("Азимов Ислом Каримович"), "УчастникФЛИП.СведФЛИП.ФИОФЛИП.ФИОСтрока": ("Азимов Ислом Каримович"),
}, },
) )
# Однострочная версия ФИО больше не обнуляет ни компоненты, ни assert row_with_one_line_fio[119] == ""
# идентификацию: колонка 119 к структурной группе ФИО не относится. assert row_with_one_line_fio[122] == ""
assert row_with_one_line_fio[119] == "1"
assert row_with_one_line_fio[122] == "1"
def test_col48_atm_number_not_leaked_from_foreign_card_block() -> None: def test_col48_atm_number_not_leaked_from_foreign_card_block() -> None:
@ -1968,61 +1910,3 @@ def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None:
groups = COLUMNS.report_groups() groups = COLUMNS.report_groups()
foreign_card_group = next(g for g in groups if g.start_index <= 97 <= g.end_index) foreign_card_group = next(g for g in groups if g.start_index <= 97 <= g.end_index)
assert foreign_card_group.start_index == 96 assert foreign_card_group.start_index == 96
def test_code_list_columns_use_reference_separator() -> None:
"""Перечисления кодов должны совпадать с эталонным отчётом АРМ «Комита»
дословно: он печатает их через запятую без пробела, а парсер склеивает
повторяющиеся теги через "; " (см. _append_value в parser.py). На сверке
за 30.01.2023 это давало расхождение в 336 записях из 5266."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ДопКодОперации": "1; 2",
"ПризнНеобОперации": "1101; 1106; 1499",
},
participant_fields={},
)
assert row[20] == "1,2"
assert row[21] == "1101,1106,1499"
def test_code_list_with_single_value_is_untouched() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={"ПризнНеобОперации": "1101"},
participant_fields={},
)
assert row[21] == "1101"
def test_card_number_stays_in_its_own_block() -> None:
"""Тег НомерКарта есть и в блоке внесения/получения наличных (колонка 81),
и в блоке карты иностранного банка (колонка 102). Короткий поиск по имени
тега уводил карту из первого блока во второй: на сверке с эталоном за
10.08.2026 это давало 63 лишних значения в колонке 102."""
own_account = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведенияВнесениеПолучениеНалДС.НомерКарта": "2200380368847984"
},
participant_fields={},
)
assert own_account[81] == "2200380368847984"
assert own_account[102] == ""
foreign_card = build_fixed_row_by_index(
file_name="f.xml",
record_id="R2",
operation_index=1,
operation_fields={"СведенияКартаИнБанк.НомерКарта": "5555444433332222"},
participant_fields={},
)
assert foreign_card[102] == "5555444433332222"
assert foreign_card[81] == ""