Files
state-corp-backend/src/apps/core/excel.py
Aleksandr Meshchriakov 08632be39f
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
fix: parse worksheets without dimensions
2026-07-22 17:20:39 +02:00

522 lines
18 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
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