75-123 rule

This commit is contained in:
Raykov-MS 2026-07-30 13:23:44 +03:00
parent 25f96438b1
commit 55f0312929
4 changed files with 179 additions and 26 deletions

View File

@ -106,6 +106,9 @@ def _build_rules(mapping_data: list[dict[str, str | int]]) -> tuple[MappingRule,
candidates = ["ИННФЛИП", "ИННФЛ", "ИННЮЛ", "ИНН", *candidates]
if column == "Тип участника":
candidates = ["Тип", "ТипУчастника", *candidates]
if column == "Отчество":
# В некоторых выгрузках встречается опечатка тега "Очт".
candidates = ["Отч", "Очт", *candidates]
if path:
for path_tag in _extract_path_candidate_keys(path):
if path_tag not in candidates:
@ -334,6 +337,7 @@ def build_fixed_row(
)
_apply_structured_group_rules(row)
_apply_conditional_rules_1_74(row)
_apply_conditional_rules_75_123(row)
for column in FIXED_REPORT_COLUMNS:
row.setdefault(column, "")
return row
@ -531,3 +535,19 @@ def _apply_conditional_rules_1_74(row: dict[str, str]) -> None:
# 62. Статус перевода заполняется только для видов 2/5/8.
if transfer_type not in {"2", "5", "8"}:
_set_row_value(row, 62, "")
def _apply_conditional_rules_75_123(row: dict[str, str]) -> None:
codes = _operation_and_extra_codes(row)
# 96. Код территории заполняется только для признака 5016 (дубль 36).
if "5016" not in codes:
_set_row_value(row, 96, "")
# 103. Если операция без участия сотрудника и данных нет -> подставляем заглушку.
if _row_value(row, 104) == "0" and not _row_value(row, 103):
_set_row_value(row, 103, "Информация отсутствует")
# 105. Аналогично для наименования иностранного банка.
if _row_value(row, 104) == "0" and not _row_value(row, 105):
_set_row_value(row, 105, "Информация отсутствует")

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import shutil
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Iterable
@ -135,13 +136,19 @@ class StreamingReportWriter:
operation_fields=row.operation_fields,
participant_fields=row.participant_fields,
)
values: list[object] = []
for index, column_name in enumerate(self.columns):
raw_value = data.get(column_name, "")
values.append(raw_value)
for index, value in enumerate(values):
cell_format = self._column_formats[index]
if cell_format is not None:
default_format = self._column_formats[index]
value, cell_format = self._normalize_cell_value(
column_name=column_name,
raw_value=raw_value,
default_format=default_format,
)
if isinstance(value, datetime) and cell_format is self._date_format:
self.sheet.write_datetime(
self._next_data_row, index, value, cell_format
)
elif cell_format is not None:
self.sheet.write(self._next_data_row, index, value, cell_format)
else:
self.sheet.write(self._next_data_row, index, value)
@ -186,6 +193,40 @@ class StreamingReportWriter:
return self._sum_format
return None
def _normalize_cell_value(
self,
*,
column_name: str,
raw_value: object,
default_format,
) -> tuple[object, object | None]:
if default_format is self._date_format:
parsed_date = self._parse_date_value(raw_value)
if parsed_date is not None:
return parsed_date, self._date_format
if raw_value in (None, ""):
return "", None
return raw_value, None
return raw_value, default_format
def _parse_date_value(self, value: object) -> datetime | None:
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
if not isinstance(value, str):
return None
normalized = value.strip()
if not normalized:
return None
date_part = normalized.split()[0]
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d.%m.%Y", "%Y/%m/%d"):
try:
return datetime.strptime(date_part, fmt)
except ValueError:
continue
return None
def _cleanup_temp_file(self) -> None:
try:
self._temp_report_path.unlink(missing_ok=True)

View File

@ -377,3 +377,62 @@ def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
assert row["БИК банка получателя"] == ""
assert row["Номер счета банка плательщика"] == ""
assert row["Номер счета банка получателя"] == ""
def test_col96_territory_code_cleared_when_operation_is_not_5016() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"КодТерИнГос": "840",
},
participant_fields={},
)
assert row["Код территории"] == ""
def test_col103_and_105_fill_info_absent_when_no_employee_and_empty() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ПризнакСотрудник": "0",
},
participant_fields={},
)
assert row["Сведения о держателе платежной карты"] == "Информация отсутствует"
assert row["Наименование иностранного банка"] == "Информация отсутствует"
def test_col103_and_105_keep_original_values_when_present() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ПризнакСотрудник": "0",
"СведДержательКарты": "Иванов И.И.",
"НаимИнБанк": "FOREIGN BANK",
},
participant_fields={},
)
assert row["Сведения о держателе платежной карты"] == "Иванов И.И."
assert row["Наименование иностранного банка"] == "FOREIGN BANK"
def test_patronymic_supports_typo_tag_ocht() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Фам": "Азимов",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Имя": "Ислом",
"УчастникФЛИП.СведФЛИП.ФИОФЛИП.Очт": "Каримович",
},
)
assert row["Отчество"] == "Каримович"

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from openpyxl import load_workbook
@ -15,7 +16,7 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
file_name="SKO115FZ_01_123456789_20260616_X00001.xml",
record_id="RID-1",
operation_index=1,
operation_fields={"СуммаОпер": "100.50", "ДатаОпер": "2026-06-16"},
operation_fields={"СумОперации": "100.50", "ДатаСообщения": "2026-06-16"},
participant_fields={},
)
]
@ -33,26 +34,11 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
]
assert "SKO115FZ_01_123456789_20260616_X00001.xml" in row_values
date_col_idx = next(
(
index
for index, column in enumerate(FIXED_REPORT_COLUMNS, start=1)
if "Дата" in column
),
None,
)
if date_col_idx is not None:
date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1
assert isinstance(sheet.cell(row=3, column=date_col_idx).value, datetime)
assert sheet.cell(row=3, column=date_col_idx).number_format == "DD.MM.YYYY"
sum_col_idx = next(
(
index
for index, column in enumerate(FIXED_REPORT_COLUMNS, start=1)
if "Сумм" in column or "Сумма" in column
),
None,
)
if sum_col_idx is not None:
sum_col_idx = FIXED_REPORT_COLUMNS.index("Сумма в валюте проведения") + 1
assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00"
@ -77,6 +63,53 @@ def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
assert merged_ranges == expected_ranges
def test_write_report_converts_date_string_to_excel_date(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
rows = [
ReportRow(
file_name="f.xml",
record_id="RID",
operation_index=1,
operation_fields={"ДатаСообщения": "23/08/2005"},
participant_fields={},
)
]
write_report(rows, destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1
cell = sheet.cell(row=3, column=date_col_idx)
assert isinstance(cell.value, datetime)
assert cell.value.date() == datetime(2005, 8, 23).date()
assert cell.number_format == "DD.MM.YYYY"
def test_write_report_keeps_invalid_date_as_text_without_date_format(
tmp_path: Path,
) -> None:
destination = tmp_path / "report.xlsx"
rows = [
ReportRow(
file_name="f.xml",
record_id="RID",
operation_index=1,
operation_fields={"ДатаСообщения": "не дата"},
participant_fields={},
)
]
write_report(rows, destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1
cell = sheet.cell(row=3, column=date_col_idx)
assert cell.value == "не дата"
assert cell.number_format != "DD.MM.YYYY"
def _column_letter(index: int) -> str:
value = index
result = ""