diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 28fb277..3b9eb58 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -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, "Информация отсутствует") diff --git a/app/pipeline/report.py b/app/pipeline/report.py index e425450..2a8e90e 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -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) diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 9ae0208..88e3a38 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -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["Отчество"] == "Каримович" diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index db25bc9..ce1bc64 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -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,27 +34,12 @@ 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: - assert sheet.cell(row=3, column=date_col_idx).number_format == "DD.MM.YYYY" + 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: - assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00" + sum_col_idx = FIXED_REPORT_COLUMNS.index("Сумма в валюте проведения") + 1 + assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00" def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None: @@ -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 = ""