Add initial implementations for forms and organization apps with serializers, factories, and admin configurations
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
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
This commit is contained in:
486
src/apps/core/excel.py
Normal file
486
src/apps/core/excel.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
Базовые классы для парсинга 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
|
||||
Reference in New Issue
Block a user