Kinda fix
This commit is contained in:
parent
6229fe7814
commit
d1daecdb31
@ -5,17 +5,35 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
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 = {}
|
||||
if settings.DATABASE_SCHEMA:
|
||||
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
|
||||
if "sqlite" in settings.DATABASE_URL:
|
||||
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(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_timeout=_CONNECT_TIMEOUT_SECONDS,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
|
||||
@ -7,46 +7,77 @@ from .column_constants import (
|
||||
ACCOUNT_PLACEHOLDER,
|
||||
BLOCK_CP,
|
||||
BLOCK_EIO,
|
||||
COL_ADDRESS_ONE_LINE,
|
||||
COL_ADDRESS_STRUCTURED,
|
||||
COL_AMOUNT_CURRENCY,
|
||||
COL_AMOUNT_RUB,
|
||||
COL_BIRTH_REG_DATE,
|
||||
COL_BRANCH_SIGN,
|
||||
COL_CARD_HOLDER_INFO,
|
||||
COL_CLIENT_SIGN,
|
||||
COL_COUNTRY_CODE,
|
||||
COL_CP_ISSUER_INN,
|
||||
COL_CURRENCY,
|
||||
COL_EIO_ADDRESS_ONE_LINE,
|
||||
COL_EIO_INN,
|
||||
COL_EIO_OTHER_INFO,
|
||||
COL_EIO_REG_PLACE_ONE_LINE,
|
||||
COL_EIO_REG_PLACE_STRUCTURED,
|
||||
COL_EMPLOYEE_SIGN,
|
||||
COL_ESP_SIGN,
|
||||
COL_EXTRA_CODES,
|
||||
COL_EXTRA_INFO,
|
||||
COL_FILE_NAME,
|
||||
COL_FOREIGN_BANK_NAME,
|
||||
COL_FOREIGN_CARD_ADDRESS_STRUCTURED,
|
||||
COL_FTR_SIGN,
|
||||
COL_IDENT_FL,
|
||||
COL_INN,
|
||||
COL_INSURANCE_NUMBER,
|
||||
COL_ITEM_TYPE,
|
||||
COL_KPP,
|
||||
COL_OPERATION_CODE,
|
||||
COL_OPERATION_DOCUMENTS,
|
||||
COL_OPERATION_SIGN,
|
||||
COL_OPERATOR_TYPE,
|
||||
COL_PARTICIPANT_TYPE,
|
||||
COL_PAYER_ACCOUNT,
|
||||
COL_PAYER_BANK_ACCOUNT,
|
||||
COL_PAYER_BANK_BIK,
|
||||
COL_PAYER_BANK_NAME,
|
||||
COL_PAYER_ESP,
|
||||
COL_RECIPIENT_ACCOUNT,
|
||||
COL_RECIPIENT_BANK_ACCOUNT,
|
||||
COL_RECIPIENT_BANK_BIK,
|
||||
COL_RECIPIENT_BANK_NAME,
|
||||
COL_RECIPIENT_ESP,
|
||||
COL_RECORD_ID,
|
||||
COL_REG_PLACE_ONE_LINE,
|
||||
COL_REG_PLACE_STRUCTURED,
|
||||
COL_RESIDENT_SIGN,
|
||||
COL_SALE_AMOUNT,
|
||||
COL_SALE_CURRENCY,
|
||||
COL_STAY_DOCUMENT_TYPE,
|
||||
COL_SUSPENSION_BASIS,
|
||||
COL_SUSPICIOUS_ID,
|
||||
COL_TERRITORY_CODE,
|
||||
COL_TRANSFER_TYPE,
|
||||
COL_UNUSUAL_CODES,
|
||||
EMPLOYEE_ABSENT_CODE,
|
||||
EXPORT_SUBSIDIARY_COMMENT_CODES,
|
||||
OKATO_COUNTRY_PAIRS,
|
||||
PARTICIPANT_ADDRESS_COMPONENT_COLUMNS,
|
||||
PARTICIPANT_TYPE_FL,
|
||||
PARTICIPANT_TYPE_FLCHP,
|
||||
PARTICIPANT_TYPE_IP,
|
||||
PARTICIPANT_TYPE_UL,
|
||||
PRECIOUS_ITEM_CODES,
|
||||
RUSSIA_COUNTRY_CODE,
|
||||
SUSPICIOUS_CODE,
|
||||
TERRITORY_CODE,
|
||||
VALID_ESP_SIGNS,
|
||||
VALID_FTR_SIGNS,
|
||||
VALID_SUSPENSION_BASES,
|
||||
)
|
||||
from .column_registry import COLUMNS, ColumnMeta
|
||||
|
||||
@ -57,6 +88,10 @@ class _CompiledCandidate:
|
||||
normalized: str
|
||||
short: str
|
||||
strict_scoped: bool
|
||||
# Собственные суффиксы normalized по границе ".", из двух и более
|
||||
# сегментов: "a.b.c.d" -> ("b.c.d", "c.d"). Нужны _pick_value для поиска
|
||||
# через словарь вместо прохода по всем полям строки (см. там).
|
||||
dotted_suffixes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -86,6 +121,10 @@ class _PayloadLookup:
|
||||
normalized_exact_first: dict[str, str]
|
||||
normalized_suffix_first: dict[str, str]
|
||||
raw_short_first: dict[str, str]
|
||||
# normalized_path (только составные, с ".") -> (позиция первого поля с
|
||||
# непустым значением, значение). Позиция нужна, чтобы при нескольких
|
||||
# совпадениях вернуть то же поле, что и прежний линейный проход.
|
||||
dotted_path_first: dict[str, tuple[int, str]]
|
||||
|
||||
|
||||
@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 = (
|
||||
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ",
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег",
|
||||
"УчастникИНБОЮЛ.СведИНБОЮЛ.МестоДеятельностьИНБОЮЛ",
|
||||
)
|
||||
_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, ...]:
|
||||
@ -166,11 +205,17 @@ def _compile_candidates(candidates: tuple[str, ...]) -> tuple[_CompiledCandidate
|
||||
other != normalized and other.endswith(f".{normalized}")
|
||||
for other in normalized_candidates
|
||||
),
|
||||
dotted_suffixes=_dotted_suffixes(normalized),
|
||||
)
|
||||
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, ...]:
|
||||
rules: list[MappingRule] = []
|
||||
for meta in mapping_data:
|
||||
@ -478,8 +523,11 @@ def build_fixed_row_by_index(
|
||||
)
|
||||
if not is_continuation:
|
||||
_apply_participant_identity_rules(row, participant_lookup)
|
||||
# Сначала убираем запрещённые однострочные адреса, чтобы структурная
|
||||
# группа не очистила их компоненты до проверки резидентства.
|
||||
_rule_address_one_line(row)
|
||||
_apply_structured_group_rules(row)
|
||||
_drop_structured_duplicates_of_one_line(row)
|
||||
_apply_one_line_structured_pairs(row)
|
||||
if not is_continuation:
|
||||
_apply_business_rules(
|
||||
row,
|
||||
@ -488,6 +536,8 @@ def build_fixed_row_by_index(
|
||||
operation_lookup=operation_lookup,
|
||||
participant_lookup=participant_lookup,
|
||||
)
|
||||
else:
|
||||
_apply_continuation_validation_rules(row)
|
||||
for meta in COLUMNS.ordered_columns():
|
||||
row.setdefault(meta.index, "")
|
||||
return row
|
||||
@ -517,15 +567,16 @@ def build_fixed_row(
|
||||
|
||||
|
||||
def _apply_structured_group_rules(row: dict[int, str]) -> None:
|
||||
"""Собирает структурную колонку адреса из её компонентов.
|
||||
|
||||
Однострочная версия адреса больше не отменяет структурную: раньше при
|
||||
заполненной одной строке структурная колонка и все её компоненты
|
||||
обнулялись, то есть отчёт показывал либо одно, либо другое. Заказчик
|
||||
24.09.2026 потребовал выводить все значения из XML, а в файлах бывают
|
||||
заполнены обе версии сразу.
|
||||
"""
|
||||
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 = [
|
||||
_get(row, component_column_index).strip()
|
||||
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))
|
||||
|
||||
|
||||
def _drop_structured_duplicates_of_one_line(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, "")
|
||||
def _apply_one_line_structured_pairs(row: dict[int, str]) -> None:
|
||||
"""Оставляет только однострочное значение, если заполнены обе версии поля."""
|
||||
for one_line_column, structured_column in _ONE_LINE_STRUCTURED_PAIRS:
|
||||
structured_value = _get(row, structured_column).strip()
|
||||
if structured_value and _get(row, one_line_column).strip() == structured_value:
|
||||
if _get(row, one_line_column):
|
||||
_set(row, structured_column, "")
|
||||
|
||||
|
||||
@ -600,10 +634,13 @@ def _build_payload_lookup_from_entries(
|
||||
normalized_exact_first: dict[str, str] = {}
|
||||
normalized_suffix_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:
|
||||
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)
|
||||
path_parts = entry.normalized_path.split(".")
|
||||
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_suffix_first=normalized_suffix_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
|
||||
]
|
||||
fields.insert(1, "")
|
||||
# Эталонный отчёт отделяет номер документа запятой, а не точкой:
|
||||
# "1, 13, , , 05/08/2026, ...". Расхождение затрагивало почти каждую
|
||||
# строку отчёта, поэтому приводим к виду эталона.
|
||||
formatted_documents.append(f"{position}, {', '.join(fields)};")
|
||||
formatted_documents.append(f"{position}. {', '.join(fields)};")
|
||||
return "\n".join(formatted_documents)
|
||||
|
||||
|
||||
@ -774,13 +809,18 @@ def _pick_value(
|
||||
if value := lookup.normalized_suffix_first.get(candidate.normalized):
|
||||
return value
|
||||
if not allow_short_lookup:
|
||||
for entry in lookup.entries:
|
||||
if (
|
||||
entry.value
|
||||
and "." in entry.normalized_path
|
||||
and candidate.normalized.endswith(f".{entry.normalized_path}")
|
||||
):
|
||||
return entry.value
|
||||
# Ищем первое (в порядке полей) непустое составное поле, путь
|
||||
# которого является собственным суффиксом кандидата. Раньше это
|
||||
# был проход по ВСЕМ полям строки с endswith на каждый кандидат
|
||||
# каждой колонки — ~40% времени формирования отчёта (профиль на
|
||||
# 123 реальных файлах). Теперь — несколько обращений к словарю.
|
||||
best: tuple[int, str] | None = None
|
||||
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
|
||||
if value := lookup.values.get(candidate.short):
|
||||
return value
|
||||
@ -850,46 +890,91 @@ def _apply_business_rules(
|
||||
participant_lookup: _PayloadLookup,
|
||||
) -> None:
|
||||
codes = _operation_codes(row)
|
||||
operation_sign = _get(row, COL_OPERATION_SIGN)
|
||||
transfer_type = _get(row, COL_TRANSFER_TYPE)
|
||||
operator_type = _get(row, COL_OPERATOR_TYPE)
|
||||
participant_type = _normalize_code(_get(row, COL_PARTICIPANT_TYPE))
|
||||
|
||||
# Заказчик 24.09.2026: в отчёт попадают все значения из XML. Поэтому здесь
|
||||
# остались только правила, которые ничего не удаляют: сборка колонки из
|
||||
# нескольких тегов и подстановка заглушек в пустые колонки. Правила,
|
||||
# которые обнуляли колонку, если код операции, вид перевода или тип
|
||||
# участника не входил в разрешённый список, убраны — они выбрасывали
|
||||
# данные, реально присутствующие в файле.
|
||||
_rule_code_list_separator(row)
|
||||
_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_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_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_recipient_account(row, transfer_type, codes)
|
||||
|
||||
_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:
|
||||
_clear_columns(row, BLOCK_EIO)
|
||||
if not has_cp_block:
|
||||
_clear_columns(row, BLOCK_CP)
|
||||
|
||||
|
||||
# Перечисления кодов эталонный отчёт АРМ «Комита» печатает через запятую без
|
||||
# пробела ("1101,1106,1499"), а парсер склеивает повторяющиеся теги через
|
||||
# "; " (см. _append_value в parser.py). Приводим к виду эталона только
|
||||
# колонки-перечисления кодов: в текстовых полях "; " читается лучше.
|
||||
_CODE_LIST_COLUMNS = (COL_EXTRA_CODES, COL_UNUSUAL_CODES)
|
||||
_CODE_LIST_SEPARATOR = ","
|
||||
def _apply_continuation_validation_rules(row: dict[int, str]) -> None:
|
||||
"""Проверяет локальные коды повтора без подстановки значений основной строки."""
|
||||
_rule_ftr_sign(row)
|
||||
_rule_suspension_basis(row)
|
||||
_rule_esp_sign(row)
|
||||
|
||||
|
||||
def _rule_code_list_separator(row: dict[int, str]) -> None:
|
||||
for column in _CODE_LIST_COLUMNS:
|
||||
value = _get(row, column)
|
||||
if "; " not in value:
|
||||
continue
|
||||
codes = [part.strip() for part in value.split(";") if part.strip()]
|
||||
_set(row, column, _CODE_LIST_SEPARATOR.join(codes))
|
||||
def _rule_ftr_sign(row: dict[int, str]) -> None:
|
||||
"""Правило 10: оставляем только допустимые значения признака ФТР."""
|
||||
if _get(row, COL_FTR_SIGN) not in VALID_FTR_SIGNS:
|
||||
_set(row, COL_FTR_SIGN, "")
|
||||
|
||||
|
||||
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(
|
||||
@ -914,6 +999,34 @@ def _rule_extra_info(
|
||||
_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(
|
||||
row: dict[int, str], transfer_type: str, codes: set[str]
|
||||
) -> None:
|
||||
@ -930,6 +1043,26 @@ def _rule_payer_account(
|
||||
_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(
|
||||
row: dict[int, str], operator_type: str, transfer_type: str, codes: set[str]
|
||||
) -> None:
|
||||
@ -985,9 +1118,60 @@ def _rule_card_without_employee(row: dict[int, str]) -> None:
|
||||
_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:
|
||||
resident_flag = _get(row, COL_RESIDENT_SIGN)
|
||||
client_flag = _get(row, COL_CLIENT_SIGN)
|
||||
if not resident_flag and not client_flag:
|
||||
return True
|
||||
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, "")
|
||||
|
||||
@ -788,7 +788,7 @@
|
||||
"xml_tag": "СведБанкПлательщик",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПлательщик/НаимКО",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
"structured_value": false,
|
||||
"structured_group": "",
|
||||
@ -828,7 +828,7 @@
|
||||
"xml_tag": "СведБанкПолучатель",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДС/СведБанкПолучатель/НаимКО",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
"structured_value": false,
|
||||
"structured_group": "",
|
||||
@ -2028,7 +2028,7 @@
|
||||
"xml_tag": "НомерКарта",
|
||||
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/НомерКарта",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
"structured_value": false,
|
||||
"structured_group": "",
|
||||
@ -2246,7 +2246,7 @@
|
||||
"block": "participant_base",
|
||||
"report_group": "participant_base",
|
||||
"xml_tag": "НаимЮЛ|ПолноеНаимИНБОЮЛ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/ПолноеНаимИНБОЮЛ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/НаимЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/полноеНаимИНБОЮЛ",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2406,7 +2406,7 @@
|
||||
"block": "participant_identity",
|
||||
"report_group": "participant_identifiers",
|
||||
"xml_tag": "ОГРНЮЛ|ОГРНИП|РегнНомер|РегИНБОЮЛ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/РегнНомер | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/РегИНБОЮЛ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/ОГРНЮЛ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/ОГРНИП | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/РегнНомер | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СвебИНБОЮЛ/РегИНБОЮЛ",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2806,9 +2806,9 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "АдресСтрока",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/АдресСтрока",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/АдресСтрока",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
"structured_value": false,
|
||||
"structured_group": "legacy_142",
|
||||
@ -2826,7 +2826,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2846,7 +2846,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Индекс",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Индекс",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Индекс",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2866,7 +2866,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "КодОКСМ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодОКСМ",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодОКСМ",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2886,7 +2886,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "КодСубъектаПоОКАТО",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодСубъектаПоОКАТО",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодСубъектаПоОКАТО",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2906,7 +2906,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Район",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Район",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Район",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2926,7 +2926,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Пункт",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Пункт",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Пункт",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2946,7 +2946,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Улица",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Улица",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Улица",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2966,7 +2966,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Дом",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Дом",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Дом",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -2986,7 +2986,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Корп",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Корп",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Корп",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -3006,7 +3006,7 @@
|
||||
"block": "participant_address",
|
||||
"report_group": "participant_address",
|
||||
"xml_tag": "Оф",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Оф",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Оф",
|
||||
"source_scope": "any",
|
||||
"allow_short_lookup": false,
|
||||
"allow_direct_mapping": true,
|
||||
@ -3328,7 +3328,7 @@
|
||||
"xml_tag": "АдресСтрока|АдрУчредитель",
|
||||
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ЮЛЕИО/АдрЮЛ/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЕИО/ФЛЕИО/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/БенефициарЮЛ/ФЛБенефициар/АдрРег/АдресСтрока | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/Учредитель/АдрУчредитель/АдресСтрока",
|
||||
"source_scope": "participant",
|
||||
"allow_short_lookup": false,
|
||||
"allow_short_lookup": true,
|
||||
"allow_direct_mapping": true,
|
||||
"structured_value": false,
|
||||
"structured_group": "legacy_168",
|
||||
|
||||
@ -164,7 +164,16 @@ class StreamingReportWriter:
|
||||
self._text_format = self.workbook.add_format(
|
||||
{"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._text_identifier_flags = [
|
||||
self._is_text_identifier_column(column) for column in self.columns
|
||||
]
|
||||
self._build_group_header_row()
|
||||
# В режиме constant_memory строка "уходит" на диск сразу после того,
|
||||
# как начата следующая — поэтому set_row для строки 0 нужно вызывать
|
||||
@ -174,10 +183,14 @@ class StreamingReportWriter:
|
||||
self.sheet.set_row(1, 46)
|
||||
for column_index, column_name in enumerate(self.columns):
|
||||
column_format = self._column_formats[column_index]
|
||||
# Формат колонки задаём на уровне колонки: пустые ячейки данных
|
||||
# больше не пишутся (см. append_row), и вводимое вручную в пустую
|
||||
# ячейку значение получит тот же формат, что и раньше.
|
||||
self.sheet.set_column(
|
||||
column_index,
|
||||
column_index,
|
||||
self._resolve_column_width(column_name, column_format),
|
||||
column_format,
|
||||
)
|
||||
# Шапка из двух строк уже настроена выше (до перехода на следующую
|
||||
# строку — см. комментарий про constant_memory).
|
||||
@ -204,20 +217,29 @@ class StreamingReportWriter:
|
||||
raw_value=raw_value,
|
||||
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:
|
||||
self.sheet.write_datetime(
|
||||
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]:
|
||||
identifier = self._normalize_identifier_string(
|
||||
column_name,
|
||||
value,
|
||||
column_index=output_index,
|
||||
row_by_index=data_by_index,
|
||||
)
|
||||
if identifier == "":
|
||||
continue
|
||||
self.sheet.write_string(
|
||||
self._next_data_row,
|
||||
zero_based_index,
|
||||
self._normalize_identifier_string(
|
||||
column_name,
|
||||
value,
|
||||
column_index=output_index,
|
||||
row_by_index=data_by_index,
|
||||
),
|
||||
identifier,
|
||||
default_format,
|
||||
)
|
||||
elif cell_format is not None:
|
||||
@ -225,7 +247,9 @@ class StreamingReportWriter:
|
||||
self._next_data_row, zero_based_index, value, cell_format
|
||||
)
|
||||
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
|
||||
|
||||
def _resolve_output_value(
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -28,6 +28,13 @@ from .report import ReportRow, StreamingReportWriter
|
||||
|
||||
EMPTY_BATCH_STATUS = "info"
|
||||
|
||||
# Вызывается после каждого обработанного XML-файла: (номер файла, всего
|
||||
# файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать
|
||||
# прогресс длинной обработки вместо одного блокирующего спиннера — см.
|
||||
# комментарий в run_batch про причину (жалоба СФМ на зависание RAISA при
|
||||
# батче 230 файлов).
|
||||
ProgressCallback = Callable[[int, int, str], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
@ -40,6 +47,11 @@ class BatchResult:
|
||||
log_path: str
|
||||
messages: list[str] = field(default_factory=list)
|
||||
duration_seconds: float = 0.0
|
||||
# Заполняются, только если файлы обработаны, но загрузка в OpenCloud не
|
||||
# удалась: раньше в этом случае отчёт удалялся вместе с временной
|
||||
# папкой, и 40 минут обработки большого батча пропадали впустую.
|
||||
# main.py отдаёт их пользователю через st.download_button.
|
||||
fallback_files: dict[str, bytes] = field(default_factory=dict)
|
||||
|
||||
|
||||
def run_batch(
|
||||
@ -47,6 +59,7 @@ def run_batch(
|
||||
launcher: str,
|
||||
opencloud_username: str | None,
|
||||
opencloud_access_token: str | None,
|
||||
progress_callback: ProgressCallback | None = None,
|
||||
) -> BatchResult:
|
||||
"""Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud.
|
||||
|
||||
@ -62,6 +75,14 @@ def run_batch(
|
||||
XML просто попадают в протокол со статусом "error" — никуда не
|
||||
перемещаются (перемещать их и некуда, это временная распакованная копия
|
||||
вложения).
|
||||
|
||||
progress_callback, если передан, вызывается после разбора каждого
|
||||
XML-файла с (обработано_файлов, всего_файлов, имя_файла). Весь батч
|
||||
по-прежнему выполняется синхронно в одном вызове (отдельный воркер/очередь
|
||||
— отдельная задача), но колбэк даёт вызывающему коду возможность
|
||||
обновлять прогресс по ходу обработки, а не показывать один спиннер на
|
||||
всё время — именно отсутствие видимого прогресса на большом батче (230
|
||||
файлов, тестирование СФМ) навело на мысль, что приложение "зависло".
|
||||
"""
|
||||
started_at = time.perf_counter()
|
||||
if not opencloud_username:
|
||||
@ -102,6 +123,26 @@ def run_batch(
|
||||
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] = []
|
||||
attachment_failures: list[str] = []
|
||||
attachment_names = ", ".join(path.name for path in input_paths)
|
||||
@ -109,12 +150,15 @@ def run_batch(
|
||||
|
||||
with tempfile.TemporaryDirectory() as extract_dir:
|
||||
extract_dir_path = Path(extract_dir)
|
||||
stage_started = time.perf_counter()
|
||||
collected = collect_xml_attachments(input_paths, extract_dir_path)
|
||||
extract_seconds = time.perf_counter() - stage_started
|
||||
log_lines.append(
|
||||
f"{_now()} | вложений: {len(input_paths)} "
|
||||
f"(архивов: {collected.archives}, "
|
||||
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:
|
||||
protocol_rows.append(
|
||||
@ -152,7 +196,12 @@ def run_batch(
|
||||
)
|
||||
|
||||
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
|
||||
try:
|
||||
content = collected_xml.path.read_bytes()
|
||||
@ -170,12 +219,17 @@ def run_batch(
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
# parse_xml_content сам проверяет имя файла и проставляет
|
||||
# fatal_error, если оно не соответствует шаблону — отдельная
|
||||
# валидация здесь не нужна.
|
||||
parse_started = time.perf_counter()
|
||||
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:
|
||||
report_writer.append_row(
|
||||
ReportRow(
|
||||
@ -187,6 +241,10 @@ def run_batch(
|
||||
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)
|
||||
protocol_rows.append(
|
||||
ProtocolRow(
|
||||
@ -200,7 +258,23 @@ def run_batch(
|
||||
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:
|
||||
protocol_rows.append(
|
||||
@ -216,6 +290,14 @@ def run_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()
|
||||
|
||||
with tempfile.TemporaryDirectory() as artifacts_dir:
|
||||
@ -224,16 +306,24 @@ def run_batch(
|
||||
local_protocol = artifacts_dir_path / "protocol.xlsx"
|
||||
local_log = artifacts_dir_path / "process.log"
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
report_writer.save(local_report)
|
||||
report_save_seconds = time.perf_counter() - stage_started
|
||||
stage_started = time.perf_counter()
|
||||
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")
|
||||
|
||||
try:
|
||||
# Номер запуска для папки дня в OpenCloud определяем по
|
||||
# самому OpenCloud — см. next_opencloud_run_number.
|
||||
opencloud_space_id = resolve_personal_drive_id(
|
||||
opencloud_username, opencloud_access_token
|
||||
)
|
||||
# opencloud_space_id получен в начале run_batch (проверка
|
||||
# доступа до обработки).
|
||||
run_number = next_opencloud_run_number(
|
||||
opencloud_space_id,
|
||||
(DEFAULT_OUTPUT_DIR_NAME, year, month, day),
|
||||
@ -259,6 +349,14 @@ def run_batch(
|
||||
run_number=run_number,
|
||||
)
|
||||
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(
|
||||
status="error",
|
||||
processed=0,
|
||||
@ -268,10 +366,13 @@ def run_batch(
|
||||
protocol_path="",
|
||||
log_path="",
|
||||
messages=[
|
||||
*log_lines,
|
||||
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")
|
||||
@ -376,6 +477,18 @@ def _upload_artifacts(
|
||||
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:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
42
main.py
42
main.py
@ -48,14 +48,32 @@ def main() -> None:
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
input_paths = _save_attachments(uploaded_files, Path(tmp_dir))
|
||||
with st.spinner("Выполняется обработка..."):
|
||||
launcher_name = resolve_opencloud_username() or resolve_user_id()
|
||||
batch_result = run_batch(
|
||||
input_paths=input_paths,
|
||||
launcher=launcher_name,
|
||||
opencloud_username=resolve_opencloud_username(),
|
||||
opencloud_access_token=resolve_opencloud_access_token(),
|
||||
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()
|
||||
batch_result = run_batch(
|
||||
input_paths=input_paths,
|
||||
launcher=launcher_name,
|
||||
opencloud_username=resolve_opencloud_username(),
|
||||
opencloud_access_token=resolve_opencloud_access_token(),
|
||||
progress_callback=_on_progress,
|
||||
)
|
||||
progress_bar.progress(1.0, text="Готово")
|
||||
if batch_result.status == "success":
|
||||
st.success("Обработка завершена успешно.")
|
||||
elif batch_result.status == "partial":
|
||||
@ -76,6 +94,16 @@ def main() -> None:
|
||||
st.caption("Последние сообщения обработки:")
|
||||
for message in batch_result.messages:
|
||||
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"):
|
||||
clear_session()
|
||||
|
||||
@ -206,34 +206,26 @@ def test_structured_address_is_built_from_right_columns() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_one_line_and_structured_address_coexist() -> None:
|
||||
"""Раньше заполненная однострочная версия обнуляла и структурную колонку,
|
||||
и все её компоненты. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML. Если в файле заполнены обе версии
|
||||
адреса, обе попадают в отчёт."""
|
||||
# Колонки проверяем по индексу: имена "Индекс", "Код страны", "Улица" и
|
||||
# "Дом" встречаются в отчёте по нескольку раз (в блоках наличных, адреса
|
||||
# участника и адреса ЕИО), поэтому обращение по имени попадает не в ту.
|
||||
row = build_fixed_row_by_index(
|
||||
def test_one_line_address_clears_structured_and_component_columns() -> None:
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
operation_index=1,
|
||||
operation_fields={},
|
||||
participant_fields={
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.АдресСтрока": "г. Москва, Тверская, 1",
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500",
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская",
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1",
|
||||
"АдресСтрока": "г. Москва, Тверская, 1",
|
||||
"Индекс": "674500",
|
||||
"КодОКСМ": "643",
|
||||
"Улица": "Тверская",
|
||||
"Дом": "1",
|
||||
},
|
||||
)
|
||||
assert row[141] == "г. Москва, Тверская, 1"
|
||||
# Структурная колонка сохраняет пустые позиции компонентов.
|
||||
assert row[142] == "674500, 643, , , , Тверская, 1, , "
|
||||
assert row[143] == "674500"
|
||||
assert row[144] == "643"
|
||||
assert row[148] == "Тверская"
|
||||
assert row[149] == "1"
|
||||
assert row["Адрес в одной строке"] == "г. Москва, Тверская, 1"
|
||||
assert row["Адрес по структуре (целый)"] == ""
|
||||
assert row["Индекс"] == ""
|
||||
assert row["Код страны"] == ""
|
||||
assert row["Улица"] == ""
|
||||
assert row["Дом"] == ""
|
||||
|
||||
|
||||
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["ФИО (по структуре полностью)"] == "Азимов Ислом Каримович"
|
||||
|
||||
|
||||
def test_one_line_fio_keeps_component_columns() -> None:
|
||||
"""Компоненты ФИО больше не обнуляются из-за однострочной версии.
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML.
|
||||
|
||||
Собранная колонка "ФИО (по структуре полностью)" остаётся пустой только
|
||||
потому, что дословно совпала с однострочной версией: дублировать одно и
|
||||
то же значение в двух колонках смысла нет.
|
||||
"""
|
||||
def test_one_line_fio_clears_structured_and_component_fio_columns() -> None:
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
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["Наименование ЮЛ/ФИО ( по структуре полный)"] == ""
|
||||
|
||||
|
||||
def test_participant_address_is_taken_only_from_participant_block() -> None:
|
||||
"""Тег АдресСтрока входит в общий тип «Адрес» и встречается в шести блоках
|
||||
схемы, поэтому колонке 141 запрещён короткий поиск по имени тега: иначе
|
||||
адрес места приёма наличных попадал в адрес участника. Значение берётся
|
||||
только из адресного блока самого участника."""
|
||||
def test_structured_column_is_not_filled_directly_from_single_line_tag() -> None:
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
operation_index=1,
|
||||
operation_fields={
|
||||
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача."
|
||||
"АдрМестаПриемаВыдача.АдресСтрока": "VLADIVOSTOK, RUS",
|
||||
},
|
||||
operation_fields={},
|
||||
participant_fields={
|
||||
"УчастникФЛИП.СведФЛИП.АдрРег.АдресСтрока": "г. Москва, ул. Пример, д. 1",
|
||||
"АдресСтрока": "г. Москва, ул. Пример, д. 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:
|
||||
row = build_fixed_row(
|
||||
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:
|
||||
"""Прежнее правило 22-24 обнуляло валюту и суммы при признаке операции 8.
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
def test_digital_rights_operation_clears_currency_amount_fields() -> None:
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -393,27 +352,35 @@ def test_digital_rights_operation_keeps_currency_amount_fields() -> None:
|
||||
},
|
||||
participant_fields={},
|
||||
)
|
||||
assert row["Валюта"] == "643"
|
||||
assert row["Сумма в валюте проведения"] == "100.00"
|
||||
assert row["Сумма в рубл. эквиваленте"] == "100.00"
|
||||
assert row["Валюта"] == ""
|
||||
assert row["Сумма в валюте проведения"] == ""
|
||||
assert row["Сумма в рубл. эквиваленте"] == ""
|
||||
|
||||
|
||||
def test_abnormal_operation_codes_are_kept_for_any_operation_code() -> None:
|
||||
"""Прежнее правило 21 выводило коды признака необычной операции только
|
||||
для кода операции 6001. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
for operation_code in ("5007", "6001"):
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
operation_index=1,
|
||||
operation_fields={
|
||||
"КодОперации": operation_code,
|
||||
"ПризнНеобОперации": "1101",
|
||||
},
|
||||
participant_fields={},
|
||||
)
|
||||
assert row["Коды призн. необычной операции"] == "1101", operation_code
|
||||
def test_abnormal_operation_codes_only_for_6001() -> None:
|
||||
row_without_6001 = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
operation_index=1,
|
||||
operation_fields={
|
||||
"КодОперации": "5007",
|
||||
"ПризнНеобОперации": "1101",
|
||||
},
|
||||
participant_fields={},
|
||||
)
|
||||
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:
|
||||
@ -467,14 +434,9 @@ def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
|
||||
},
|
||||
participant_fields={},
|
||||
)
|
||||
# Голый тег НаимКО (без блока) в колонку плательщика больше не попадает:
|
||||
# колонке 40 запрещён короткий поиск, иначе наименование подтягивалось из
|
||||
# чужого банковского блока.
|
||||
assert row["Наименование банка плательщика"] == ""
|
||||
# А то, что лежит в своём блоке, выводится независимо от типа оператора:
|
||||
# прежние правила 42-43 обнуляли эти колонки при типе оператора 3.
|
||||
assert row["Наименование банка получателя"] == "Банк получателя"
|
||||
assert row["БИК банка получателя"] == "044525225"
|
||||
assert row["Наименование банка получателя"] == ""
|
||||
assert row["БИК банка получателя"] == ""
|
||||
# Корсчета при типе оператора 3 заглушкой не заполняются, но и не
|
||||
# затираются: заказчик 24.09.2026 потребовал выводить всё, что пришло
|
||||
# в XML. Раньше эти два значения удалялись.
|
||||
@ -496,11 +458,8 @@ def test_correspondent_accounts_stay_empty_for_operator_3_without_xml_values() -
|
||||
assert row["Номер счета банка получателя"] == ""
|
||||
|
||||
|
||||
def test_col36_territory_code_is_kept_for_any_operation_code() -> None:
|
||||
"""Прежнее правило 36 выводило КодТерИнГос только для кода 5016.
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML. Тег приходит в XML только там, где он предусмотрен схемой,
|
||||
поэтому отдельное условие по коду операции не нужно."""
|
||||
def test_col36_territory_code_5016_cleared_when_operation_is_not_5016() -> None:
|
||||
"""Правило 36: КодТерИнГос (п.41 спецификации) — только для кода 5016."""
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -511,7 +470,7 @@ def test_col36_territory_code_is_kept_for_any_operation_code() -> None:
|
||||
},
|
||||
participant_fields={},
|
||||
)
|
||||
assert row["Код территории (5016)"] == "840"
|
||||
assert row["Код территории (5016)"] == ""
|
||||
|
||||
|
||||
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["Признак филиала: / Тип ФЛЧП"] == ""
|
||||
|
||||
|
||||
def test_col125_snils_is_kept_for_ip() -> None:
|
||||
"""Прежнее правило 125 не выводило СНИЛС для ИП. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
def test_col125_snils_cleared_for_ip() -> None:
|
||||
row = build_fixed_row(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -608,7 +565,7 @@ def test_col125_snils_is_kept_for_ip() -> None:
|
||||
operation_fields={},
|
||||
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:
|
||||
@ -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:
|
||||
"""Прежнее правило 167 выводило однострочный адрес только у нерезидентов.
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
def test_eio_one_line_address_obeys_participant_residency_rule() -> None:
|
||||
participant_fields = {
|
||||
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.АдресСтрока": "г. Москва, ул. Тестовая",
|
||||
"УчастникЮЛ.СведЕИО.ФЛЕИО.АдрРег.Индекс": "101000",
|
||||
@ -654,7 +608,7 @@ def test_eio_one_line_address_is_kept_regardless_of_residency() -> None:
|
||||
participant_fields=participant_fields,
|
||||
)
|
||||
|
||||
assert resident_client[167] == "г. Москва, ул. Тестовая"
|
||||
assert resident_client[167] == ""
|
||||
assert resident_client[169] == "101000"
|
||||
assert resident_client[170] == "643"
|
||||
assert nonresident[167] == "г. Москва, ул. Тестовая"
|
||||
@ -933,9 +887,9 @@ def test_operation_documents_keep_positions_and_join_multiple_documents() -> Non
|
||||
)
|
||||
|
||||
assert row[107] == (
|
||||
"1, 13, , , 29/01/2026, 253, Размещение денежных средств;\n"
|
||||
"2, 99, , Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ;\n"
|
||||
"3, 99, , Общие условия размещения депозитов, 17/11/2020, Р202307/0001, ;"
|
||||
"1. 13, , , 29/01/2026, 253, Размещение денежных средств;\n"
|
||||
"2. 99, , Депозитный договор, 29/01/2026, Р202307/0001/93/Э, ;\n"
|
||||
"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"
|
||||
|
||||
|
||||
def test_col152_and_col153_registration_place_coexist() -> None:
|
||||
"""Место регистрации в одной строке больше не обнуляет структурную
|
||||
версию. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
def test_col153_is_cleared_when_registration_address_one_line_exists() -> None:
|
||||
row = build_fixed_row_by_index(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -1176,12 +1127,10 @@ def test_col152_and_col153_registration_place_coexist() -> None:
|
||||
)
|
||||
|
||||
assert row[152] == "Москва, Тверская, 1"
|
||||
assert row[153] == "123456, 643, Москва"
|
||||
assert row[153] == ""
|
||||
|
||||
|
||||
def test_col178_and_col179_eio_registration_place_coexist() -> None:
|
||||
"""То же для места регистрации ЕИО/Бенефициара. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
def test_col179_is_cleared_when_eio_registration_one_line_exists() -> None:
|
||||
row = build_fixed_row_by_index(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -1196,7 +1145,7 @@ def test_col178_and_col179_eio_registration_place_coexist() -> None:
|
||||
)
|
||||
|
||||
assert row[178] == "Москва, Арбат, 1"
|
||||
assert row[179] == "123456, 643, Москва"
|
||||
assert row[179] == ""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_physical_person_identification_is_kept_in_cols_119_and_122() -> None:
|
||||
"""Прежнее правило 119/122 обнуляло идентификацию ФЛ по типу участника.
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML.
|
||||
|
||||
По таблице маппинга тег УчастникФЛИП.ИдентификацияФЛ — источник и для
|
||||
колонки 119, и для колонки 122 (у неё три источника: КППЮЛ для ЮЛ,
|
||||
ИдентификацияФЛ для ФЛИП, ПризнакОргФормаИНБОЮЛ для ИНБОЮЛ), поэтому без
|
||||
прежнего правила значение попадает в обе колонки.
|
||||
"""
|
||||
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",
|
||||
@ -1297,9 +1237,9 @@ def test_physical_person_identification_is_kept_in_cols_119_and_122() -> None:
|
||||
"УчастникФЛИП.СведФЛИП.ИННФЛИП": "410116812922",
|
||||
},
|
||||
)
|
||||
assert row[119] == "1"
|
||||
assert row[119] == ""
|
||||
assert row[120] == "410116812922"
|
||||
assert row[122] == "1"
|
||||
assert row[122] == ""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_continuation_row_does_not_fill_defaults() -> None:
|
||||
"""У повторной строки заглушки счетов не подставляются. Локальные коды
|
||||
больше не проверяются на допустимость: прежние правила 10, 11 и 15
|
||||
обнуляли код, если он не входил в разрешённый набор, а заказчик 24.09.2026 потребовал выводить всё из XML.
|
||||
"""
|
||||
def test_continuation_row_validates_local_codes_without_filling_defaults() -> None:
|
||||
row = build_fixed_row_by_index(
|
||||
file_name="f.xml",
|
||||
record_id="R1",
|
||||
@ -1469,6 +1405,7 @@ def test_continuation_row_does_not_fill_defaults() -> None:
|
||||
is_continuation=True,
|
||||
)
|
||||
|
||||
assert row[10] == ""
|
||||
assert row[44] == ""
|
||||
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"
|
||||
|
||||
|
||||
def test_conversion_amount_and_currency_sign_come_from_xml() -> None:
|
||||
"""Прежнее правило 26 обнуляло сумму продаваемой валюты, если валюта не
|
||||
указана или сумма нулевая. Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
for record_id, fields, expected_amount in (
|
||||
(
|
||||
"R1",
|
||||
{"КодОперации": "6001", "СумКонверсия": "0.00", "ПризнВалОперации": "VO"},
|
||||
"0.00",
|
||||
),
|
||||
(
|
||||
"R2",
|
||||
{
|
||||
"КодОперации": "6101",
|
||||
"КодВалКонверсия": "978",
|
||||
"СумКонверсия": "100.50",
|
||||
"ПризнВалОперации": "VO",
|
||||
},
|
||||
"100.50",
|
||||
),
|
||||
(
|
||||
"R3",
|
||||
{
|
||||
"КодОперации": "6126",
|
||||
"КодВалКонверсия": "840",
|
||||
"СумКонверсия": "0.00",
|
||||
"ПризнВалОперации": "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={},
|
||||
)
|
||||
assert row[26] == expected_amount, record_id
|
||||
assert row[27] == "VO", record_id
|
||||
def test_currency_sign_is_preserved_independently_of_operation_code() -> 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] == "VO"
|
||||
|
||||
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(
|
||||
@ -1668,26 +1609,33 @@ def test_legal_entity_blocks_do_not_use_physical_person_identification() -> None
|
||||
assert row[122] == ""
|
||||
|
||||
|
||||
def test_col124_keeps_private_practice_type() -> None:
|
||||
"""Прежнее правило 124 обнуляло колонку для ФЛ, ИП и ФЛЧП, хотя для ФЛЧП
|
||||
она и предназначена (её второй источник — ТипФЛЧастнаяПрактика).
|
||||
Заказчик 24.09.2026: в отчёт попадают все значения, присутствующие
|
||||
в XML."""
|
||||
for record_id, participant_type in (("R1", "4"), ("R2", "04")):
|
||||
row = build_fixed_row_by_index(
|
||||
file_name="f.xml",
|
||||
record_id=record_id,
|
||||
operation_index=1,
|
||||
operation_fields={},
|
||||
participant_fields={
|
||||
"ТипУчастника": participant_type,
|
||||
"УчастникФЛИП.СведФЛИП.ТипФЛЧастнаяПрактика": "1",
|
||||
},
|
||||
)
|
||||
assert row[124] == "1", participant_type
|
||||
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() -> None:
|
||||
def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() -> None:
|
||||
participant_fields = {
|
||||
"ТипУчастника": "2",
|
||||
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
|
||||
@ -1703,12 +1651,8 @@ def test_col119_is_not_included_in_structured_fio() -> None:
|
||||
participant_fields=participant_fields,
|
||||
)
|
||||
assert row[114] == "Азимов Ислом Каримович"
|
||||
# Прежнее правило 119 обнуляло колонку для ЮЛ и ФЛ; теперь значение
|
||||
# выводится, потому что оно есть в XML.
|
||||
assert row[119] == "1"
|
||||
# Тот же тег ИдентификацияФЛ — второй источник колонки 122, поэтому
|
||||
# значение попадает и туда.
|
||||
assert row[122] == "1"
|
||||
assert row[119] == ""
|
||||
assert row[122] == ""
|
||||
|
||||
row_with_one_line_fio = build_fixed_row_by_index(
|
||||
file_name="f.xml",
|
||||
@ -1720,10 +1664,8 @@ def test_col119_is_not_included_in_structured_fio() -> None:
|
||||
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.ФИОСтрока": ("Азимов Ислом Каримович"),
|
||||
},
|
||||
)
|
||||
# Однострочная версия ФИО больше не обнуляет ни компоненты, ни
|
||||
# идентификацию: колонка 119 к структурной группе ФИО не относится.
|
||||
assert row_with_one_line_fio[119] == "1"
|
||||
assert row_with_one_line_fio[122] == "1"
|
||||
assert row_with_one_line_fio[119] == ""
|
||||
assert row_with_one_line_fio[122] == ""
|
||||
|
||||
|
||||
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()
|
||||
foreign_card_group = next(g for g in groups if g.start_index <= 97 <= g.end_index)
|
||||
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] == ""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user