All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 9m1s
CI/CD Pipeline / Run Tests (pull_request) Successful in 24m1s
CI/CD Pipeline / Build and Push Dev Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (pull_request) Has been skipped
522 lines
18 KiB
Python
522 lines
18 KiB
Python
"""
|
||
Базовые классы для парсинга 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
|