Fix Errors
This commit is contained in:
parent
44f23947a3
commit
1d46f8cc6d
@ -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, "")
|
||||
|
||||
@ -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": "СерияДок",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user