""" Базовые классы для парсинга Excel файлов. Предоставляет: - BaseExcelParser - базовый класс парсера с валидацией - Dataclasses для передачи данных между слоями - Валидаторы для ИНН, ОГРН, КПП """ import logging import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation from io import BytesIO from typing import Any, Generic, TypeVar import openpyxl from django.core.files.uploadedfile import UploadedFile from openpyxl.worksheet.worksheet import Worksheet logger = logging.getLogger(__name__) T = TypeVar("T") # ============================================================================= # Dataclasses # ============================================================================= @dataclass class FieldError: """Ошибка валидации поля.""" field: str message: str value: Any = None @dataclass class RowValidationError: """Ошибка валидации строки Excel.""" row: int inn: str | None kpp: str | None organization_name: str | None errors: list[FieldError] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Преобразование в словарь для API ответа.""" return { "row": self.row, "inn": self.inn, "kpp": self.kpp, "organization_name": self.organization_name, "errors": [{"field": e.field, "message": e.message} for e in self.errors], } @dataclass class ParseResult: """Результат парсинга Excel файла.""" batch_id: int loaded_count: int = 0 skipped_count: int = 0 errors: list[RowValidationError] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Преобразование в словарь для API ответа.""" return { "batch_id": self.batch_id, "loaded_count": self.loaded_count, "skipped_count": self.skipped_count, "errors": [e.to_dict() for e in self.errors], } @dataclass class ColumnMapping: """Маппинг колонки Excel на поле модели.""" excel_column: int # Индекс колонки (0-based) excel_header: str # Название заголовка в Excel model_field: str # Название поля модели required: bool = False field_type: str = "str" # str, int, decimal, bool, date validator: Any = None @property def field_name(self) -> str: """Backward-compatible alias for legacy tests.""" return self.model_field @dataclass class RowData: """Данные строки после парсинга.""" row_number: int organization_name: str | None inn: str | None ogrn: str | None kpp: str | None okpo: str | None fields: dict[str, Any] = field(default_factory=dict) # ============================================================================= # Исключения # ============================================================================= class ExcelValidationError(Exception): """Ошибка валидации Excel файла.""" def __init__(self, message: str, errors: list[FieldError] | None = None): super().__init__(message) self.message = message self.errors = errors or [] class ExcelParseError(Exception): """Ошибка парсинга Excel файла.""" pass # ============================================================================= # Валидаторы # ============================================================================= def validate_inn(value: str | None) -> tuple[bool, str]: """ Валидация ИНН. ИНН юрлица - 10 цифр, ИП - 12 цифр. """ if not value: return False, "ИНН обязателен" cleaned = re.sub(r"\D", "", str(value)) if len(cleaned) not in (10, 12): return False, f"ИНН должен содержать 10 или 12 цифр, получено {len(cleaned)}" return True, "" def validate_ogrn(value: str | None) -> tuple[bool, str]: """ Валидация ОГРН. ОГРН юрлица - 13 цифр, ОГРНИП - 15 цифр. """ if not value: return False, "ОГРН обязателен" cleaned = re.sub(r"\D", "", str(value)) if len(cleaned) not in (13, 15): return False, f"ОГРН должен содержать 13 или 15 цифр, получено {len(cleaned)}" return True, "" def validate_kpp(value: str | None) -> tuple[bool, str]: """ Валидация КПП. КПП - 9 цифр. """ if not value: return True, "" # КПП необязателен cleaned = re.sub(r"\D", "", str(value)) if len(cleaned) != 9: return False, f"КПП должен содержать 9 цифр, получено {len(cleaned)}" return True, "" def validate_okpo(value: str | None) -> tuple[bool, str]: """ Валидация ОКПО. ОКПО - 8 или 10 цифр. """ if not value: return True, "" # ОКПО необязателен cleaned = re.sub(r"\D", "", str(value)) if len(cleaned) not in (8, 10): return False, f"ОКПО должен содержать 8 или 10 цифр, получено {len(cleaned)}" return True, "" def clean_inn(value: str | None) -> str | None: """Очистка ИНН от нецифровых символов.""" if not value: return None return re.sub(r"\D", "", str(value)) or None def clean_ogrn(value: str | None) -> str | None: """Очистка ОГРН от нецифровых символов.""" if not value: return None return re.sub(r"\D", "", str(value)) or None def clean_kpp(value: str | None) -> str | None: """Очистка КПП от нецифровых символов.""" if not value: return None return re.sub(r"\D", "", str(value)) or None def clean_okpo(value: str | None) -> str | None: """Очистка ОКПО от нецифровых символов.""" if not value: return None return re.sub(r"\D", "", str(value)) or None # ============================================================================= # Базовый парсер # ============================================================================= class BaseExcelParser(ABC, Generic[T]): """ Базовый класс для парсинга Excel файлов. Наследники должны реализовать: - get_column_mappings() - маппинг колонок - create_record() - создание записи в БД - get_next_batch_id() - получение следующего batch_id Использование: class FormF1Parser(BaseExcelParser[FormF1Record]): def get_column_mappings(self) -> list[ColumnMapping]: return [ ColumnMapping(0, "Наименование организации", "name", required=True), ColumnMapping(1, "ОКПО", "okpo"), ... ] def create_record(self, row_data: RowData) -> FormF1Record: org = OrganizationService.get_or_create_from_form_identifiers(...) return FormF1Record.objects.create(organization=org, ...) """ # Индексы стандартных колонок организации (0-based) ORG_NAME_COLUMN: int = 0 OKPO_COLUMN: int = 1 OGRN_COLUMN: int = 2 INN_COLUMN: int = 3 KPP_COLUMN: int | None = None # Если есть в файле # Строка заголовков (1-based, как в Excel) HEADER_ROW: int = 1 # Первая строка данных (1-based) DATA_START_ROW: int = 2 def __init__(self): self._workbook: openpyxl.Workbook | None = None self._sheet: Worksheet | None = None self._column_mappings: list[ColumnMapping] | None = None @abstractmethod def get_column_mappings(self) -> list[ColumnMapping]: """Возвращает маппинг колонок Excel на поля модели.""" raise NotImplementedError @abstractmethod def create_record(self, row_data: RowData, batch_id: int) -> T: """Создаёт запись в БД на основе данных строки.""" raise NotImplementedError @abstractmethod def get_next_batch_id(self) -> int: """Возвращает следующий номер batch_id.""" raise NotImplementedError def parse(self, file: UploadedFile | BytesIO) -> ParseResult: """ Парсит Excel файл и сохраняет данные в БД. Args: file: Загруженный файл или BytesIO Returns: ParseResult с результатами парсинга """ batch_id = self.get_next_batch_id() result = ParseResult(batch_id=batch_id) try: self._load_workbook(file) self._column_mappings = self.get_column_mappings() for row_num in range(self.DATA_START_ROW, self._sheet.max_row + 1): row_data = self._parse_row(row_num) if row_data is None: continue # Пустая строка # Валидация строки errors = self._validate_row(row_data) if errors: result.errors.append( RowValidationError( row=row_num, inn=row_data.inn, kpp=row_data.kpp, organization_name=row_data.organization_name, errors=errors, ) ) result.skipped_count += 1 continue # Создание записи try: self.create_record(row_data, batch_id) result.loaded_count += 1 except Exception as e: logger.exception(f"Ошибка создания записи для строки {row_num}") result.errors.append( RowValidationError( row=row_num, inn=row_data.inn, kpp=row_data.kpp, organization_name=row_data.organization_name, errors=[FieldError(field="__all__", message=str(e))], ) ) result.skipped_count += 1 except Exception as e: logger.exception("Ошибка парсинга Excel файла") raise ExcelParseError(f"Ошибка парсинга файла: {e}") from e finally: if self._workbook: self._workbook.close() self._workbook = None self._sheet = None return result def _load_workbook(self, file: UploadedFile | BytesIO) -> None: """Загружает Excel файл.""" content = BytesIO(file.read()) if isinstance(file, UploadedFile) else file self._workbook = openpyxl.load_workbook(content, read_only=True, data_only=True) self._sheet = self._workbook.active if self._sheet.max_row is None or self._sheet.max_column is None: self._sheet.calculate_dimension(force=True) def _parse_row(self, row_num: int) -> RowData | None: """Парсит одну строку Excel.""" # Получение значений стандартных полей организации org_name = self._get_cell_value(row_num, self.ORG_NAME_COLUMN) okpo = self._get_cell_value(row_num, self.OKPO_COLUMN) ogrn = self._get_cell_value(row_num, self.OGRN_COLUMN) inn = self._get_cell_value(row_num, self.INN_COLUMN) kpp = None if self.KPP_COLUMN is not None: kpp = self._get_cell_value(row_num, self.KPP_COLUMN) # Проверка на пустую строку if not org_name and not inn: return None # Парсинг дополнительных полей по маппингу fields: dict[str, Any] = {} for mapping in self._column_mappings: raw_value = self._get_cell_value(row_num, mapping.excel_column) fields[mapping.model_field] = self._convert_value( raw_value, mapping.field_type ) return RowData( row_number=row_num, organization_name=str(org_name).strip() if org_name else None, inn=clean_inn(str(inn) if inn else None), ogrn=clean_ogrn(str(ogrn) if ogrn else None), kpp=clean_kpp(str(kpp) if kpp else None), okpo=clean_okpo(str(okpo) if okpo else None), fields=fields, ) def _get_cell_value(self, row: int, col: int) -> Any: """Получает значение ячейки (row и col - 0-based для col, 1-based для row).""" cell = self._sheet.cell(row=row, column=col + 1) return cell.value def _normalize_row_data(self, row_data: RowData | dict[str, Any]) -> RowData: """Support legacy tests that still pass plain dict payloads.""" if isinstance(row_data, RowData): return row_data payload = dict(row_data) organization_name = payload.pop("organization_name", payload.pop("name", None)) equipment_name = payload.pop("equipment_name", None) if equipment_name is not None and "name" not in payload: payload["name"] = equipment_name return RowData( row_number=0, organization_name=organization_name, inn=payload.pop("inn", None), ogrn=payload.pop("ogrn", None), kpp=payload.pop("kpp", None), okpo=payload.pop("okpo", None), fields=payload, ) def _convert_value(self, value: Any, field_type: str) -> Any: """Конвертирует значение в нужный тип.""" if value is None: return None try: if field_type == "str": return str(value).strip() if value else None elif field_type == "int": if isinstance(value, int | float): return int(value) return int(float(str(value).replace(",", ".").replace(" ", ""))) elif field_type == "decimal": if isinstance(value, int | float | Decimal): return Decimal(str(value)) cleaned = str(value).replace(",", ".").replace(" ", "") return Decimal(cleaned) if cleaned else None elif field_type == "bool": if isinstance(value, bool): return value str_val = str(value).lower().strip() return str_val in ("да", "yes", "1", "true", "+") elif field_type == "date": from datetime import date, datetime if isinstance(value, date | datetime): return value.date() if isinstance(value, datetime) else value return None else: return value except (ValueError, InvalidOperation, TypeError): return None def _validate_row(self, row_data: RowData) -> list[FieldError]: """Валидирует данные строки.""" errors: list[FieldError] = [] # Валидация обязательных полей организации if not row_data.organization_name: errors.append( FieldError( field="organization_name", message="Наименование организации обязательно", ) ) # Валидация ИНН valid, msg = validate_inn(row_data.inn) if not valid: errors.append(FieldError(field="inn", message=msg)) # Валидация ОГРН valid, msg = validate_ogrn(row_data.ogrn) if not valid: errors.append(FieldError(field="ogrn", message=msg)) # Валидация КПП (если есть) if row_data.kpp: valid, msg = validate_kpp(row_data.kpp) if not valid: errors.append(FieldError(field="kpp", message=msg)) # Валидация ОКПО (если есть) if row_data.okpo: valid, msg = validate_okpo(row_data.okpo) if not valid: errors.append(FieldError(field="okpo", message=msg)) # Валидация полей по маппингу for mapping in self._column_mappings: value = row_data.fields.get(mapping.model_field) if mapping.required and value is None: errors.append( FieldError( field=mapping.model_field, message=f"Поле '{mapping.excel_header}' обязательно", ) ) # Валидация числовых полей (должны быть >= 0) if ( mapping.field_type in ("int", "decimal") and value is not None and value < 0 ): errors.append( FieldError( field=mapping.model_field, message="Значение должно быть >= 0", ) ) return errors