From d1daecdb31db069d68b75c06d934b4050d679727 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 9 Oct 2026 17:35:36 +0300 Subject: [PATCH] Kinda fix --- app/db/base.py | 18 ++ app/pipeline/mapping.py | 320 +++++++++++++++++++------ app/pipeline/mapping_table.json | 36 +-- app/pipeline/report.py | 40 +++- app/pipeline/service.py | 131 ++++++++++- main.py | 42 +++- tests/unit/test_mapping.py | 402 ++++++++++++-------------------- 7 files changed, 620 insertions(+), 369 deletions(-) diff --git a/app/db/base.py b/app/db/base.py index dee8e08..1b9d85e 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -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, ) diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index e549c29..6de30a4 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -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, "") diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index e1cdb92..c4302b9 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -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", diff --git a/app/pipeline/report.py b/app/pipeline/report.py index 3352823..be886ce 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -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( diff --git a/app/pipeline/service.py b/app/pipeline/service.py index 4784737..6e8e8e2 100644 --- a/app/pipeline/service.py +++ b/app/pipeline/service.py @@ -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") diff --git a/main.py b/main.py index 82826bd..2cbf404 100644 --- a/main.py +++ b/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() diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 1f157cf..0a201cb 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -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] == ""