Fix Errors

This commit is contained in:
Raykov-MS 2026-09-16 11:58:29 +03:00
parent 44f23947a3
commit 1d46f8cc6d
5 changed files with 291 additions and 26 deletions

View File

@ -64,7 +64,6 @@ from .column_constants import (
COL_SUSPENSION_BASIS,
COL_SUSPICIOUS_ID,
COL_TERRITORY_CODE,
COL_TERRITORY_CODE_CARD,
COL_TRANSFER_STATUS,
COL_TRANSFER_TYPE,
COL_UNUSUAL_CODES,
@ -864,11 +863,11 @@ def _apply_business_rules(
_rule_item_type(row, codes)
_rule_territory_codes(row, codes)
_rule_payer_account(row, transfer_type)
_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)
_rule_correspondent_accounts(row, operator_type, transfer_type, codes)
_rule_recipient_account(row, transfer_type)
_rule_transfer_status(row, transfer_type)
@ -978,14 +977,32 @@ def _rule_item_type(row: dict[int, str], codes: set[str]) -> None:
def _rule_territory_codes(row: dict[int, str], codes: set[str]) -> None:
"""Правила 36 и 96: код территории только для операции 5016."""
"""Правило 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, "")
_set(row, COL_TERRITORY_CODE_CARD, "")
def _rule_payer_account(row: dict[int, str], transfer_type: str) -> None:
"""Правило 38: счёт плательщика зависит от вида перевода."""
def _rule_payer_account(
row: dict[int, str], transfer_type: str, codes: set[str]
) -> None:
"""Правило 38: счёт плательщика зависит от вида перевода.
Для операций с кодом 5016 (п.6 правок) заглушку не подставляем — такого
блока (номера счетов) для 5016 в спецификации нет.
"""
if TERRITORY_CODE in codes:
_set(row, COL_PAYER_ACCOUNT, "")
return
if transfer_type in {"1", "2", "3", "10"}:
if not _get(row, COL_PAYER_ACCOUNT):
_set(row, COL_PAYER_ACCOUNT, ACCOUNT_PLACEHOLDER)
@ -1014,9 +1031,18 @@ def _rule_recipient_bank(row: dict[int, str], operator_type: str) -> None:
def _rule_correspondent_accounts(
row: dict[int, str], operator_type: str, transfer_type: str
row: dict[int, str], operator_type: str, transfer_type: str, codes: set[str]
) -> None:
"""Правила 44-45: корсчета зависят от оператора и вида перевода."""
"""Правила 44-45: корсчета зависят от оператора и вида перевода.
Для операций с кодом 5016 (п.6 правок) заглушку не подставляем — такого
блока (номера счетов) для 5016 в спецификации нет.
"""
if TERRITORY_CODE in codes:
_set(row, COL_PAYER_BANK_ACCOUNT, "")
_set(row, COL_RECIPIENT_BANK_ACCOUNT, "")
return
if not operator_type and not transfer_type:
_set(row, COL_PAYER_BANK_ACCOUNT, "")
_set(row, COL_RECIPIENT_BANK_ACCOUNT, "")

View File

@ -948,7 +948,7 @@
"xml_tag": "ИдТерминал",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияПереводыДс/СведПриемНалДС/ИдТерминал",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "",
@ -1903,10 +1903,10 @@
"index": 96,
"column_id": "legacy_096",
"column_name": "Код территории",
"block": "cash_operation",
"report_group": "own_account_cash",
"xml_tag": "КодТерИнГос",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияВнесениеПолучениеНалДС/КодТерИнГос",
"block": "foreign_card",
"report_group": "foreign_card_operation",
"xml_tag": "КодТерИнБанк",
"xml_path": "СообщОперКО/ИнформЧасть/СведКО/Операция/СведенияКартаИнБанк/КодТерИнБанк",
"source_scope": "any",
"allow_short_lookup": false,
"allow_direct_mapping": true,
@ -1917,7 +1917,7 @@
"join_with_space": false,
"multiplies": "нет",
"required": "необязательно",
"comment": "Числовой показатель, поле может остаться пустым и не поступать в XML"
"comment": "Обязательный показатель для блока СведенияКартаИнБанк (код операции 1009) согласно официальной спецификации (п.59) — код территории иностранного банка, эмитировавшего карту. Ранее был ошибочно замаплен на несуществующий путь СведенияВнесениеПолучениеНалДС/КодТерИнГос (другой одноимённый показатель, п.41, применим только к коду 5016) и относился к блоку cash_operation, из-за чего колонка визуально прилипала к предыдущей группе и всегда была пустой."
},
{
"index": 97,
@ -1962,7 +1962,7 @@
{
"index": 99,
"column_id": "legacy_099",
"column_name": "Адрес места приема наличных денежных средств (по структуре полный)",
"column_name": "Адрес места приема (выдачи) наличных денежных средств (по структуре полный)",
"block": "foreign_card",
"report_group": "foreign_card_operation",
"xml_tag": "АдрМестаПриемаВыдача",
@ -3642,7 +3642,7 @@
{
"index": 183,
"column_id": "legacy_183",
"column_name": "Номер",
"column_name": "Серия",
"block": "eio",
"report_group": "eio",
"xml_tag": "СерияДок",

View File

@ -43,6 +43,39 @@ TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = tuple(
)
_WIDE_TEXT_KEYWORDS = (
"Наименование",
"Адрес",
"Назначение платежа",
"Доп. сведения",
"Дополнительн",
"Сведения",
"Иные сведения",
"ФИО",
"Место государственной регистрации",
"Орган, выдавший документ",
"Сведения миграционной карты",
)
_NARROW_KEYWORDS = (
"Код",
"БИК",
"ИНН",
"ОГРН",
"КПП",
"Индекс",
"Дом",
"Корпус",
"Офис",
"Квартира",
"Серия",
"Тип",
"Признак",
"ОКАТО",
"Валюта",
"СВИФТ",
)
class StreamingReportWriter:
def __init__(self) -> None:
self.columns = list(FIXED_REPORT_COLUMNS)
@ -74,12 +107,29 @@ class StreamingReportWriter:
)
self._date_format = self.workbook.add_format({"num_format": "DD.MM.YYYY"})
self._sum_format = self.workbook.add_format({"num_format": "#,##0.00"})
# Дефолтный формат для обычных текстовых ячеек — раньше такие ячейки
# писались вообще без формата (cell_format is None), поэтому перенос
# текста применялся только к шапке, а не к данным.
self._text_format = self.workbook.add_format(
{"text_wrap": True, "valign": "top"}
)
self._column_formats = [self._resolve_format(column) for column in self.columns]
self._build_group_header_row()
# В режиме constant_memory строка "уходит" на диск сразу после того,
# как начата следующая — поэтому set_row для строки 0 нужно вызывать
# ДО построения строки 1, иначе вызов молча ничего не сделает.
self.sheet.set_row(0, 22)
self._build_header_row()
for column_index, column_format in enumerate(self._column_formats):
if column_format is self._date_format:
self.sheet.set_column(column_index, column_index, 12)
self.sheet.set_row(1, 46)
for column_index, column_name in enumerate(self.columns):
column_format = self._column_formats[column_index]
self.sheet.set_column(
column_index,
column_index,
self._resolve_column_width(column_name, column_format),
)
# Шапка из двух строк уже настроена выше (до перехода на следующую
# строку — см. комментарий про constant_memory).
# Фиксируем обе строки шапки.
self.sheet.freeze_panes(2, 0)
self.sheet.autofilter(1, 0, 1, len(self.columns) - 1)
@ -170,7 +220,22 @@ class StreamingReportWriter:
return self._date_format
if "Сумм" in column_name or "Сумма" in column_name:
return self._sum_format
return None
return self._text_format
def _resolve_column_width(self, column_name: str, column_format) -> float:
if column_format is self._date_format:
return 12
if column_format is self._sum_format:
return 14
if "Номер счета" in column_name:
return 20
if "Номер платежной карты" in column_name or "Номер банкомата" in column_name:
return 16
if any(keyword in column_name for keyword in _WIDE_TEXT_KEYWORDS):
return 32
if any(keyword in column_name for keyword in _NARROW_KEYWORDS):
return 11
return 14
def _normalize_cell_value(
self,

View File

@ -441,7 +441,8 @@ def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
assert row["Номер счета банка получателя"] == ""
def test_col96_territory_code_cleared_when_operation_is_not_5016() -> None:
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",
@ -452,7 +453,26 @@ def test_col96_territory_code_cleared_when_operation_is_not_5016() -> None:
},
participant_fields={},
)
assert row["Код территории"] == ""
assert row["Код территории (5016)"] == ""
def test_col96_territory_code_card_filled_for_1009_regardless_of_5016() -> None:
"""Правило 59 спецификации: КодТерИнБанк — для кода 1009 (карта ин. банка),
не зависит от того, есть ли в записи код 5016. Раньше колонка ошибочно
чистилась тем же условием, что и колонка 36 (код 5016), и всегда
оставалась пустой для 1009-записей."""
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
"СведенияКартаИнБанк.КодТерИнБанк": "410",
},
participant_fields={},
)
assert row["Код территории"] == "410"
assert row["Код территории (5016)"] == ""
def test_col103_and_105_fill_info_absent_when_no_employee_and_empty() -> None:
@ -1602,3 +1622,115 @@ def test_col119_is_not_included_in_structured_fio_or_cleared_by_one_line_fio() -
)
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:
"""Регрессия: legacy_048 (AV, cash_receipt) раньше подхватывал ИдТерминал
из совсем другого блока (СведенияКартаИнБанк, foreign_card) через
короткий поиск по голому имени тега. Реальный кейс из отчёта заказчика:
операция 1009, ИдТерминал только в СведенияКартаИнБанк.СведМестоОперация.
"""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "1009",
"СведенияКартаИнБанк.СведМестоОперация.ИдТерминал": "S1AM9302",
},
participant_fields={},
)
assert row[97] == "S1AM9302" # CS, foreign_card — должно быть тут
assert row[48] == "" # AV, cash_receipt — тут быть не должно
assert row[82] == "" # CD, cash_operation — тоже не должно
def test_col38_44_45_payer_accounts_blank_for_5016() -> None:
"""Правка п.6: для кода операции 5016 номера счетов (плательщика и
корсчетов) не подставляются заглушкой из нулей — такого блока для 5016
в спецификации нет. Номер счёта получателя (46), если он реально пришёл
в XML, не трогаем."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5016",
"ВидПереводДС": "1",
"ТипОператорДС": "2",
"НомерСчетПолучатель": "40817810106240020211",
},
participant_fields={},
)
assert row[38] == "" # Номер счета плательщика
assert row[44] == "" # Номер счета банка плательщика
assert row[45] == "" # Номер счета банка получателя
assert row[46] == "40817810106240020211" # реальные данные не трогаем
def test_col38_44_payer_accounts_still_get_placeholder_without_5016() -> None:
"""Убеждаемся, что правка п.6 не задела остальные виды перевода —
заглушка по-прежнему подставляется, если 5016 нет в кодах операции."""
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"ВидПереводДС": "1",
"ТипОператорДС": "2",
},
participant_fields={},
)
assert row[38] == "00000000000000000000"
assert row[44] == "00000000000000000000"
def test_col183_eio_series_column_named_correctly() -> None:
"""Регрессия: колонка 183 (СерияДок ЕИО/Бенефициара) раньше называлась
"Номер", хотя реально тянет серию документа — при этом соседняя, 184-я,
тоже называлась "Номер" (и действительно тянет номер). Проверяем и
заголовок, и что данные при этом были и остаются на своих местах."""
assert FIXED_REPORT_COLUMNS[182] == "Серия"
assert FIXED_REPORT_COLUMNS[183] == "Номер"
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.СерияДок": "4018",
"УчастникЮЛ.БенефициарЮЛ.ФЛБенефициар.СведДокУдЛичн.НомДок": "044762",
},
)
assert row[183] == "4018" # Серия
assert row[184] == "044762" # Номер
def test_col99_foreign_card_address_header_renamed() -> None:
"""Заказчик попросил переименовать заголовок колонки 99 (адрес по
структуре, блок СведенияКартаИнБанк, код 1009) — было "Адрес места
приема наличных денежных средств (по структуре полный)", стало с учётом
того, что операция может быть как приёмом, так и выдачей наличных."""
assert (
FIXED_REPORT_COLUMNS[98]
== "Адрес места приема (выдачи) наличных денежных средств "
"(по структуре полный)"
)
def test_col96_territory_code_card_belongs_to_foreign_card_group() -> None:
"""Регрессия: колонка 96 раньше входила в группу заголовков блока
'своих' кассовых операций (cash_operation/own_account_cash), из-за чего
визуально 'прилипала' не к своему блоку. Теперь должна входить в ту же
report_group, что и соседние колонки блока СведенияКартаИнБанк (97+)."""
from app.pipeline.column_registry import COLUMNS
columns_by_index = {meta.index: meta for meta in COLUMNS.ordered_columns()}
assert columns_by_index[96].block == "foreign_card"
assert columns_by_index[96].report_group == columns_by_index[97].report_group
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

