Files
state-corp-backend/src/apps/core/excel.py
Aleksandr Meshchriakov 8ed3e1175c
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 5m5s
CI/CD Pipeline / Run Tests (push) Failing after 5m5s
CI/CD Pipeline / Build Docker Images (push) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (push) Has been skipped
CI/CD Pipeline / Deploy to Server (push) Has been skipped
Add initial implementations for forms and organization apps with serializers, factories, and admin configurations
2026-02-17 09:26:08 +01:00

487 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Базовые классы для парсинга 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
@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
@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_by_inn(...)
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 файл."""
if isinstance(file, UploadedFile):
content = BytesIO(file.read())
else:
content = file
self._workbook = openpyxl.load_workbook(content, read_only=True, data_only=True)
self._sheet = self._workbook.active
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 _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:
if value < 0:
errors.append(
FieldError(
field=mapping.model_field,
message=f"Значение должно быть >= 0",
)
)
return errors