View File

@ -45,7 +45,7 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00"
def test_write_report_expands_only_date_columns(tmp_path: Path) -> None:
def test_write_report_expands_date_and_other_columns(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
write_report([], destination)
@ -56,7 +56,11 @@ def test_write_report_expands_only_date_columns(tmp_path: Path) -> None:
assert date_column_letter in sheet.column_dimensions
assert sheet.column_dimensions[date_column_letter].width >= 12
assert "A" not in sheet.column_dimensions
# Теперь ширина задаётся не только у дат — у всех колонок, по группам
# (узкие коды/ИНН/БИК, широкие текстовые поля вроде наименований банков
# и адресов, остальные — среднего размера).
assert "A" in sheet.column_dimensions
assert sheet.column_dimensions["A"].width >= 12
def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
@ -359,6 +363,44 @@ def test_write_report_keeps_only_repeat_fields_on_continuation_row(
assert sheet.cell(row=4, column=45).value in (None, "")
def test_write_report_wraps_text_in_data_cells(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
rows = [
ReportRow(
file_name="SKO115FZ_01_123456789_20260616_X00001.xml",
record_id="RID-1",
operation_index=1,
operation_fields={},
participant_fields={},
)
]
write_report(rows, destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
file_name_cell = sheet.cell(row=3, column=1)
assert file_name_cell.alignment.wrap_text is True
def test_write_report_widens_bank_name_and_address_columns(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
write_report([], destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
bank_col_idx = FIXED_REPORT_COLUMNS.index("Наименование банка плательщика") + 1
bank_col_letter = get_column_letter(bank_col_idx)
inn_col_idx = next(
index
for index, name in enumerate(FIXED_REPORT_COLUMNS, start=1)
if "ИНН" in name
)
inn_col_letter = get_column_letter(inn_col_idx)
assert sheet.column_dimensions[bank_col_letter].width >= 28
assert sheet.column_dimensions[inn_col_letter].width < 20
def _column_letter(index: int) -> str:
value = index
result = ""