diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..5f8637c
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,121 @@
+services:
+ db:
+ image: postgres:15.10
+ container_name: state_corp_db
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - ./data/db:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ networks:
+ - state_corp_network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ redis:
+ image: redis:7-alpine
+ container_name: state_corp_redis
+ restart: unless-stopped
+ ports:
+ - "6379:6379"
+ volumes:
+ - ./data/redis:/data
+ networks:
+ - state_corp_network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ web:
+ image: 10.10.0.10:3000/avm/state_corp-web:${IMAGE_TAG:-dev}
+ container_name: state_corp_web
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ - DEBUG=False
+ - SECRET_KEY=${SECRET_KEY}
+ - POSTGRES_HOST=db
+ - POSTGRES_PORT=5432
+ - POSTGRES_DB=${POSTGRES_DB}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - REDIS_URL=redis://redis:6379/0
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ volumes:
+ - ./logs:/app/logs
+ - ./media:/app/media
+ - ./staticfiles:/app/staticfiles
+ ports:
+ - "8000:8000"
+ command: >
+ sh -c "python src/manage.py migrate &&
+ python src/manage.py collectstatic --noinput &&
+ gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3"
+
+ celery_worker:
+ image: 10.10.0.10:3000/avm/state_corp-celery:${IMAGE_TAG:-dev}
+ container_name: state_corp_celery_worker
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ - DEBUG=False
+ - POSTGRES_HOST=db
+ - POSTGRES_PORT=5432
+ - POSTGRES_DB=${POSTGRES_DB}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - REDIS_URL=redis://redis:6379/0
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ volumes:
+ - ./logs:/app/logs
+ networks:
+ - state_corp_network
+ command: celery -A config worker --loglevel=info
+
+ celery_beat:
+ image: 10.10.0.10:3000/avm/state_corp-celery:${IMAGE_TAG:-dev}
+ container_name: state_corp_celery_beat
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ - DEBUG=False
+ - POSTGRES_HOST=db
+ - POSTGRES_PORT=5432
+ - POSTGRES_DB=${POSTGRES_DB}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - REDIS_URL=redis://redis:6379/0
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ volumes:
+ - ./logs:/app/logs
+ networks:
+ - state_corp_network
+ command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
+
+networks:
+ state_corp_network:
+ driver: bridge
diff --git a/sitecustomize.py b/sitecustomize.py
new file mode 100644
index 0000000..53a550d
--- /dev/null
+++ b/sitecustomize.py
@@ -0,0 +1,10 @@
+"""Ensure src/ is on sys.path for tooling like pytest-django."""
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+SRC = ROOT / "src"
+
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
diff --git a/src/apps/core/admin.py b/src/apps/core/admin.py
new file mode 100644
index 0000000..7fe9e2e
--- /dev/null
+++ b/src/apps/core/admin.py
@@ -0,0 +1,152 @@
+"""
+Admin configuration for core app.
+"""
+
+from apps.core.models import BackgroundJob
+from django.contrib import admin
+from django.utils.html import format_html
+
+
+@admin.register(BackgroundJob)
+class BackgroundJobAdmin(admin.ModelAdmin):
+ """Admin для фоновых задач."""
+
+ list_display = [
+ "task_name_short",
+ "status_badge",
+ "progress_bar",
+ "user_id",
+ "started_at",
+ "duration_display",
+ "created_at",
+ ]
+ list_filter = ["status", "task_name", "created_at"]
+ search_fields = ["task_id", "task_name", "error"]
+ readonly_fields = [
+ "id",
+ "task_id",
+ "task_name",
+ "status",
+ "progress",
+ "progress_message",
+ "result",
+ "error",
+ "traceback",
+ "started_at",
+ "completed_at",
+ "user_id",
+ "meta",
+ "created_at",
+ "updated_at",
+ ]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (
+ "Задача",
+ {"fields": ("id", "task_id", "task_name", "user_id")},
+ ),
+ (
+ "Статус",
+ {"fields": ("status", "progress", "progress_message")},
+ ),
+ (
+ "Результат",
+ {"fields": ("result",), "classes": ("collapse",)},
+ ),
+ (
+ "Ошибка",
+ {"fields": ("error", "traceback"), "classes": ("collapse",)},
+ ),
+ (
+ "Время",
+ {"fields": ("started_at", "completed_at", "created_at", "updated_at")},
+ ),
+ (
+ "Метаданные",
+ {"fields": ("meta",), "classes": ("collapse",)},
+ ),
+ )
+
+ def task_name_short(self, obj):
+ """Сокращённое имя задачи."""
+ name = obj.task_name or ""
+ # Берём только последнюю часть пути
+ parts = name.split(".")
+ if len(parts) > 2:
+ return parts[-1]
+ return name
+
+ task_name_short.short_description = "Задача"
+ task_name_short.admin_order_field = "task_name"
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ colors = {
+ "pending": "#6c757d",
+ "started": "#007bff",
+ "success": "#28a745",
+ "failure": "#dc3545",
+ "revoked": "#ffc107",
+ "retry": "#17a2b8",
+ }
+ color = colors.get(obj.status, "#6c757d")
+ return format_html(
+ '{}',
+ color,
+ obj.get_status_display(),
+ )
+
+ status_badge.short_description = "Статус"
+ status_badge.admin_order_field = "status"
+
+ def progress_bar(self, obj):
+ """Прогресс-бар."""
+ progress = obj.progress or 0
+ color = "#28a745" if progress == 100 else "#007bff"
+ return format_html(
+ '
",
+ progress,
+ color,
+ progress,
+ )
+
+ progress_bar.short_description = "Прогресс"
+
+ def duration_display(self, obj):
+ """Длительность выполнения."""
+ duration = obj.duration
+ if duration is None:
+ return "-"
+ if duration < 60:
+ return f"{duration:.1f} сек"
+ return f"{duration / 60:.1f} мин"
+
+ duration_display.short_description = "Длительность"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
+
+ actions = ["revoke_jobs"]
+
+ @admin.action(description="Отменить выбранные задачи")
+ def revoke_jobs(self, request, queryset):
+ from celery import current_app
+
+ count = 0
+ for job in queryset.filter(status__in=["pending", "started"]):
+ current_app.control.revoke(job.task_id, terminate=True)
+ job.revoke()
+ count += 1
+ self.message_user(request, f"Отменено {count} задач")
diff --git a/src/apps/core/excel.py b/src/apps/core/excel.py
new file mode 100644
index 0000000..46109c8
--- /dev/null
+++ b/src/apps/core/excel.py
@@ -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
diff --git a/src/apps/form_1/__init__.py b/src/apps/form_1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_1/admin.py b/src/apps/form_1/admin.py
new file mode 100644
index 0000000..9bf5699
--- /dev/null
+++ b/src/apps/form_1/admin.py
@@ -0,0 +1,131 @@
+"""Административный интерфейс для формы Ф-1."""
+
+from apps.form_1.models import FormF1Record
+from django.contrib import admin
+
+
+@admin.register(FormF1Record)
+class FormF1RecordAdmin(admin.ModelAdmin):
+ """Админка для записей формы Ф-1."""
+
+ list_display = [
+ "organization",
+ "load_batch",
+ "military_output_actual",
+ "civilian_output_actual",
+ "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = ["organization__name", "organization__inn"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ (
+ "Основная информация",
+ {
+ "fields": ["id", "organization", "load_batch"],
+ },
+ ),
+ (
+ "Выпуск военной продукции (факт. цены)",
+ {
+ "fields": [
+ "military_output_actual",
+ "military_domestic_actual",
+ "military_export_actual",
+ ],
+ },
+ ),
+ (
+ "Выпуск гражданской продукции (факт. цены)",
+ {
+ "fields": [
+ "civilian_output_actual",
+ "civilian_domestic_actual",
+ "civilian_export_actual",
+ ],
+ },
+ ),
+ (
+ "Высокотехнологичная продукция (факт. цены)",
+ {
+ "fields": [
+ "hightech_output_actual",
+ "hightech_domestic_actual",
+ "hightech_export_actual",
+ ],
+ },
+ ),
+ (
+ "НИОКР (факт. цены)",
+ {
+ "fields": [
+ "rd_volume_actual",
+ "rd_defense_actual",
+ ],
+ },
+ ),
+ (
+ "Выпуск военной продукции (фикс. цены)",
+ {
+ "fields": [
+ "military_output_fixed",
+ "military_domestic_fixed",
+ "military_export_fixed",
+ ],
+ "classes": ["collapse"],
+ },
+ ),
+ (
+ "Выпуск гражданской продукции (фикс. цены)",
+ {
+ "fields": [
+ "civilian_output_fixed",
+ "civilian_domestic_fixed",
+ "civilian_export_fixed",
+ ],
+ "classes": ["collapse"],
+ },
+ ),
+ (
+ "Высокотехнологичная продукция (фикс. цены)",
+ {
+ "fields": [
+ "hightech_output_fixed",
+ "hightech_domestic_fixed",
+ "hightech_export_fixed",
+ ],
+ "classes": ["collapse"],
+ },
+ ),
+ (
+ "НИОКР (фикс. цены)",
+ {
+ "fields": [
+ "rd_volume_fixed",
+ "rd_defense_fixed",
+ ],
+ "classes": ["collapse"],
+ },
+ ),
+ (
+ "Кадры и заработная плата",
+ {
+ "fields": [
+ "avg_employees",
+ "avg_payroll_employees",
+ "payroll_fund",
+ "salary_arrears",
+ ],
+ },
+ ),
+ (
+ "Системные поля",
+ {
+ "fields": ["created_at", "updated_at"],
+ "classes": ["collapse"],
+ },
+ ),
+ ]
diff --git a/src/apps/form_1/api.py b/src/apps/form_1/api.py
new file mode 100644
index 0000000..256cd1b
--- /dev/null
+++ b/src/apps/form_1/api.py
@@ -0,0 +1,153 @@
+"""
+API для формы Ф-1.
+
+Содержит:
+- FormF1UploadView - загрузка файла
+- FormF1RecordViewSet - CRUD для записей
+"""
+
+import logging
+
+from apps.core.response import api_response
+from apps.core.viewsets import ReadOnlyViewSet
+from apps.form_1.models import FormF1Record
+from apps.form_1.serializers import (
+ BatchInfoSerializer,
+ FormF1RecordListSerializer,
+ FormF1RecordSerializer,
+ FormF1UploadSerializer,
+ ParseResultSerializer,
+)
+from apps.form_1.services import FormF1Service, parse_form_f1_file
+from apps.form_1.tasks import process_form_f1_file
+from django.core.files.storage import default_storage
+from django_filters import rest_framework as filters
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+
+# Порог для фоновой обработки (количество строк)
+BACKGROUND_THRESHOLD = 100
+
+
+class FormF1Filter(filters.FilterSet):
+ """Фильтры для записей формы Ф-1."""
+
+ organization = filters.UUIDFilter(field_name="organization__id")
+ organization_inn = filters.CharFilter(field_name="organization__inn")
+ load_batch = filters.NumberFilter()
+
+ class Meta:
+ model = FormF1Record
+ fields = ["organization", "organization_inn", "load_batch"]
+
+
+class FormF1UploadView(APIView):
+ """
+ Загрузка файла формы Ф-1.
+
+ POST /api/v1/forms/f1/upload/
+ """
+
+ permission_classes = [IsAuthenticated]
+ parser_classes = [MultiPartParser]
+
+ @swagger_auto_schema(
+ tags=["Форма Ф-1"],
+ operation_summary="Загрузка файла Ф-1",
+ operation_description=(
+ "Загружает Excel файл формы Ф-1 и обрабатывает данные.\n"
+ "Для больших файлов обработка выполняется в фоновом режиме."
+ ),
+ request_body=FormF1UploadSerializer,
+ responses={
+ 200: ParseResultSerializer,
+ 202: "Задача поставлена в очередь (для больших файлов)",
+ 400: "Ошибка валидации",
+ },
+ )
+ def post(self, request: Request) -> Response:
+ """Загрузка и обработка файла."""
+ serializer = FormF1UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ file = serializer.validated_data["file"]
+
+ # Определяем размер файла для выбора режима обработки
+ file_size = file.size
+
+ # Для небольших файлов - синхронная обработка
+ if file_size < 1024 * 1024: # < 1MB
+ result = parse_form_f1_file(file)
+ return api_response(result.to_dict())
+
+ # Для больших файлов - фоновая обработка
+ # Сохраняем файл во временное хранилище
+ file_path = f"uploads/form_f1/{file.name}"
+ saved_path = default_storage.save(file_path, file)
+
+ # Запускаем фоновую задачу
+ task = process_form_f1_file.delay(
+ file_path=saved_path,
+ user_id=request.user.id,
+ )
+
+ return Response(
+ {
+ "success": True,
+ "data": {
+ "task_id": task.id,
+ "message": "Файл поставлен в очередь на обработку",
+ },
+ },
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+
+class FormF1RecordViewSet(ReadOnlyViewSet[FormF1Record]):
+ """
+ ViewSet для записей формы Ф-1.
+
+ Эндпоинты:
+ GET /api/v1/forms/f1/records/ - список записей
+ GET /api/v1/forms/f1/records/{id}/ - детали записи
+ GET /api/v1/forms/f1/records/batches/ - список загрузок
+ """
+
+ queryset = FormF1Record.objects.select_related("organization").all()
+ serializer_class = FormF1RecordSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = FormF1Filter
+ search_fields = ["organization__name", "organization__inn"]
+ ordering_fields = ["created_at", "load_batch"]
+ ordering = ["-created_at"]
+
+ serializer_classes = {
+ "list": FormF1RecordListSerializer,
+ }
+
+ def get_serializer_class(self):
+ """Возвращает serializer в зависимости от action."""
+ if self.action in self.serializer_classes:
+ return self.serializer_classes[self.action]
+ return super().get_serializer_class()
+
+ @swagger_auto_schema(
+ tags=["Форма Ф-1"],
+ operation_summary="Список загрузок",
+ operation_description="Возвращает список всех загрузок с количеством записей.",
+ responses={200: BatchInfoSerializer(many=True)},
+ )
+ @action(detail=False, methods=["get"])
+ def batches(self, request: Request) -> Response:
+ """Получить список загрузок."""
+ batches = FormF1Service.get_batches()
+ serializer = BatchInfoSerializer(batches, many=True)
+ return api_response(serializer.data)
diff --git a/src/apps/form_1/apps.py b/src/apps/form_1/apps.py
new file mode 100644
index 0000000..cfcff09
--- /dev/null
+++ b/src/apps/form_1/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_1."""
+
+from django.apps import AppConfig
+
+
+class Form1Config(AppConfig):
+ """Конфигурация приложения формы Ф-1 (Выпуск продукции)."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_1"
+ verbose_name = "Форма Ф-1 (Выпуск продукции)"
diff --git a/src/apps/form_1/migrations/0001_initial.py b/src/apps/form_1/migrations/0001_initial.py
new file mode 100644
index 0000000..1356d5f
--- /dev/null
+++ b/src/apps/form_1/migrations/0001_initial.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF1Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('military_output_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск военной продукции (факт. цены)')),
+ ('military_domestic_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='военная продукция на внутренний рынок (факт.)')),
+ ('military_export_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='военная продукция на экспорт (факт.)')),
+ ('civilian_output_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск гражданской продукции (факт. цены)')),
+ ('civilian_domestic_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='гражданская продукция на внутренний рынок (факт.)')),
+ ('civilian_export_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='гражданская продукция на экспорт (факт.)')),
+ ('hightech_output_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск высокотехнологичной продукции (факт. цены)')),
+ ('hightech_domestic_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='высокотехнологичная продукция на внутренний рынок (факт.)')),
+ ('hightech_export_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='высокотехнологичная продукция на экспорт (факт.)')),
+ ('rd_volume_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='объём НИОКР (факт. цены)')),
+ ('rd_defense_actual', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='НИОКР в интересах обороны (факт.)')),
+ ('military_output_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск военной продукции (фикс. цены)')),
+ ('military_domestic_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='военная продукция на внутренний рынок (фикс.)')),
+ ('military_export_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='военная продукция на экспорт (фикс.)')),
+ ('civilian_output_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск гражданской продукции (фикс. цены)')),
+ ('civilian_domestic_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='гражданская продукция на внутренний рынок (фикс.)')),
+ ('civilian_export_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='гражданская продукция на экспорт (фикс.)')),
+ ('hightech_output_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выпуск высокотехнологичной продукции (фикс. цены)')),
+ ('hightech_domestic_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='высокотехнологичная продукция на внутренний рынок (фикс.)')),
+ ('hightech_export_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='высокотехнологичная продукция на экспорт (фикс.)')),
+ ('rd_volume_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='объём НИОКР (фикс. цены)')),
+ ('rd_defense_fixed', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='НИОКР в интересах обороны (фикс.)')),
+ ('avg_employees', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='средняя численность работников')),
+ ('avg_payroll_employees', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='среднесписочная численность работников')),
+ ('payroll_fund', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='фонд начисленной заработной платы')),
+ ('salary_arrears', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='просроченная задолженность по зарплате')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f1_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-1',
+ 'verbose_name_plural': 'записи Ф-1',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf1record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_1_form_organiz_f99cbd_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf1record',
+ index=models.Index(fields=['load_batch'], name='form_1_form_load_ba_eb5f8d_idx'),
+ ),
+ ]
diff --git a/src/apps/form_1/migrations/__init__.py b/src/apps/form_1/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_1/models.py b/src/apps/form_1/models.py
new file mode 100644
index 0000000..a69039e
--- /dev/null
+++ b/src/apps/form_1/models.py
@@ -0,0 +1,252 @@
+"""
+Модели формы Ф-1 (Выпуск продукции).
+
+Содержит:
+- FormF1Record - запись формы Ф-1
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF1Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-1 (Выпуск продукции).
+
+ Данные о выпуске военной и гражданской продукции,
+ объёмах НИОКР, численности и заработной плате.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f1_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === Выпуск военной продукции (фактические цены) ===
+ military_output_actual = models.DecimalField(
+ _("выпуск военной продукции (факт. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ military_domestic_actual = models.DecimalField(
+ _("военная продукция на внутренний рынок (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ military_export_actual = models.DecimalField(
+ _("военная продукция на экспорт (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Выпуск гражданской продукции (фактические цены) ===
+ civilian_output_actual = models.DecimalField(
+ _("выпуск гражданской продукции (факт. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ civilian_domestic_actual = models.DecimalField(
+ _("гражданская продукция на внутренний рынок (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ civilian_export_actual = models.DecimalField(
+ _("гражданская продукция на экспорт (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Высокотехнологичная продукция (фактические цены) ===
+ hightech_output_actual = models.DecimalField(
+ _("выпуск высокотехнологичной продукции (факт. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ hightech_domestic_actual = models.DecimalField(
+ _("высокотехнологичная продукция на внутренний рынок (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ hightech_export_actual = models.DecimalField(
+ _("высокотехнологичная продукция на экспорт (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === НИОКР (фактические цены) ===
+ rd_volume_actual = models.DecimalField(
+ _("объём НИОКР (факт. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ rd_defense_actual = models.DecimalField(
+ _("НИОКР в интересах обороны (факт.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Выпуск военной продукции (фиксированные цены) ===
+ military_output_fixed = models.DecimalField(
+ _("выпуск военной продукции (фикс. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ military_domestic_fixed = models.DecimalField(
+ _("военная продукция на внутренний рынок (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ military_export_fixed = models.DecimalField(
+ _("военная продукция на экспорт (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Выпуск гражданской продукции (фиксированные цены) ===
+ civilian_output_fixed = models.DecimalField(
+ _("выпуск гражданской продукции (фикс. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ civilian_domestic_fixed = models.DecimalField(
+ _("гражданская продукция на внутренний рынок (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ civilian_export_fixed = models.DecimalField(
+ _("гражданская продукция на экспорт (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Высокотехнологичная продукция (фиксированные цены) ===
+ hightech_output_fixed = models.DecimalField(
+ _("выпуск высокотехнологичной продукции (фикс. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ hightech_domestic_fixed = models.DecimalField(
+ _("высокотехнологичная продукция на внутренний рынок (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ hightech_export_fixed = models.DecimalField(
+ _("высокотехнологичная продукция на экспорт (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === НИОКР (фиксированные цены) ===
+ rd_volume_fixed = models.DecimalField(
+ _("объём НИОКР (фикс. цены)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ rd_defense_fixed = models.DecimalField(
+ _("НИОКР в интересах обороны (фикс.)"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ # === Кадры и заработная плата ===
+ avg_employees = models.DecimalField(
+ _("средняя численность работников"),
+ max_digits=12,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ avg_payroll_employees = models.DecimalField(
+ _("среднесписочная численность работников"),
+ max_digits=12,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ payroll_fund = models.DecimalField(
+ _("фонд начисленной заработной платы"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+ salary_arrears = models.DecimalField(
+ _("просроченная задолженность по зарплате"),
+ max_digits=20,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-1")
+ verbose_name_plural = _("записи Ф-1")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-1: {self.organization.name} (batch: {self.load_batch})"
diff --git a/src/apps/form_1/serializers.py b/src/apps/form_1/serializers.py
new file mode 100644
index 0000000..095dfdf
--- /dev/null
+++ b/src/apps/form_1/serializers.py
@@ -0,0 +1,141 @@
+"""
+Сериализаторы для формы Ф-1.
+
+Содержит:
+- FormF1RecordSerializer - полный сериализатор
+- FormF1RecordListSerializer - краткий для списков
+- FormF1UploadSerializer - для загрузки файла
+- FormF1ParseResultSerializer - результат парсинга
+"""
+
+from apps.form_1.models import FormF1Record
+from apps.organization.serializers import OrganizationListSerializer
+from rest_framework import serializers
+
+
+class FormF1RecordSerializer(serializers.ModelSerializer):
+ """Полный сериализатор записи формы Ф-1."""
+
+ organization = OrganizationListSerializer(read_only=True)
+
+ class Meta:
+ model = FormF1Record
+ fields = [
+ "id",
+ "organization",
+ "load_batch",
+ # Военная продукция (факт.)
+ "military_output_actual",
+ "military_domestic_actual",
+ "military_export_actual",
+ # Гражданская продукция (факт.)
+ "civilian_output_actual",
+ "civilian_domestic_actual",
+ "civilian_export_actual",
+ # Высокотехнологичная продукция (факт.)
+ "hightech_output_actual",
+ "hightech_domestic_actual",
+ "hightech_export_actual",
+ # НИОКР (факт.)
+ "rd_volume_actual",
+ "rd_defense_actual",
+ # Военная продукция (фикс.)
+ "military_output_fixed",
+ "military_domestic_fixed",
+ "military_export_fixed",
+ # Гражданская продукция (фикс.)
+ "civilian_output_fixed",
+ "civilian_domestic_fixed",
+ "civilian_export_fixed",
+ # Высокотехнологичная продукция (фикс.)
+ "hightech_output_fixed",
+ "hightech_domestic_fixed",
+ "hightech_export_fixed",
+ # НИОКР (фикс.)
+ "rd_volume_fixed",
+ "rd_defense_fixed",
+ # Кадры
+ "avg_employees",
+ "avg_payroll_employees",
+ "payroll_fund",
+ "salary_arrears",
+ # Системные
+ "created_at",
+ "updated_at",
+ ]
+
+
+class FormF1RecordListSerializer(serializers.ModelSerializer):
+ """Краткий сериализатор для списков."""
+
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF1Record
+ fields = [
+ "id",
+ "organization_name",
+ "organization_inn",
+ "load_batch",
+ "military_output_actual",
+ "civilian_output_actual",
+ "created_at",
+ ]
+
+
+class FormF1UploadSerializer(serializers.Serializer):
+ """Сериализатор для загрузки файла."""
+
+ file = serializers.FileField(
+ help_text="Excel файл формы Ф-1 (.xlsx)"
+ )
+
+ def validate_file(self, value):
+ """Валидация загруженного файла."""
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError(
+ "Файл должен быть в формате Excel (.xlsx или .xls)"
+ )
+
+ # Проверка размера файла (макс. 50MB)
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError(
+ "Размер файла не должен превышать 50MB"
+ )
+
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки поля."""
+
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки строки."""
+
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class ParseResultSerializer(serializers.Serializer):
+ """Сериализатор результата парсинга."""
+
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
+
+
+class BatchInfoSerializer(serializers.Serializer):
+ """Сериализатор информации о загрузке."""
+
+ load_batch = serializers.IntegerField()
+ count = serializers.IntegerField()
+ created_at = serializers.DateTimeField()
diff --git a/src/apps/form_1/services.py b/src/apps/form_1/services.py
new file mode 100644
index 0000000..e002cb2
--- /dev/null
+++ b/src/apps/form_1/services.py
@@ -0,0 +1,162 @@
+"""
+Сервисы для работы с формой Ф-1.
+
+Содержит:
+- FormF1Service - CRUD операции
+- FormF1ParserService - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_1.models import FormF1Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF1Service(BulkOperationsMixin, BaseService[FormF1Record]):
+ """
+ Сервис для работы с записями формы Ф-1.
+
+ Методы:
+ get_by_batch: Получить записи по номеру загрузки
+ get_next_batch_id: Получить следующий номер загрузки
+ get_batches: Получить список загрузок
+ """
+
+ model = FormF1Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ """Получить записи по номеру загрузки."""
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ """Получить следующий номер загрузки."""
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ """Получить список загрузок с количеством записей."""
+ from django.db.models import Count
+
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(
+ count=Count("id"),
+ created_at=Max("created_at"),
+ )
+ .order_by("-load_batch")
+ )
+
+
+class FormF1Parser(BaseExcelParser[FormF1Record]):
+ """
+ Парсер Excel файла формы Ф-1.
+
+ Колонки:
+ 0: Наименование организации
+ 1: ОКПО
+ 2: ОГРН
+ 3: ИНН
+ 4-29: Данные формы
+ """
+
+ # Индексы стандартных колонок
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ """Маппинг колонок Excel на поля модели."""
+ return [
+ # Выпуск военной продукции (факт.)
+ ColumnMapping(4, "Выпуск военной продукции (факт.)", "military_output_actual", field_type="decimal"),
+ ColumnMapping(5, "Военная на внутренний рынок (факт.)", "military_domestic_actual", field_type="decimal"),
+ ColumnMapping(6, "Военная на экспорт (факт.)", "military_export_actual", field_type="decimal"),
+ # Выпуск гражданской продукции (факт.)
+ ColumnMapping(7, "Выпуск гражданской продукции (факт.)", "civilian_output_actual", field_type="decimal"),
+ ColumnMapping(8, "Гражданская на внутренний рынок (факт.)", "civilian_domestic_actual", field_type="decimal"),
+ ColumnMapping(9, "Гражданская на экспорт (факт.)", "civilian_export_actual", field_type="decimal"),
+ # Высокотехнологичная продукция (факт.)
+ ColumnMapping(10, "Высокотехнологичная продукция (факт.)", "hightech_output_actual", field_type="decimal"),
+ ColumnMapping(11, "Высокотехнологичная на внутренний рынок (факт.)", "hightech_domestic_actual", field_type="decimal"),
+ ColumnMapping(12, "Высокотехнологичная на экспорт (факт.)", "hightech_export_actual", field_type="decimal"),
+ # НИОКР (факт.)
+ ColumnMapping(13, "Объём НИОКР (факт.)", "rd_volume_actual", field_type="decimal"),
+ ColumnMapping(14, "НИОКР в интересах обороны (факт.)", "rd_defense_actual", field_type="decimal"),
+ # Выпуск военной продукции (фикс.)
+ ColumnMapping(15, "Выпуск военной продукции (фикс.)", "military_output_fixed", field_type="decimal"),
+ ColumnMapping(16, "Военная на внутренний рынок (фикс.)", "military_domestic_fixed", field_type="decimal"),
+ ColumnMapping(17, "Военная на экспорт (фикс.)", "military_export_fixed", field_type="decimal"),
+ # Выпуск гражданской продукции (фикс.)
+ ColumnMapping(18, "Выпуск гражданской продукции (фикс.)", "civilian_output_fixed", field_type="decimal"),
+ ColumnMapping(19, "Гражданская на внутренний рынок (фикс.)", "civilian_domestic_fixed", field_type="decimal"),
+ ColumnMapping(20, "Гражданская на экспорт (фикс.)", "civilian_export_fixed", field_type="decimal"),
+ # Высокотехнологичная продукция (фикс.)
+ ColumnMapping(21, "Высокотехнологичная продукция (фикс.)", "hightech_output_fixed", field_type="decimal"),
+ ColumnMapping(22, "Высокотехнологичная на внутренний рынок (фикс.)", "hightech_domestic_fixed", field_type="decimal"),
+ ColumnMapping(23, "Высокотехнологичная на экспорт (фикс.)", "hightech_export_fixed", field_type="decimal"),
+ # НИОКР (фикс.)
+ ColumnMapping(24, "Объём НИОКР (фикс.)", "rd_volume_fixed", field_type="decimal"),
+ ColumnMapping(25, "НИОКР в интересах обороны (фикс.)", "rd_defense_fixed", field_type="decimal"),
+ # Кадры и ЗП
+ ColumnMapping(26, "Средняя численность работников", "avg_employees", field_type="decimal"),
+ ColumnMapping(27, "Среднесписочная численность", "avg_payroll_employees", field_type="decimal"),
+ ColumnMapping(28, "Фонд начисленной зарплаты", "payroll_fund", field_type="decimal"),
+ ColumnMapping(29, "Просроченная задолженность по ЗП", "salary_arrears", field_type="decimal"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ """Получить следующий номер загрузки."""
+ return FormF1Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF1Record:
+ """Создать запись формы Ф-1."""
+ # Получаем или создаём организацию
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+
+ # Создаём запись формы
+ record = FormF1Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+ return record
+
+
+def parse_form_f1_file(file) -> ParseResult:
+ """
+ Парсит Excel файл формы Ф-1.
+
+ Args:
+ file: Загруженный файл
+
+ Returns:
+ ParseResult с результатами парсинга
+ """
+ parser = FormF1Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_1/tasks.py b/src/apps/form_1/tasks.py
new file mode 100644
index 0000000..c93fa67
--- /dev/null
+++ b/src/apps/form_1/tasks.py
@@ -0,0 +1,55 @@
+"""
+Celery задачи для формы Ф-1.
+
+Содержит:
+- process_form_f1_file - фоновая обработка файла
+"""
+
+import logging
+
+from apps.core.tasks import TrackedTask
+from apps.form_1.services import parse_form_f1_file
+from celery import shared_task
+from django.core.files.storage import default_storage
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(base=TrackedTask, bind=True)
+def process_form_f1_file(self, file_path: str, user_id: int | None = None):
+ """
+ Фоновая обработка файла формы Ф-1.
+
+ Args:
+ file_path: Путь к файлу в storage
+ user_id: ID пользователя (для отслеживания)
+
+ Returns:
+ dict с результатами парсинга
+ """
+ job = self.get_job()
+ job.update_progress(10, "Загрузка файла...")
+
+ try:
+ # Открываем файл из storage
+ with default_storage.open(file_path, "rb") as f:
+ job.update_progress(20, "Парсинг данных...")
+
+ # Парсим файл
+ result = parse_form_f1_file(f)
+
+ job.update_progress(90, "Завершение...")
+
+ # Удаляем временный файл
+ default_storage.delete(file_path)
+
+ return result.to_dict()
+
+ except Exception as e:
+ logger.exception(f"Ошибка обработки файла Ф-1: {e}")
+ # Пытаемся удалить файл при ошибке
+ try:
+ default_storage.delete(file_path)
+ except Exception:
+ pass
+ raise
diff --git a/src/apps/form_1/urls.py b/src/apps/form_1/urls.py
new file mode 100644
index 0000000..531bc09
--- /dev/null
+++ b/src/apps/form_1/urls.py
@@ -0,0 +1,13 @@
+"""URL маршруты для формы Ф-1."""
+
+from apps.form_1.api import FormF1RecordViewSet, FormF1UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF1RecordViewSet, basename="form-f1-record")
+
+urlpatterns = [
+ path("upload/", FormF1UploadView.as_view(), name="form-f1-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_2/__init__.py b/src/apps/form_2/__init__.py
new file mode 100644
index 0000000..d83bafe
--- /dev/null
+++ b/src/apps/form_2/__init__.py
@@ -0,0 +1 @@
+"""Приложение формы Ф-2 (Бухгалтерский баланс)."""
diff --git a/src/apps/form_2/admin.py b/src/apps/form_2/admin.py
new file mode 100644
index 0000000..f53fc57
--- /dev/null
+++ b/src/apps/form_2/admin.py
@@ -0,0 +1,86 @@
+"""
+Админка формы Ф-2.
+"""
+
+from apps.form_2.models import FormF2Record
+from django.contrib import admin
+
+
+@admin.register(FormF2Record)
+class FormF2RecordAdmin(admin.ModelAdmin):
+ """Админка записей формы Ф-2."""
+
+ list_display = [
+ "organization",
+ "load_batch",
+ "total_assets",
+ "total_liabilities",
+ "revenue",
+ "net_profit",
+ "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = ["organization__name", "organization__inn"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ ("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
+ ("I. Внеоборотные активы", {
+ "fields": [
+ "intangible_assets", "rd_results", "intangible_search_assets",
+ "tangible_search_assets", "fixed_assets", "profitable_investments",
+ "financial_investments_non_current", "deferred_tax_assets",
+ "other_non_current_assets", "total_non_current_assets",
+ ],
+ }),
+ ("II. Оборотные активы", {
+ "fields": [
+ "inventories", "vat_on_acquired_assets", "receivables",
+ "financial_investments_current", "cash_and_equivalents",
+ "other_current_assets", "total_current_assets", "total_assets",
+ ],
+ }),
+ ("III. Капитал и резервы", {
+ "fields": [
+ "authorized_capital", "own_shares_bought_back",
+ "revaluation_of_non_current_assets", "additional_capital",
+ "reserve_capital", "retained_earnings", "total_equity",
+ ],
+ "classes": ["collapse"],
+ }),
+ ("IV. Долгосрочные обязательства", {
+ "fields": [
+ "borrowings_non_current", "deferred_tax_liabilities",
+ "estimated_liabilities_non_current", "other_liabilities_non_current",
+ "total_non_current_liabilities",
+ ],
+ "classes": ["collapse"],
+ }),
+ ("V. Краткосрочные обязательства", {
+ "fields": [
+ "borrowings_current", "payables", "deferred_income",
+ "estimated_liabilities_current", "other_liabilities_current",
+ "total_current_liabilities", "total_liabilities",
+ ],
+ "classes": ["collapse"],
+ }),
+ ("Отчёт о финансовых результатах", {
+ "fields": [
+ "revenue", "cost_of_sales", "gross_profit", "selling_expenses",
+ "administrative_expenses", "profit_from_sales", "interest_receivable",
+ "interest_payable", "other_income", "other_expenses",
+ "profit_before_tax", "income_tax", "net_profit",
+ ],
+ }),
+ ("Дополнительные показатели", {
+ "fields": ["ebitda", "depreciation", "working_capital", "net_debt"],
+ "classes": ["collapse"],
+ }),
+ ("Прошлый период", {
+ "fields": ["total_assets_prev", "total_liabilities_prev", "revenue_prev", "net_profit_prev"],
+ "classes": ["collapse"],
+ }),
+ ("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
+ ]
diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py
new file mode 100644
index 0000000..b716d8c
--- /dev/null
+++ b/src/apps/form_2/api.py
@@ -0,0 +1,109 @@
+"""
+API формы Ф-2.
+
+Содержит:
+- FormF2UploadView - загрузка файла
+- FormF2RecordViewSet - CRUD записей
+"""
+
+import logging
+
+from apps.core.viewsets import BaseViewSet
+from apps.form_2.models import FormF2Record
+from apps.form_2.serializers import (
+ FormF2ParseResultSerializer,
+ FormF2RecordListSerializer,
+ FormF2RecordSerializer,
+ FormF2UploadSerializer,
+)
+from apps.form_2.services import FormF2Service, parse_form_f2_file
+from apps.form_2.tasks import process_form_f2_file
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+
+# Порог для фоновой обработки (байты)
+BACKGROUND_THRESHOLD = 1024 * 1024 # 1MB
+
+
+class FormF2UploadView(APIView):
+ """
+ Загрузка файла формы Ф-2.
+
+ POST /api/v1/forms/f2/upload/
+ """
+
+ parser_classes = [MultiPartParser]
+
+ def post(self, request):
+ """Загрузка и обработка файла."""
+ serializer = FormF2UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ file = serializer.validated_data["file"]
+
+ # Для больших файлов - фоновая обработка
+ if file.size > BACKGROUND_THRESHOLD:
+ file_content = file.read()
+ task = process_form_f2_file.delay(file_content, file.name)
+
+ return Response(
+ {
+ "success": True,
+ "message": "Файл поставлен в очередь на обработку",
+ "task_id": task.id,
+ },
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+ # Синхронная обработка
+ try:
+ result = parse_form_f2_file(file)
+ result_serializer = FormF2ParseResultSerializer(result)
+
+ return Response(
+ {
+ "success": True,
+ "data": result_serializer.data,
+ },
+ status=status.HTTP_200_OK,
+ )
+ except Exception as e:
+ logger.exception("Ошибка обработки файла Ф-2")
+ return Response(
+ {
+ "success": False,
+ "error": str(e),
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class FormF2RecordViewSet(BaseViewSet):
+ """
+ ViewSet для записей формы Ф-2.
+
+ GET /api/v1/forms/f2/records/ - список записей
+ GET /api/v1/forms/f2/records/{id}/ - детали записи
+ """
+
+ queryset = FormF2Record.objects.select_related("organization").all()
+ serializer_class = FormF2RecordSerializer
+ service_class = FormF2Service
+
+ def get_serializer_class(self):
+ """Выбор сериализатора в зависимости от действия."""
+ if self.action == "list":
+ return FormF2RecordListSerializer
+ return FormF2RecordSerializer
+
+ def get_queryset(self):
+ """Фильтрация по batch_id."""
+ queryset = super().get_queryset()
+ batch_id = self.request.query_params.get("batch_id")
+ if batch_id:
+ queryset = queryset.filter(load_batch=batch_id)
+ return queryset
diff --git a/src/apps/form_2/apps.py b/src/apps/form_2/apps.py
new file mode 100644
index 0000000..3d0d597
--- /dev/null
+++ b/src/apps/form_2/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_2."""
+
+from django.apps import AppConfig
+
+
+class Form2Config(AppConfig):
+ """Конфигурация приложения формы Ф-2."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_2"
+ verbose_name = "Форма Ф-2 (Бухгалтерский баланс)"
diff --git a/src/apps/form_2/migrations/0001_initial.py b/src/apps/form_2/migrations/0001_initial.py
new file mode 100644
index 0000000..cb9cd9e
--- /dev/null
+++ b/src/apps/form_2/migrations/0001_initial.py
@@ -0,0 +1,98 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF2Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('intangible_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='нематериальные активы')),
+ ('rd_results', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='результаты исследований и разработок')),
+ ('intangible_search_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='нематериальные поисковые активы')),
+ ('tangible_search_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='материальные поисковые активы')),
+ ('fixed_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='основные средства')),
+ ('profitable_investments', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='доходные вложения в материальные ценности')),
+ ('financial_investments_non_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='финансовые вложения (внеоборотные)')),
+ ('deferred_tax_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='отложенные налоговые активы')),
+ ('other_non_current_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие внеоборотные активы')),
+ ('total_non_current_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='итого внеоборотные активы')),
+ ('inventories', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='запасы')),
+ ('vat_on_acquired_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='НДС по приобретённым ценностям')),
+ ('receivables', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='дебиторская задолженность')),
+ ('financial_investments_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='финансовые вложения (оборотные)')),
+ ('cash_and_equivalents', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='денежные средства и эквиваленты')),
+ ('other_current_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие оборотные активы')),
+ ('total_current_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='итого оборотные активы')),
+ ('total_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='баланс (актив)')),
+ ('authorized_capital', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='уставный капитал')),
+ ('own_shares_bought_back', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='собственные акции, выкупленные у акционеров')),
+ ('revaluation_of_non_current_assets', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='переоценка внеоборотных активов')),
+ ('additional_capital', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='добавочный капитал')),
+ ('reserve_capital', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='резервный капитал')),
+ ('retained_earnings', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='нераспределённая прибыль')),
+ ('total_equity', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='итого капитал и резервы')),
+ ('borrowings_non_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='заёмные средства (долгосрочные)')),
+ ('deferred_tax_liabilities', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='отложенные налоговые обязательства')),
+ ('estimated_liabilities_non_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='оценочные обязательства (долгосрочные)')),
+ ('other_liabilities_non_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие обязательства (долгосрочные)')),
+ ('total_non_current_liabilities', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='итого долгосрочные обязательства')),
+ ('borrowings_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='заёмные средства (краткосрочные)')),
+ ('payables', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='кредиторская задолженность')),
+ ('deferred_income', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='доходы будущих периодов')),
+ ('estimated_liabilities_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='оценочные обязательства (краткосрочные)')),
+ ('other_liabilities_current', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие обязательства (краткосрочные)')),
+ ('total_current_liabilities', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='итого краткосрочные обязательства')),
+ ('total_liabilities', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='баланс (пассив)')),
+ ('revenue', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка')),
+ ('cost_of_sales', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='себестоимость продаж')),
+ ('gross_profit', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='валовая прибыль')),
+ ('selling_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='коммерческие расходы')),
+ ('administrative_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='управленческие расходы')),
+ ('profit_from_sales', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прибыль от продаж')),
+ ('interest_receivable', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='проценты к получению')),
+ ('interest_payable', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='проценты к уплате')),
+ ('other_income', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие доходы')),
+ ('other_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прочие расходы')),
+ ('profit_before_tax', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='прибыль до налогообложения')),
+ ('income_tax', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='текущий налог на прибыль')),
+ ('net_profit', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистая прибыль')),
+ ('ebitda', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='EBITDA')),
+ ('depreciation', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='амортизация')),
+ ('working_capital', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='оборотный капитал')),
+ ('net_debt', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистый долг')),
+ ('total_assets_prev', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='баланс (актив) - прошлый период')),
+ ('total_liabilities_prev', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='баланс (пассив) - прошлый период')),
+ ('revenue_prev', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка - прошлый период')),
+ ('net_profit_prev', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистая прибыль - прошлый период')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f2_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-2',
+ 'verbose_name_plural': 'записи Ф-2',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf2record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_2_form_organiz_20281b_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf2record',
+ index=models.Index(fields=['load_batch'], name='form_2_form_load_ba_2ee4b2_idx'),
+ ),
+ ]
diff --git a/src/apps/form_2/migrations/__init__.py b/src/apps/form_2/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_2/models.py b/src/apps/form_2/models.py
new file mode 100644
index 0000000..3c580ce
--- /dev/null
+++ b/src/apps/form_2/models.py
@@ -0,0 +1,304 @@
+"""
+Модели формы Ф-2 (Бухгалтерский баланс).
+
+Содержит:
+- FormF2Record - запись формы Ф-2
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF2Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-2 (Бухгалтерский баланс).
+
+ Данные бухгалтерского баланса организации:
+ активы, пассивы, капитал, обязательства.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f2_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === АКТИВ ===
+ # I. Внеоборотные активы
+ intangible_assets = models.DecimalField(
+ _("нематериальные активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ rd_results = models.DecimalField(
+ _("результаты исследований и разработок"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ intangible_search_assets = models.DecimalField(
+ _("нематериальные поисковые активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ tangible_search_assets = models.DecimalField(
+ _("материальные поисковые активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ fixed_assets = models.DecimalField(
+ _("основные средства"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ profitable_investments = models.DecimalField(
+ _("доходные вложения в материальные ценности"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ financial_investments_non_current = models.DecimalField(
+ _("финансовые вложения (внеоборотные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ deferred_tax_assets = models.DecimalField(
+ _("отложенные налоговые активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_non_current_assets = models.DecimalField(
+ _("прочие внеоборотные активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_non_current_assets = models.DecimalField(
+ _("итого внеоборотные активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # II. Оборотные активы
+ inventories = models.DecimalField(
+ _("запасы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ vat_on_acquired_assets = models.DecimalField(
+ _("НДС по приобретённым ценностям"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ receivables = models.DecimalField(
+ _("дебиторская задолженность"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ financial_investments_current = models.DecimalField(
+ _("финансовые вложения (оборотные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ cash_and_equivalents = models.DecimalField(
+ _("денежные средства и эквиваленты"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_current_assets = models.DecimalField(
+ _("прочие оборотные активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_current_assets = models.DecimalField(
+ _("итого оборотные активы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ total_assets = models.DecimalField(
+ _("баланс (актив)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === ПАССИВ ===
+ # III. Капитал и резервы
+ authorized_capital = models.DecimalField(
+ _("уставный капитал"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ own_shares_bought_back = models.DecimalField(
+ _("собственные акции, выкупленные у акционеров"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ revaluation_of_non_current_assets = models.DecimalField(
+ _("переоценка внеоборотных активов"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ additional_capital = models.DecimalField(
+ _("добавочный капитал"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ reserve_capital = models.DecimalField(
+ _("резервный капитал"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ retained_earnings = models.DecimalField(
+ _("нераспределённая прибыль"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_equity = models.DecimalField(
+ _("итого капитал и резервы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # IV. Долгосрочные обязательства
+ borrowings_non_current = models.DecimalField(
+ _("заёмные средства (долгосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ deferred_tax_liabilities = models.DecimalField(
+ _("отложенные налоговые обязательства"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ estimated_liabilities_non_current = models.DecimalField(
+ _("оценочные обязательства (долгосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_liabilities_non_current = models.DecimalField(
+ _("прочие обязательства (долгосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_non_current_liabilities = models.DecimalField(
+ _("итого долгосрочные обязательства"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # V. Краткосрочные обязательства
+ borrowings_current = models.DecimalField(
+ _("заёмные средства (краткосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ payables = models.DecimalField(
+ _("кредиторская задолженность"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ deferred_income = models.DecimalField(
+ _("доходы будущих периодов"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ estimated_liabilities_current = models.DecimalField(
+ _("оценочные обязательства (краткосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_liabilities_current = models.DecimalField(
+ _("прочие обязательства (краткосрочные)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_current_liabilities = models.DecimalField(
+ _("итого краткосрочные обязательства"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ total_liabilities = models.DecimalField(
+ _("баланс (пассив)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Отчёт о финансовых результатах ===
+ revenue = models.DecimalField(
+ _("выручка"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ cost_of_sales = models.DecimalField(
+ _("себестоимость продаж"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ gross_profit = models.DecimalField(
+ _("валовая прибыль"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ selling_expenses = models.DecimalField(
+ _("коммерческие расходы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ administrative_expenses = models.DecimalField(
+ _("управленческие расходы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ profit_from_sales = models.DecimalField(
+ _("прибыль от продаж"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ interest_receivable = models.DecimalField(
+ _("проценты к получению"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ interest_payable = models.DecimalField(
+ _("проценты к уплате"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_income = models.DecimalField(
+ _("прочие доходы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ other_expenses = models.DecimalField(
+ _("прочие расходы"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ profit_before_tax = models.DecimalField(
+ _("прибыль до налогообложения"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ income_tax = models.DecimalField(
+ _("текущий налог на прибыль"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_profit = models.DecimalField(
+ _("чистая прибыль"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Дополнительные показатели ===
+ ebitda = models.DecimalField(
+ _("EBITDA"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ depreciation = models.DecimalField(
+ _("амортизация"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ working_capital = models.DecimalField(
+ _("оборотный капитал"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_debt = models.DecimalField(
+ _("чистый долг"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Прошлый период (для сравнения) ===
+ total_assets_prev = models.DecimalField(
+ _("баланс (актив) - прошлый период"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_liabilities_prev = models.DecimalField(
+ _("баланс (пассив) - прошлый период"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ revenue_prev = models.DecimalField(
+ _("выручка - прошлый период"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_profit_prev = models.DecimalField(
+ _("чистая прибыль - прошлый период"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-2")
+ verbose_name_plural = _("записи Ф-2")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-2: {self.organization.name} (batch: {self.load_batch})"
diff --git a/src/apps/form_2/serializers.py b/src/apps/form_2/serializers.py
new file mode 100644
index 0000000..9173681
--- /dev/null
+++ b/src/apps/form_2/serializers.py
@@ -0,0 +1,91 @@
+"""
+Сериализаторы формы Ф-2.
+
+Содержит:
+- FormF2RecordSerializer - сериализатор записи
+- FormF2UploadSerializer - сериализатор загрузки файла
+- FormF2ParseResultSerializer - результат парсинга
+"""
+
+from apps.form_2.models import FormF2Record
+from apps.organization.serializers import OrganizationSerializer
+from rest_framework import serializers
+
+
+class FormF2RecordSerializer(serializers.ModelSerializer):
+ """Сериализатор записи формы Ф-2."""
+
+ organization = OrganizationSerializer(read_only=True)
+
+ class Meta:
+ model = FormF2Record
+ fields = "__all__"
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class FormF2RecordListSerializer(serializers.ModelSerializer):
+ """Сериализатор списка записей формы Ф-2 (сокращённый)."""
+
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF2Record
+ fields = [
+ "id",
+ "organization_name",
+ "organization_inn",
+ "load_batch",
+ "total_assets",
+ "total_liabilities",
+ "revenue",
+ "net_profit",
+ "created_at",
+ ]
+
+
+class FormF2UploadSerializer(serializers.Serializer):
+ """Сериализатор загрузки файла формы Ф-2."""
+
+ file = serializers.FileField(
+ help_text="Excel файл формы Ф-2 (.xlsx)"
+ )
+
+ def validate_file(self, value):
+ """Валидация загруженного файла."""
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError(
+ "Неподдерживаемый формат файла. Используйте .xlsx или .xls"
+ )
+ # Ограничение размера: 50MB
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError(
+ "Размер файла превышает 50MB"
+ )
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки поля."""
+
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки валидации строки."""
+
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class FormF2ParseResultSerializer(serializers.Serializer):
+ """Сериализатор результата парсинга."""
+
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
diff --git a/src/apps/form_2/services.py b/src/apps/form_2/services.py
new file mode 100644
index 0000000..0e7f975
--- /dev/null
+++ b/src/apps/form_2/services.py
@@ -0,0 +1,188 @@
+"""
+Сервисы для работы с формой Ф-2.
+
+Содержит:
+- FormF2Service - CRUD операции
+- FormF2Parser - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_2.models import FormF2Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Count, Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF2Service(BulkOperationsMixin, BaseService[FormF2Record]):
+ """
+ Сервис для работы с записями формы Ф-2.
+
+ Методы:
+ get_by_batch: Получить записи по номеру загрузки
+ get_next_batch_id: Получить следующий номер загрузки
+ get_batches: Получить список загрузок
+ """
+
+ model = FormF2Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ """Получить записи по номеру загрузки."""
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ """Получить следующий номер загрузки."""
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ """Получить список загрузок с количеством записей."""
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(
+ count=Count("id"),
+ created_at=Max("created_at"),
+ )
+ .order_by("-load_batch")
+ )
+
+
+class FormF2Parser(BaseExcelParser[FormF2Record]):
+ """
+ Парсер Excel файла формы Ф-2 (Бухгалтерский баланс).
+
+ Колонки:
+ 0: Наименование организации
+ 1: ОКПО
+ 2: ОГРН
+ 3: ИНН
+ 4+: Данные баланса
+ """
+
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ """Маппинг колонок Excel на поля модели."""
+ return [
+ # I. Внеоборотные активы
+ ColumnMapping(4, "Нематериальные активы", "intangible_assets", field_type="decimal"),
+ ColumnMapping(5, "Результаты исследований и разработок", "rd_results", field_type="decimal"),
+ ColumnMapping(6, "Нематериальные поисковые активы", "intangible_search_assets", field_type="decimal"),
+ ColumnMapping(7, "Материальные поисковые активы", "tangible_search_assets", field_type="decimal"),
+ ColumnMapping(8, "Основные средства", "fixed_assets", field_type="decimal"),
+ ColumnMapping(9, "Доходные вложения в материальные ценности", "profitable_investments", field_type="decimal"),
+ ColumnMapping(10, "Финансовые вложения (внеоборотные)", "financial_investments_non_current", field_type="decimal"),
+ ColumnMapping(11, "Отложенные налоговые активы", "deferred_tax_assets", field_type="decimal"),
+ ColumnMapping(12, "Прочие внеоборотные активы", "other_non_current_assets", field_type="decimal"),
+ ColumnMapping(13, "Итого внеоборотные активы", "total_non_current_assets", field_type="decimal"),
+ # II. Оборотные активы
+ ColumnMapping(14, "Запасы", "inventories", field_type="decimal"),
+ ColumnMapping(15, "НДС по приобретённым ценностям", "vat_on_acquired_assets", field_type="decimal"),
+ ColumnMapping(16, "Дебиторская задолженность", "receivables", field_type="decimal"),
+ ColumnMapping(17, "Финансовые вложения (оборотные)", "financial_investments_current", field_type="decimal"),
+ ColumnMapping(18, "Денежные средства и эквиваленты", "cash_and_equivalents", field_type="decimal"),
+ ColumnMapping(19, "Прочие оборотные активы", "other_current_assets", field_type="decimal"),
+ ColumnMapping(20, "Итого оборотные активы", "total_current_assets", field_type="decimal"),
+ ColumnMapping(21, "Баланс (актив)", "total_assets", field_type="decimal"),
+ # III. Капитал и резервы
+ ColumnMapping(22, "Уставный капитал", "authorized_capital", field_type="decimal"),
+ ColumnMapping(23, "Собственные акции, выкупленные у акционеров", "own_shares_bought_back", field_type="decimal"),
+ ColumnMapping(24, "Переоценка внеоборотных активов", "revaluation_of_non_current_assets", field_type="decimal"),
+ ColumnMapping(25, "Добавочный капитал", "additional_capital", field_type="decimal"),
+ ColumnMapping(26, "Резервный капитал", "reserve_capital", field_type="decimal"),
+ ColumnMapping(27, "Нераспределённая прибыль", "retained_earnings", field_type="decimal"),
+ ColumnMapping(28, "Итого капитал и резервы", "total_equity", field_type="decimal"),
+ # IV. Долгосрочные обязательства
+ ColumnMapping(29, "Заёмные средства (долгосрочные)", "borrowings_non_current", field_type="decimal"),
+ ColumnMapping(30, "Отложенные налоговые обязательства", "deferred_tax_liabilities", field_type="decimal"),
+ ColumnMapping(31, "Оценочные обязательства (долгосрочные)", "estimated_liabilities_non_current", field_type="decimal"),
+ ColumnMapping(32, "Прочие обязательства (долгосрочные)", "other_liabilities_non_current", field_type="decimal"),
+ ColumnMapping(33, "Итого долгосрочные обязательства", "total_non_current_liabilities", field_type="decimal"),
+ # V. Краткосрочные обязательства
+ ColumnMapping(34, "Заёмные средства (краткосрочные)", "borrowings_current", field_type="decimal"),
+ ColumnMapping(35, "Кредиторская задолженность", "payables", field_type="decimal"),
+ ColumnMapping(36, "Доходы будущих периодов", "deferred_income", field_type="decimal"),
+ ColumnMapping(37, "Оценочные обязательства (краткосрочные)", "estimated_liabilities_current", field_type="decimal"),
+ ColumnMapping(38, "Прочие обязательства (краткосрочные)", "other_liabilities_current", field_type="decimal"),
+ ColumnMapping(39, "Итого краткосрочные обязательства", "total_current_liabilities", field_type="decimal"),
+ ColumnMapping(40, "Баланс (пассив)", "total_liabilities", field_type="decimal"),
+ # Отчёт о финансовых результатах
+ ColumnMapping(41, "Выручка", "revenue", field_type="decimal"),
+ ColumnMapping(42, "Себестоимость продаж", "cost_of_sales", field_type="decimal"),
+ ColumnMapping(43, "Валовая прибыль", "gross_profit", field_type="decimal"),
+ ColumnMapping(44, "Коммерческие расходы", "selling_expenses", field_type="decimal"),
+ ColumnMapping(45, "Управленческие расходы", "administrative_expenses", field_type="decimal"),
+ ColumnMapping(46, "Прибыль от продаж", "profit_from_sales", field_type="decimal"),
+ ColumnMapping(47, "Проценты к получению", "interest_receivable", field_type="decimal"),
+ ColumnMapping(48, "Проценты к уплате", "interest_payable", field_type="decimal"),
+ ColumnMapping(49, "Прочие доходы", "other_income", field_type="decimal"),
+ ColumnMapping(50, "Прочие расходы", "other_expenses", field_type="decimal"),
+ ColumnMapping(51, "Прибыль до налогообложения", "profit_before_tax", field_type="decimal"),
+ ColumnMapping(52, "Текущий налог на прибыль", "income_tax", field_type="decimal"),
+ ColumnMapping(53, "Чистая прибыль", "net_profit", field_type="decimal"),
+ # Дополнительные показатели
+ ColumnMapping(54, "EBITDA", "ebitda", field_type="decimal"),
+ ColumnMapping(55, "Амортизация", "depreciation", field_type="decimal"),
+ ColumnMapping(56, "Оборотный капитал", "working_capital", field_type="decimal"),
+ ColumnMapping(57, "Чистый долг", "net_debt", field_type="decimal"),
+ # Прошлый период
+ ColumnMapping(58, "Баланс (актив) - прошлый период", "total_assets_prev", field_type="decimal"),
+ ColumnMapping(59, "Баланс (пассив) - прошлый период", "total_liabilities_prev", field_type="decimal"),
+ ColumnMapping(60, "Выручка - прошлый период", "revenue_prev", field_type="decimal"),
+ ColumnMapping(61, "Чистая прибыль - прошлый период", "net_profit_prev", field_type="decimal"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ """Получить следующий номер загрузки."""
+ return FormF2Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF2Record:
+ """Создать запись формы Ф-2."""
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+
+ record = FormF2Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+ return record
+
+
+def parse_form_f2_file(file) -> ParseResult:
+ """
+ Парсит Excel файл формы Ф-2.
+
+ Args:
+ file: Загруженный файл
+
+ Returns:
+ ParseResult с результатами парсинга
+ """
+ parser = FormF2Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_2/tasks.py b/src/apps/form_2/tasks.py
new file mode 100644
index 0000000..eb0f084
--- /dev/null
+++ b/src/apps/form_2/tasks.py
@@ -0,0 +1,46 @@
+"""
+Celery задачи для формы Ф-2.
+
+Содержит:
+- process_form_f2_file - фоновая обработка файла
+"""
+
+import logging
+
+from apps.core.tasks import TrackedTask
+from apps.form_2.services import FormF2Parser
+from celery import shared_task
+from django.core.files.uploadedfile import InMemoryUploadedFile
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f2_file(self, file_content: bytes, file_name: str) -> dict:
+ """
+ Фоновая обработка файла формы Ф-2.
+
+ Args:
+ file_content: Содержимое файла в байтах
+ file_name: Имя файла
+
+ Returns:
+ Результат парсинга в виде словаря
+ """
+ from io import BytesIO
+
+ logger.info(f"Начало обработки файла Ф-2: {file_name}")
+
+ # Создаём файловый объект из байтов
+ file_io = BytesIO(file_content)
+
+ # Парсим файл
+ parser = FormF2Parser()
+ result = parser.parse(file_io)
+
+ logger.info(
+ f"Обработка Ф-2 завершена: загружено {result.loaded_count}, "
+ f"пропущено {result.skipped_count}"
+ )
+
+ return result.to_dict()
diff --git a/src/apps/form_2/urls.py b/src/apps/form_2/urls.py
new file mode 100644
index 0000000..14f1d3e
--- /dev/null
+++ b/src/apps/form_2/urls.py
@@ -0,0 +1,15 @@
+"""
+URL маршруты формы Ф-2.
+"""
+
+from apps.form_2.api import FormF2RecordViewSet, FormF2UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF2RecordViewSet, basename="form-f2-records")
+
+urlpatterns = [
+ path("upload/", FormF2UploadView.as_view(), name="form-f2-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_3/__init__.py b/src/apps/form_3/__init__.py
new file mode 100644
index 0000000..226b44f
--- /dev/null
+++ b/src/apps/form_3/__init__.py
@@ -0,0 +1 @@
+"""Приложение формы Ф-3 (Кадры и оборудование)."""
diff --git a/src/apps/form_3/admin.py b/src/apps/form_3/admin.py
new file mode 100644
index 0000000..aae8c7d
--- /dev/null
+++ b/src/apps/form_3/admin.py
@@ -0,0 +1,48 @@
+"""Админка формы Ф-3."""
+
+from apps.form_3.models import FormF3Record
+from django.contrib import admin
+
+
+@admin.register(FormF3Record)
+class FormF3RecordAdmin(admin.ModelAdmin):
+ """Админка записей формы Ф-3."""
+
+ list_display = [
+ "organization",
+ "load_batch",
+ "avg_employees",
+ "total_equipment",
+ "physical_wear_percent",
+ "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = ["organization__name", "organization__inn"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ ("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
+ ("Кадры", {
+ "fields": [
+ "avg_employees", "production_workers",
+ "engineering_workers", "administrative_workers",
+ ],
+ }),
+ ("Оборудование - общие данные", {
+ "fields": ["total_equipment", "domestic_equipment", "imported_equipment"],
+ }),
+ ("Оборудование по возрасту", {
+ "fields": [
+ "equipment_age_under_5", "equipment_age_5_10", "equipment_age_10_15",
+ "equipment_age_15_20", "equipment_age_over_20",
+ ],
+ "classes": ["collapse"],
+ }),
+ ("Износ и использование", {
+ "fields": ["physical_wear_percent", "utilization_rate", "avg_shift_work"],
+ }),
+ ("Потребности", {"fields": ["equipment_needed", "workers_needed"]}),
+ ("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
+ ]
diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py
new file mode 100644
index 0000000..fd2ff54
--- /dev/null
+++ b/src/apps/form_3/api.py
@@ -0,0 +1,106 @@
+"""
+API формы Ф-3.
+
+Содержит:
+- FormF3UploadView - загрузка файла
+- FormF3RecordViewSet - CRUD записей
+"""
+
+import logging
+
+from apps.core.viewsets import BaseViewSet
+from apps.form_3.models import FormF3Record
+from apps.form_3.serializers import (
+ FormF3ParseResultSerializer,
+ FormF3RecordListSerializer,
+ FormF3RecordSerializer,
+ FormF3UploadSerializer,
+)
+from apps.form_3.services import FormF3Service, parse_form_f3_file
+from apps.form_3.tasks import process_form_f3_file
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+
+BACKGROUND_THRESHOLD = 1024 * 1024 # 1MB
+
+
+class FormF3UploadView(APIView):
+ """
+ Загрузка файла формы Ф-3.
+
+ POST /api/v1/forms/f3/upload/
+ """
+
+ parser_classes = [MultiPartParser]
+
+ def post(self, request):
+ """Загрузка и обработка файла."""
+ serializer = FormF3UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ file = serializer.validated_data["file"]
+
+ if file.size > BACKGROUND_THRESHOLD:
+ file_content = file.read()
+ task = process_form_f3_file.delay(file_content, file.name)
+
+ return Response(
+ {
+ "success": True,
+ "message": "Файл поставлен в очередь на обработку",
+ "task_id": task.id,
+ },
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+ try:
+ result = parse_form_f3_file(file)
+ result_serializer = FormF3ParseResultSerializer(result)
+
+ return Response(
+ {
+ "success": True,
+ "data": result_serializer.data,
+ },
+ status=status.HTTP_200_OK,
+ )
+ except Exception as e:
+ logger.exception("Ошибка обработки файла Ф-3")
+ return Response(
+ {
+ "success": False,
+ "error": str(e),
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class FormF3RecordViewSet(BaseViewSet):
+ """
+ ViewSet для записей формы Ф-3.
+
+ GET /api/v1/forms/f3/records/ - список записей
+ GET /api/v1/forms/f3/records/{id}/ - детали записи
+ """
+
+ queryset = FormF3Record.objects.select_related("organization").all()
+ serializer_class = FormF3RecordSerializer
+ service_class = FormF3Service
+
+ def get_serializer_class(self):
+ """Выбор сериализатора в зависимости от действия."""
+ if self.action == "list":
+ return FormF3RecordListSerializer
+ return FormF3RecordSerializer
+
+ def get_queryset(self):
+ """Фильтрация по batch_id."""
+ queryset = super().get_queryset()
+ batch_id = self.request.query_params.get("batch_id")
+ if batch_id:
+ queryset = queryset.filter(load_batch=batch_id)
+ return queryset
diff --git a/src/apps/form_3/apps.py b/src/apps/form_3/apps.py
new file mode 100644
index 0000000..c1aeccb
--- /dev/null
+++ b/src/apps/form_3/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_3."""
+
+from django.apps import AppConfig
+
+
+class Form3Config(AppConfig):
+ """Конфигурация приложения формы Ф-3."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_3"
+ verbose_name = "Форма Ф-3 (Кадры и оборудование)"
diff --git a/src/apps/form_3/migrations/0001_initial.py b/src/apps/form_3/migrations/0001_initial.py
new file mode 100644
index 0000000..5937593
--- /dev/null
+++ b/src/apps/form_3/migrations/0001_initial.py
@@ -0,0 +1,57 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF3Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('avg_employees', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='средняя численность работников')),
+ ('production_workers', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='производственный персонал')),
+ ('engineering_workers', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='инженерно-технические работники')),
+ ('administrative_workers', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='административный персонал')),
+ ('total_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='всего оборудования')),
+ ('domestic_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='отечественное оборудование')),
+ ('imported_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование')),
+ ('equipment_age_under_5', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование до 5 лет')),
+ ('equipment_age_5_10', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование 5-10 лет')),
+ ('equipment_age_10_15', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование 10-15 лет')),
+ ('equipment_age_15_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование 15-20 лет')),
+ ('equipment_age_over_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование свыше 20 лет')),
+ ('physical_wear_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='физический износ, %')),
+ ('utilization_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='коэффициент загрузки')),
+ ('avg_shift_work', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средняя сменность работы')),
+ ('equipment_needed', models.PositiveIntegerField(blank=True, null=True, verbose_name='потребность в оборудовании')),
+ ('workers_needed', models.PositiveIntegerField(blank=True, null=True, verbose_name='потребность в кадрах')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f3_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-3',
+ 'verbose_name_plural': 'записи Ф-3',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf3record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_3_form_organiz_80cdae_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf3record',
+ index=models.Index(fields=['load_batch'], name='form_3_form_load_ba_421300_idx'),
+ ),
+ ]
diff --git a/src/apps/form_3/migrations/__init__.py b/src/apps/form_3/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_3/models.py b/src/apps/form_3/models.py
new file mode 100644
index 0000000..0ad3d4d
--- /dev/null
+++ b/src/apps/form_3/models.py
@@ -0,0 +1,129 @@
+"""
+Модели формы Ф-3 (Кадры и оборудование).
+
+Содержит:
+- FormF3Record - запись формы Ф-3
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF3Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-3 (Кадры и оборудование).
+
+ Данные о численности персонала и состоянии оборудования.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f3_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === Кадры ===
+ avg_employees = models.DecimalField(
+ _("средняя численность работников"),
+ max_digits=12, decimal_places=2, null=True, blank=True,
+ )
+ production_workers = models.DecimalField(
+ _("производственный персонал"),
+ max_digits=12, decimal_places=2, null=True, blank=True,
+ )
+ engineering_workers = models.DecimalField(
+ _("инженерно-технические работники"),
+ max_digits=12, decimal_places=2, null=True, blank=True,
+ )
+ administrative_workers = models.DecimalField(
+ _("административный персонал"),
+ max_digits=12, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Оборудование - общие данные ===
+ total_equipment = models.PositiveIntegerField(
+ _("всего оборудования"),
+ null=True, blank=True,
+ )
+ domestic_equipment = models.PositiveIntegerField(
+ _("отечественное оборудование"),
+ null=True, blank=True,
+ )
+ imported_equipment = models.PositiveIntegerField(
+ _("импортное оборудование"),
+ null=True, blank=True,
+ )
+
+ # === Оборудование по возрасту ===
+ equipment_age_under_5 = models.PositiveIntegerField(
+ _("оборудование до 5 лет"),
+ null=True, blank=True,
+ )
+ equipment_age_5_10 = models.PositiveIntegerField(
+ _("оборудование 5-10 лет"),
+ null=True, blank=True,
+ )
+ equipment_age_10_15 = models.PositiveIntegerField(
+ _("оборудование 10-15 лет"),
+ null=True, blank=True,
+ )
+ equipment_age_15_20 = models.PositiveIntegerField(
+ _("оборудование 15-20 лет"),
+ null=True, blank=True,
+ )
+ equipment_age_over_20 = models.PositiveIntegerField(
+ _("оборудование свыше 20 лет"),
+ null=True, blank=True,
+ )
+
+ # === Износ и использование ===
+ physical_wear_percent = models.DecimalField(
+ _("физический износ, %"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ utilization_rate = models.DecimalField(
+ _("коэффициент загрузки"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ avg_shift_work = models.DecimalField(
+ _("средняя сменность работы"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Потребности ===
+ equipment_needed = models.PositiveIntegerField(
+ _("потребность в оборудовании"),
+ null=True, blank=True,
+ )
+ workers_needed = models.PositiveIntegerField(
+ _("потребность в кадрах"),
+ null=True, blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-3")
+ verbose_name_plural = _("записи Ф-3")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-3: {self.organization.name} (batch: {self.load_batch})"
diff --git a/src/apps/form_3/serializers.py b/src/apps/form_3/serializers.py
new file mode 100644
index 0000000..33b6273
--- /dev/null
+++ b/src/apps/form_3/serializers.py
@@ -0,0 +1,89 @@
+"""
+Сериализаторы формы Ф-3.
+
+Содержит:
+- FormF3RecordSerializer - сериализатор записи
+- FormF3UploadSerializer - сериализатор загрузки файла
+- FormF3ParseResultSerializer - результат парсинга
+"""
+
+from apps.form_3.models import FormF3Record
+from apps.organization.serializers import OrganizationSerializer
+from rest_framework import serializers
+
+
+class FormF3RecordSerializer(serializers.ModelSerializer):
+ """Сериализатор записи формы Ф-3."""
+
+ organization = OrganizationSerializer(read_only=True)
+
+ class Meta:
+ model = FormF3Record
+ fields = "__all__"
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class FormF3RecordListSerializer(serializers.ModelSerializer):
+ """Сериализатор списка записей формы Ф-3 (сокращённый)."""
+
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF3Record
+ fields = [
+ "id",
+ "organization_name",
+ "organization_inn",
+ "load_batch",
+ "avg_employees",
+ "total_equipment",
+ "physical_wear_percent",
+ "created_at",
+ ]
+
+
+class FormF3UploadSerializer(serializers.Serializer):
+ """Сериализатор загрузки файла формы Ф-3."""
+
+ file = serializers.FileField(
+ help_text="Excel файл формы Ф-3 (.xlsx)"
+ )
+
+ def validate_file(self, value):
+ """Валидация загруженного файла."""
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError(
+ "Неподдерживаемый формат файла. Используйте .xlsx или .xls"
+ )
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError(
+ "Размер файла превышает 50MB"
+ )
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки поля."""
+
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ """Сериализатор ошибки валидации строки."""
+
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class FormF3ParseResultSerializer(serializers.Serializer):
+ """Сериализатор результата парсинга."""
+
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
diff --git a/src/apps/form_3/services.py b/src/apps/form_3/services.py
new file mode 100644
index 0000000..8f04658
--- /dev/null
+++ b/src/apps/form_3/services.py
@@ -0,0 +1,144 @@
+"""
+Сервисы для работы с формой Ф-3.
+
+Содержит:
+- FormF3Service - CRUD операции
+- FormF3Parser - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_3.models import FormF3Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Count, Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF3Service(BulkOperationsMixin, BaseService[FormF3Record]):
+ """
+ Сервис для работы с записями формы Ф-3.
+
+ Методы:
+ get_by_batch: Получить записи по номеру загрузки
+ get_next_batch_id: Получить следующий номер загрузки
+ get_batches: Получить список загрузок
+ """
+
+ model = FormF3Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ """Получить записи по номеру загрузки."""
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ """Получить следующий номер загрузки."""
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ """Получить список загрузок с количеством записей."""
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(
+ count=Count("id"),
+ created_at=Max("created_at"),
+ )
+ .order_by("-load_batch")
+ )
+
+
+class FormF3Parser(BaseExcelParser[FormF3Record]):
+ """
+ Парсер Excel файла формы Ф-3 (Кадры и оборудование).
+
+ Колонки:
+ 0: Наименование организации
+ 1: ОКПО
+ 2: ОГРН
+ 3: ИНН
+ 4+: Данные о кадрах и оборудовании
+ """
+
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ """Маппинг колонок Excel на поля модели."""
+ return [
+ # Кадры
+ ColumnMapping(4, "Средняя численность работников", "avg_employees", field_type="decimal"),
+ ColumnMapping(5, "Производственный персонал", "production_workers", field_type="decimal"),
+ ColumnMapping(6, "Инженерно-технические работники", "engineering_workers", field_type="decimal"),
+ ColumnMapping(7, "Административный персонал", "administrative_workers", field_type="decimal"),
+ # Оборудование - общие данные
+ ColumnMapping(8, "Всего оборудования", "total_equipment", field_type="int"),
+ ColumnMapping(9, "Отечественное оборудование", "domestic_equipment", field_type="int"),
+ ColumnMapping(10, "Импортное оборудование", "imported_equipment", field_type="int"),
+ # Оборудование по возрасту
+ ColumnMapping(11, "Оборудование до 5 лет", "equipment_age_under_5", field_type="int"),
+ ColumnMapping(12, "Оборудование 5-10 лет", "equipment_age_5_10", field_type="int"),
+ ColumnMapping(13, "Оборудование 10-15 лет", "equipment_age_10_15", field_type="int"),
+ ColumnMapping(14, "Оборудование 15-20 лет", "equipment_age_15_20", field_type="int"),
+ ColumnMapping(15, "Оборудование свыше 20 лет", "equipment_age_over_20", field_type="int"),
+ # Износ и использование
+ ColumnMapping(16, "Физический износ, %", "physical_wear_percent", field_type="decimal"),
+ ColumnMapping(17, "Коэффициент загрузки", "utilization_rate", field_type="decimal"),
+ ColumnMapping(18, "Средняя сменность работы", "avg_shift_work", field_type="decimal"),
+ # Потребности
+ ColumnMapping(19, "Потребность в оборудовании", "equipment_needed", field_type="int"),
+ ColumnMapping(20, "Потребность в кадрах", "workers_needed", field_type="int"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ """Получить следующий номер загрузки."""
+ return FormF3Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF3Record:
+ """Создать запись формы Ф-3."""
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+
+ record = FormF3Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+ return record
+
+
+def parse_form_f3_file(file) -> ParseResult:
+ """
+ Парсит Excel файл формы Ф-3.
+
+ Args:
+ file: Загруженный файл
+
+ Returns:
+ ParseResult с результатами парсинга
+ """
+ parser = FormF3Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_3/tasks.py b/src/apps/form_3/tasks.py
new file mode 100644
index 0000000..dac0220
--- /dev/null
+++ b/src/apps/form_3/tasks.py
@@ -0,0 +1,42 @@
+"""
+Celery задачи для формы Ф-3.
+
+Содержит:
+- process_form_f3_file - фоновая обработка файла
+"""
+
+import logging
+
+from apps.core.tasks import TrackedTask
+from apps.form_3.services import FormF3Parser
+from celery import shared_task
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f3_file(self, file_content: bytes, file_name: str) -> dict:
+ """
+ Фоновая обработка файла формы Ф-3.
+
+ Args:
+ file_content: Содержимое файла в байтах
+ file_name: Имя файла
+
+ Returns:
+ Результат парсинга в виде словаря
+ """
+ from io import BytesIO
+
+ logger.info(f"Начало обработки файла Ф-3: {file_name}")
+
+ file_io = BytesIO(file_content)
+ parser = FormF3Parser()
+ result = parser.parse(file_io)
+
+ logger.info(
+ f"Обработка Ф-3 завершена: загружено {result.loaded_count}, "
+ f"пропущено {result.skipped_count}"
+ )
+
+ return result.to_dict()
diff --git a/src/apps/form_3/urls.py b/src/apps/form_3/urls.py
new file mode 100644
index 0000000..8c25423
--- /dev/null
+++ b/src/apps/form_3/urls.py
@@ -0,0 +1,15 @@
+"""
+URL маршруты формы Ф-3.
+"""
+
+from apps.form_3.api import FormF3RecordViewSet, FormF3UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF3RecordViewSet, basename="form-f3-records")
+
+urlpatterns = [
+ path("upload/", FormF3UploadView.as_view(), name="form-f3-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_4/__init__.py b/src/apps/form_4/__init__.py
new file mode 100644
index 0000000..19bd468
--- /dev/null
+++ b/src/apps/form_4/__init__.py
@@ -0,0 +1 @@
+"""Приложение формы Ф-4 (Сводные финансовые данные)."""
diff --git a/src/apps/form_4/admin.py b/src/apps/form_4/admin.py
new file mode 100644
index 0000000..a214050
--- /dev/null
+++ b/src/apps/form_4/admin.py
@@ -0,0 +1,45 @@
+"""Админка формы Ф-4."""
+
+from apps.form_4.models import FormF4Record
+from django.contrib import admin
+
+
+@admin.register(FormF4Record)
+class FormF4RecordAdmin(admin.ModelAdmin):
+ """Админка записей формы Ф-4."""
+
+ list_display = [
+ "organization", "load_batch", "revenue_rsbu",
+ "net_profit_rsbu", "ebitda_rsbu", "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = ["organization__name", "organization__inn"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ ("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
+ ("Выручка", {
+ "fields": ["revenue_rsbu", "revenue_ifrs", "revenue_prev_rsbu", "revenue_prev_ifrs"],
+ }),
+ ("Прибыль", {
+ "fields": ["net_profit_rsbu", "net_profit_ifrs", "gross_profit_rsbu", "operating_profit_rsbu"],
+ }),
+ ("EBITDA", {"fields": ["ebitda_rsbu", "ebitda_ifrs"]}),
+ ("Долговая нагрузка", {
+ "fields": [
+ "loans_rsbu", "loans_ifrs", "net_debt_rsbu",
+ "net_debt_ifrs", "debt_to_ebitda",
+ ],
+ "classes": ["collapse"],
+ }),
+ ("Активы и капитал", {
+ "fields": ["total_assets_rsbu", "total_assets_ifrs", "equity_rsbu", "equity_ifrs"],
+ "classes": ["collapse"],
+ }),
+ ("Рентабельность", {"fields": ["roe", "roa", "ros"], "classes": ["collapse"]}),
+ ("Инвестиции", {"fields": ["capex", "rd_expenses"], "classes": ["collapse"]}),
+ ("Дивиденды", {"fields": ["dividends_paid", "dividend_yield"], "classes": ["collapse"]}),
+ ("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
+ ]
diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py
new file mode 100644
index 0000000..acd9168
--- /dev/null
+++ b/src/apps/form_4/api.py
@@ -0,0 +1,58 @@
+"""API формы Ф-4."""
+
+import logging
+
+from apps.core.viewsets import BaseViewSet
+from apps.form_4.models import FormF4Record
+from apps.form_4.serializers import (
+ FormF4ParseResultSerializer,
+ FormF4RecordListSerializer,
+ FormF4RecordSerializer,
+ FormF4UploadSerializer,
+)
+from apps.form_4.services import FormF4Service, parse_form_f4_file
+from apps.form_4.tasks import process_form_f4_file
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+BACKGROUND_THRESHOLD = 1024 * 1024
+
+
+class FormF4UploadView(APIView):
+ parser_classes = [MultiPartParser]
+
+ def post(self, request):
+ serializer = FormF4UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ file = serializer.validated_data["file"]
+
+ if file.size > BACKGROUND_THRESHOLD:
+ task = process_form_f4_file.delay(file.read(), file.name)
+ return Response(
+ {"success": True, "message": "Файл поставлен в очередь", "task_id": task.id},
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+ try:
+ result = parse_form_f4_file(file)
+ return Response({"success": True, "data": FormF4ParseResultSerializer(result).data})
+ except Exception as e:
+ logger.exception("Ошибка обработки файла Ф-4")
+ return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class FormF4RecordViewSet(BaseViewSet):
+ queryset = FormF4Record.objects.select_related("organization").all()
+ serializer_class = FormF4RecordSerializer
+ service_class = FormF4Service
+
+ def get_serializer_class(self):
+ return FormF4RecordListSerializer if self.action == "list" else FormF4RecordSerializer
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ batch_id = self.request.query_params.get("batch_id")
+ return qs.filter(load_batch=batch_id) if batch_id else qs
diff --git a/src/apps/form_4/apps.py b/src/apps/form_4/apps.py
new file mode 100644
index 0000000..f230cd4
--- /dev/null
+++ b/src/apps/form_4/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_4."""
+
+from django.apps import AppConfig
+
+
+class Form4Config(AppConfig):
+ """Конфигурация приложения формы Ф-4."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_4"
+ verbose_name = "Форма Ф-4 (Сводные финансовые данные)"
diff --git a/src/apps/form_4/migrations/0001_initial.py b/src/apps/form_4/migrations/0001_initial.py
new file mode 100644
index 0000000..8acd134
--- /dev/null
+++ b/src/apps/form_4/migrations/0001_initial.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF4Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('revenue_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка (РСБУ)')),
+ ('revenue_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка (МСФО)')),
+ ('revenue_prev_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка прошлого года (РСБУ)')),
+ ('revenue_prev_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выручка прошлого года (МСФО)')),
+ ('net_profit_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистая прибыль (РСБУ)')),
+ ('net_profit_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистая прибыль (МСФО)')),
+ ('gross_profit_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='валовая прибыль (РСБУ)')),
+ ('operating_profit_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='операционная прибыль (РСБУ)')),
+ ('ebitda_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='EBITDA (РСБУ)')),
+ ('ebitda_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='EBITDA (МСФО)')),
+ ('loans_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='кредиты и займы (РСБУ)')),
+ ('loans_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='кредиты и займы (МСФО)')),
+ ('net_debt_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистый долг (РСБУ)')),
+ ('net_debt_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='чистый долг (МСФО)')),
+ ('debt_to_ebitda', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='долг/EBITDA')),
+ ('total_assets_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='активы (РСБУ)')),
+ ('total_assets_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='активы (МСФО)')),
+ ('equity_rsbu', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='собственный капитал (РСБУ)')),
+ ('equity_ifrs', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='собственный капитал (МСФО)')),
+ ('roe', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='рентабельность собственного капитала (ROE)')),
+ ('roa', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='рентабельность активов (ROA)')),
+ ('ros', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='рентабельность продаж (ROS)')),
+ ('capex', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='капитальные затраты (CAPEX)')),
+ ('rd_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='затраты на НИОКР')),
+ ('dividends_paid', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='выплаченные дивиденды')),
+ ('dividend_yield', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='дивидендная доходность')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f4_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-4',
+ 'verbose_name_plural': 'записи Ф-4',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf4record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_4_form_organiz_387e01_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf4record',
+ index=models.Index(fields=['load_batch'], name='form_4_form_load_ba_3e4efc_idx'),
+ ),
+ ]
diff --git a/src/apps/form_4/migrations/__init__.py b/src/apps/form_4/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_4/models.py b/src/apps/form_4/models.py
new file mode 100644
index 0000000..5d12be6
--- /dev/null
+++ b/src/apps/form_4/models.py
@@ -0,0 +1,171 @@
+"""
+Модели формы Ф-4 (Сводные финансовые данные).
+
+Содержит:
+- FormF4Record - запись формы Ф-4
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF4Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-4 (Сводные финансовые данные).
+
+ Финансовые показатели по РСБУ и МСФО.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f4_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === Выручка ===
+ revenue_rsbu = models.DecimalField(
+ _("выручка (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ revenue_ifrs = models.DecimalField(
+ _("выручка (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ revenue_prev_rsbu = models.DecimalField(
+ _("выручка прошлого года (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ revenue_prev_ifrs = models.DecimalField(
+ _("выручка прошлого года (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Прибыль ===
+ net_profit_rsbu = models.DecimalField(
+ _("чистая прибыль (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_profit_ifrs = models.DecimalField(
+ _("чистая прибыль (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ gross_profit_rsbu = models.DecimalField(
+ _("валовая прибыль (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ operating_profit_rsbu = models.DecimalField(
+ _("операционная прибыль (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === EBITDA ===
+ ebitda_rsbu = models.DecimalField(
+ _("EBITDA (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ ebitda_ifrs = models.DecimalField(
+ _("EBITDA (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Долговая нагрузка ===
+ loans_rsbu = models.DecimalField(
+ _("кредиты и займы (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ loans_ifrs = models.DecimalField(
+ _("кредиты и займы (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_debt_rsbu = models.DecimalField(
+ _("чистый долг (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ net_debt_ifrs = models.DecimalField(
+ _("чистый долг (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ debt_to_ebitda = models.DecimalField(
+ _("долг/EBITDA"),
+ max_digits=10, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Активы и капитал ===
+ total_assets_rsbu = models.DecimalField(
+ _("активы (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ total_assets_ifrs = models.DecimalField(
+ _("активы (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ equity_rsbu = models.DecimalField(
+ _("собственный капитал (РСБУ)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ equity_ifrs = models.DecimalField(
+ _("собственный капитал (МСФО)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Рентабельность ===
+ roe = models.DecimalField(
+ _("рентабельность собственного капитала (ROE)"),
+ max_digits=10, decimal_places=2, null=True, blank=True,
+ )
+ roa = models.DecimalField(
+ _("рентабельность активов (ROA)"),
+ max_digits=10, decimal_places=2, null=True, blank=True,
+ )
+ ros = models.DecimalField(
+ _("рентабельность продаж (ROS)"),
+ max_digits=10, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Инвестиции ===
+ capex = models.DecimalField(
+ _("капитальные затраты (CAPEX)"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ rd_expenses = models.DecimalField(
+ _("затраты на НИОКР"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Дивиденды ===
+ dividends_paid = models.DecimalField(
+ _("выплаченные дивиденды"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ dividend_yield = models.DecimalField(
+ _("дивидендная доходность"),
+ max_digits=10, decimal_places=2, null=True, blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-4")
+ verbose_name_plural = _("записи Ф-4")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-4: {self.organization.name} (batch: {self.load_batch})"
diff --git a/src/apps/form_4/serializers.py b/src/apps/form_4/serializers.py
new file mode 100644
index 0000000..39fbc1a
--- /dev/null
+++ b/src/apps/form_4/serializers.py
@@ -0,0 +1,57 @@
+"""Сериализаторы формы Ф-4."""
+
+from apps.form_4.models import FormF4Record
+from apps.organization.serializers import OrganizationSerializer
+from rest_framework import serializers
+
+
+class FormF4RecordSerializer(serializers.ModelSerializer):
+ organization = OrganizationSerializer(read_only=True)
+
+ class Meta:
+ model = FormF4Record
+ fields = "__all__"
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class FormF4RecordListSerializer(serializers.ModelSerializer):
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF4Record
+ fields = [
+ "id", "organization_name", "organization_inn", "load_batch",
+ "revenue_rsbu", "net_profit_rsbu", "ebitda_rsbu", "created_at",
+ ]
+
+
+class FormF4UploadSerializer(serializers.Serializer):
+ file = serializers.FileField(help_text="Excel файл формы Ф-4 (.xlsx)")
+
+ def validate_file(self, value):
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError("Неподдерживаемый формат файла")
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError("Размер файла превышает 50MB")
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class FormF4ParseResultSerializer(serializers.Serializer):
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
diff --git a/src/apps/form_4/services.py b/src/apps/form_4/services.py
new file mode 100644
index 0000000..3b82336
--- /dev/null
+++ b/src/apps/form_4/services.py
@@ -0,0 +1,119 @@
+"""
+Сервисы для работы с формой Ф-4.
+
+Содержит:
+- FormF4Service - CRUD операции
+- FormF4Parser - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_4.models import FormF4Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Count, Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF4Service(BulkOperationsMixin, BaseService[FormF4Record]):
+ """Сервис для работы с записями формы Ф-4."""
+
+ model = FormF4Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(count=Count("id"), created_at=Max("created_at"))
+ .order_by("-load_batch")
+ )
+
+
+class FormF4Parser(BaseExcelParser[FormF4Record]):
+ """Парсер Excel файла формы Ф-4 (Сводные финансовые данные)."""
+
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ return [
+ # Выручка
+ ColumnMapping(4, "Выручка (РСБУ)", "revenue_rsbu", field_type="decimal"),
+ ColumnMapping(5, "Выручка (МСФО)", "revenue_ifrs", field_type="decimal"),
+ ColumnMapping(6, "Выручка прошлого года (РСБУ)", "revenue_prev_rsbu", field_type="decimal"),
+ ColumnMapping(7, "Выручка прошлого года (МСФО)", "revenue_prev_ifrs", field_type="decimal"),
+ # Прибыль
+ ColumnMapping(8, "Чистая прибыль (РСБУ)", "net_profit_rsbu", field_type="decimal"),
+ ColumnMapping(9, "Чистая прибыль (МСФО)", "net_profit_ifrs", field_type="decimal"),
+ ColumnMapping(10, "Валовая прибыль (РСБУ)", "gross_profit_rsbu", field_type="decimal"),
+ ColumnMapping(11, "Операционная прибыль (РСБУ)", "operating_profit_rsbu", field_type="decimal"),
+ # EBITDA
+ ColumnMapping(12, "EBITDA (РСБУ)", "ebitda_rsbu", field_type="decimal"),
+ ColumnMapping(13, "EBITDA (МСФО)", "ebitda_ifrs", field_type="decimal"),
+ # Долговая нагрузка
+ ColumnMapping(14, "Кредиты и займы (РСБУ)", "loans_rsbu", field_type="decimal"),
+ ColumnMapping(15, "Кредиты и займы (МСФО)", "loans_ifrs", field_type="decimal"),
+ ColumnMapping(16, "Чистый долг (РСБУ)", "net_debt_rsbu", field_type="decimal"),
+ ColumnMapping(17, "Чистый долг (МСФО)", "net_debt_ifrs", field_type="decimal"),
+ ColumnMapping(18, "Долг/EBITDA", "debt_to_ebitda", field_type="decimal"),
+ # Активы и капитал
+ ColumnMapping(19, "Активы (РСБУ)", "total_assets_rsbu", field_type="decimal"),
+ ColumnMapping(20, "Активы (МСФО)", "total_assets_ifrs", field_type="decimal"),
+ ColumnMapping(21, "Собственный капитал (РСБУ)", "equity_rsbu", field_type="decimal"),
+ ColumnMapping(22, "Собственный капитал (МСФО)", "equity_ifrs", field_type="decimal"),
+ # Рентабельность
+ ColumnMapping(23, "ROE", "roe", field_type="decimal"),
+ ColumnMapping(24, "ROA", "roa", field_type="decimal"),
+ ColumnMapping(25, "ROS", "ros", field_type="decimal"),
+ # Инвестиции
+ ColumnMapping(26, "CAPEX", "capex", field_type="decimal"),
+ ColumnMapping(27, "Затраты на НИОКР", "rd_expenses", field_type="decimal"),
+ # Дивиденды
+ ColumnMapping(28, "Выплаченные дивиденды", "dividends_paid", field_type="decimal"),
+ ColumnMapping(29, "Дивидендная доходность", "dividend_yield", field_type="decimal"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ return FormF4Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF4Record:
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+ return FormF4Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+
+def parse_form_f4_file(file) -> ParseResult:
+ parser = FormF4Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_4/tasks.py b/src/apps/form_4/tasks.py
new file mode 100644
index 0000000..fb334fc
--- /dev/null
+++ b/src/apps/form_4/tasks.py
@@ -0,0 +1,19 @@
+"""Celery задачи для формы Ф-4."""
+
+import logging
+from io import BytesIO
+
+from apps.core.tasks import TrackedTask
+from apps.form_4.services import FormF4Parser
+from celery import shared_task
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f4_file(self, file_content: bytes, file_name: str) -> dict:
+ logger.info(f"Начало обработки файла Ф-4: {file_name}")
+ parser = FormF4Parser()
+ result = parser.parse(BytesIO(file_content))
+ logger.info(f"Обработка Ф-4 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено")
+ return result.to_dict()
diff --git a/src/apps/form_4/urls.py b/src/apps/form_4/urls.py
new file mode 100644
index 0000000..cc769bf
--- /dev/null
+++ b/src/apps/form_4/urls.py
@@ -0,0 +1,13 @@
+"""URL маршруты формы Ф-4."""
+
+from apps.form_4.api import FormF4RecordViewSet, FormF4UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF4RecordViewSet, basename="form-f4-records")
+
+urlpatterns = [
+ path("upload/", FormF4UploadView.as_view(), name="form-f4-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_5/__init__.py b/src/apps/form_5/__init__.py
new file mode 100644
index 0000000..551a8df
--- /dev/null
+++ b/src/apps/form_5/__init__.py
@@ -0,0 +1 @@
+"""Приложение формы Ф-5 (Инвентаризация оборудования)."""
diff --git a/src/apps/form_5/admin.py b/src/apps/form_5/admin.py
new file mode 100644
index 0000000..2a4dc81
--- /dev/null
+++ b/src/apps/form_5/admin.py
@@ -0,0 +1,45 @@
+"""Админка формы Ф-5."""
+
+from apps.form_5.models import FormF5Record
+from django.contrib import admin
+
+
+@admin.register(FormF5Record)
+class FormF5RecordAdmin(admin.ModelAdmin):
+ """Админка записей формы Ф-5."""
+
+ list_display = [
+ "organization", "load_batch", "equipment_id", "name",
+ "year_manufacture", "physical_wear_percent", "created_at",
+ ]
+ list_filter = ["load_batch", "is_domestic", "has_cnc", "is_operational", "created_at"]
+ search_fields = ["organization__name", "organization__inn", "equipment_id", "inventory_number", "name"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ ("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
+ ("Идентификация оборудования", {
+ "fields": ["equipment_id", "inventory_number", "name", "model"],
+ }),
+ ("Производитель", {
+ "fields": ["manufacturer", "country_origin", "is_domestic"],
+ }),
+ ("Технические характеристики", {
+ "fields": ["year_manufacture", "has_cnc", "equipment_type", "equipment_category"],
+ }),
+ ("Эксплуатация", {
+ "fields": ["commissioning_date", "location", "production_site"],
+ "classes": ["collapse"],
+ }),
+ ("Состояние и использование", {
+ "fields": [
+ "utilization_rate", "physical_wear_percent",
+ "is_operational", "requires_repair", "requires_replacement",
+ ],
+ }),
+ ("Стоимость", {"fields": ["initial_cost", "residual_value"], "classes": ["collapse"]}),
+ ("Примечания", {"fields": ["notes"], "classes": ["collapse"]}),
+ ("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
+ ]
diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py
new file mode 100644
index 0000000..fdd462e
--- /dev/null
+++ b/src/apps/form_5/api.py
@@ -0,0 +1,58 @@
+"""API формы Ф-5."""
+
+import logging
+
+from apps.core.viewsets import BaseViewSet
+from apps.form_5.models import FormF5Record
+from apps.form_5.serializers import (
+ FormF5ParseResultSerializer,
+ FormF5RecordListSerializer,
+ FormF5RecordSerializer,
+ FormF5UploadSerializer,
+)
+from apps.form_5.services import FormF5Service, parse_form_f5_file
+from apps.form_5.tasks import process_form_f5_file
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+BACKGROUND_THRESHOLD = 1024 * 1024
+
+
+class FormF5UploadView(APIView):
+ parser_classes = [MultiPartParser]
+
+ def post(self, request):
+ serializer = FormF5UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ file = serializer.validated_data["file"]
+
+ if file.size > BACKGROUND_THRESHOLD:
+ task = process_form_f5_file.delay(file.read(), file.name)
+ return Response(
+ {"success": True, "message": "Файл поставлен в очередь", "task_id": task.id},
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+ try:
+ result = parse_form_f5_file(file)
+ return Response({"success": True, "data": FormF5ParseResultSerializer(result).data})
+ except Exception as e:
+ logger.exception("Ошибка обработки файла Ф-5")
+ return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class FormF5RecordViewSet(BaseViewSet):
+ queryset = FormF5Record.objects.select_related("organization").all()
+ serializer_class = FormF5RecordSerializer
+ service_class = FormF5Service
+
+ def get_serializer_class(self):
+ return FormF5RecordListSerializer if self.action == "list" else FormF5RecordSerializer
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ batch_id = self.request.query_params.get("batch_id")
+ return qs.filter(load_batch=batch_id) if batch_id else qs
diff --git a/src/apps/form_5/apps.py b/src/apps/form_5/apps.py
new file mode 100644
index 0000000..7c2b839
--- /dev/null
+++ b/src/apps/form_5/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_5."""
+
+from django.apps import AppConfig
+
+
+class Form5Config(AppConfig):
+ """Конфигурация приложения формы Ф-5."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_5"
+ verbose_name = "Форма Ф-5 (Инвентаризация оборудования)"
diff --git a/src/apps/form_5/migrations/0001_initial.py b/src/apps/form_5/migrations/0001_initial.py
new file mode 100644
index 0000000..27be49f
--- /dev/null
+++ b/src/apps/form_5/migrations/0001_initial.py
@@ -0,0 +1,70 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF5Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('equipment_id', models.CharField(blank=True, default='', max_length=50, verbose_name='идентификационный код')),
+ ('inventory_number', models.CharField(blank=True, default='', max_length=50, verbose_name='инвентарный номер')),
+ ('name', models.CharField(blank=True, default='', max_length=500, verbose_name='наименование оборудования')),
+ ('model', models.CharField(blank=True, default='', max_length=200, verbose_name='модель')),
+ ('manufacturer', models.CharField(blank=True, default='', max_length=300, verbose_name='производитель')),
+ ('country_origin', models.CharField(blank=True, default='', max_length=100, verbose_name='страна происхождения')),
+ ('is_domestic', models.BooleanField(blank=True, null=True, verbose_name='отечественное производство')),
+ ('year_manufacture', models.PositiveIntegerField(blank=True, null=True, verbose_name='год выпуска')),
+ ('has_cnc', models.BooleanField(blank=True, null=True, verbose_name='наличие ЧПУ')),
+ ('equipment_type', models.CharField(blank=True, default='', max_length=200, verbose_name='тип оборудования')),
+ ('equipment_category', models.CharField(blank=True, default='', max_length=200, verbose_name='категория оборудования')),
+ ('commissioning_date', models.DateField(blank=True, null=True, verbose_name='дата ввода в эксплуатацию')),
+ ('location', models.CharField(blank=True, default='', max_length=300, verbose_name='местонахождение')),
+ ('production_site', models.CharField(blank=True, default='', max_length=200, verbose_name='производственный участок')),
+ ('utilization_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='коэффициент использования')),
+ ('physical_wear_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='фактический износ, %')),
+ ('is_operational', models.BooleanField(blank=True, null=True, verbose_name='в рабочем состоянии')),
+ ('requires_repair', models.BooleanField(blank=True, null=True, verbose_name='требует ремонта')),
+ ('requires_replacement', models.BooleanField(blank=True, null=True, verbose_name='требует замены')),
+ ('initial_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='первоначальная стоимость')),
+ ('residual_value', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, verbose_name='остаточная стоимость')),
+ ('notes', models.TextField(blank=True, default='', verbose_name='примечания')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f5_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-5',
+ 'verbose_name_plural': 'записи Ф-5',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf5record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_5_form_organiz_20eb33_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf5record',
+ index=models.Index(fields=['load_batch'], name='form_5_form_load_ba_814dbb_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf5record',
+ index=models.Index(fields=['equipment_id'], name='form_5_form_equipme_4eedfe_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf5record',
+ index=models.Index(fields=['inventory_number'], name='form_5_form_invento_4c3fd9_idx'),
+ ),
+ ]
diff --git a/src/apps/form_5/migrations/__init__.py b/src/apps/form_5/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_5/models.py b/src/apps/form_5/models.py
new file mode 100644
index 0000000..a77df49
--- /dev/null
+++ b/src/apps/form_5/models.py
@@ -0,0 +1,155 @@
+"""
+Модели формы Ф-5 (Инвентаризация оборудования).
+
+Содержит:
+- FormF5Record - запись формы Ф-5 (детальная информация по единице оборудования)
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF5Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-5 (Инвентаризация оборудования).
+
+ Детальная информация по каждой единице оборудования.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f5_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === Идентификация оборудования ===
+ equipment_id = models.CharField(
+ _("идентификационный код"),
+ max_length=50, blank=True, default="",
+ )
+ inventory_number = models.CharField(
+ _("инвентарный номер"),
+ max_length=50, blank=True, default="",
+ )
+ name = models.CharField(
+ _("наименование оборудования"),
+ max_length=500, blank=True, default="",
+ )
+ model = models.CharField(
+ _("модель"),
+ max_length=200, blank=True, default="",
+ )
+
+ # === Производитель ===
+ manufacturer = models.CharField(
+ _("производитель"),
+ max_length=300, blank=True, default="",
+ )
+ country_origin = models.CharField(
+ _("страна происхождения"),
+ max_length=100, blank=True, default="",
+ )
+ is_domestic = models.BooleanField(
+ _("отечественное производство"),
+ null=True, blank=True,
+ )
+
+ # === Технические характеристики ===
+ year_manufacture = models.PositiveIntegerField(
+ _("год выпуска"),
+ null=True, blank=True,
+ )
+ has_cnc = models.BooleanField(
+ _("наличие ЧПУ"),
+ null=True, blank=True,
+ )
+ equipment_type = models.CharField(
+ _("тип оборудования"),
+ max_length=200, blank=True, default="",
+ )
+ equipment_category = models.CharField(
+ _("категория оборудования"),
+ max_length=200, blank=True, default="",
+ )
+
+ # === Эксплуатация ===
+ commissioning_date = models.DateField(
+ _("дата ввода в эксплуатацию"),
+ null=True, blank=True,
+ )
+ location = models.CharField(
+ _("местонахождение"),
+ max_length=300, blank=True, default="",
+ )
+ production_site = models.CharField(
+ _("производственный участок"),
+ max_length=200, blank=True, default="",
+ )
+
+ # === Состояние и использование ===
+ utilization_rate = models.DecimalField(
+ _("коэффициент использования"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ physical_wear_percent = models.DecimalField(
+ _("фактический износ, %"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ is_operational = models.BooleanField(
+ _("в рабочем состоянии"),
+ null=True, blank=True,
+ )
+ requires_repair = models.BooleanField(
+ _("требует ремонта"),
+ null=True, blank=True,
+ )
+ requires_replacement = models.BooleanField(
+ _("требует замены"),
+ null=True, blank=True,
+ )
+
+ # === Стоимость ===
+ initial_cost = models.DecimalField(
+ _("первоначальная стоимость"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+ residual_value = models.DecimalField(
+ _("остаточная стоимость"),
+ max_digits=20, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Дополнительная информация ===
+ notes = models.TextField(
+ _("примечания"),
+ blank=True, default="",
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-5")
+ verbose_name_plural = _("записи Ф-5")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ models.Index(fields=["equipment_id"]),
+ models.Index(fields=["inventory_number"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-5: {self.name or self.inventory_number} ({self.organization.name})"
diff --git a/src/apps/form_5/serializers.py b/src/apps/form_5/serializers.py
new file mode 100644
index 0000000..45bbd65
--- /dev/null
+++ b/src/apps/form_5/serializers.py
@@ -0,0 +1,58 @@
+"""Сериализаторы формы Ф-5."""
+
+from apps.form_5.models import FormF5Record
+from apps.organization.serializers import OrganizationSerializer
+from rest_framework import serializers
+
+
+class FormF5RecordSerializer(serializers.ModelSerializer):
+ organization = OrganizationSerializer(read_only=True)
+
+ class Meta:
+ model = FormF5Record
+ fields = "__all__"
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class FormF5RecordListSerializer(serializers.ModelSerializer):
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF5Record
+ fields = [
+ "id", "organization_name", "organization_inn", "load_batch",
+ "equipment_id", "name", "model", "year_manufacture", "physical_wear_percent",
+ "created_at",
+ ]
+
+
+class FormF5UploadSerializer(serializers.Serializer):
+ file = serializers.FileField(help_text="Excel файл формы Ф-5 (.xlsx)")
+
+ def validate_file(self, value):
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError("Неподдерживаемый формат файла")
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError("Размер файла превышает 50MB")
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class FormF5ParseResultSerializer(serializers.Serializer):
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
diff --git a/src/apps/form_5/services.py b/src/apps/form_5/services.py
new file mode 100644
index 0000000..270dfd5
--- /dev/null
+++ b/src/apps/form_5/services.py
@@ -0,0 +1,114 @@
+"""
+Сервисы для работы с формой Ф-5.
+
+Содержит:
+- FormF5Service - CRUD операции
+- FormF5Parser - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_5.models import FormF5Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Count, Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF5Service(BulkOperationsMixin, BaseService[FormF5Record]):
+ """Сервис для работы с записями формы Ф-5."""
+
+ model = FormF5Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(count=Count("id"), created_at=Max("created_at"))
+ .order_by("-load_batch")
+ )
+
+
+class FormF5Parser(BaseExcelParser[FormF5Record]):
+ """Парсер Excel файла формы Ф-5 (Инвентаризация оборудования)."""
+
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ return [
+ # Идентификация
+ ColumnMapping(4, "Идентификационный код", "equipment_id", field_type="str"),
+ ColumnMapping(5, "Инвентарный номер", "inventory_number", field_type="str"),
+ ColumnMapping(6, "Наименование оборудования", "name", field_type="str"),
+ ColumnMapping(7, "Модель", "model", field_type="str"),
+ # Производитель
+ ColumnMapping(8, "Производитель", "manufacturer", field_type="str"),
+ ColumnMapping(9, "Страна происхождения", "country_origin", field_type="str"),
+ ColumnMapping(10, "Отечественное производство", "is_domestic", field_type="bool"),
+ # Технические характеристики
+ ColumnMapping(11, "Год выпуска", "year_manufacture", field_type="int"),
+ ColumnMapping(12, "Наличие ЧПУ", "has_cnc", field_type="bool"),
+ ColumnMapping(13, "Тип оборудования", "equipment_type", field_type="str"),
+ ColumnMapping(14, "Категория оборудования", "equipment_category", field_type="str"),
+ # Эксплуатация
+ ColumnMapping(15, "Дата ввода в эксплуатацию", "commissioning_date", field_type="date"),
+ ColumnMapping(16, "Местонахождение", "location", field_type="str"),
+ ColumnMapping(17, "Производственный участок", "production_site", field_type="str"),
+ # Состояние
+ ColumnMapping(18, "Коэффициент использования", "utilization_rate", field_type="decimal"),
+ ColumnMapping(19, "Фактический износ, %", "physical_wear_percent", field_type="decimal"),
+ ColumnMapping(20, "В рабочем состоянии", "is_operational", field_type="bool"),
+ ColumnMapping(21, "Требует ремонта", "requires_repair", field_type="bool"),
+ ColumnMapping(22, "Требует замены", "requires_replacement", field_type="bool"),
+ # Стоимость
+ ColumnMapping(23, "Первоначальная стоимость", "initial_cost", field_type="decimal"),
+ ColumnMapping(24, "Остаточная стоимость", "residual_value", field_type="decimal"),
+ # Примечания
+ ColumnMapping(25, "Примечания", "notes", field_type="str"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ return FormF5Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF5Record:
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+ return FormF5Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+
+def parse_form_f5_file(file) -> ParseResult:
+ parser = FormF5Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_5/tasks.py b/src/apps/form_5/tasks.py
new file mode 100644
index 0000000..46c2cb6
--- /dev/null
+++ b/src/apps/form_5/tasks.py
@@ -0,0 +1,19 @@
+"""Celery задачи для формы Ф-5."""
+
+import logging
+from io import BytesIO
+
+from apps.core.tasks import TrackedTask
+from apps.form_5.services import FormF5Parser
+from celery import shared_task
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f5_file(self, file_content: bytes, file_name: str) -> dict:
+ logger.info(f"Начало обработки файла Ф-5: {file_name}")
+ parser = FormF5Parser()
+ result = parser.parse(BytesIO(file_content))
+ logger.info(f"Обработка Ф-5 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено")
+ return result.to_dict()
diff --git a/src/apps/form_5/urls.py b/src/apps/form_5/urls.py
new file mode 100644
index 0000000..07cdca5
--- /dev/null
+++ b/src/apps/form_5/urls.py
@@ -0,0 +1,13 @@
+"""URL маршруты формы Ф-5."""
+
+from apps.form_5.api import FormF5RecordViewSet, FormF5UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF5RecordViewSet, basename="form-f5-records")
+
+urlpatterns = [
+ path("upload/", FormF5UploadView.as_view(), name="form-f5-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/form_6/__init__.py b/src/apps/form_6/__init__.py
new file mode 100644
index 0000000..8859081
--- /dev/null
+++ b/src/apps/form_6/__init__.py
@@ -0,0 +1 @@
+"""Приложение формы Ф-6 (Возрастная структура оборудования)."""
diff --git a/src/apps/form_6/admin.py b/src/apps/form_6/admin.py
new file mode 100644
index 0000000..8242c8a
--- /dev/null
+++ b/src/apps/form_6/admin.py
@@ -0,0 +1,39 @@
+"""Админка формы Ф-6."""
+
+from apps.form_6.models import FormF6Record
+from django.contrib import admin
+
+
+@admin.register(FormF6Record)
+class FormF6RecordAdmin(admin.ModelAdmin):
+ """Админка записей формы Ф-6."""
+
+ list_display = [
+ "organization", "load_batch", "row_code", "category",
+ "total_equipment", "physical_wear_percent", "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = ["organization__name", "organization__inn", "row_code", "category"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ raw_id_fields = ["organization"]
+ ordering = ["-created_at"]
+
+ fieldsets = [
+ ("Основная информация", {"fields": ["id", "organization", "load_batch"]}),
+ ("Категоризация", {"fields": ["row_code", "category"]}),
+ ("Общие данные", {
+ "fields": ["total_equipment", "domestic_equipment", "imported_equipment"],
+ }),
+ ("Возрастная структура", {
+ "fields": ["age_under_5", "age_5_10", "age_10_15", "age_15_20", "age_over_20"],
+ }),
+ ("С ЧПУ по возрасту", {
+ "fields": ["cnc_total", "cnc_under_5", "cnc_5_10", "cnc_10_15", "cnc_15_20", "cnc_over_20"],
+ "classes": ["collapse"],
+ }),
+ ("Показатели использования", {
+ "fields": ["avg_shift_work", "utilization_rate", "physical_wear_percent"],
+ }),
+ ("Потребности", {"fields": ["workplaces_without_equipment", "equipment_to_replace"]}),
+ ("Системные поля", {"fields": ["created_at", "updated_at"], "classes": ["collapse"]}),
+ ]
diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py
new file mode 100644
index 0000000..5b08403
--- /dev/null
+++ b/src/apps/form_6/api.py
@@ -0,0 +1,58 @@
+"""API формы Ф-6."""
+
+import logging
+
+from apps.core.viewsets import BaseViewSet
+from apps.form_6.models import FormF6Record
+from apps.form_6.serializers import (
+ FormF6ParseResultSerializer,
+ FormF6RecordListSerializer,
+ FormF6RecordSerializer,
+ FormF6UploadSerializer,
+)
+from apps.form_6.services import FormF6Service, parse_form_f6_file
+from apps.form_6.tasks import process_form_f6_file
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+logger = logging.getLogger(__name__)
+BACKGROUND_THRESHOLD = 1024 * 1024
+
+
+class FormF6UploadView(APIView):
+ parser_classes = [MultiPartParser]
+
+ def post(self, request):
+ serializer = FormF6UploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ file = serializer.validated_data["file"]
+
+ if file.size > BACKGROUND_THRESHOLD:
+ task = process_form_f6_file.delay(file.read(), file.name)
+ return Response(
+ {"success": True, "message": "Файл поставлен в очередь", "task_id": task.id},
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+ try:
+ result = parse_form_f6_file(file)
+ return Response({"success": True, "data": FormF6ParseResultSerializer(result).data})
+ except Exception as e:
+ logger.exception("Ошибка обработки файла Ф-6")
+ return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+
+class FormF6RecordViewSet(BaseViewSet):
+ queryset = FormF6Record.objects.select_related("organization").all()
+ serializer_class = FormF6RecordSerializer
+ service_class = FormF6Service
+
+ def get_serializer_class(self):
+ return FormF6RecordListSerializer if self.action == "list" else FormF6RecordSerializer
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ batch_id = self.request.query_params.get("batch_id")
+ return qs.filter(load_batch=batch_id) if batch_id else qs
diff --git a/src/apps/form_6/apps.py b/src/apps/form_6/apps.py
new file mode 100644
index 0000000..8ae403b
--- /dev/null
+++ b/src/apps/form_6/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения form_6."""
+
+from django.apps import AppConfig
+
+
+class Form6Config(AppConfig):
+ """Конфигурация приложения формы Ф-6."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.form_6"
+ verbose_name = "Форма Ф-6 (Возрастная структура оборудования)"
diff --git a/src/apps/form_6/migrations/0001_initial.py b/src/apps/form_6/migrations/0001_initial.py
new file mode 100644
index 0000000..8960df0
--- /dev/null
+++ b/src/apps/form_6/migrations/0001_initial.py
@@ -0,0 +1,65 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('organization', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FormF6Record',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='номер загрузки')),
+ ('row_code', models.CharField(blank=True, default='', max_length=20, verbose_name='код строки')),
+ ('category', models.CharField(blank=True, default='', max_length=200, verbose_name='категория оборудования')),
+ ('total_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='всего оборудования')),
+ ('domestic_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='отечественное оборудование')),
+ ('imported_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='импортное оборудование')),
+ ('age_under_5', models.PositiveIntegerField(blank=True, null=True, verbose_name='до 5 лет')),
+ ('age_5_10', models.PositiveIntegerField(blank=True, null=True, verbose_name='5-10 лет')),
+ ('age_10_15', models.PositiveIntegerField(blank=True, null=True, verbose_name='10-15 лет')),
+ ('age_15_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='15-20 лет')),
+ ('age_over_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='свыше 20 лет')),
+ ('cnc_total', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ всего')),
+ ('cnc_under_5', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ до 5 лет')),
+ ('cnc_5_10', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 5-10 лет')),
+ ('cnc_10_15', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 10-15 лет')),
+ ('cnc_15_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ 15-20 лет')),
+ ('cnc_over_20', models.PositiveIntegerField(blank=True, null=True, verbose_name='с ЧПУ свыше 20 лет')),
+ ('avg_shift_work', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='средняя сменность работы')),
+ ('utilization_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='коэффициент загрузки')),
+ ('physical_wear_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='физический износ, %')),
+ ('workplaces_without_equipment', models.PositiveIntegerField(blank=True, null=True, verbose_name='рабочие места без оборудования')),
+ ('equipment_to_replace', models.PositiveIntegerField(blank=True, null=True, verbose_name='оборудование к замене')),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_f6_records', to='organization.organization', verbose_name='организация')),
+ ],
+ options={
+ 'verbose_name': 'запись Ф-6',
+ 'verbose_name_plural': 'записи Ф-6',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formf6record',
+ index=models.Index(fields=['organization', 'load_batch'], name='form_6_form_organiz_2cf6ba_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf6record',
+ index=models.Index(fields=['load_batch'], name='form_6_form_load_ba_faecd4_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formf6record',
+ index=models.Index(fields=['row_code'], name='form_6_form_row_cod_97ebc4_idx'),
+ ),
+ ]
diff --git a/src/apps/form_6/migrations/__init__.py b/src/apps/form_6/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/form_6/models.py b/src/apps/form_6/models.py
new file mode 100644
index 0000000..089f5f7
--- /dev/null
+++ b/src/apps/form_6/models.py
@@ -0,0 +1,148 @@
+"""
+Модели формы Ф-6 (Возрастная структура оборудования).
+
+Содержит:
+- FormF6Record - запись формы Ф-6 (сводная информация по возрастным группам)
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from apps.organization.models import Organization
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class FormF6Record(TimestampMixin, models.Model):
+ """
+ Запись формы Ф-6 (Возрастная структура оборудования).
+
+ Сводные данные о возрастной структуре оборудования по категориям.
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ organization = models.ForeignKey(
+ Organization,
+ on_delete=models.CASCADE,
+ related_name="form_f6_records",
+ verbose_name=_("организация"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("номер загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+
+ # === Категоризация ===
+ row_code = models.CharField(
+ _("код строки"),
+ max_length=20, blank=True, default="",
+ )
+ category = models.CharField(
+ _("категория оборудования"),
+ max_length=200, blank=True, default="",
+ )
+
+ # === Общие данные ===
+ total_equipment = models.PositiveIntegerField(
+ _("всего оборудования"),
+ null=True, blank=True,
+ )
+ domestic_equipment = models.PositiveIntegerField(
+ _("отечественное оборудование"),
+ null=True, blank=True,
+ )
+ imported_equipment = models.PositiveIntegerField(
+ _("импортное оборудование"),
+ null=True, blank=True,
+ )
+
+ # === Возрастная структура ===
+ age_under_5 = models.PositiveIntegerField(
+ _("до 5 лет"),
+ null=True, blank=True,
+ )
+ age_5_10 = models.PositiveIntegerField(
+ _("5-10 лет"),
+ null=True, blank=True,
+ )
+ age_10_15 = models.PositiveIntegerField(
+ _("10-15 лет"),
+ null=True, blank=True,
+ )
+ age_15_20 = models.PositiveIntegerField(
+ _("15-20 лет"),
+ null=True, blank=True,
+ )
+ age_over_20 = models.PositiveIntegerField(
+ _("свыше 20 лет"),
+ null=True, blank=True,
+ )
+
+ # === С ЧПУ по возрасту ===
+ cnc_total = models.PositiveIntegerField(
+ _("с ЧПУ всего"),
+ null=True, blank=True,
+ )
+ cnc_under_5 = models.PositiveIntegerField(
+ _("с ЧПУ до 5 лет"),
+ null=True, blank=True,
+ )
+ cnc_5_10 = models.PositiveIntegerField(
+ _("с ЧПУ 5-10 лет"),
+ null=True, blank=True,
+ )
+ cnc_10_15 = models.PositiveIntegerField(
+ _("с ЧПУ 10-15 лет"),
+ null=True, blank=True,
+ )
+ cnc_15_20 = models.PositiveIntegerField(
+ _("с ЧПУ 15-20 лет"),
+ null=True, blank=True,
+ )
+ cnc_over_20 = models.PositiveIntegerField(
+ _("с ЧПУ свыше 20 лет"),
+ null=True, blank=True,
+ )
+
+ # === Показатели использования ===
+ avg_shift_work = models.DecimalField(
+ _("средняя сменность работы"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ utilization_rate = models.DecimalField(
+ _("коэффициент загрузки"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+ physical_wear_percent = models.DecimalField(
+ _("физический износ, %"),
+ max_digits=5, decimal_places=2, null=True, blank=True,
+ )
+
+ # === Потребности ===
+ workplaces_without_equipment = models.PositiveIntegerField(
+ _("рабочие места без оборудования"),
+ null=True, blank=True,
+ )
+ equipment_to_replace = models.PositiveIntegerField(
+ _("оборудование к замене"),
+ null=True, blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("запись Ф-6")
+ verbose_name_plural = _("записи Ф-6")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["organization", "load_batch"]),
+ models.Index(fields=["load_batch"]),
+ models.Index(fields=["row_code"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"Ф-6: {self.category or self.row_code} ({self.organization.name})"
diff --git a/src/apps/form_6/serializers.py b/src/apps/form_6/serializers.py
new file mode 100644
index 0000000..8ddfcce
--- /dev/null
+++ b/src/apps/form_6/serializers.py
@@ -0,0 +1,58 @@
+"""Сериализаторы формы Ф-6."""
+
+from apps.form_6.models import FormF6Record
+from apps.organization.serializers import OrganizationSerializer
+from rest_framework import serializers
+
+
+class FormF6RecordSerializer(serializers.ModelSerializer):
+ organization = OrganizationSerializer(read_only=True)
+
+ class Meta:
+ model = FormF6Record
+ fields = "__all__"
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class FormF6RecordListSerializer(serializers.ModelSerializer):
+ organization_name = serializers.CharField(source="organization.name", read_only=True)
+ organization_inn = serializers.CharField(source="organization.inn", read_only=True)
+
+ class Meta:
+ model = FormF6Record
+ fields = [
+ "id", "organization_name", "organization_inn", "load_batch",
+ "row_code", "category", "total_equipment", "physical_wear_percent",
+ "created_at",
+ ]
+
+
+class FormF6UploadSerializer(serializers.Serializer):
+ file = serializers.FileField(help_text="Excel файл формы Ф-6 (.xlsx)")
+
+ def validate_file(self, value):
+ if not value.name.endswith((".xlsx", ".xls")):
+ raise serializers.ValidationError("Неподдерживаемый формат файла")
+ if value.size > 50 * 1024 * 1024:
+ raise serializers.ValidationError("Размер файла превышает 50MB")
+ return value
+
+
+class FieldErrorSerializer(serializers.Serializer):
+ field = serializers.CharField()
+ message = serializers.CharField()
+
+
+class RowValidationErrorSerializer(serializers.Serializer):
+ row = serializers.IntegerField()
+ inn = serializers.CharField(allow_null=True)
+ kpp = serializers.CharField(allow_null=True)
+ organization_name = serializers.CharField(allow_null=True)
+ errors = FieldErrorSerializer(many=True)
+
+
+class FormF6ParseResultSerializer(serializers.Serializer):
+ batch_id = serializers.IntegerField()
+ loaded_count = serializers.IntegerField()
+ skipped_count = serializers.IntegerField()
+ errors = RowValidationErrorSerializer(many=True)
diff --git a/src/apps/form_6/services.py b/src/apps/form_6/services.py
new file mode 100644
index 0000000..ab897f2
--- /dev/null
+++ b/src/apps/form_6/services.py
@@ -0,0 +1,112 @@
+"""
+Сервисы для работы с формой Ф-6.
+
+Содержит:
+- FormF6Service - CRUD операции
+- FormF6Parser - парсинг Excel
+"""
+
+import logging
+from typing import Any
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ ParseResult,
+ RowData,
+)
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.form_6.models import FormF6Record
+from apps.organization.services import OrganizationService
+from django.db import transaction
+from django.db.models import Count, Max
+
+logger = logging.getLogger(__name__)
+
+
+class FormF6Service(BulkOperationsMixin, BaseService[FormF6Record]):
+ """Сервис для работы с записями формы Ф-6."""
+
+ model = FormF6Record
+
+ @classmethod
+ def get_by_batch(cls, batch_id: int):
+ return cls.get_queryset().filter(load_batch=batch_id)
+
+ @classmethod
+ def get_next_batch_id(cls) -> int:
+ max_batch = cls.model.objects.aggregate(max_batch=Max("load_batch"))
+ return (max_batch["max_batch"] or 0) + 1
+
+ @classmethod
+ def get_batches(cls) -> list[dict[str, Any]]:
+ return list(
+ cls.model.objects.values("load_batch")
+ .annotate(count=Count("id"), created_at=Max("created_at"))
+ .order_by("-load_batch")
+ )
+
+
+class FormF6Parser(BaseExcelParser[FormF6Record]):
+ """Парсер Excel файла формы Ф-6 (Возрастная структура оборудования)."""
+
+ ORG_NAME_COLUMN = 0
+ OKPO_COLUMN = 1
+ OGRN_COLUMN = 2
+ INN_COLUMN = 3
+
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ return [
+ # Категоризация
+ ColumnMapping(4, "Код строки", "row_code", field_type="str"),
+ ColumnMapping(5, "Категория оборудования", "category", field_type="str"),
+ # Общие данные
+ ColumnMapping(6, "Всего оборудования", "total_equipment", field_type="int"),
+ ColumnMapping(7, "Отечественное оборудование", "domestic_equipment", field_type="int"),
+ ColumnMapping(8, "Импортное оборудование", "imported_equipment", field_type="int"),
+ # Возрастная структура
+ ColumnMapping(9, "До 5 лет", "age_under_5", field_type="int"),
+ ColumnMapping(10, "5-10 лет", "age_5_10", field_type="int"),
+ ColumnMapping(11, "10-15 лет", "age_10_15", field_type="int"),
+ ColumnMapping(12, "15-20 лет", "age_15_20", field_type="int"),
+ ColumnMapping(13, "Свыше 20 лет", "age_over_20", field_type="int"),
+ # С ЧПУ
+ ColumnMapping(14, "С ЧПУ всего", "cnc_total", field_type="int"),
+ ColumnMapping(15, "С ЧПУ до 5 лет", "cnc_under_5", field_type="int"),
+ ColumnMapping(16, "С ЧПУ 5-10 лет", "cnc_5_10", field_type="int"),
+ ColumnMapping(17, "С ЧПУ 10-15 лет", "cnc_10_15", field_type="int"),
+ ColumnMapping(18, "С ЧПУ 15-20 лет", "cnc_15_20", field_type="int"),
+ ColumnMapping(19, "С ЧПУ свыше 20 лет", "cnc_over_20", field_type="int"),
+ # Показатели
+ ColumnMapping(20, "Средняя сменность работы", "avg_shift_work", field_type="decimal"),
+ ColumnMapping(21, "Коэффициент загрузки", "utilization_rate", field_type="decimal"),
+ ColumnMapping(22, "Физический износ, %", "physical_wear_percent", field_type="decimal"),
+ # Потребности
+ ColumnMapping(23, "Рабочие места без оборудования", "workplaces_without_equipment", field_type="int"),
+ ColumnMapping(24, "Оборудование к замене", "equipment_to_replace", field_type="int"),
+ ]
+
+ def get_next_batch_id(self) -> int:
+ return FormF6Service.get_next_batch_id()
+
+ @transaction.atomic
+ def create_record(self, row_data: RowData, batch_id: int) -> FormF6Record:
+ org, _ = OrganizationService.get_or_create_by_inn(
+ inn=row_data.inn,
+ defaults={
+ "name": row_data.organization_name,
+ "ogrn": row_data.ogrn or "",
+ "okpo": row_data.okpo or "",
+ "kpp": row_data.kpp or "",
+ },
+ )
+ return FormF6Record.objects.create(
+ organization=org,
+ load_batch=batch_id,
+ **row_data.fields,
+ )
+
+
+def parse_form_f6_file(file) -> ParseResult:
+ parser = FormF6Parser()
+ return parser.parse(file)
diff --git a/src/apps/form_6/tasks.py b/src/apps/form_6/tasks.py
new file mode 100644
index 0000000..7ffa09a
--- /dev/null
+++ b/src/apps/form_6/tasks.py
@@ -0,0 +1,19 @@
+"""Celery задачи для формы Ф-6."""
+
+import logging
+from io import BytesIO
+
+from apps.core.tasks import TrackedTask
+from apps.form_6.services import FormF6Parser
+from celery import shared_task
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f6_file(self, file_content: bytes, file_name: str) -> dict:
+ logger.info(f"Начало обработки файла Ф-6: {file_name}")
+ parser = FormF6Parser()
+ result = parser.parse(BytesIO(file_content))
+ logger.info(f"Обработка Ф-6 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено")
+ return result.to_dict()
diff --git a/src/apps/form_6/urls.py b/src/apps/form_6/urls.py
new file mode 100644
index 0000000..c669e1b
--- /dev/null
+++ b/src/apps/form_6/urls.py
@@ -0,0 +1,13 @@
+"""URL маршруты формы Ф-6."""
+
+from apps.form_6.api import FormF6RecordViewSet, FormF6UploadView
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("records", FormF6RecordViewSet, basename="form-f6-records")
+
+urlpatterns = [
+ path("upload/", FormF6UploadView.as_view(), name="form-f6-upload"),
+ path("", include(router.urls)),
+]
diff --git a/src/apps/organization/__init__.py b/src/apps/organization/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/organization/admin.py b/src/apps/organization/admin.py
new file mode 100644
index 0000000..b337bf8
--- /dev/null
+++ b/src/apps/organization/admin.py
@@ -0,0 +1,37 @@
+"""Административный интерфейс для организаций."""
+
+from apps.organization.models import Organization
+from django.contrib import admin
+
+
+@admin.register(Organization)
+class OrganizationAdmin(admin.ModelAdmin):
+ """Админка для организаций."""
+
+ list_display = ["name", "inn", "ogrn", "kpp", "created_at"]
+ list_filter = ["created_at"]
+ search_fields = ["name", "inn", "ogrn"]
+ readonly_fields = ["id", "created_at", "updated_at"]
+ ordering = ["name"]
+
+ fieldsets = [
+ (
+ "Основная информация",
+ {
+ "fields": ["id", "name"],
+ },
+ ),
+ (
+ "Идентификаторы",
+ {
+ "fields": ["inn", "ogrn", "kpp", "okpo"],
+ },
+ ),
+ (
+ "Системные поля",
+ {
+ "fields": ["created_at", "updated_at"],
+ "classes": ["collapse"],
+ },
+ ),
+ ]
diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py
new file mode 100644
index 0000000..2ab3662
--- /dev/null
+++ b/src/apps/organization/api.py
@@ -0,0 +1,57 @@
+"""
+API ViewSets для организаций.
+
+Содержит:
+- OrganizationViewSet - CRUD для организаций
+"""
+
+from apps.core.viewsets import ReadOnlyViewSet
+from apps.organization.models import Organization
+from apps.organization.serializers import (
+ OrganizationListSerializer,
+ OrganizationSerializer,
+)
+from django_filters import rest_framework as filters
+from rest_framework.permissions import IsAuthenticated
+
+
+class OrganizationFilter(filters.FilterSet):
+ """Фильтры для организаций."""
+
+ name = filters.CharFilter(lookup_expr="icontains")
+ inn = filters.CharFilter(lookup_expr="exact")
+ ogrn = filters.CharFilter(lookup_expr="exact")
+
+ class Meta:
+ model = Organization
+ fields = ["name", "inn", "ogrn"]
+
+
+class OrganizationViewSet(ReadOnlyViewSet[Organization]):
+ """
+ ViewSet для просмотра организаций.
+
+ Только чтение - организации создаются автоматически при загрузке форм.
+
+ Эндпоинты:
+ GET /organizations/ - список организаций
+ GET /organizations/{id}/ - детали организации
+ """
+
+ queryset = Organization.objects.all()
+ serializer_class = OrganizationSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_class = OrganizationFilter
+ search_fields = ["name", "inn", "ogrn"]
+ ordering_fields = ["name", "inn", "created_at"]
+ ordering = ["name"]
+
+ serializer_classes = {
+ "list": OrganizationListSerializer,
+ }
+
+ def get_serializer_class(self):
+ """Возвращает serializer в зависимости от action."""
+ if self.action in self.serializer_classes:
+ return self.serializer_classes[self.action]
+ return super().get_serializer_class()
diff --git a/src/apps/organization/apps.py b/src/apps/organization/apps.py
new file mode 100644
index 0000000..299545f
--- /dev/null
+++ b/src/apps/organization/apps.py
@@ -0,0 +1,11 @@
+"""Конфигурация приложения organization."""
+
+from django.apps import AppConfig
+
+
+class OrganizationConfig(AppConfig):
+ """Конфигурация приложения справочника организаций."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.organization"
+ verbose_name = "Организации"
diff --git a/src/apps/organization/migrations/0001_initial.py b/src/apps/organization/migrations/0001_initial.py
new file mode 100644
index 0000000..eb3b99a
--- /dev/null
+++ b/src/apps/organization/migrations/0001_initial.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Organization',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(db_index=True, help_text='Полное наименование организации', max_length=500, verbose_name='наименование')),
+ ('inn', models.CharField(db_index=True, help_text='Идентификационный номер налогоплательщика (10 или 12 цифр)', max_length=12, unique=True, verbose_name='ИНН')),
+ ('ogrn', models.CharField(blank=True, db_index=True, default='', help_text='Основной государственный регистрационный номер (13 или 15 цифр)', max_length=15, verbose_name='ОГРН')),
+ ('kpp', models.CharField(blank=True, default='', help_text='Код причины постановки на учёт (9 цифр)', max_length=9, verbose_name='КПП')),
+ ('okpo', models.CharField(blank=True, default='', help_text='Общероссийский классификатор предприятий и организаций', max_length=20, verbose_name='ОКПО')),
+ ],
+ options={
+ 'verbose_name': 'организация',
+ 'verbose_name_plural': 'организации',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['inn'], name='organizatio_inn_6cdafb_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['ogrn'], name='organizatio_ogrn_c5495f_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='organization',
+ index=models.Index(fields=['name'], name='organizatio_name_2d216c_idx'),
+ ),
+ ]
diff --git a/src/apps/organization/migrations/__init__.py b/src/apps/organization/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/organization/models.py b/src/apps/organization/models.py
new file mode 100644
index 0000000..f3039a2
--- /dev/null
+++ b/src/apps/organization/models.py
@@ -0,0 +1,83 @@
+"""
+Модели справочника организаций.
+
+Содержит:
+- Organization - справочник организаций (ИНН, ОГРН, КПП, наименование)
+"""
+
+import uuid
+
+from apps.core.mixins import TimestampMixin
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class Organization(TimestampMixin, models.Model):
+ """
+ Справочник организаций.
+
+ Централизованное хранение данных организаций для нормализации БД.
+ Все формы отчётности ссылаются на эту таблицу через FK.
+
+ Поля:
+ name: Наименование организации
+ inn: ИНН (уникальный)
+ ogrn: ОГРН
+ kpp: КПП
+ okpo: ОКПО
+ """
+
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ verbose_name=_("ID"),
+ )
+ name = models.CharField(
+ _("наименование"),
+ max_length=500,
+ db_index=True,
+ help_text=_("Полное наименование организации"),
+ )
+ inn = models.CharField(
+ _("ИНН"),
+ max_length=12,
+ unique=True,
+ db_index=True,
+ help_text=_("Идентификационный номер налогоплательщика (10 или 12 цифр)"),
+ )
+ ogrn = models.CharField(
+ _("ОГРН"),
+ max_length=15,
+ db_index=True,
+ blank=True,
+ default="",
+ help_text=_("Основной государственный регистрационный номер (13 или 15 цифр)"),
+ )
+ kpp = models.CharField(
+ _("КПП"),
+ max_length=9,
+ blank=True,
+ default="",
+ help_text=_("Код причины постановки на учёт (9 цифр)"),
+ )
+ okpo = models.CharField(
+ _("ОКПО"),
+ max_length=20,
+ blank=True,
+ default="",
+ help_text=_("Общероссийский классификатор предприятий и организаций"),
+ )
+
+ class Meta:
+ verbose_name = _("организация")
+ verbose_name_plural = _("организации")
+ ordering = ["name"]
+ indexes = [
+ models.Index(fields=["inn"]),
+ models.Index(fields=["ogrn"]),
+ models.Index(fields=["name"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.name} (ИНН: {self.inn})"
diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py
new file mode 100644
index 0000000..c1e8754
--- /dev/null
+++ b/src/apps/organization/serializers.py
@@ -0,0 +1,41 @@
+"""
+Сериализаторы для организаций.
+
+Содержит:
+- OrganizationSerializer - полный сериализатор
+- OrganizationListSerializer - краткий для списков
+"""
+
+from apps.organization.models import Organization
+from rest_framework import serializers
+
+
+class OrganizationSerializer(serializers.ModelSerializer):
+ """Полный сериализатор организации."""
+
+ class Meta:
+ model = Organization
+ fields = [
+ "id",
+ "name",
+ "inn",
+ "ogrn",
+ "kpp",
+ "okpo",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+
+class OrganizationListSerializer(serializers.ModelSerializer):
+ """Краткий сериализатор для списков."""
+
+ class Meta:
+ model = Organization
+ fields = [
+ "id",
+ "name",
+ "inn",
+ "ogrn",
+ ]
diff --git a/src/apps/organization/services.py b/src/apps/organization/services.py
new file mode 100644
index 0000000..5b3e83b
--- /dev/null
+++ b/src/apps/organization/services.py
@@ -0,0 +1,107 @@
+"""
+Сервисы для работы с организациями.
+
+Содержит:
+- OrganizationService - CRUD операции и бизнес-логика
+"""
+
+import logging
+from typing import Any
+
+from apps.core.services import BaseService
+from apps.organization.models import Organization
+from django.db import transaction
+
+logger = logging.getLogger(__name__)
+
+
+class OrganizationService(BaseService[Organization]):
+ """
+ Сервис для работы с организациями.
+
+ Методы:
+ get_or_create_by_inn: Получить или создать организацию по ИНН
+ update_organization: Обновить данные организации
+ search_by_name: Поиск по наименованию
+ """
+
+ model = Organization
+
+ @classmethod
+ @transaction.atomic
+ def get_or_create_by_inn(
+ cls,
+ inn: str,
+ defaults: dict[str, Any] | None = None,
+ ) -> tuple[Organization, bool]:
+ """
+ Получить или создать организацию по ИНН.
+
+ Args:
+ inn: ИНН организации
+ defaults: Значения по умолчанию для создания
+
+ Returns:
+ (Organization, created) - организация и флаг создания
+ """
+ defaults = defaults or {}
+
+ org, created = cls.model.objects.get_or_create(
+ inn=inn,
+ defaults=defaults,
+ )
+
+ if created:
+ logger.info(
+ f"Создана организация: {org.name} (ИНН: {inn})",
+ extra={"inn": inn, "org_id": str(org.id)},
+ )
+ else:
+ # Обновляем данные если переданы новые
+ updated_fields = []
+ for field, value in defaults.items():
+ if value and getattr(org, field, None) != value:
+ # Обновляем только пустые поля или если значение изменилось
+ current = getattr(org, field, None)
+ if not current or field == "name":
+ setattr(org, field, value)
+ updated_fields.append(field)
+
+ if updated_fields:
+ org.save(update_fields=updated_fields + ["updated_at"])
+ logger.info(
+ f"Обновлена организация: {org.name} (ИНН: {inn}), поля: {updated_fields}",
+ extra={"inn": inn, "org_id": str(org.id), "fields": updated_fields},
+ )
+
+ return org, created
+
+ @classmethod
+ def search_by_name(cls, query: str, limit: int = 20):
+ """
+ Поиск организаций по наименованию.
+
+ Args:
+ query: Строка поиска
+ limit: Максимальное количество результатов
+
+ Returns:
+ QuerySet организаций
+ """
+ return cls.model.objects.filter(name__icontains=query)[:limit]
+
+ @classmethod
+ def get_by_inn(cls, inn: str) -> Organization | None:
+ """
+ Получить организацию по ИНН.
+
+ Args:
+ inn: ИНН организации
+
+ Returns:
+ Organization или None
+ """
+ try:
+ return cls.model.objects.get(inn=inn)
+ except cls.model.DoesNotExist:
+ return None
diff --git a/src/apps/organization/urls.py b/src/apps/organization/urls.py
new file mode 100644
index 0000000..f254f7d
--- /dev/null
+++ b/src/apps/organization/urls.py
@@ -0,0 +1,12 @@
+"""URL маршруты для организаций."""
+
+from apps.organization.api import OrganizationViewSet
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+router = DefaultRouter()
+router.register("", OrganizationViewSet, basename="organization")
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py
new file mode 100644
index 0000000..124a62d
--- /dev/null
+++ b/src/apps/user/admin.py
@@ -0,0 +1,181 @@
+"""
+Admin configuration for user app.
+"""
+
+from apps.user.models import Profile, User
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+
+class ProfileInline(admin.StackedInline):
+ """Inline для профиля пользователя."""
+
+ model = Profile
+ can_delete = False
+ verbose_name_plural = "Профиль"
+ fk_name = "user"
+ fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"]
+
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+ """Admin для пользователей."""
+
+ inlines = [ProfileInline]
+
+ list_display = [
+ "username",
+ "email",
+ "phone",
+ "is_verified_badge",
+ "is_active_badge",
+ "is_staff",
+ "created_at",
+ ]
+ list_filter = ["is_staff", "is_superuser", "is_active", "is_verified", "created_at"]
+ search_fields = ["username", "email", "phone"]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (None, {"fields": ("username", "password")}),
+ (
+ _("Personal info"),
+ {"fields": ("email", "phone")},
+ ),
+ (
+ _("Permissions"),
+ {
+ "fields": (
+ "is_active",
+ "is_staff",
+ "is_superuser",
+ "is_verified",
+ "groups",
+ "user_permissions",
+ ),
+ "classes": ("collapse",),
+ },
+ ),
+ (
+ _("Important dates"),
+ {"fields": ("last_login", "date_joined", "created_at", "updated_at")},
+ ),
+ )
+
+ add_fieldsets = (
+ (
+ None,
+ {
+ "classes": ("wide",),
+ "fields": (
+ "username",
+ "email",
+ "password1",
+ "password2",
+ "is_staff",
+ "is_active",
+ ),
+ },
+ ),
+ )
+
+ readonly_fields = ["created_at", "updated_at", "last_login", "date_joined"]
+
+ def is_verified_badge(self, obj):
+ """Бейдж верификации."""
+ if obj.is_verified:
+ return format_html(
+ '✓'
+ )
+ return format_html(
+ '✗'
+ )
+
+ is_verified_badge.short_description = "Верифицирован"
+ is_verified_badge.admin_order_field = "is_verified"
+
+ def is_active_badge(self, obj):
+ """Бейдж активности."""
+ if obj.is_active:
+ return format_html(
+ 'Активен'
+ )
+ return format_html(
+ 'Неактивен'
+ )
+
+ is_active_badge.short_description = "Статус"
+ is_active_badge.admin_order_field = "is_active"
+
+ actions = ["verify_users", "unverify_users", "activate_users", "deactivate_users"]
+
+ @admin.action(description="Верифицировать выбранных пользователей")
+ def verify_users(self, request, queryset):
+ updated = queryset.update(is_verified=True)
+ self.message_user(request, f"Верифицировано {updated} пользователей")
+
+ @admin.action(description="Снять верификацию")
+ def unverify_users(self, request, queryset):
+ updated = queryset.update(is_verified=False)
+ self.message_user(request, f"Снята верификация у {updated} пользователей")
+
+ @admin.action(description="Активировать пользователей")
+ def activate_users(self, request, queryset):
+ updated = queryset.update(is_active=True)
+ self.message_user(request, f"Активировано {updated} пользователей")
+
+ @admin.action(description="Деактивировать пользователей")
+ def deactivate_users(self, request, queryset):
+ updated = queryset.update(is_active=False)
+ self.message_user(request, f"Деактивировано {updated} пользователей")
+
+
+@admin.register(Profile)
+class ProfileAdmin(admin.ModelAdmin):
+ """Admin для профилей."""
+
+ list_display = [
+ "user",
+ "full_name",
+ "date_of_birth",
+ "has_avatar",
+ "created_at",
+ ]
+ list_filter = ["created_at"]
+ search_fields = ["user__username", "user__email", "first_name", "last_name"]
+ readonly_fields = ["created_at", "updated_at"]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ raw_id_fields = ["user"]
+
+ fieldsets = (
+ ("Пользователь", {"fields": ("user",)}),
+ (
+ "Личная информация",
+ {"fields": ("first_name", "last_name", "bio", "date_of_birth")},
+ ),
+ ("Аватар", {"fields": ("avatar",)}),
+ ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
+ )
+
+ def has_avatar(self, obj):
+ """Есть ли аватар."""
+ if obj.avatar:
+ return format_html(
+ 'Да'
+ )
+ return format_html(
+ 'Нет'
+ )
+
+ has_avatar.short_description = "Аватар"
diff --git a/src/apps/user/migrations/0002_remove_firstname_lastname.py b/src/apps/user/migrations/0002_remove_firstname_lastname.py
new file mode 100644
index 0000000..c12bb96
--- /dev/null
+++ b/src/apps/user/migrations/0002_remove_firstname_lastname.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.25 on 2026-01-21 17:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('user', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='first_name',
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='last_name',
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='groups',
+ field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'),
+ ),
+ ]
diff --git a/src/apps/user/migrations/0003_alter_user_groups.py b/src/apps/user/migrations/0003_alter_user_groups.py
new file mode 100644
index 0000000..ea20f81
--- /dev/null
+++ b/src/apps/user/migrations/0003_alter_user_groups.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2026-02-05 11:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('user', '0002_remove_firstname_lastname'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='groups',
+ field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'),
+ ),
+ ]
diff --git a/src/apps/user/migrations/0004_alter_user_groups.py b/src/apps/user/migrations/0004_alter_user_groups.py
new file mode 100644
index 0000000..806a2a6
--- /dev/null
+++ b/src/apps/user/migrations/0004_alter_user_groups.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2026-02-06 12:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('user', '0003_alter_user_groups'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='groups',
+ field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'),
+ ),
+ ]
diff --git a/src/input/fns/fin_0000605_1027700169089.xlsx b/src/input/fns/fin_0000605_1027700169089.xlsx
new file mode 100644
index 0000000..2df2a0d
Binary files /dev/null and b/src/input/fns/fin_0000605_1027700169089.xlsx differ
diff --git a/tests/apps/core/test_excel.py b/tests/apps/core/test_excel.py
new file mode 100644
index 0000000..afdae04
--- /dev/null
+++ b/tests/apps/core/test_excel.py
@@ -0,0 +1,172 @@
+"""Tests for core excel parser."""
+
+from io import BytesIO
+from unittest.mock import MagicMock, patch
+
+from apps.core.excel import (
+ BaseExcelParser,
+ ColumnMapping,
+ FieldError,
+ ParseResult,
+ RowData,
+ RowValidationError,
+ validate_inn,
+ validate_kpp,
+ validate_ogrn,
+ validate_okpo,
+)
+from django.test import TestCase
+
+
+class ValidatorsTest(TestCase):
+ """Tests for validators."""
+
+ def test_validate_inn_valid_10_digits(self):
+ """Test valid 10-digit INN."""
+ self.assertEqual(validate_inn("1234567890"), "1234567890")
+
+ def test_validate_inn_valid_12_digits(self):
+ """Test valid 12-digit INN."""
+ self.assertEqual(validate_inn("123456789012"), "123456789012")
+
+ def test_validate_inn_strips_whitespace(self):
+ """Test INN strips whitespace."""
+ self.assertEqual(validate_inn(" 1234567890 "), "1234567890")
+
+ def test_validate_inn_invalid_length(self):
+ """Test INN with invalid length raises ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ validate_inn("12345")
+ self.assertIn("10 или 12", str(ctx.exception))
+
+ def test_validate_inn_none_returns_none(self):
+ """Test None INN returns None."""
+ self.assertIsNone(validate_inn(None))
+
+ def test_validate_ogrn_valid_13_digits(self):
+ """Test valid 13-digit OGRN."""
+ self.assertEqual(validate_ogrn("1234567890123"), "1234567890123")
+
+ def test_validate_ogrn_valid_15_digits(self):
+ """Test valid 15-digit OGRN."""
+ self.assertEqual(validate_ogrn("123456789012345"), "123456789012345")
+
+ def test_validate_ogrn_invalid_length(self):
+ """Test OGRN with invalid length raises ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ validate_ogrn("12345")
+ self.assertIn("13 или 15", str(ctx.exception))
+
+ def test_validate_kpp_valid(self):
+ """Test valid 9-digit KPP."""
+ self.assertEqual(validate_kpp("123456789"), "123456789")
+
+ def test_validate_kpp_invalid_length(self):
+ """Test KPP with invalid length raises ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ validate_kpp("12345")
+ self.assertIn("9 цифр", str(ctx.exception))
+
+ def test_validate_okpo_valid_8_digits(self):
+ """Test valid 8-digit OKPO."""
+ self.assertEqual(validate_okpo("12345678"), "12345678")
+
+ def test_validate_okpo_valid_10_digits(self):
+ """Test valid 10-digit OKPO."""
+ self.assertEqual(validate_okpo("1234567890"), "1234567890")
+
+
+class DataclassesTest(TestCase):
+ """Tests for dataclasses."""
+
+ def test_column_mapping_creation(self):
+ """Test ColumnMapping creation."""
+ mapping = ColumnMapping(
+ column_index=1,
+ field_name="test_field",
+ header_pattern="Test Header",
+ )
+ self.assertEqual(mapping.column_index, 1)
+ self.assertEqual(mapping.field_name, "test_field")
+ self.assertFalse(mapping.required)
+
+ def test_column_mapping_with_validator(self):
+ """Test ColumnMapping with validator."""
+ mapping = ColumnMapping(
+ column_index=1,
+ field_name="inn",
+ header_pattern="ИНН",
+ required=True,
+ validator=validate_inn,
+ )
+ self.assertTrue(mapping.required)
+ self.assertIsNotNone(mapping.validator)
+
+ def test_row_data_creation(self):
+ """Test RowData creation."""
+ data = RowData(
+ row_number=5,
+ data={"field1": "value1", "field2": 123},
+ )
+ self.assertEqual(data.row_number, 5)
+ self.assertEqual(data.data["field1"], "value1")
+
+ def test_field_error_creation(self):
+ """Test FieldError creation."""
+ error = FieldError(
+ field="inn",
+ message="Invalid INN",
+ value="12345",
+ )
+ self.assertEqual(error.field, "inn")
+ self.assertEqual(error.message, "Invalid INN")
+
+ def test_row_validation_error_creation(self):
+ """Test RowValidationError creation."""
+ error = RowValidationError(
+ row_number=10,
+ errors=[FieldError(field="inn", message="Invalid", value="x")],
+ )
+ self.assertEqual(error.row_number, 10)
+ self.assertEqual(len(error.errors), 1)
+
+ def test_parse_result_creation(self):
+ """Test ParseResult creation."""
+ result = ParseResult(
+ success=True,
+ records_created=5,
+ records_failed=1,
+ errors=[],
+ load_batch="batch-123",
+ )
+ self.assertTrue(result.success)
+ self.assertEqual(result.records_created, 5)
+ self.assertEqual(result.load_batch, "batch-123")
+
+
+class BaseExcelParserTest(TestCase):
+ """Tests for BaseExcelParser."""
+
+ def test_parser_abstract_methods(self):
+ """Test parser has abstract methods."""
+ with self.assertRaises(TypeError):
+ BaseExcelParser()
+
+ def test_concrete_parser_implementation(self):
+ """Test concrete parser implementation."""
+
+ class TestParser(BaseExcelParser):
+ def get_column_mappings(self):
+ return [
+ ColumnMapping(column_index=1, field_name="inn", header_pattern="ИНН", required=True),
+ ColumnMapping(column_index=2, field_name="name", header_pattern="Наименование"),
+ ]
+
+ def create_record(self, row_data: dict):
+ return MagicMock(id=1)
+
+ parser = TestParser()
+ mappings = parser.get_column_mappings()
+
+ self.assertEqual(len(mappings), 2)
+ self.assertEqual(mappings[0].field_name, "inn")
diff --git a/tests/apps/form_1/__init__.py b/tests/apps/form_1/__init__.py
new file mode 100644
index 0000000..7b63b03
--- /dev/null
+++ b/tests/apps/form_1/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_1 app."""
diff --git a/tests/apps/form_1/factories.py b/tests/apps/form_1/factories.py
new file mode 100644
index 0000000..a7359c9
--- /dev/null
+++ b/tests/apps/form_1/factories.py
@@ -0,0 +1,34 @@
+"""Factories for form_1 app."""
+
+import factory
+from apps.form_1.models import FormF1Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF1RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF1Record model."""
+
+ class Meta:
+ model = FormF1Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Выпуск военной продукции (факт. цены)
+ military_output_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+ military_domestic_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+ military_export_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+
+ # Выпуск гражданской продукции (факт. цены)
+ civilian_output_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+ civilian_domestic_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+ civilian_export_actual = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=1000000, left_digits=10, right_digits=2))
+
+ # Кадры
+ avg_employees = factory.LazyAttribute(lambda _: fake.random_int(min=10, max=10000))
+ avg_payroll_employees = factory.LazyAttribute(lambda _: fake.random_int(min=10, max=10000))
+ payroll_fund = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=10000000, left_digits=12, right_digits=2))
diff --git a/tests/apps/form_1/test_models.py b/tests/apps/form_1/test_models.py
new file mode 100644
index 0000000..5584cd6
--- /dev/null
+++ b/tests/apps/form_1/test_models.py
@@ -0,0 +1,48 @@
+"""Tests for FormF1 model."""
+
+from django.test import TestCase
+
+from .factories import FormF1RecordFactory
+
+
+class FormF1RecordModelTest(TestCase):
+ """Tests for FormF1Record model."""
+
+ def setUp(self):
+ self.record = FormF1RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.load_batch)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-1: {self.record.organization} ({self.record.load_batch})"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+ self.assertIsNotNone(self.record.organization.name)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("military_output_actual")
+ self.assertEqual(field.max_digits, 18)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_integer_fields(self):
+ """Test integer fields."""
+ self.assertIsInstance(self.record.avg_employees, int)
+ self.assertIsInstance(self.record.avg_payroll_employees, int)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
+
+ def test_timestamps_auto_created(self):
+ """Test timestamps are auto-created."""
+ self.assertIsNotNone(self.record.created_at)
+ self.assertIsNotNone(self.record.updated_at)
diff --git a/tests/apps/form_1/test_services.py b/tests/apps/form_1/test_services.py
new file mode 100644
index 0000000..1ef89bc
--- /dev/null
+++ b/tests/apps/form_1/test_services.py
@@ -0,0 +1,87 @@
+"""Tests for FormF1 services."""
+
+from io import BytesIO
+from unittest.mock import MagicMock, patch
+
+from apps.form_1.services import FormF1Parser, FormF1Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF1RecordFactory
+
+
+class FormF1ServiceTest(TestCase):
+ """Tests for FormF1Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF1RecordFactory.create(organization=org)
+ FormF1RecordFactory.create(organization=org)
+ FormF1RecordFactory.create() # Другая организация
+
+ results = FormF1Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-123"
+ FormF1RecordFactory.create(load_batch=batch_id)
+ FormF1RecordFactory.create(load_batch=batch_id)
+ FormF1RecordFactory.create(load_batch="other-batch")
+
+ results = FormF1Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch"
+ FormF1RecordFactory.create(load_batch=batch_id)
+ FormF1RecordFactory.create(load_batch=batch_id)
+ other = FormF1RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF1Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ # Проверяем что другие записи остались
+ from apps.form_1.models import FormF1Record
+
+ self.assertTrue(FormF1Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF1ParserTest(TestCase):
+ """Tests for FormF1Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF1Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ # Проверяем наличие обязательных полей
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("military_output_actual", field_names)
+ self.assertIn("civilian_output_actual", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF1Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "1234567890",
+ "name": "Тестовая организация",
+ "military_output_actual": 100.0,
+ "civilian_output_actual": 200.0,
+ "avg_employees": 50,
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "1234567890")
+ self.assertEqual(record.military_output_actual, 100.0)
diff --git a/tests/apps/form_2/__init__.py b/tests/apps/form_2/__init__.py
new file mode 100644
index 0000000..ab2e20a
--- /dev/null
+++ b/tests/apps/form_2/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_2 app."""
diff --git a/tests/apps/form_2/factories.py b/tests/apps/form_2/factories.py
new file mode 100644
index 0000000..1a8fab4
--- /dev/null
+++ b/tests/apps/form_2/factories.py
@@ -0,0 +1,33 @@
+"""Factories for form_2 app."""
+
+import factory
+from apps.form_2.models import FormF2Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF2RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF2Record model."""
+
+ class Meta:
+ model = FormF2Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Активы
+ total_assets = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1000000, max_value=10000000000, left_digits=15, right_digits=2))
+ fixed_assets = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=100000, max_value=1000000000, left_digits=15, right_digits=2))
+ inventories = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=10000, max_value=100000000, left_digits=15, right_digits=2))
+ cash_and_equivalents = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=10000, max_value=100000000, left_digits=15, right_digits=2))
+
+ # Пассивы
+ total_liabilities = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1000000, max_value=10000000000, left_digits=15, right_digits=2))
+ total_equity = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=100000, max_value=5000000000, left_digits=15, right_digits=2))
+
+ # Финрезультаты
+ revenue = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1000000, max_value=50000000000, left_digits=15, right_digits=2))
+ net_profit = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-1000000000, max_value=5000000000, left_digits=15, right_digits=2))
diff --git a/tests/apps/form_2/test_models.py b/tests/apps/form_2/test_models.py
new file mode 100644
index 0000000..b7e00fa
--- /dev/null
+++ b/tests/apps/form_2/test_models.py
@@ -0,0 +1,43 @@
+"""Tests for FormF2 model."""
+
+from django.test import TestCase
+
+from .factories import FormF2RecordFactory
+
+
+class FormF2RecordModelTest(TestCase):
+ """Tests for FormF2Record model."""
+
+ def setUp(self):
+ self.record = FormF2RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.total_assets)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-2: {self.record.organization} ({self.record.load_batch})"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("total_assets")
+ self.assertEqual(field.max_digits, 18)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_nullable_fields(self):
+ """Test that financial fields are nullable."""
+ field = self.record._meta.get_field("ebitda")
+ self.assertTrue(field.null)
+ self.assertTrue(field.blank)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
diff --git a/tests/apps/form_2/test_services.py b/tests/apps/form_2/test_services.py
new file mode 100644
index 0000000..e29099e
--- /dev/null
+++ b/tests/apps/form_2/test_services.py
@@ -0,0 +1,79 @@
+"""Tests for FormF2 services."""
+
+from apps.form_2.services import FormF2Parser, FormF2Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF2RecordFactory
+
+
+class FormF2ServiceTest(TestCase):
+ """Tests for FormF2Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF2RecordFactory.create(organization=org)
+ FormF2RecordFactory.create(organization=org)
+ FormF2RecordFactory.create()
+
+ results = FormF2Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-f2"
+ FormF2RecordFactory.create(load_batch=batch_id)
+ FormF2RecordFactory.create(load_batch=batch_id)
+
+ results = FormF2Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch-f2"
+ FormF2RecordFactory.create(load_batch=batch_id)
+ FormF2RecordFactory.create(load_batch=batch_id)
+ other = FormF2RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF2Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ from apps.form_2.models import FormF2Record
+
+ self.assertTrue(FormF2Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF2ParserTest(TestCase):
+ """Tests for FormF2Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF2Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("total_assets", field_names)
+ self.assertIn("revenue", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF2Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "2234567890",
+ "name": "Тестовая организация Ф-2",
+ "total_assets": 1000000.0,
+ "revenue": 500000.0,
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "2234567890")
diff --git a/tests/apps/form_3/__init__.py b/tests/apps/form_3/__init__.py
new file mode 100644
index 0000000..b6364bf
--- /dev/null
+++ b/tests/apps/form_3/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_3 app."""
diff --git a/tests/apps/form_3/factories.py b/tests/apps/form_3/factories.py
new file mode 100644
index 0000000..f18534a
--- /dev/null
+++ b/tests/apps/form_3/factories.py
@@ -0,0 +1,34 @@
+"""Factories for form_3 app."""
+
+import factory
+from apps.form_3.models import FormF3Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF3RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF3Record model."""
+
+ class Meta:
+ model = FormF3Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Кадры
+ avg_employees = factory.LazyAttribute(lambda _: fake.random_int(min=50, max=5000))
+ production_workers = factory.LazyAttribute(lambda _: fake.random_int(min=20, max=3000))
+ engineering_workers = factory.LazyAttribute(lambda _: fake.random_int(min=10, max=500))
+ administrative_workers = factory.LazyAttribute(lambda _: fake.random_int(min=5, max=300))
+
+ # Оборудование
+ total_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=10, max=1000))
+ domestic_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=5, max=500))
+ imported_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=5, max=500))
+
+ # Износ
+ physical_wear_percent = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
+ utilization_rate = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
diff --git a/tests/apps/form_3/test_models.py b/tests/apps/form_3/test_models.py
new file mode 100644
index 0000000..5001782
--- /dev/null
+++ b/tests/apps/form_3/test_models.py
@@ -0,0 +1,42 @@
+"""Tests for FormF3 model."""
+
+from django.test import TestCase
+
+from .factories import FormF3RecordFactory
+
+
+class FormF3RecordModelTest(TestCase):
+ """Tests for FormF3Record model."""
+
+ def setUp(self):
+ self.record = FormF3RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.avg_employees)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-3: {self.record.organization} ({self.record.load_batch})"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+
+ def test_integer_fields(self):
+ """Test integer fields."""
+ self.assertIsInstance(self.record.avg_employees, int)
+ self.assertIsInstance(self.record.total_equipment, int)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("physical_wear_percent")
+ self.assertEqual(field.max_digits, 5)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
diff --git a/tests/apps/form_3/test_services.py b/tests/apps/form_3/test_services.py
new file mode 100644
index 0000000..1bc756f
--- /dev/null
+++ b/tests/apps/form_3/test_services.py
@@ -0,0 +1,79 @@
+"""Tests for FormF3 services."""
+
+from apps.form_3.services import FormF3Parser, FormF3Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF3RecordFactory
+
+
+class FormF3ServiceTest(TestCase):
+ """Tests for FormF3Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF3RecordFactory.create(organization=org)
+ FormF3RecordFactory.create(organization=org)
+ FormF3RecordFactory.create()
+
+ results = FormF3Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-f3"
+ FormF3RecordFactory.create(load_batch=batch_id)
+ FormF3RecordFactory.create(load_batch=batch_id)
+
+ results = FormF3Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch-f3"
+ FormF3RecordFactory.create(load_batch=batch_id)
+ FormF3RecordFactory.create(load_batch=batch_id)
+ other = FormF3RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF3Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ from apps.form_3.models import FormF3Record
+
+ self.assertTrue(FormF3Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF3ParserTest(TestCase):
+ """Tests for FormF3Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF3Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("avg_employees", field_names)
+ self.assertIn("total_equipment", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF3Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "3234567890",
+ "name": "Тестовая организация Ф-3",
+ "avg_employees": 100,
+ "total_equipment": 50,
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "3234567890")
diff --git a/tests/apps/form_4/__init__.py b/tests/apps/form_4/__init__.py
new file mode 100644
index 0000000..b1281bb
--- /dev/null
+++ b/tests/apps/form_4/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_4 app."""
diff --git a/tests/apps/form_4/factories.py b/tests/apps/form_4/factories.py
new file mode 100644
index 0000000..a71981b
--- /dev/null
+++ b/tests/apps/form_4/factories.py
@@ -0,0 +1,39 @@
+"""Factories for form_4 app."""
+
+import factory
+from apps.form_4.models import FormF4Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF4RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF4Record model."""
+
+ class Meta:
+ model = FormF4Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Выручка
+ revenue_rsbu = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1000000, max_value=100000000000, left_digits=15, right_digits=2))
+ revenue_ifrs = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1000000, max_value=100000000000, left_digits=15, right_digits=2))
+
+ # Прибыль
+ net_profit_rsbu = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-10000000000, max_value=50000000000, left_digits=15, right_digits=2))
+ net_profit_ifrs = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-10000000000, max_value=50000000000, left_digits=15, right_digits=2))
+
+ # EBITDA
+ ebitda_rsbu = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=50000000000, left_digits=15, right_digits=2))
+ ebitda_ifrs = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=50000000000, left_digits=15, right_digits=2))
+
+ # Долг
+ net_debt_rsbu = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100000000000, left_digits=15, right_digits=2))
+
+ # Рентабельность
+ roe = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-100, max_value=100, left_digits=5, right_digits=2))
+ roa = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-100, max_value=100, left_digits=5, right_digits=2))
+ ros = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=-100, max_value=100, left_digits=5, right_digits=2))
diff --git a/tests/apps/form_4/test_models.py b/tests/apps/form_4/test_models.py
new file mode 100644
index 0000000..b316b33
--- /dev/null
+++ b/tests/apps/form_4/test_models.py
@@ -0,0 +1,43 @@
+"""Tests for FormF4 model."""
+
+from django.test import TestCase
+
+from .factories import FormF4RecordFactory
+
+
+class FormF4RecordModelTest(TestCase):
+ """Tests for FormF4Record model."""
+
+ def setUp(self):
+ self.record = FormF4RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.revenue_rsbu)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-4: {self.record.organization} ({self.record.load_batch})"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("revenue_rsbu")
+ self.assertEqual(field.max_digits, 18)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_ratio_fields_precision(self):
+ """Test ratio fields precision."""
+ field = self.record._meta.get_field("roe")
+ self.assertEqual(field.max_digits, 8)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
diff --git a/tests/apps/form_4/test_services.py b/tests/apps/form_4/test_services.py
new file mode 100644
index 0000000..b6170b3
--- /dev/null
+++ b/tests/apps/form_4/test_services.py
@@ -0,0 +1,79 @@
+"""Tests for FormF4 services."""
+
+from apps.form_4.services import FormF4Parser, FormF4Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF4RecordFactory
+
+
+class FormF4ServiceTest(TestCase):
+ """Tests for FormF4Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF4RecordFactory.create(organization=org)
+ FormF4RecordFactory.create(organization=org)
+ FormF4RecordFactory.create()
+
+ results = FormF4Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-f4"
+ FormF4RecordFactory.create(load_batch=batch_id)
+ FormF4RecordFactory.create(load_batch=batch_id)
+
+ results = FormF4Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch-f4"
+ FormF4RecordFactory.create(load_batch=batch_id)
+ FormF4RecordFactory.create(load_batch=batch_id)
+ other = FormF4RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF4Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ from apps.form_4.models import FormF4Record
+
+ self.assertTrue(FormF4Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF4ParserTest(TestCase):
+ """Tests for FormF4Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF4Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("revenue_rsbu", field_names)
+ self.assertIn("net_profit_rsbu", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF4Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "4234567890",
+ "name": "Тестовая организация Ф-4",
+ "revenue_rsbu": 1000000.0,
+ "net_profit_rsbu": 50000.0,
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "4234567890")
diff --git a/tests/apps/form_5/__init__.py b/tests/apps/form_5/__init__.py
new file mode 100644
index 0000000..73e0eb1
--- /dev/null
+++ b/tests/apps/form_5/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_5 app."""
diff --git a/tests/apps/form_5/factories.py b/tests/apps/form_5/factories.py
new file mode 100644
index 0000000..e1ec13c
--- /dev/null
+++ b/tests/apps/form_5/factories.py
@@ -0,0 +1,44 @@
+"""Factories for form_5 app."""
+
+import factory
+from apps.form_5.models import FormF5Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF5RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF5Record model."""
+
+ class Meta:
+ model = FormF5Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Идентификация
+ equipment_id = factory.LazyAttribute(lambda _: fake.numerify("EQ-######"))
+ inventory_number = factory.LazyAttribute(lambda _: fake.numerify("INV-########"))
+ name = factory.LazyAttribute(lambda _: fake.word().capitalize() + " " + fake.word())
+ model = factory.LazyAttribute(lambda _: fake.bothify("??-####"))
+
+ # Производитель
+ manufacturer = factory.LazyAttribute(lambda _: fake.company())
+ country_origin = factory.LazyAttribute(lambda _: fake.country())
+ is_domestic = factory.LazyAttribute(lambda _: fake.boolean())
+
+ # Характеристики
+ year_manufacture = factory.LazyAttribute(lambda _: fake.random_int(min=1990, max=2024))
+ has_cnc = factory.LazyAttribute(lambda _: fake.boolean())
+ equipment_type = factory.LazyAttribute(lambda _: fake.random_element(["Токарный", "Фрезерный", "Шлифовальный", "Сверлильный"]))
+
+ # Состояние
+ utilization_rate = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
+ physical_wear_percent = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
+ is_operational = factory.LazyAttribute(lambda _: fake.boolean(chance_of_getting_true=85))
+
+ # Стоимость
+ initial_cost = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=100000, max_value=100000000, left_digits=12, right_digits=2))
+ residual_value = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=10000, max_value=50000000, left_digits=12, right_digits=2))
diff --git a/tests/apps/form_5/test_models.py b/tests/apps/form_5/test_models.py
new file mode 100644
index 0000000..edc676e
--- /dev/null
+++ b/tests/apps/form_5/test_models.py
@@ -0,0 +1,48 @@
+"""Tests for FormF5 model."""
+
+from django.test import TestCase
+
+from .factories import FormF5RecordFactory
+
+
+class FormF5RecordModelTest(TestCase):
+ """Tests for FormF5Record model."""
+
+ def setUp(self):
+ self.record = FormF5RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.equipment_id)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-5: {self.record.organization} - {self.record.equipment_id}"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+
+ def test_string_fields_max_length(self):
+ """Test string fields max length."""
+ self.assertEqual(self.record._meta.get_field("equipment_id").max_length, 50)
+ self.assertEqual(self.record._meta.get_field("name").max_length, 500)
+
+ def test_boolean_fields(self):
+ """Test boolean fields."""
+ self.assertIsInstance(self.record.is_domestic, bool)
+ self.assertIsInstance(self.record.has_cnc, bool)
+ self.assertIsInstance(self.record.is_operational, bool)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("initial_cost")
+ self.assertEqual(field.max_digits, 18)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
diff --git a/tests/apps/form_5/test_services.py b/tests/apps/form_5/test_services.py
new file mode 100644
index 0000000..ad884f4
--- /dev/null
+++ b/tests/apps/form_5/test_services.py
@@ -0,0 +1,79 @@
+"""Tests for FormF5 services."""
+
+from apps.form_5.services import FormF5Parser, FormF5Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF5RecordFactory
+
+
+class FormF5ServiceTest(TestCase):
+ """Tests for FormF5Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF5RecordFactory.create(organization=org)
+ FormF5RecordFactory.create(organization=org)
+ FormF5RecordFactory.create()
+
+ results = FormF5Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-f5"
+ FormF5RecordFactory.create(load_batch=batch_id)
+ FormF5RecordFactory.create(load_batch=batch_id)
+
+ results = FormF5Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch-f5"
+ FormF5RecordFactory.create(load_batch=batch_id)
+ FormF5RecordFactory.create(load_batch=batch_id)
+ other = FormF5RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF5Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ from apps.form_5.models import FormF5Record
+
+ self.assertTrue(FormF5Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF5ParserTest(TestCase):
+ """Tests for FormF5Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF5Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("equipment_id", field_names)
+ self.assertIn("name", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF5Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "5234567890",
+ "name": "Тестовая организация Ф-5",
+ "equipment_id": "EQ-001",
+ "equipment_name": "Токарный станок",
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "5234567890")
diff --git a/tests/apps/form_6/__init__.py b/tests/apps/form_6/__init__.py
new file mode 100644
index 0000000..b73b6de
--- /dev/null
+++ b/tests/apps/form_6/__init__.py
@@ -0,0 +1 @@
+"""Tests for form_6 app."""
diff --git a/tests/apps/form_6/factories.py b/tests/apps/form_6/factories.py
new file mode 100644
index 0000000..e9e44e5
--- /dev/null
+++ b/tests/apps/form_6/factories.py
@@ -0,0 +1,43 @@
+"""Factories for form_6 app."""
+
+import factory
+from apps.form_6.models import FormF6Record
+from faker import Faker
+
+from tests.apps.organization.factories import OrganizationFactory
+
+fake = Faker("ru_RU")
+
+
+class FormF6RecordFactory(factory.django.DjangoModelFactory):
+ """Factory for FormF6Record model."""
+
+ class Meta:
+ model = FormF6Record
+
+ organization = factory.SubFactory(OrganizationFactory)
+ load_batch = factory.LazyAttribute(lambda _: fake.uuid4())
+
+ # Категоризация
+ row_code = factory.LazyAttribute(lambda _: fake.numerify("###"))
+ category = factory.LazyAttribute(lambda _: fake.random_element(["Металлорежущее", "Кузнечно-прессовое", "Литейное", "Сварочное"]))
+
+ # Общие данные
+ total_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=10, max=500))
+ domestic_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=5, max=250))
+ imported_equipment = factory.LazyAttribute(lambda _: fake.random_int(min=5, max=250))
+
+ # Возрастная структура
+ age_under_5 = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=50))
+ age_5_10 = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=100))
+ age_10_15 = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=100))
+ age_15_20 = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=100))
+ age_over_20 = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=150))
+
+ # С ЧПУ
+ cnc_total = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=100))
+
+ # Показатели
+ avg_shift_work = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1, max_value=3, left_digits=1, right_digits=2))
+ utilization_rate = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
+ physical_wear_percent = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=0, max_value=100, left_digits=3, right_digits=2))
diff --git a/tests/apps/form_6/test_models.py b/tests/apps/form_6/test_models.py
new file mode 100644
index 0000000..938e8a8
--- /dev/null
+++ b/tests/apps/form_6/test_models.py
@@ -0,0 +1,47 @@
+"""Tests for FormF6 model."""
+
+from django.test import TestCase
+
+from .factories import FormF6RecordFactory
+
+
+class FormF6RecordModelTest(TestCase):
+ """Tests for FormF6Record model."""
+
+ def setUp(self):
+ self.record = FormF6RecordFactory.create()
+
+ def test_record_creation(self):
+ """Test record creation."""
+ self.assertIsNotNone(self.record.id)
+ self.assertIsNotNone(self.record.organization)
+ self.assertIsNotNone(self.record.row_code)
+
+ def test_record_str_representation(self):
+ """Test string representation."""
+ expected = f"Ф-6: {self.record.organization} - {self.record.row_code}"
+ self.assertEqual(str(self.record), expected)
+
+ def test_organization_relationship(self):
+ """Test organization FK relationship."""
+ self.assertIsNotNone(self.record.organization.inn)
+
+ def test_string_fields_max_length(self):
+ """Test string fields max length."""
+ self.assertEqual(self.record._meta.get_field("row_code").max_length, 10)
+ self.assertEqual(self.record._meta.get_field("category").max_length, 200)
+
+ def test_integer_fields(self):
+ """Test integer fields."""
+ self.assertIsInstance(self.record.total_equipment, int)
+ self.assertIsInstance(self.record.cnc_total, int)
+
+ def test_decimal_fields_precision(self):
+ """Test decimal fields precision."""
+ field = self.record._meta.get_field("physical_wear_percent")
+ self.assertEqual(field.max_digits, 5)
+ self.assertEqual(field.decimal_places, 2)
+
+ def test_load_batch_index(self):
+ """Test load_batch has db_index."""
+ self.assertTrue(self.record._meta.get_field("load_batch").db_index)
diff --git a/tests/apps/form_6/test_services.py b/tests/apps/form_6/test_services.py
new file mode 100644
index 0000000..e108a6d
--- /dev/null
+++ b/tests/apps/form_6/test_services.py
@@ -0,0 +1,80 @@
+"""Tests for FormF6 services."""
+
+from apps.form_6.services import FormF6Parser, FormF6Service
+from django.test import TestCase
+
+from tests.apps.organization.factories import OrganizationFactory
+
+from .factories import FormF6RecordFactory
+
+
+class FormF6ServiceTest(TestCase):
+ """Tests for FormF6Service."""
+
+ def test_get_by_organization(self):
+ """Test getting records by organization."""
+ org = OrganizationFactory.create()
+ FormF6RecordFactory.create(organization=org)
+ FormF6RecordFactory.create(organization=org)
+ FormF6RecordFactory.create()
+
+ results = FormF6Service.get_by_organization(org.id)
+ self.assertEqual(results.count(), 2)
+
+ def test_get_by_load_batch(self):
+ """Test getting records by load batch."""
+ batch_id = "test-batch-f6"
+ FormF6RecordFactory.create(load_batch=batch_id)
+ FormF6RecordFactory.create(load_batch=batch_id)
+
+ results = FormF6Service.get_by_load_batch(batch_id)
+ self.assertEqual(results.count(), 2)
+
+ def test_delete_by_load_batch(self):
+ """Test deleting records by load batch."""
+ batch_id = "delete-batch-f6"
+ FormF6RecordFactory.create(load_batch=batch_id)
+ FormF6RecordFactory.create(load_batch=batch_id)
+ other = FormF6RecordFactory.create(load_batch="keep-batch")
+
+ count = FormF6Service.delete_by_load_batch(batch_id)
+ self.assertEqual(count, 2)
+
+ from apps.form_6.models import FormF6Record
+
+ self.assertTrue(FormF6Record.objects.filter(pk=other.pk).exists())
+
+
+class FormF6ParserTest(TestCase):
+ """Tests for FormF6Parser."""
+
+ def test_get_column_mappings_returns_mappings(self):
+ """Test get_column_mappings returns correct mappings."""
+ parser = FormF6Parser()
+ mappings = parser.get_column_mappings()
+
+ self.assertIsInstance(mappings, list)
+ self.assertTrue(len(mappings) > 0)
+
+ field_names = [m.field_name for m in mappings]
+ self.assertIn("inn", field_names)
+ self.assertIn("row_code", field_names)
+ self.assertIn("total_equipment", field_names)
+
+ def test_create_record_creates_organization(self):
+ """Test create_record creates organization if not exists."""
+ parser = FormF6Parser()
+ parser.load_batch = "test-batch"
+
+ row_data = {
+ "inn": "6234567890",
+ "name": "Тестовая организация Ф-6",
+ "row_code": "001",
+ "category": "Металлорежущее",
+ "total_equipment": 100,
+ }
+
+ record = parser.create_record(row_data)
+
+ self.assertIsNotNone(record)
+ self.assertEqual(record.organization.inn, "6234567890")
diff --git a/tests/apps/organization/__init__.py b/tests/apps/organization/__init__.py
new file mode 100644
index 0000000..05e2baa
--- /dev/null
+++ b/tests/apps/organization/__init__.py
@@ -0,0 +1 @@
+"""Tests for organization app."""
diff --git a/tests/apps/organization/factories.py b/tests/apps/organization/factories.py
new file mode 100644
index 0000000..afcca94
--- /dev/null
+++ b/tests/apps/organization/factories.py
@@ -0,0 +1,25 @@
+"""Factories for organization app."""
+
+import factory
+from apps.organization.models import Organization
+from faker import Faker
+
+fake = Faker("ru_RU")
+
+
+class OrganizationFactory(factory.django.DjangoModelFactory):
+ """Factory for Organization model."""
+
+ class Meta:
+ model = Organization
+
+ name = factory.LazyAttribute(lambda _: fake.company())
+ inn = factory.LazyAttribute(lambda _: fake.numerify("##########"))
+ ogrn = factory.LazyAttribute(lambda _: fake.numerify("#############"))
+ kpp = factory.LazyAttribute(lambda _: fake.numerify("#########"))
+ okpo = factory.LazyAttribute(lambda _: fake.numerify("########"))
+
+ @classmethod
+ def create_organization(cls, **kwargs):
+ """Create organization with defaults."""
+ return cls.create(**kwargs)
diff --git a/tests/apps/organization/test_models.py b/tests/apps/organization/test_models.py
new file mode 100644
index 0000000..f854f39
--- /dev/null
+++ b/tests/apps/organization/test_models.py
@@ -0,0 +1,52 @@
+"""Tests for Organization model."""
+
+from django.test import TestCase
+
+from .factories import OrganizationFactory
+
+
+class OrganizationModelTest(TestCase):
+ """Tests for Organization model."""
+
+ def setUp(self):
+ self.org = OrganizationFactory.create()
+
+ def test_organization_creation(self):
+ """Test organization creation."""
+ self.assertIsNotNone(self.org.id)
+ self.assertTrue(self.org.name)
+ self.assertTrue(self.org.inn)
+
+ def test_organization_str_representation(self):
+ """Test string representation."""
+ expected = f"{self.org.name} (ИНН: {self.org.inn})"
+ self.assertEqual(str(self.org), expected)
+
+ def test_inn_unique(self):
+ """Test INN field is unique."""
+ self.assertTrue(self.org._meta.get_field("inn").unique)
+
+ def test_inn_max_length(self):
+ """Test INN max length."""
+ self.assertEqual(self.org._meta.get_field("inn").max_length, 12)
+
+ def test_ogrn_max_length(self):
+ """Test OGRN max length."""
+ self.assertEqual(self.org._meta.get_field("ogrn").max_length, 15)
+
+ def test_kpp_max_length(self):
+ """Test KPP max length."""
+ self.assertEqual(self.org._meta.get_field("kpp").max_length, 9)
+
+ def test_name_max_length(self):
+ """Test name max length."""
+ self.assertEqual(self.org._meta.get_field("name").max_length, 500)
+
+ def test_timestamps_auto_created(self):
+ """Test timestamps are auto-created."""
+ self.assertIsNotNone(self.org.created_at)
+ self.assertIsNotNone(self.org.updated_at)
+
+ def test_ogrn_index_exists(self):
+ """Test OGRN has db_index."""
+ self.assertTrue(self.org._meta.get_field("ogrn").db_index)
diff --git a/tests/apps/organization/test_services.py b/tests/apps/organization/test_services.py
new file mode 100644
index 0000000..5697752
--- /dev/null
+++ b/tests/apps/organization/test_services.py
@@ -0,0 +1,52 @@
+"""Tests for Organization services."""
+
+from apps.organization.services import OrganizationService
+from django.test import TestCase
+
+from .factories import OrganizationFactory
+
+
+class OrganizationServiceTest(TestCase):
+ """Tests for OrganizationService."""
+
+ def test_get_or_create_by_inn_creates_new(self):
+ """Test get_or_create_by_inn creates new organization."""
+ org, created = OrganizationService.get_or_create_by_inn(
+ inn="1234567890",
+ defaults={"name": "Test Org", "ogrn": "1234567890123"},
+ )
+ self.assertTrue(created)
+ self.assertEqual(org.inn, "1234567890")
+ self.assertEqual(org.name, "Test Org")
+
+ def test_get_or_create_by_inn_returns_existing(self):
+ """Test get_or_create_by_inn returns existing organization."""
+ existing = OrganizationFactory.create(inn="9876543210")
+
+ org, created = OrganizationService.get_or_create_by_inn(
+ inn="9876543210",
+ defaults={"name": "Different Name"},
+ )
+ self.assertFalse(created)
+ self.assertEqual(org.pk, existing.pk)
+ self.assertEqual(org.name, existing.name)
+
+ def test_get_by_inn_found(self):
+ """Test get_by_inn returns organization."""
+ existing = OrganizationFactory.create(inn="5555555555")
+ result = OrganizationService.get_by_inn("5555555555")
+ self.assertEqual(result.pk, existing.pk)
+
+ def test_get_by_inn_not_found(self):
+ """Test get_by_inn returns None when not found."""
+ result = OrganizationService.get_by_inn("0000000000")
+ self.assertIsNone(result)
+
+ def test_search_by_name(self):
+ """Test search_by_name functionality."""
+ OrganizationFactory.create(name="ООО Ромашка")
+ OrganizationFactory.create(name="ООО Василек")
+ OrganizationFactory.create(name="АО Ромашка-Плюс")
+
+ results = OrganizationService.search_by_name("Ромашка")
+ self.assertEqual(results.count(), 2)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..1ba9682
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,10 @@
+"""Pytest configuration to ensure src/ is importable."""
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 0000000..81d9f79
--- /dev/null
+++ b/tests/utils/__init__.py
@@ -0,0 +1,5 @@
+"""Test utilities."""
+
+from .http_server import TestHTTPServer, Response
+
+__all__ = ["TestHTTPServer", "Response"]
diff --git a/tests/utils/fixtures.py b/tests/utils/fixtures.py
new file mode 100644
index 0000000..7d3c925
--- /dev/null
+++ b/tests/utils/fixtures.py
@@ -0,0 +1,241 @@
+"""Fixture builders for integration tests (Faker-based)."""
+
+from __future__ import annotations
+
+import io
+import zipfile
+from dataclasses import dataclass
+from typing import Iterable
+
+from faker import Faker
+from openpyxl import Workbook
+
+fake = Faker("ru_RU")
+
+
+@dataclass(frozen=True)
+class CertificateRow:
+ issue_date: str
+ certificate_number: str
+ expiry_date: str
+ certificate_file_url: str
+ organisation_name: str
+ inn: str
+ ogrn: str
+
+
+@dataclass(frozen=True)
+class ManufacturerRow:
+ full_legal_name: str
+ inn: str
+ ogrn: str
+ address: str
+
+
+@dataclass(frozen=True)
+class InspectionRow:
+ registration_number: str
+ inn: str
+ ogrn: str
+ organisation_name: str
+ control_authority: str
+ inspection_type: str
+ inspection_form: str
+ start_date: str
+ end_date: str
+ status: str
+ legal_basis: str
+ result: str
+
+
+@dataclass(frozen=True)
+class ProcurementRow:
+ purchase_number: str
+ purchase_name: str
+ customer_inn: str
+ customer_kpp: str
+ customer_ogrn: str
+ customer_name: str
+ max_price: str
+ publish_date: str
+ end_date: str
+ status: str
+ href: str
+
+
+def _digits(length: int) -> str:
+ return "".join(str(fake.random_int(0, 9)) for _ in range(length))
+
+
+def build_minpromtorg_certificates_excel(count: int = 5) -> tuple[bytes, list[CertificateRow]]:
+ wb = Workbook()
+ ws = wb.active
+ ws.append(
+ [
+ "issue_date",
+ "certificate_number",
+ "expiry_date",
+ "certificate_file_url",
+ "organisation_name",
+ "inn",
+ "ogrn",
+ ]
+ )
+
+ rows: list[CertificateRow] = []
+ for _ in range(count):
+ row = CertificateRow(
+ issue_date=str(fake.date()),
+ certificate_number=f"{fake.bothify(text='??-####-#####')}",
+ expiry_date=str(fake.date()),
+ certificate_file_url=fake.url(),
+ organisation_name=fake.company(),
+ inn=_digits(10),
+ ogrn=_digits(13),
+ )
+ rows.append(row)
+ ws.append(
+ [
+ row.issue_date,
+ row.certificate_number,
+ row.expiry_date,
+ row.certificate_file_url,
+ row.organisation_name,
+ row.inn,
+ row.ogrn,
+ ]
+ )
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ wb.close()
+ return buf.getvalue(), rows
+
+
+def build_minpromtorg_manufacturers_excel(
+ count: int = 5,
+) -> tuple[bytes, list[ManufacturerRow]]:
+ wb = Workbook()
+ ws = wb.active
+ ws.append(["full_legal_name", "inn", "ogrn", "address"])
+
+ rows: list[ManufacturerRow] = []
+ for _ in range(count):
+ row = ManufacturerRow(
+ full_legal_name=fake.company(),
+ inn=_digits(10),
+ ogrn=_digits(13),
+ address=fake.address().replace("\n", ", "),
+ )
+ rows.append(row)
+ ws.append([row.full_legal_name, row.inn, row.ogrn, row.address])
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ wb.close()
+ return buf.getvalue(), rows
+
+
+def build_proverki_xml(count: int = 3) -> tuple[bytes, list[InspectionRow]]:
+ rows: list[InspectionRow] = []
+ parts = ["", ""]
+
+ for _ in range(count):
+ row = InspectionRow(
+ registration_number=_digits(12),
+ inn=_digits(10),
+ ogrn=_digits(13),
+ organisation_name=fake.company(),
+ control_authority=fake.company(),
+ inspection_type=fake.word(),
+ inspection_form=fake.word(),
+ start_date=str(fake.date()),
+ end_date=str(fake.date()),
+ status=fake.word(),
+ legal_basis=fake.sentence(nb_words=4),
+ result=fake.sentence(nb_words=3),
+ )
+ rows.append(row)
+ parts.append(
+ ""
+ )
+
+ parts.append("")
+ xml = "".join(parts).encode("utf-8")
+ return xml, rows
+
+
+def build_zakupki_xml(count: int = 3) -> tuple[bytes, list[ProcurementRow]]:
+ rows: list[ProcurementRow] = []
+ parts = ["", ""]
+
+ for _ in range(count):
+ row = ProcurementRow(
+ purchase_number=_digits(19),
+ purchase_name=fake.sentence(nb_words=6),
+ customer_inn=_digits(10),
+ customer_kpp=_digits(9),
+ customer_ogrn=_digits(13),
+ customer_name=fake.company(),
+ max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)),
+ publish_date=str(fake.date()),
+ end_date=str(fake.date()),
+ status=fake.word(),
+ href=fake.url(),
+ )
+ rows.append(row)
+ parts.append(
+ ""
+ f"{row.purchase_number}"
+ f"{row.purchase_name}"
+ ""
+ f"{row.customer_inn}"
+ f"{row.customer_kpp}"
+ f"{row.customer_ogrn}"
+ f"{row.customer_name}"
+ ""
+ f"{row.max_price}"
+ f"{row.publish_date}"
+ f"{row.end_date}"
+ f"{row.status}"
+ f"{row.href}"
+ ""
+ )
+
+ parts.append("")
+ xml = "".join(parts).encode("utf-8")
+ return xml, rows
+
+
+def build_zip(files: Iterable[tuple[str, bytes]]) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name, content in files:
+ zf.writestr(name, content)
+ return buf.getvalue()
+
+
+__all__ = [
+ "fake",
+ "CertificateRow",
+ "ManufacturerRow",
+ "InspectionRow",
+ "ProcurementRow",
+ "build_minpromtorg_certificates_excel",
+ "build_minpromtorg_manufacturers_excel",
+ "build_proverki_xml",
+ "build_zakupki_xml",
+ "build_zip",
+]
diff --git a/tests/utils/http_server.py b/tests/utils/http_server.py
new file mode 100644
index 0000000..ba41655
--- /dev/null
+++ b/tests/utils/http_server.py
@@ -0,0 +1,134 @@
+"""Lightweight in-memory HTTP router for integration tests (no sockets)."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from types import SimpleNamespace
+from typing import Callable
+from urllib.parse import urlparse
+
+import requests
+from requests.adapters import BaseAdapter
+from requests.models import Response as RequestsResponse
+
+
+@dataclass
+class Response:
+ status: int = 200
+ body: bytes = b""
+ headers: dict[str, str] = field(default_factory=dict)
+
+
+RouteHandler = Callable[[SimpleNamespace, bytes], Response]
+
+
+def _json_response(data: object, status: int = 200) -> Response:
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
+ return Response(
+ status=status,
+ body=body,
+ headers={"Content-Type": "application/json; charset=utf-8"},
+ )
+
+
+class _InMemoryAdapter(BaseAdapter):
+ def __init__(self, routes: dict[tuple[str, str], Response | RouteHandler]) -> None:
+ super().__init__()
+ self._routes = routes
+
+ def send(self, request, **_kwargs): # noqa: D401
+ parsed = urlparse(request.url)
+ key = (request.method.upper(), parsed.path)
+ route = self._routes.get(key)
+
+ if route is None:
+ response = Response(status=404, body=b"", headers={})
+ else:
+ body = request.body or b""
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ if callable(route):
+ response = route(
+ SimpleNamespace(
+ path=parsed.path,
+ query=parsed.query,
+ method=request.method.upper(),
+ ),
+ body,
+ )
+ else:
+ response = route
+
+ return self._build_response(request, response)
+
+ def _build_response(self, request, response: Response) -> RequestsResponse:
+ resp = RequestsResponse()
+ resp.status_code = response.status
+ resp._content = response.body
+ resp.headers.update(response.headers)
+ resp.url = request.url
+ resp.request = request
+ return resp
+
+ def close(self) -> None: # noqa: D401
+ return
+
+
+class TestHTTPServer:
+ """Context-managed in-memory HTTP router with requests adapter."""
+
+ def __init__(self) -> None:
+ self._routes: dict[tuple[str, str], Response | RouteHandler] = {}
+ self._adapter = _InMemoryAdapter(self._routes)
+ self._base_url = "http://testserver"
+ self._started = False
+
+ @property
+ def base_url(self) -> str:
+ return self._base_url
+
+ @property
+ def adapter(self) -> BaseAdapter:
+ return self._adapter
+
+ def mount(self, client_or_session) -> None:
+ session = getattr(client_or_session, "session", client_or_session)
+ session.mount(self._base_url, self._adapter)
+ session.mount(self._base_url.replace("http://", "https://", 1), self._adapter)
+
+ def add_json(self, path: str, data: object, *, status: int = 200) -> None:
+ self._routes[("GET", path)] = _json_response(data, status=status)
+
+ def add_bytes(
+ self,
+ path: str,
+ data: bytes,
+ *,
+ content_type: str = "application/octet-stream",
+ status: int = 200,
+ ) -> None:
+ self._routes[("GET", path)] = Response(
+ status=status,
+ body=data,
+ headers={"Content-Type": content_type},
+ )
+
+ def add_route(self, method: str, path: str, handler: RouteHandler) -> None:
+ self._routes[(method.upper(), path)] = handler
+
+ def start(self) -> None:
+ self._started = True
+
+ def stop(self) -> None:
+ self._started = False
+
+ def __enter__(self) -> "TestHTTPServer":
+ self.start()
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ self.stop()
+
+
+__all__ = ["TestHTTPServer", "Response"]
diff --git a/ТЕХНИЧЕСКОЕ_ОПИСАНИЕ.md b/ТЕХНИЧЕСКОЕ_ОПИСАНИЕ.md
new file mode 100644
index 0000000..98dd924
--- /dev/null
+++ b/ТЕХНИЧЕСКОЕ_ОПИСАНИЕ.md
@@ -0,0 +1,1801 @@
+# ТЕХНИЧЕСКОЕ ОПИСАНИЕ ПРОГРАММНОГО КОМПЛЕКСА УЧЁТА И ОБРАБОТКИ ОТЧЁТНЫХ ФОРМ ГОСКОРПОРАЦИЙ (STATE CORP BACKEND)
+
+**Версия документа:** 1.0
+**Статус:** Утверждённая редакция
+**Дата разработки:** 09.02.2026
+
+Настоящее техническое описание устанавливает назначение, состав, структуру, программно‑технологические решения и правила эксплуатации программного комплекса **State Corp Backend**, а также требования к форматам входных данных. Документ предназначен для заказчика, эксплуатирующих подразделений и команд сопровождения.
+
+---
+
+## СОДЕРЖАНИЕ
+
+1. [Общие сведения о программном обеспечении](#1-общие-сведения-о-программном-обеспечении)
+2. [Описание программной реализации моделей информационного обмена и сбора данных](#2-описание-программной-реализации-моделей-информационного-обмена-и-сбора-данных)
+3. [Программно-технологические решения по систематизации и обработке данных](#3-программно-технологические-решения-по-систематизации-и-обработке-данных)
+4. [Структура базы данных](#4-структура-базы-данных)
+5. [Архитектура программного интерфейса (API)](#5-архитектура-программного-интерфейса-api)
+6. [Механизмы обеспечения надёжности и отказоустойчивости](#6-механизмы-обеспечения-надёжности-и-отказоустойчивости)
+7. [Порядок развёртывания и испытаний](#7-порядок-развёртывания-и-испытаний)
+8. [Заключение](#8-заключение)
+9. [Приложение А (обязательное). Форматы входных Excel-шаблонов](#приложение-а-обязательное-форматы-входных-excel-шаблонов)
+10. [Приложение Б (рекомендуемое). Примеры входных файлов](#приложение-б-рекомендуемое-примеры-входных-файлов)
+11. [Приложение В (справочное). Справочник показателей](#приложение-в-справочное-справочник-показателей)
+
+---
+
+## 1. ОБЩИЕ СВЕДЕНИЯ О ПРОГРАММНОМ ОБЕСПЕЧЕНИИ
+
+### 1.1. Назначение системы
+
+Программный комплекс **State Corp Backend** предназначен для централизованного приёма, валидации, систематизации и хранения отчётных данных, предоставляемых предприятиями в рамках форм Ф‑1…Ф‑6. Система обеспечивает полный цикл работы с данными: от загрузки исходных файлов Excel до предоставления унифицированного API для дальнейшей аналитики и интеграции с внешними контурами.
+
+Ключевая особенность решения — ориентация на **достоверность и воспроизводимость** результата. Каждый пакет данных получает идентификатор загрузки (`load_batch`), что позволяет однозначно прослеживать происхождение и последовательность изменений, формировать регистры контроля, проводить сверку и повторные выгрузки при необходимости.
+
+Система рассчитана на эксплуатацию в корпоративном и ведомственном контуре. Реализованы стандартизированные механизмы журналирования, фоновой обработки, контроля целостности и унифицированной выдачи ошибок, а также предусмотрены инструменты мониторинга (health‑check, readiness/liveness probes), необходимые для промышленной эксплуатации и оркестрации.
+
+### 1.2. Используемый технологический стек
+
+Разработка выполнена на базе устойчивого и зрелого стека, обеспечивающего надёжность, масштабируемость и соответствие требованиям безопасности и сопровождения:
+
+| Компонент | Версия | Назначение |
+|-----------|--------|------------|
+| Операционная система | Astra Linux 1.8 / Linux x86_64 | Целевой контур эксплуатации и сопровождения |
+| Язык программирования | Python 3.11.2 | Основной язык серверной логики |
+| Веб-фреймворк | Django 3.2.25 | ORM, маршрутизация, управление приложением |
+| REST API | Django REST Framework 3.14.0 | Реализация REST интерфейса |
+| СУБД | PostgreSQL 15.10 | Реляционное хранение и транзакционность |
+| Очередь задач | Celery 5.3.6 | Асинхронная обработка больших загрузок |
+| Брокер / кэш | Redis 7.x | Хранилище задач и кеширование |
+| Веб-сервер | Apache 2.4.57 | Обслуживание HTTP/HTTPS трафика |
+| WSGI | Gunicorn 21.2.0 | Исполнение Python‑приложения |
+| Документация API | drf-yasg 1.21.10 | Swagger/OpenAPI спецификация |
+| Excel‑парсинг | openpyxl 3.1.5 | Потоковое чтение Excel (.xlsx) |
+
+Выбор компонентов обусловлен их стабильностью, широким использованием в промышленной эксплуатации и высокой готовностью к сопровождению в корпоративных контурах.
+
+### 1.3. Библиотеки для обработки и валидации данных
+
+| Библиотека / модуль | Версия | Назначение |
+|---------------------|--------|------------|
+| openpyxl | 3.1.5 | Потоковый парсинг Excel, чтение больших файлов |
+| pandas | 2.0.3 | Аналитические преобразования (по необходимости) |
+| django-filter | 23.5 | Стандартизованная фильтрация в API |
+| djangorestframework-simplejwt | 5.3.1 | JWT‑аутентификация |
+| django-redis | 5.4.0 | Кэш и синхронизация фоновых задач |
+| celery / django-celery-results | 5.3.6 / 2.5.1 | Управление задачами и хранение результатов |
+| drf-yasg | 1.21.10 | Генерация OpenAPI и Swagger UI |
+
+### 1.4. Термины и сокращения
+
+| Термин | Определение |
+|--------|------------|
+| Форма Ф‑1…Ф‑6 | Утверждённые отчётные формы предприятий |
+| `load_batch` | Идентификатор пакета загрузки данных |
+| Парсер | Программный модуль обработки Excel‑файла |
+| TrackedTask | Базовый класс Celery‑задач с отслеживанием прогресса |
+| BackgroundJob | Модель статусов фоновых задач |
+
+---
+
+## 2. ОПИСАНИЕ ПРОГРАММНОЙ РЕАЛИЗАЦИИ МОДЕЛЕЙ ИНФОРМАЦИОННОГО ОБМЕНА И СБОРА ДАННЫХ
+
+### 2.1. Архитектура подсистемы загрузки и парсинга
+
+Подсистема загрузки данных построена по модульному принципу. Для каждой формы отчётности предусмотрен отдельный парсер, использующий общий базовый механизм `BaseExcelParser`. Такой подход обеспечивает единый стандарт обработки данных, снижает стоимость сопровождения и позволяет расширять систему новыми формами без пересмотра архитектуры.
+
+Общий поток обработки:
+
+1. Пользователь загружает Excel‑файл через API.
+2. Для небольших файлов выполняется синхронная обработка, для крупных файлов — постановка задачи в очередь Celery.
+3. Базовый парсер читает Excel в потоковом режиме (`read_only=True`).
+4. Выполняется построчная валидация и нормализация полей (ИНН, ОГРН, КПП, ОКПО).
+5. Создаются или обновляются справочные записи организаций.
+6. Формируется набор записей формы с привязкой к `load_batch`.
+
+Фрагмент базового парсера, демонстрирующий ключевую механику обработки:
+
+```python
+class BaseExcelParser(ABC, Generic[T]):
+ def parse(self, file: UploadedFile | BytesIO) -> 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
+ finally:
+ if self._workbook:
+ self._workbook.close()
+
+ return result
+```
+
+### 2.2. Алгоритмы загрузки данных по ключевым формам
+
+#### 2.2.1. Форма Ф‑1 (выпуск продукции)
+
+Форма Ф‑1 содержит производственные показатели: объёмы военной и гражданской продукции, НИОКР, кадровые показатели. Маппинг колонок задаётся декларативно и соответствует утверждённому шаблону Excel.
+
+```python
+class FormF1Parser(BaseExcelParser[FormF1Record]):
+ def get_column_mappings(self) -> list[ColumnMapping]:
+ return [
+ ColumnMapping(4, "Выпуск военной продукции (факт.)", "military_output_actual", field_type="decimal"),
+ ColumnMapping(5, "Военная на внутренний рынок (факт.)", "military_domestic_actual", field_type="decimal"),
+ ColumnMapping(6, "Военная на экспорт (факт.)", "military_export_actual", field_type="decimal"),
+ ColumnMapping(7, "Выпуск гражданской продукции (факт.)", "civilian_output_actual", field_type="decimal"),
+ ColumnMapping(8, "Гражданская на внутренний рынок (факт.)", "civilian_domestic_actual", field_type="decimal"),
+ ColumnMapping(9, "Гражданская на экспорт (факт.)", "civilian_export_actual", field_type="decimal"),
+ ColumnMapping(10, "Высокотехнологичная продукция (факт.)", "hightech_output_actual", field_type="decimal"),
+ ColumnMapping(11, "Высокотехнологичная на внутренний рынок (факт.)", "hightech_domestic_actual", field_type="decimal"),
+ ColumnMapping(12, "Высокотехнологичная на экспорт (факт.)", "hightech_export_actual", field_type="decimal"),
+ ColumnMapping(13, "Объём НИОКР (факт.)", "rd_volume_actual", field_type="decimal"),
+ ColumnMapping(14, "НИОКР в интересах обороны (факт.)", "rd_defense_actual", field_type="decimal"),
+ ]
+```
+
+#### 2.2.2. Форма Ф‑2 (бухгалтерский баланс)
+
+Форма Ф‑2 — наиболее объёмная по структуре. Для крупных файлов применяется фоновая обработка, исключающая тайм‑ауты на стороне клиента.
+
+```python
+@shared_task(bind=True, base=TrackedTask)
+def process_form_f2_file(self, file_content: bytes, file_name: str) -> dict:
+ from io import BytesIO
+
+ file_io = BytesIO(file_content)
+ parser = FormF2Parser()
+ result = parser.parse(file_io)
+
+ return result.to_dict()
+```
+
+#### 2.2.3. Формы Ф‑5 и Ф‑6 (оборудование)
+
+Формы Ф‑5 и Ф‑6 ориентированы на детальную и агрегированную инвентаризацию оборудования. В системе предусмотрены типы полей `bool`, `date`, `decimal`, что обеспечивает корректную интерпретацию признаков и дат ввода в эксплуатацию.
+
+### 2.3. Механизмы контроля качества и безопасной загрузки
+
+#### 2.3.1. Валидация ключевых идентификаторов
+
+Стандартные проверки реализованы на уровне базового парсера и исключают попадание некорректных идентификаторов в БД:
+
+```python
+def validate_inn(value: str | None) -> tuple[bool, str]:
+ 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, ""
+```
+
+#### 2.3.2. Идентификация пакетов и прослеживаемость
+
+Каждая загрузка формирует уникальный `load_batch`. Это позволяет вести историю, сравнивать данные по периодам, быстро изолировать ошибочный пакет и выполнять повторную обработку.
+
+#### 2.3.3. Фоновая обработка и контроль прогресса
+
+Для тяжёлых файлов используется `TrackedTask`, автоматически создающий записи `BackgroundJob` и позволяющий пользователю получать прогресс в API:
+
+```python
+class TrackedTask(TimedTask):
+ def before_start(self, task_id, args, kwargs):
+ super().before_start(task_id, args, kwargs)
+ from apps.core.services import BackgroundJobService
+
+ user_id = kwargs.get("user_id")
+ BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name=self.name,
+ user_id=user_id,
+ meta={"args": str(args)[:500], "kwargs": str(kwargs)[:500]},
+ )
+
+ job = BackgroundJobService.get_by_task_id(task_id)
+ job.mark_started()
+```
+
+---
+
+## 3. ПРОГРАММНО-ТЕХНОЛОГИЧЕСКИЕ РЕШЕНИЯ ПО СИСТЕМАТИЗАЦИИ И ОБРАБОТКЕ ДАННЫХ
+
+### 3.1. Слой хранения данных (База данных)
+
+#### 3.1.1. СУБД PostgreSQL
+
+В качестве центра хранения используется PostgreSQL 15.10. Выбор обусловлен следующими факторами:
+
+1. поддержка ACID‑транзакций и строгой целостности данных;
+2. эффективная индексация и масштабируемость при росте объёма отчётных записей;
+3. возможности резервного копирования и репликации;
+4. подтверждённая стабильность в промышленной эксплуатации.
+
+#### 3.1.2. Проектирование моделей данных и миксины
+
+Слой моделей построен вокруг набора типовых миксинов (`apps.core.mixins`), которые стандартизируют общие поля и поведение. Это обеспечивает единый формат аудита и упрощает поддержку.
+
+```python
+class TimestampMixin(models.Model):
+ created_at = models.DateTimeField(
+ _("создано"),
+ auto_now_add=True,
+ db_index=True,
+ help_text=_("Дата и время создания записи"),
+ )
+ updated_at = models.DateTimeField(
+ _("обновлено"),
+ auto_now=True,
+ help_text=_("Дата и время последнего обновления"),
+ )
+
+ class Meta:
+ abstract = True
+```
+
+### 3.2. Сервисный слой и бизнес-логика
+
+Сервисный слой реализует унифицированный интерфейс CRUD и массовых операций. Это позволяет централизовать бизнес‑правила и переиспользовать их в API и фоновых задачах.
+
+```python
+class BaseService(Generic[M]):
+ model: type[M]
+
+ @classmethod
+ def get_queryset(cls) -> QuerySet[M]:
+ return cls.model.objects.all()
+
+ @classmethod
+ @transaction.atomic
+ def create(cls, **kwargs: Any) -> M:
+ return cls.model.objects.create(**kwargs)
+
+ @classmethod
+ @transaction.atomic
+ def update(cls, instance: M, **kwargs: Any) -> M:
+ for field, value in kwargs.items():
+ setattr(instance, field, value)
+ update_fields = set(kwargs.keys())
+ if hasattr(instance, "updated_at"):
+ update_fields.add("updated_at")
+ instance.save(update_fields=list(update_fields))
+ return instance
+```
+
+Дополнительно реализован миксин массовых операций для оптимизации загрузки больших массивов данных:
+
+```python
+class BulkOperationsMixin:
+ @classmethod
+ @transaction.atomic
+ def bulk_create_chunked(
+ cls,
+ instances: list,
+ *,
+ chunk_size: int = 500,
+ ignore_conflicts: bool = False,
+ update_conflicts: bool = False,
+ update_fields: list[str] | None = None,
+ unique_fields: list[str] | None = None,
+ ) -> int:
+ total_created = 0
+ for i in range(0, len(instances), chunk_size):
+ chunk = instances[i : i + chunk_size]
+ created = cls.model.objects.bulk_create(chunk, ignore_conflicts=ignore_conflicts)
+ total_created += len(created)
+ return total_created
+```
+
+---
+
+## 4. СТРУКТУРА БАЗЫ ДАННЫХ
+
+### 4.1. Общие сведения о схеме данных
+
+База данных формируется по нормализованной схеме, где справочник организаций вынесен в отдельную сущность, а все формы отчётности используют внешнюю связь. Такая структура обеспечивает консистентность, минимизирует дублирование и упрощает ведение справочной информации.
+
+#### 4.1.1. Перечень таблиц базы данных
+
+| № | Имя таблицы | Назначение | Источник данных | Примерный объём |
+|---|-------------|------------|-----------------|-----------------|
+| 1 | `organization_organization` | Справочник организаций | Все формы | десятки тысяч |
+| 2 | `form_1_formf1record` | Ф‑1: выпуск продукции | Excel‑формы | десятки тысяч |
+| 3 | `form_2_formf2record` | Ф‑2: бухгалтерский баланс | Excel‑формы | десятки тысяч |
+| 4 | `form_3_formf3record` | Ф‑3: кадры и оборудование | Excel‑формы | десятки тысяч |
+| 5 | `form_4_formf4record` | Ф‑4: сводные финансы | Excel‑формы | десятки тысяч |
+| 6 | `form_5_formf5record` | Ф‑5: инвентаризация оборудования | Excel‑формы | сотни тысяч |
+| 7 | `form_6_formf6record` | Ф‑6: возрастная структура | Excel‑формы | десятки тысяч |
+| 8 | `core_backgroundjob` | Статусы фоновых задач | Системные | тысячи |
+| 9 | `users` | Учётные записи пользователей | Системные | сотни |
+| 10 | `profiles` | Профили пользователей | Системные | сотни |
+
+#### 4.1.2. Общие правила именования
+
+| Элемент | Конвенция | Пример |
+|---------|-----------|--------|
+| Таблицы | `{app_label}_{model}` | `form_2_formf2record` |
+| Первичные ключи | `id` типа UUID | `id UUID PRIMARY KEY` |
+| Внешние ключи | `{entity}_id` | `organization_id` |
+| Временные метки | `created_at`, `updated_at` | `created_at TIMESTAMP WITH TIME ZONE` |
+| Индексы | `{table}_{fields}_idx` | `form_1_form_organiz_f99cbd_idx` |
+
+#### 4.1.3. Типовые поля
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+| `load_batch` | INTEGER | Идентификатор пакета загрузки |
+| `organization_id` | UUID | Ссылка на организацию |
+
+### 4.2. Таблица `organization_organization`
+
+**Назначение:** централизованный справочник организаций. Используется всеми формами отчётности и исключает дублирование данных.
+
+**Источник данных:** формы Ф‑1…Ф‑6 (автоматическое создание/обновление по ИНН).
+
+#### 4.2.1. Полный перечень полей
+
+| Поле | Тип | Ограничения | Описание |
+|------|-----|-------------|----------|
+| `id` | UUID | PK | Уникальный идентификатор |
+| `name` | VARCHAR(500) | NOT NULL, INDEX | Наименование организации |
+| `inn` | VARCHAR(12) | UNIQUE, INDEX | ИНН |
+| `ogrn` | VARCHAR(15) | INDEX | ОГРН |
+| `kpp` | VARCHAR(9) | — | КПП |
+| `okpo` | VARCHAR(20) | — | ОКПО |
+| `created_at` | TIMESTAMP | INDEX | Дата создания |
+| `updated_at` | TIMESTAMP | — | Дата обновления |
+
+#### 4.2.2. Модель (фрагмент кода)
+
+```python
+class Organization(TimestampMixin, models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ name = models.CharField(max_length=500, db_index=True)
+ inn = models.CharField(max_length=12, unique=True, db_index=True)
+ ogrn = models.CharField(max_length=15, db_index=True, blank=True, default="")
+ kpp = models.CharField(max_length=9, blank=True, default="")
+ okpo = models.CharField(max_length=20, blank=True, default="")
+```
+
+### 4.3. Таблица `form_1_formf1record`
+
+**Назначение:** хранение показателей формы Ф‑1 (выпуск продукции, НИОКР, кадры).
+
+#### 4.3.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `military_output_actual` | DECIMAL(20,2) | Выпуск военной продукции (факт. цены) |
+| `military_domestic_actual` | DECIMAL(20,2) | Военная продукция на внутренний рынок (факт.) |
+| `military_export_actual` | DECIMAL(20,2) | Военная продукция на экспорт (факт.) |
+| `civilian_output_actual` | DECIMAL(20,2) | Выпуск гражданской продукции (факт. цены) |
+| `civilian_domestic_actual` | DECIMAL(20,2) | Гражданская продукция на внутренний рынок (факт.) |
+| `civilian_export_actual` | DECIMAL(20,2) | Гражданская продукция на экспорт (факт.) |
+| `hightech_output_actual` | DECIMAL(20,2) | Высокотехнологичная продукция (факт. цены) |
+| `hightech_domestic_actual` | DECIMAL(20,2) | Высокотехнологичная продукция на внутренний рынок (факт.) |
+| `hightech_export_actual` | DECIMAL(20,2) | Высокотехнологичная продукция на экспорт (факт.) |
+| `rd_volume_actual` | DECIMAL(20,2) | Объём НИОКР (факт. цены) |
+| `rd_defense_actual` | DECIMAL(20,2) | НИОКР в интересах обороны (факт.) |
+| `military_output_fixed` | DECIMAL(20,2) | Выпуск военной продукции (фикс. цены) |
+| `military_domestic_fixed` | DECIMAL(20,2) | Военная продукция на внутренний рынок (фикс.) |
+| `military_export_fixed` | DECIMAL(20,2) | Военная продукция на экспорт (фикс.) |
+| `civilian_output_fixed` | DECIMAL(20,2) | Выпуск гражданской продукции (фикс. цены) |
+| `civilian_domestic_fixed` | DECIMAL(20,2) | Гражданская продукция на внутренний рынок (фикс.) |
+| `civilian_export_fixed` | DECIMAL(20,2) | Гражданская продукция на экспорт (фикс.) |
+| `hightech_output_fixed` | DECIMAL(20,2) | Высокотехнологичная продукция (фикс. цены) |
+| `hightech_domestic_fixed` | DECIMAL(20,2) | Высокотехнологичная продукция на внутренний рынок (фикс.) |
+| `hightech_export_fixed` | DECIMAL(20,2) | Высокотехнологичная продукция на экспорт (фикс.) |
+| `rd_volume_fixed` | DECIMAL(20,2) | Объём НИОКР (фикс. цены) |
+| `rd_defense_fixed` | DECIMAL(20,2) | НИОКР в интересах обороны (фикс.) |
+| `avg_employees` | DECIMAL(12,2) | Средняя численность работников |
+| `avg_payroll_employees` | DECIMAL(12,2) | Среднесписочная численность работников |
+| `payroll_fund` | DECIMAL(20,2) | Фонд начисленной заработной платы |
+| `salary_arrears` | DECIMAL(20,2) | Просроченная задолженность по зарплате |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.4. Таблица `form_2_formf2record`
+
+**Назначение:** бухгалтерский баланс, финансовые результаты и производные показатели.
+
+#### 4.4.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `intangible_assets` | DECIMAL(20,2) | Нематериальные активы |
+| `rd_results` | DECIMAL(20,2) | Результаты исследований и разработок |
+| `intangible_search_assets` | DECIMAL(20,2) | Нематериальные поисковые активы |
+| `tangible_search_assets` | DECIMAL(20,2) | Материальные поисковые активы |
+| `fixed_assets` | DECIMAL(20,2) | Основные средства |
+| `profitable_investments` | DECIMAL(20,2) | Доходные вложения в материальные ценности |
+| `financial_investments_non_current` | DECIMAL(20,2) | Финансовые вложения (внеоборотные) |
+| `deferred_tax_assets` | DECIMAL(20,2) | Отложенные налоговые активы |
+| `other_non_current_assets` | DECIMAL(20,2) | Прочие внеоборотные активы |
+| `total_non_current_assets` | DECIMAL(20,2) | Итого внеоборотные активы |
+| `inventories` | DECIMAL(20,2) | Запасы |
+| `vat_on_acquired_assets` | DECIMAL(20,2) | НДС по приобретённым ценностям |
+| `receivables` | DECIMAL(20,2) | Дебиторская задолженность |
+| `financial_investments_current` | DECIMAL(20,2) | Финансовые вложения (оборотные) |
+| `cash_and_equivalents` | DECIMAL(20,2) | Денежные средства и эквиваленты |
+| `other_current_assets` | DECIMAL(20,2) | Прочие оборотные активы |
+| `total_current_assets` | DECIMAL(20,2) | Итого оборотные активы |
+| `total_assets` | DECIMAL(20,2) | Баланс (актив) |
+| `authorized_capital` | DECIMAL(20,2) | Уставный капитал |
+| `own_shares_bought_back` | DECIMAL(20,2) | Собственные акции, выкупленные у акционеров |
+| `revaluation_of_non_current_assets` | DECIMAL(20,2) | Переоценка внеоборотных активов |
+| `additional_capital` | DECIMAL(20,2) | Добавочный капитал |
+| `reserve_capital` | DECIMAL(20,2) | Резервный капитал |
+| `retained_earnings` | DECIMAL(20,2) | Нераспределённая прибыль |
+| `total_equity` | DECIMAL(20,2) | Итого капитал и резервы |
+| `borrowings_non_current` | DECIMAL(20,2) | Заёмные средства (долгосрочные) |
+| `deferred_tax_liabilities` | DECIMAL(20,2) | Отложенные налоговые обязательства |
+| `estimated_liabilities_non_current` | DECIMAL(20,2) | Оценочные обязательства (долгосрочные) |
+| `other_liabilities_non_current` | DECIMAL(20,2) | Прочие обязательства (долгосрочные) |
+| `total_non_current_liabilities` | DECIMAL(20,2) | Итого долгосрочные обязательства |
+| `borrowings_current` | DECIMAL(20,2) | Заёмные средства (краткосрочные) |
+| `payables` | DECIMAL(20,2) | Кредиторская задолженность |
+| `deferred_income` | DECIMAL(20,2) | Доходы будущих периодов |
+| `estimated_liabilities_current` | DECIMAL(20,2) | Оценочные обязательства (краткосрочные) |
+| `other_liabilities_current` | DECIMAL(20,2) | Прочие обязательства (краткосрочные) |
+| `total_current_liabilities` | DECIMAL(20,2) | Итого краткосрочные обязательства |
+| `total_liabilities` | DECIMAL(20,2) | Баланс (пассив) |
+| `revenue` | DECIMAL(20,2) | Выручка |
+| `cost_of_sales` | DECIMAL(20,2) | Себестоимость продаж |
+| `gross_profit` | DECIMAL(20,2) | Валовая прибыль |
+| `selling_expenses` | DECIMAL(20,2) | Коммерческие расходы |
+| `administrative_expenses` | DECIMAL(20,2) | Управленческие расходы |
+| `profit_from_sales` | DECIMAL(20,2) | Прибыль от продаж |
+| `interest_receivable` | DECIMAL(20,2) | Проценты к получению |
+| `interest_payable` | DECIMAL(20,2) | Проценты к уплате |
+| `other_income` | DECIMAL(20,2) | Прочие доходы |
+| `other_expenses` | DECIMAL(20,2) | Прочие расходы |
+| `profit_before_tax` | DECIMAL(20,2) | Прибыль до налогообложения |
+| `income_tax` | DECIMAL(20,2) | Текущий налог на прибыль |
+| `net_profit` | DECIMAL(20,2) | Чистая прибыль |
+| `ebitda` | DECIMAL(20,2) | EBITDA |
+| `depreciation` | DECIMAL(20,2) | Амортизация |
+| `working_capital` | DECIMAL(20,2) | Оборотный капитал |
+| `net_debt` | DECIMAL(20,2) | Чистый долг |
+| `total_assets_prev` | DECIMAL(20,2) | Баланс (актив) — прошлый период |
+| `total_liabilities_prev` | DECIMAL(20,2) | Баланс (пассив) — прошлый период |
+| `revenue_prev` | DECIMAL(20,2) | Выручка — прошлый период |
+| `net_profit_prev` | DECIMAL(20,2) | Чистая прибыль — прошлый период |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.5. Таблица `form_3_formf3record`
+
+**Назначение:** кадровые показатели и оборудование по категориям.
+
+#### 4.5.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `avg_employees` | DECIMAL(12,2) | Средняя численность работников |
+| `production_workers` | DECIMAL(12,2) | Производственный персонал |
+| `engineering_workers` | DECIMAL(12,2) | Инженерно‑технические работники |
+| `administrative_workers` | DECIMAL(12,2) | Административный персонал |
+| `total_equipment` | INTEGER | Всего оборудования |
+| `domestic_equipment` | INTEGER | Отечественное оборудование |
+| `imported_equipment` | INTEGER | Импортное оборудование |
+| `equipment_age_under_5` | INTEGER | Оборудование до 5 лет |
+| `equipment_age_5_10` | INTEGER | Оборудование 5‑10 лет |
+| `equipment_age_10_15` | INTEGER | Оборудование 10‑15 лет |
+| `equipment_age_15_20` | INTEGER | Оборудование 15‑20 лет |
+| `equipment_age_over_20` | INTEGER | Оборудование свыше 20 лет |
+| `physical_wear_percent` | DECIMAL(5,2) | Физический износ, % |
+| `utilization_rate` | DECIMAL(5,2) | Коэффициент загрузки |
+| `avg_shift_work` | DECIMAL(5,2) | Средняя сменность работы |
+| `equipment_needed` | INTEGER | Потребность в оборудовании |
+| `workers_needed` | INTEGER | Потребность в кадрах |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.6. Таблица `form_4_formf4record`
+
+**Назначение:** агрегированные финансовые показатели (РСБУ/МСФО, EBITDA, долговая нагрузка).
+
+#### 4.6.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `revenue_rsbu` | DECIMAL(20,2) | Выручка (РСБУ) |
+| `revenue_ifrs` | DECIMAL(20,2) | Выручка (МСФО) |
+| `revenue_prev_rsbu` | DECIMAL(20,2) | Выручка прошлого года (РСБУ) |
+| `revenue_prev_ifrs` | DECIMAL(20,2) | Выручка прошлого года (МСФО) |
+| `net_profit_rsbu` | DECIMAL(20,2) | Чистая прибыль (РСБУ) |
+| `net_profit_ifrs` | DECIMAL(20,2) | Чистая прибыль (МСФО) |
+| `gross_profit_rsbu` | DECIMAL(20,2) | Валовая прибыль (РСБУ) |
+| `operating_profit_rsbu` | DECIMAL(20,2) | Операционная прибыль (РСБУ) |
+| `ebitda_rsbu` | DECIMAL(20,2) | EBITDA (РСБУ) |
+| `ebitda_ifrs` | DECIMAL(20,2) | EBITDA (МСФО) |
+| `loans_rsbu` | DECIMAL(20,2) | Кредиты и займы (РСБУ) |
+| `loans_ifrs` | DECIMAL(20,2) | Кредиты и займы (МСФО) |
+| `net_debt_rsbu` | DECIMAL(20,2) | Чистый долг (РСБУ) |
+| `net_debt_ifrs` | DECIMAL(20,2) | Чистый долг (МСФО) |
+| `debt_to_ebitda` | DECIMAL(10,2) | Долг/EBITDA |
+| `total_assets_rsbu` | DECIMAL(20,2) | Активы (РСБУ) |
+| `total_assets_ifrs` | DECIMAL(20,2) | Активы (МСФО) |
+| `equity_rsbu` | DECIMAL(20,2) | Собственный капитал (РСБУ) |
+| `equity_ifrs` | DECIMAL(20,2) | Собственный капитал (МСФО) |
+| `roe` | DECIMAL(10,2) | Рентабельность собственного капитала (ROE) |
+| `roa` | DECIMAL(10,2) | Рентабельность активов (ROA) |
+| `ros` | DECIMAL(10,2) | Рентабельность продаж (ROS) |
+| `capex` | DECIMAL(20,2) | Капитальные затраты (CAPEX) |
+| `rd_expenses` | DECIMAL(20,2) | Затраты на НИОКР |
+| `dividends_paid` | DECIMAL(20,2) | Выплаченные дивиденды |
+| `dividend_yield` | DECIMAL(10,2) | Дивидендная доходность |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.7. Таблица `form_5_formf5record`
+
+**Назначение:** детальная инвентаризация оборудования по каждой единице.
+
+#### 4.7.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `equipment_id` | VARCHAR(50) | Идентификационный код |
+| `inventory_number` | VARCHAR(50) | Инвентарный номер |
+| `name` | VARCHAR(500) | Наименование оборудования |
+| `model` | VARCHAR(200) | Модель |
+| `manufacturer` | VARCHAR(300) | Производитель |
+| `country_origin` | VARCHAR(100) | Страна происхождения |
+| `is_domestic` | BOOLEAN | Отечественное производство |
+| `year_manufacture` | INTEGER | Год выпуска |
+| `has_cnc` | BOOLEAN | Наличие ЧПУ |
+| `equipment_type` | VARCHAR(200) | Тип оборудования |
+| `equipment_category` | VARCHAR(200) | Категория оборудования |
+| `commissioning_date` | DATE | Дата ввода в эксплуатацию |
+| `location` | VARCHAR(300) | Местонахождение |
+| `production_site` | VARCHAR(200) | Производственный участок |
+| `utilization_rate` | DECIMAL(5,2) | Коэффициент использования |
+| `physical_wear_percent` | DECIMAL(5,2) | Фактический износ, % |
+| `is_operational` | BOOLEAN | В рабочем состоянии |
+| `requires_repair` | BOOLEAN | Требует ремонта |
+| `requires_replacement` | BOOLEAN | Требует замены |
+| `initial_cost` | DECIMAL(20,2) | Первоначальная стоимость |
+| `residual_value` | DECIMAL(20,2) | Остаточная стоимость |
+| `notes` | TEXT | Примечания |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.8. Таблица `form_6_formf6record`
+
+**Назначение:** сводная возрастная структура оборудования по категориям.
+
+#### 4.8.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор записи |
+| `organization_id` | UUID (FK) | Организация |
+| `load_batch` | INTEGER | Пакет загрузки |
+| `row_code` | VARCHAR(20) | Код строки |
+| `category` | VARCHAR(200) | Категория оборудования |
+| `total_equipment` | INTEGER | Всего оборудования |
+| `domestic_equipment` | INTEGER | Отечественное оборудование |
+| `imported_equipment` | INTEGER | Импортное оборудование |
+| `age_under_5` | INTEGER | До 5 лет |
+| `age_5_10` | INTEGER | 5‑10 лет |
+| `age_10_15` | INTEGER | 10‑15 лет |
+| `age_15_20` | INTEGER | 15‑20 лет |
+| `age_over_20` | INTEGER | Свыше 20 лет |
+| `cnc_total` | INTEGER | С ЧПУ всего |
+| `cnc_under_5` | INTEGER | С ЧПУ до 5 лет |
+| `cnc_5_10` | INTEGER | С ЧПУ 5‑10 лет |
+| `cnc_10_15` | INTEGER | С ЧПУ 10‑15 лет |
+| `cnc_15_20` | INTEGER | С ЧПУ 15‑20 лет |
+| `cnc_over_20` | INTEGER | С ЧПУ свыше 20 лет |
+| `avg_shift_work` | DECIMAL(5,2) | Средняя сменность работы |
+| `utilization_rate` | DECIMAL(5,2) | Коэффициент загрузки |
+| `physical_wear_percent` | DECIMAL(5,2) | Физический износ, % |
+| `workplaces_without_equipment` | INTEGER | Рабочие места без оборудования |
+| `equipment_to_replace` | INTEGER | Оборудование к замене |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.9. Таблица `core_backgroundjob`
+
+**Назначение:** контроль выполнения фоновых задач и прогресса обработки больших файлов.
+
+#### 4.9.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | UUID | Уникальный идентификатор задачи |
+| `task_id` | VARCHAR(255) | Идентификатор задачи Celery |
+| `task_name` | VARCHAR(255) | Полное имя задачи |
+| `status` | VARCHAR(20) | Статус выполнения |
+| `progress` | INTEGER | Прогресс в процентах |
+| `progress_message` | VARCHAR(500) | Сообщение о прогрессе |
+| `result` | JSON | Результат выполнения |
+| `error` | TEXT | Текст ошибки |
+| `traceback` | TEXT | Traceback ошибки |
+| `started_at` | TIMESTAMP | Время начала |
+| `completed_at` | TIMESTAMP | Время завершения |
+| `user_id` | INTEGER | Пользователь (опционально) |
+| `meta` | JSON | Дополнительные метаданные |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.10. Таблица `users`
+
+**Назначение:** учётные записи пользователей системы (аутентификация, авторизация, доступ в админ‑интерфейс и API).
+
+#### 4.10.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | BIGINT | Уникальный идентификатор пользователя |
+| `password` | VARCHAR(128) | Хэш пароля |
+| `last_login` | TIMESTAMP | Дата и время последнего входа |
+| `is_superuser` | BOOLEAN | Признак суперпользователя |
+| `username` | VARCHAR(150) | Имя пользователя (логин) |
+| `is_staff` | BOOLEAN | Доступ в админ‑интерфейс |
+| `is_active` | BOOLEAN | Активность учётной записи |
+| `date_joined` | TIMESTAMP | Дата регистрации |
+| `email` | VARCHAR(254) | Email пользователя (уникальный) |
+| `phone` | VARCHAR(20) | Телефон (международный формат) |
+| `is_verified` | BOOLEAN | Признак подтверждения email |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+Примечание: связи с группами и разрешениями реализуются через промежуточные таблицы, автоматически создаваемые Django ORM.
+
+### 4.11. Таблица `profiles`
+
+**Назначение:** расширенные профильные данные пользователя (ФИО, био, аватар, дата рождения).
+
+#### 4.11.1. Полный перечень полей
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | BIGINT | Уникальный идентификатор профиля |
+| `user_id` | BIGINT (FK) | Ссылка на пользователя |
+| `first_name` | VARCHAR(50) | Имя |
+| `last_name` | VARCHAR(50) | Фамилия |
+| `bio` | TEXT | Описание / биография |
+| `avatar` | VARCHAR(100) | Путь к файлу аватара |
+| `date_of_birth` | DATE | Дата рождения |
+| `created_at` | TIMESTAMP | Дата и время создания |
+| `updated_at` | TIMESTAMP | Дата и время обновления |
+
+### 4.12. Диаграмма связей между таблицами
+
+```mermaid
+erDiagram
+ ORGANIZATION ||--o{ FORM_F1_RECORD : "organization_id"
+ ORGANIZATION ||--o{ FORM_F2_RECORD : "organization_id"
+ ORGANIZATION ||--o{ FORM_F3_RECORD : "organization_id"
+ ORGANIZATION ||--o{ FORM_F4_RECORD : "organization_id"
+ ORGANIZATION ||--o{ FORM_F5_RECORD : "organization_id"
+ ORGANIZATION ||--o{ FORM_F6_RECORD : "organization_id"
+ USERS ||--|| PROFILES : "user_id"
+ USERS ||..o{ CORE_BACKGROUNDJOB : "user_id (опц.)"
+```
+
+---
+
+## 5. АРХИТЕКТУРА ПРОГРАММНОГО ИНТЕРФЕЙСА (API)
+
+### 5.1. Общие сведения
+
+API реализован на базе Django REST Framework и соответствует REST‑подходу. Все запросы и ответы используют JSON, кодировка UTF‑8. Версионирование задано в URL‑пути.
+
+**Базовый URL:** `https://{hostname}/api/v1/`
+**Документация:** Swagger UI доступна на корневом URL (`/`).
+
+#### 5.1.1. Принципы взаимодействия
+
+1. ресурсы представлены как коллекции и отдельные сущности;
+2. используется стандартный набор HTTP‑методов (GET/POST/PUT/PATCH/DELETE);
+3. ответы унифицированы, включая пагинацию и ошибки;
+4. все изменения данных фиксируются на уровне модели через поля `created_at/updated_at`.
+
+#### 5.1.2. Формат запросов
+
+Обязательные заголовки:
+
+```http
+Authorization: Bearer
+Content-Type: application/json
+Accept: application/json
+```
+
+Параметры пагинации (по умолчанию):
+
+| Параметр | Тип | По умолчанию | Описание |
+|----------|-----|--------------|----------|
+| `page` | integer | 1 | Номер страницы |
+| `page_size` | integer | 20 | Количество записей (1–100) |
+
+#### 5.1.3. Формат ответов
+
+Успешные ответы возвращаются в едином формате:
+
+```json
+{
+ "success": true,
+ "data": [
+ {"id": "...", "field": "value"}
+ ],
+ "errors": null,
+ "meta": {
+ "pagination": {
+ "page": 1,
+ "page_size": 20,
+ "total_count": 100,
+ "total_pages": 5,
+ "has_next": true,
+ "has_previous": false
+ }
+ }
+}
+```
+
+### 5.2. Аутентификация и авторизация
+
+Система использует JWT (SimpleJWT). Параметры безопасности:
+
+1. Access Token — 60 минут.
+2. Refresh Token — 7 дней.
+3. Ротация refresh‑токена включена.
+4. Алгоритм подписи HS256.
+
+Фрагмент модели пользователя:
+
+```python
+class User(AbstractUser):
+ first_name = None
+ last_name = None
+
+ email = models.EmailField(unique=True)
+ phone = models.CharField(max_length=20, blank=True, null=True)
+ is_verified = models.BooleanField(default=False)
+
+ USERNAME_FIELD = "email"
+ REQUIRED_FIELDS = ["username"]
+```
+
+Модель профиля:
+
+```python
+class Profile(models.Model):
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
+ first_name = models.CharField(max_length=50, blank=True, null=True)
+ last_name = models.CharField(max_length=50, blank=True, null=True)
+ bio = models.TextField(blank=True, null=True)
+ avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
+```
+
+### 5.3. Структура API
+
+| Группа | Базовый путь | Назначение |
+|--------|-------------|------------|
+| Пользователи | `/api/v1/users/` | регистрация, авторизация, профиль |
+| Организации | `/api/v1/organizations/` | справочник организаций |
+| Формы | `/api/v1/forms/f1/` … `/api/v1/forms/f6/` | отчётные формы |
+| Задачи | `/api/v1/jobs/` | мониторинг фоновых задач |
+| Health | `/health/` | мониторинг доступности |
+
+### 5.4. API пользователей и профилей
+
+**Регистрация:** `POST /api/v1/users/register/`
+**Вход:** `POST /api/v1/users/login/`
+**Обновление токена:** `POST /api/v1/users/token/refresh/`
+**Профиль:** `GET /api/v1/users/profile/`
+
+### 5.5. API организаций
+
+**Список:** `GET /api/v1/organizations/`
+**Детали:** `GET /api/v1/organizations/{id}/`
+
+Фильтры: `name`, `inn`, `ogrn`.
+
+### 5.6. API формы Ф‑1
+
+**Загрузка:** `POST /api/v1/forms/f1/upload/`
+**Список записей:** `GET /api/v1/forms/f1/records/`
+**Детали:** `GET /api/v1/forms/f1/records/{id}/`
+
+Для крупных файлов выполняется фоновая обработка с возвратом `task_id`.
+
+### 5.7. API формы Ф‑2
+
+**Загрузка:** `POST /api/v1/forms/f2/upload/`
+**Список записей:** `GET /api/v1/forms/f2/records/`
+**Детали:** `GET /api/v1/forms/f2/records/{id}/`
+
+Поддерживается фильтр `batch_id` для выборки по пакету загрузки.
+
+### 5.8. API формы Ф‑3
+
+**Загрузка:** `POST /api/v1/forms/f3/upload/`
+**Список записей:** `GET /api/v1/forms/f3/records/`
+**Детали:** `GET /api/v1/forms/f3/records/{id}/`
+
+### 5.9. API формы Ф‑4
+
+**Загрузка:** `POST /api/v1/forms/f4/upload/`
+**Список записей:** `GET /api/v1/forms/f4/records/`
+**Детали:** `GET /api/v1/forms/f4/records/{id}/`
+
+### 5.10. API формы Ф‑5 и Ф‑6
+
+Форма Ф‑5:
+
+1. `POST /api/v1/forms/f5/upload/`
+2. `GET /api/v1/forms/f5/records/`
+
+Форма Ф‑6:
+
+1. `POST /api/v1/forms/f6/upload/`
+2. `GET /api/v1/forms/f6/records/`
+
+### 5.11. Коды ответов HTTP и формат ошибок
+
+Единый формат ошибок реализован через `custom_exception_handler`:
+
+```json
+{
+ "success": false,
+ "data": null,
+ "errors": [
+ {
+ "code": "validation_error",
+ "message": "Validation failed",
+ "details": {
+ "fields": {
+ "inn": ["ИНН обязателен"]
+ }
+ }
+ }
+ ],
+ "meta": {
+ "request_id": "3f6d4a3a-2c5f-4a0a-a0d6-4d3b78f7c60a"
+ }
+}
+```
+
+Типовые коды HTTP:
+
+| Код | Описание |
+|-----|----------|
+| 200 | Успешный запрос |
+| 201 | Ресурс создан |
+| 202 | Задача принята в очередь |
+| 400 | Ошибка валидации |
+| 401 | Неавторизован |
+| 403 | Недостаточно прав |
+| 404 | Ресурс не найден |
+| 500 | Внутренняя ошибка |
+
+---
+
+## 6. МЕХАНИЗМЫ ОБЕСПЕЧЕНИЯ НАДЁЖНОСТИ И ОТКАЗОУСТОЙЧИВОСТИ
+
+### 6.1. Система фоновых задач (BackgroundJobs)
+
+Асинхронная обработка крупных файлов исключает блокировку API и повышает масштабируемость. Каждая задача регистрируется в таблице `core_backgroundjob`, что позволяет:
+
+1. получать статус в реальном времени;
+2. отображать прогресс и сообщения;
+3. фиксировать ошибки и трассировки;
+4. обеспечивать контроль исполнения на уровне пользователя.
+
+### 6.2. Инкрементальная загрузка и идентификация пакетов
+
+Механизм `load_batch` обеспечивает последовательную нумерацию пакетов загрузки. В результате:
+
+1. каждое обновление формы идентифицируется как самостоятельный пакет;
+2. возможна аналитика по временным срезам;
+3. упрощается повторная загрузка без потери предшествующих данных.
+
+### 6.3. Валидация, дедупликация и целостность
+
+1. уникальность справочника организаций обеспечивается ограничением по ИНН;
+2. валидация ИНН/ОГРН/КПП/ОКПО выполняется на уровне парсера;
+3. все операции записи выполняются в транзакции;
+4. ошибочные строки не прерывают общий процесс, фиксируются в `ParseResult`.
+
+### 6.4. Логирование, мониторинг и health-check
+
+Система использует middleware `RequestIDMiddleware` для трассировки запросов, а также реализует endpoints мониторинга:
+
+1. `GET /health/` — комплексная проверка;
+2. `GET /health/live/` — liveness probe;
+3. `GET /health/ready/` — readiness probe.
+
+### 6.5. Требования к информационной безопасности
+
+В целях обеспечения защиты данных и устойчивости работы системы должны соблюдаться организационные и технические меры безопасности, определяемые внутренними регламентами заказчика и действующим законодательством. На уровне программной реализации предусмотрены следующие базовые механизмы, обязательные к использованию при эксплуатации:
+
+1. доступ к защищённым ресурсам осуществляется по JWT‑токену (заголовок `Authorization: Bearer `), время жизни access‑токена — 60 минут, refresh‑токена — 7 дней;
+2. разграничение доступа реализовано на уровне прав пользователя; доступ к административным функциям имеет только пользователь с `is_staff=true`, доступ к статусам задач — только владелец задачи или администратор;
+3. пароли пользователей хранятся исключительно в виде криптографических хэшей, что исключает хранение исходных паролей в базе данных;
+4. секреты и параметры подключения (SECRET_KEY, параметры БД/Redis) задаются через переменные окружения и должны храниться в защищённом конфигурационном контуре;
+5. список доверенных источников (CORS) задаётся параметром `CORS_ALLOWED_ORIGINS` и должен быть ограничен утверждёнными доменами;
+6. механизм ограничения частоты запросов включён на уровне DRF (анонимные запросы — 100/час, авторизованные — 1000/час) и при необходимости подлежит корректировке в соответствии с политиками эксплуатации;
+7. трассировка запросов осуществляется через `X-Request-ID`, что обеспечивает аудит и расследование инцидентов.
+
+Дополнительно при эксплуатации рекомендуется обеспечить шифрование каналов передачи данных (TLS), сегментацию сети и ограничение административного доступа по IP‑спискам.
+
+### 6.6. Эксплуатационные регламенты
+
+Для обеспечения стабильной работы системы в промышленной эксплуатации рекомендуется утвердить следующие регламенты:
+
+1. резервное копирование базы данных — ежедневно, с хранением не менее 30 календарных дней;
+2. резервное копирование файловых каталогов (`media/`, `input/`) — ежедневно, синхронно с резервным копированием БД;
+3. проверка восстановления резервных копий — не реже 1 раза в месяц;
+4. плановая очистка устаревших записей фоновых задач — через периодический запуск `BackgroundJobService.cleanup_old_jobs(days=30)` (настройка через Celery Beat);
+5. контроль свободного дискового пространства и роста логов — по порогам эксплуатации (рекомендуется 20% свободного места как минимальный запас);
+6. обновление программных компонентов — по утверждённому окну сопровождения с обязательным выполнением миграций и smoke‑проверок;
+7. контроль работоспособности — регулярные проверки `/health/`, `/health/ready/` и мониторинг логов ошибок.
+
+### 6.7. Сценарии отказов и реакции
+
+| Сценарий | Признак | Источник контроля | Рекомендованная реакция |
+|----------|---------|-------------------|-------------------------|
+| Недоступна БД | `health` возвращает `unhealthy`, readiness = 503 | `/health/`, `/health/ready/`, логи Django | Проверить доступность PostgreSQL, сетевые правила, восстановить из резервной копии при необходимости |
+| Недоступен Redis | Статус `degraded`, ошибки кеша/очередей | `/health/`, логи Redis | Перезапуск Redis, проверка параметров подключения, повторная проверка задач |
+| Нет активных Celery workers | `include_celery=true` возвращает `down` | `/health/?include_celery=true`, логи Celery | Запустить/перезапустить воркеры, проверить очередь и брокер |
+| Ошибка обработки файла | BackgroundJob = `failure`, заполнены `error/traceback` | `/api/v1/jobs/{task_id}/` | Исправить файл, повторить загрузку, при необходимости задать новый `load_batch` |
+| Ошибки валидации строк | `ParseResult.errors` содержит список ошибок | Ответ API загрузки | Исправить исходные строки и повторить загрузку |
+| Ошибка аутентификации | HTTP 401 | Ответ API | Обновить токен через refresh либо выполнить повторный вход |
+| Превышение лимита запросов | HTTP 429 | Ответ API, логи DRF | Снизить частоту запросов или скорректировать лимиты |
+
+---
+
+## 7. ПОРЯДОК РАЗВЁРТЫВАНИЯ И ИСПЫТАНИЙ
+
+### 7.1. Подготовка операционной среды
+
+Минимальные системные зависимости:
+
+1. Python 3.11.
+2. PostgreSQL 15.
+3. Redis 7.
+4. Apache / Gunicorn.
+
+Переменные окружения задаются в `.env` (PostgreSQL, Redis, SECRET_KEY, CORS и др.).
+
+### 7.2. Развёртывание и запуск
+
+Рекомендуемый вариант для разработки — Docker Compose:
+
+```bash
+docker-compose up -d
+```
+
+Для установки в продуктивном контуре предусмотрен скрипт `deploy/scripts/deploy.sh`, выполняющий:
+
+1. установку зависимостей;
+2. настройку базы данных;
+3. миграции и сбор статических файлов;
+4. запуск Gunicorn и Celery сервисов.
+
+### 7.3. Проверка качества и тестирование
+
+В проекте предусмотрены автоматические тесты на уровне моделей, сервисов и API:
+
+```bash
+make test
+# или
+python run_tests.py
+```
+
+---
+
+## 8. ЗАКЛЮЧЕНИЕ
+
+Программный комплекс **State Corp Backend** реализует надёжную и масштабируемую архитектуру обработки отчётных данных. Он обеспечивает:
+
+1. стандартизированный приём и валидацию форм Ф‑1…Ф‑6;
+2. централизованное хранение и нормализацию данных организаций;
+3. прозрачный механизм фоновой обработки с контролем прогресса;
+4. удобный REST API с единой спецификацией и расширенной документацией;
+5. готовность к промышленной эксплуатации и мониторингу.
+
+Таким образом, решение удовлетворяет требованиям заказчика к надёжности, трассируемости и полноте данных, обеспечивая долгосрочную устойчивость и возможность развития функциональности в дальнейшем.
+
+---
+
+## ПРИЛОЖЕНИЕ А (ОБЯЗАТЕЛЬНОЕ). ФОРМАТЫ ВХОДНЫХ EXCEL-ШАБЛОНОВ
+
+Настоящее приложение устанавливает требования к структуре входных Excel‑файлов по формам Ф‑1…Ф‑6. Указанные требования являются обязательными для корректной загрузки.
+
+### А.1. Общие требования к Excel‑файлам
+
+1. Формат файла: `.xlsx`.
+2. Используется первый лист рабочей книги (active sheet).
+3. Строка заголовков — 1.
+4. Данные начинаются со строки 2.
+5. Заголовки должны строго соответствовать перечню ниже.
+6. Порядок колонок является фиксированным.
+7. Пустые строки допускаются и игнорируются.
+8. Типы данных:
+
+| Тип | Требование |
+|-----|------------|
+| `str` | Текстовое значение |
+| `int` | Целое число (без дробной части) |
+| `decimal` | Дробные числа, допускаются запятая и пробел как разделитель |
+| `bool` | Значения: «да», «yes», «1», «true», «+» (регистр не важен) |
+| `date` | Ячейка должна иметь тип даты Excel |
+
+### А.2. Шаблон формы Ф‑1 (Выпуск продукции)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Выпуск военной продукции (факт.) | `military_output_actual` | decimal |
+| 5 | Военная на внутренний рынок (факт.) | `military_domestic_actual` | decimal |
+| 6 | Военная на экспорт (факт.) | `military_export_actual` | decimal |
+| 7 | Выпуск гражданской продукции (факт.) | `civilian_output_actual` | decimal |
+| 8 | Гражданская на внутренний рынок (факт.) | `civilian_domestic_actual` | decimal |
+| 9 | Гражданская на экспорт (факт.) | `civilian_export_actual` | decimal |
+| 10 | Высокотехнологичная продукция (факт.) | `hightech_output_actual` | decimal |
+| 11 | Высокотехнологичная на внутренний рынок (факт.) | `hightech_domestic_actual` | decimal |
+| 12 | Высокотехнологичная на экспорт (факт.) | `hightech_export_actual` | decimal |
+| 13 | Объём НИОКР (факт.) | `rd_volume_actual` | decimal |
+| 14 | НИОКР в интересах обороны (факт.) | `rd_defense_actual` | decimal |
+| 15 | Выпуск военной продукции (фикс.) | `military_output_fixed` | decimal |
+| 16 | Военная на внутренний рынок (фикс.) | `military_domestic_fixed` | decimal |
+| 17 | Военная на экспорт (фикс.) | `military_export_fixed` | decimal |
+| 18 | Выпуск гражданской продукции (фикс.) | `civilian_output_fixed` | decimal |
+| 19 | Гражданская на внутренний рынок (фикс.) | `civilian_domestic_fixed` | decimal |
+| 20 | Гражданская на экспорт (фикс.) | `civilian_export_fixed` | decimal |
+| 21 | Высокотехнологичная продукция (фикс.) | `hightech_output_fixed` | decimal |
+| 22 | Высокотехнологичная на внутренний рынок (фикс.) | `hightech_domestic_fixed` | decimal |
+| 23 | Высокотехнологичная на экспорт (фикс.) | `hightech_export_fixed` | decimal |
+| 24 | Объём НИОКР (фикс.) | `rd_volume_fixed` | decimal |
+| 25 | НИОКР в интересах обороны (фикс.) | `rd_defense_fixed` | decimal |
+| 26 | Средняя численность работников | `avg_employees` | decimal |
+| 27 | Среднесписочная численность | `avg_payroll_employees` | decimal |
+| 28 | Фонд начисленной зарплаты | `payroll_fund` | decimal |
+| 29 | Просроченная задолженность по ЗП | `salary_arrears` | decimal |
+
+### А.3. Шаблон формы Ф‑2 (Бухгалтерский баланс)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Нематериальные активы | `intangible_assets` | decimal |
+| 5 | Результаты исследований и разработок | `rd_results` | decimal |
+| 6 | Нематериальные поисковые активы | `intangible_search_assets` | decimal |
+| 7 | Материальные поисковые активы | `tangible_search_assets` | decimal |
+| 8 | Основные средства | `fixed_assets` | decimal |
+| 9 | Доходные вложения в материальные ценности | `profitable_investments` | decimal |
+| 10 | Финансовые вложения (внеоборотные) | `financial_investments_non_current` | decimal |
+| 11 | Отложенные налоговые активы | `deferred_tax_assets` | decimal |
+| 12 | Прочие внеоборотные активы | `other_non_current_assets` | decimal |
+| 13 | Итого внеоборотные активы | `total_non_current_assets` | decimal |
+| 14 | Запасы | `inventories` | decimal |
+| 15 | НДС по приобретённым ценностям | `vat_on_acquired_assets` | decimal |
+| 16 | Дебиторская задолженность | `receivables` | decimal |
+| 17 | Финансовые вложения (оборотные) | `financial_investments_current` | decimal |
+| 18 | Денежные средства и эквиваленты | `cash_and_equivalents` | decimal |
+| 19 | Прочие оборотные активы | `other_current_assets` | decimal |
+| 20 | Итого оборотные активы | `total_current_assets` | decimal |
+| 21 | Баланс (актив) | `total_assets` | decimal |
+| 22 | Уставный капитал | `authorized_capital` | decimal |
+| 23 | Собственные акции, выкупленные у акционеров | `own_shares_bought_back` | decimal |
+| 24 | Переоценка внеоборотных активов | `revaluation_of_non_current_assets` | decimal |
+| 25 | Добавочный капитал | `additional_capital` | decimal |
+| 26 | Резервный капитал | `reserve_capital` | decimal |
+| 27 | Нераспределённая прибыль | `retained_earnings` | decimal |
+| 28 | Итого капитал и резервы | `total_equity` | decimal |
+| 29 | Заёмные средства (долгосрочные) | `borrowings_non_current` | decimal |
+| 30 | Отложенные налоговые обязательства | `deferred_tax_liabilities` | decimal |
+| 31 | Оценочные обязательства (долгосрочные) | `estimated_liabilities_non_current` | decimal |
+| 32 | Прочие обязательства (долгосрочные) | `other_liabilities_non_current` | decimal |
+| 33 | Итого долгосрочные обязательства | `total_non_current_liabilities` | decimal |
+| 34 | Заёмные средства (краткосрочные) | `borrowings_current` | decimal |
+| 35 | Кредиторская задолженность | `payables` | decimal |
+| 36 | Доходы будущих периодов | `deferred_income` | decimal |
+| 37 | Оценочные обязательства (краткосрочные) | `estimated_liabilities_current` | decimal |
+| 38 | Прочие обязательства (краткосрочные) | `other_liabilities_current` | decimal |
+| 39 | Итого краткосрочные обязательства | `total_current_liabilities` | decimal |
+| 40 | Баланс (пассив) | `total_liabilities` | decimal |
+| 41 | Выручка | `revenue` | decimal |
+| 42 | Себестоимость продаж | `cost_of_sales` | decimal |
+| 43 | Валовая прибыль | `gross_profit` | decimal |
+| 44 | Коммерческие расходы | `selling_expenses` | decimal |
+| 45 | Управленческие расходы | `administrative_expenses` | decimal |
+| 46 | Прибыль от продаж | `profit_from_sales` | decimal |
+| 47 | Проценты к получению | `interest_receivable` | decimal |
+| 48 | Проценты к уплате | `interest_payable` | decimal |
+| 49 | Прочие доходы | `other_income` | decimal |
+| 50 | Прочие расходы | `other_expenses` | decimal |
+| 51 | Прибыль до налогообложения | `profit_before_tax` | decimal |
+| 52 | Текущий налог на прибыль | `income_tax` | decimal |
+| 53 | Чистая прибыль | `net_profit` | decimal |
+| 54 | EBITDA | `ebitda` | decimal |
+| 55 | Амортизация | `depreciation` | decimal |
+| 56 | Оборотный капитал | `working_capital` | decimal |
+| 57 | Чистый долг | `net_debt` | decimal |
+| 58 | Баланс (актив) - прошлый период | `total_assets_prev` | decimal |
+| 59 | Баланс (пассив) - прошлый период | `total_liabilities_prev` | decimal |
+| 60 | Выручка - прошлый период | `revenue_prev` | decimal |
+| 61 | Чистая прибыль - прошлый период | `net_profit_prev` | decimal |
+
+### А.4. Шаблон формы Ф‑3 (Кадры и оборудование)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Средняя численность работников | `avg_employees` | decimal |
+| 5 | Производственный персонал | `production_workers` | decimal |
+| 6 | Инженерно‑технические работники | `engineering_workers` | decimal |
+| 7 | Административный персонал | `administrative_workers` | decimal |
+| 8 | Всего оборудования | `total_equipment` | int |
+| 9 | Отечественное оборудование | `domestic_equipment` | int |
+| 10 | Импортное оборудование | `imported_equipment` | int |
+| 11 | Оборудование до 5 лет | `equipment_age_under_5` | int |
+| 12 | Оборудование 5‑10 лет | `equipment_age_5_10` | int |
+| 13 | Оборудование 10‑15 лет | `equipment_age_10_15` | int |
+| 14 | Оборудование 15‑20 лет | `equipment_age_15_20` | int |
+| 15 | Оборудование свыше 20 лет | `equipment_age_over_20` | int |
+| 16 | Физический износ, % | `physical_wear_percent` | decimal |
+| 17 | Коэффициент загрузки | `utilization_rate` | decimal |
+| 18 | Средняя сменность работы | `avg_shift_work` | decimal |
+| 19 | Потребность в оборудовании | `equipment_needed` | int |
+| 20 | Потребность в кадрах | `workers_needed` | int |
+
+### А.5. Шаблон формы Ф‑4 (Сводные финансовые данные)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Выручка (РСБУ) | `revenue_rsbu` | decimal |
+| 5 | Выручка (МСФО) | `revenue_ifrs` | decimal |
+| 6 | Выручка прошлого года (РСБУ) | `revenue_prev_rsbu` | decimal |
+| 7 | Выручка прошлого года (МСФО) | `revenue_prev_ifrs` | decimal |
+| 8 | Чистая прибыль (РСБУ) | `net_profit_rsbu` | decimal |
+| 9 | Чистая прибыль (МСФО) | `net_profit_ifrs` | decimal |
+| 10 | Валовая прибыль (РСБУ) | `gross_profit_rsbu` | decimal |
+| 11 | Операционная прибыль (РСБУ) | `operating_profit_rsbu` | decimal |
+| 12 | EBITDA (РСБУ) | `ebitda_rsbu` | decimal |
+| 13 | EBITDA (МСФО) | `ebitda_ifrs` | decimal |
+| 14 | Кредиты и займы (РСБУ) | `loans_rsbu` | decimal |
+| 15 | Кредиты и займы (МСФО) | `loans_ifrs` | decimal |
+| 16 | Чистый долг (РСБУ) | `net_debt_rsbu` | decimal |
+| 17 | Чистый долг (МСФО) | `net_debt_ifrs` | decimal |
+| 18 | Долг/EBITDA | `debt_to_ebitda` | decimal |
+| 19 | Активы (РСБУ) | `total_assets_rsbu` | decimal |
+| 20 | Активы (МСФО) | `total_assets_ifrs` | decimal |
+| 21 | Собственный капитал (РСБУ) | `equity_rsbu` | decimal |
+| 22 | Собственный капитал (МСФО) | `equity_ifrs` | decimal |
+| 23 | ROE | `roe` | decimal |
+| 24 | ROA | `roa` | decimal |
+| 25 | ROS | `ros` | decimal |
+| 26 | CAPEX | `capex` | decimal |
+| 27 | Затраты на НИОКР | `rd_expenses` | decimal |
+| 28 | Выплаченные дивиденды | `dividends_paid` | decimal |
+| 29 | Дивидендная доходность | `dividend_yield` | decimal |
+
+### А.6. Шаблон формы Ф‑5 (Инвентаризация оборудования)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Идентификационный код | `equipment_id` | str |
+| 5 | Инвентарный номер | `inventory_number` | str |
+| 6 | Наименование оборудования | `name` | str |
+| 7 | Модель | `model` | str |
+| 8 | Производитель | `manufacturer` | str |
+| 9 | Страна происхождения | `country_origin` | str |
+| 10 | Отечественное производство | `is_domestic` | bool |
+| 11 | Год выпуска | `year_manufacture` | int |
+| 12 | Наличие ЧПУ | `has_cnc` | bool |
+| 13 | Тип оборудования | `equipment_type` | str |
+| 14 | Категория оборудования | `equipment_category` | str |
+| 15 | Дата ввода в эксплуатацию | `commissioning_date` | date |
+| 16 | Местонахождение | `location` | str |
+| 17 | Производственный участок | `production_site` | str |
+| 18 | Коэффициент использования | `utilization_rate` | decimal |
+| 19 | Фактический износ, % | `physical_wear_percent` | decimal |
+| 20 | В рабочем состоянии | `is_operational` | bool |
+| 21 | Требует ремонта | `requires_repair` | bool |
+| 22 | Требует замены | `requires_replacement` | bool |
+| 23 | Первоначальная стоимость | `initial_cost` | decimal |
+| 24 | Остаточная стоимость | `residual_value` | decimal |
+| 25 | Примечания | `notes` | str |
+
+### А.7. Шаблон формы Ф‑6 (Возрастная структура оборудования)
+
+Стандартные колонки организации:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 0 | Наименование организации | `organization_name` | str |
+| 1 | ОКПО | `okpo` | str |
+| 2 | ОГРН | `ogrn` | str |
+| 3 | ИНН | `inn` | str |
+
+Поля формы:
+
+| № | Заголовок | Поле модели | Тип |
+|---|-----------|-------------|-----|
+| 4 | Код строки | `row_code` | str |
+| 5 | Категория оборудования | `category` | str |
+| 6 | Всего оборудования | `total_equipment` | int |
+| 7 | Отечественное оборудование | `domestic_equipment` | int |
+| 8 | Импортное оборудование | `imported_equipment` | int |
+| 9 | До 5 лет | `age_under_5` | int |
+| 10 | 5‑10 лет | `age_5_10` | int |
+| 11 | 10‑15 лет | `age_10_15` | int |
+| 12 | 15‑20 лет | `age_15_20` | int |
+| 13 | Свыше 20 лет | `age_over_20` | int |
+| 14 | С ЧПУ всего | `cnc_total` | int |
+| 15 | С ЧПУ до 5 лет | `cnc_under_5` | int |
+| 16 | С ЧПУ 5‑10 лет | `cnc_5_10` | int |
+| 17 | С ЧПУ 10‑15 лет | `cnc_10_15` | int |
+| 18 | С ЧПУ 15‑20 лет | `cnc_15_20` | int |
+| 19 | С ЧПУ свыше 20 лет | `cnc_over_20` | int |
+| 20 | Средняя сменность работы | `avg_shift_work` | decimal |
+| 21 | Коэффициент загрузки | `utilization_rate` | decimal |
+| 22 | Физический износ, % | `physical_wear_percent` | decimal |
+| 23 | Рабочие места без оборудования | `workplaces_without_equipment` | int |
+| 24 | Оборудование к замене | `equipment_to_replace` | int |
+
+---
+
+## ПРИЛОЖЕНИЕ Б (РЕКОМЕНДУЕМОЕ). ПРИМЕРЫ ВХОДНЫХ ФАЙЛОВ
+
+### Б.1. Общие указания к примерам
+Примеры приведены для ориентира. Значения условные и должны соответствовать фактическим данным отчётности. Порядок колонок обязателен и соответствует приложению А.
+### Б.2. Пример заполнения формы Ф-1 (Выпуск продукции)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Выпуск военной продукции (факт.) | decimal | 1000.00 |
+| 5 | Военная на внутренний рынок (факт.) | decimal | 1000.00 |
+| 6 | Военная на экспорт (факт.) | decimal | 1000.00 |
+| 7 | Выпуск гражданской продукции (факт.) | decimal | 1000.00 |
+| 8 | Гражданская на внутренний рынок (факт.) | decimal | 1000.00 |
+| 9 | Гражданская на экспорт (факт.) | decimal | 1000.00 |
+| 10 | Высокотехнологичная продукция (факт.) | decimal | 1000.00 |
+| 11 | Высокотехнологичная на внутренний рынок (факт.) | decimal | 1000.00 |
+| 12 | Высокотехнологичная на экспорт (факт.) | decimal | 1000.00 |
+| 13 | Объём НИОКР (факт.) | decimal | 1000.00 |
+| 14 | НИОКР в интересах обороны (факт.) | decimal | 1000.00 |
+| 15 | Выпуск военной продукции (фикс.) | decimal | 1000.00 |
+| 16 | Военная на внутренний рынок (фикс.) | decimal | 1000.00 |
+| 17 | Военная на экспорт (фикс.) | decimal | 1000.00 |
+| 18 | Выпуск гражданской продукции (фикс.) | decimal | 1000.00 |
+| 19 | Гражданская на внутренний рынок (фикс.) | decimal | 1000.00 |
+| 20 | Гражданская на экспорт (фикс.) | decimal | 1000.00 |
+| 21 | Высокотехнологичная продукция (фикс.) | decimal | 1000.00 |
+| 22 | Высокотехнологичная на внутренний рынок (фикс.) | decimal | 1000.00 |
+| 23 | Высокотехнологичная на экспорт (фикс.) | decimal | 1000.00 |
+| 24 | Объём НИОКР (фикс.) | decimal | 1000.00 |
+| 25 | НИОКР в интересах обороны (фикс.) | decimal | 1000.00 |
+| 26 | Средняя численность работников | decimal | 1000.00 |
+| 27 | Среднесписочная численность | decimal | 1000.00 |
+| 28 | Фонд начисленной зарплаты | decimal | 1000.00 |
+| 29 | Просроченная задолженность по ЗП | decimal | 1000.00 |
+
+### Б.3. Пример заполнения формы Ф-2 (Бухгалтерский баланс)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Нематериальные активы | decimal | 1000.00 |
+| 5 | Результаты исследований и разработок | decimal | 1000.00 |
+| 6 | Нематериальные поисковые активы | decimal | 1000.00 |
+| 7 | Материальные поисковые активы | decimal | 1000.00 |
+| 8 | Основные средства | decimal | 1000.00 |
+| 9 | Доходные вложения в материальные ценности | decimal | 1000.00 |
+| 10 | Финансовые вложения (внеоборотные) | decimal | 1000.00 |
+| 11 | Отложенные налоговые активы | decimal | 1000.00 |
+| 12 | Прочие внеоборотные активы | decimal | 1000.00 |
+| 13 | Итого внеоборотные активы | decimal | 1000.00 |
+| 14 | Запасы | decimal | 1000.00 |
+| 15 | НДС по приобретённым ценностям | decimal | 1000.00 |
+| 16 | Дебиторская задолженность | decimal | 1000.00 |
+| 17 | Финансовые вложения (оборотные) | decimal | 1000.00 |
+| 18 | Денежные средства и эквиваленты | decimal | 1000.00 |
+| 19 | Прочие оборотные активы | decimal | 1000.00 |
+| 20 | Итого оборотные активы | decimal | 1000.00 |
+| 21 | Баланс (актив) | decimal | 1000.00 |
+| 22 | Уставный капитал | decimal | 1000.00 |
+| 23 | Собственные акции, выкупленные у акционеров | decimal | 1000.00 |
+| 24 | Переоценка внеоборотных активов | decimal | 1000.00 |
+| 25 | Добавочный капитал | decimal | 1000.00 |
+| 26 | Резервный капитал | decimal | 1000.00 |
+| 27 | Нераспределённая прибыль | decimal | 1000.00 |
+| 28 | Итого капитал и резервы | decimal | 1000.00 |
+| 29 | Заёмные средства (долгосрочные) | decimal | 1000.00 |
+| 30 | Отложенные налоговые обязательства | decimal | 1000.00 |
+| 31 | Оценочные обязательства (долгосрочные) | decimal | 1000.00 |
+| 32 | Прочие обязательства (долгосрочные) | decimal | 1000.00 |
+| 33 | Итого долгосрочные обязательства | decimal | 1000.00 |
+| 34 | Заёмные средства (краткосрочные) | decimal | 1000.00 |
+| 35 | Кредиторская задолженность | decimal | 1000.00 |
+| 36 | Доходы будущих периодов | decimal | 1000.00 |
+| 37 | Оценочные обязательства (краткосрочные) | decimal | 1000.00 |
+| 38 | Прочие обязательства (краткосрочные) | decimal | 1000.00 |
+| 39 | Итого краткосрочные обязательства | decimal | 1000.00 |
+| 40 | Баланс (пассив) | decimal | 1000.00 |
+| 41 | Выручка | decimal | 1000.00 |
+| 42 | Себестоимость продаж | decimal | 1000.00 |
+| 43 | Валовая прибыль | decimal | 1000.00 |
+| 44 | Коммерческие расходы | decimal | 1000.00 |
+| 45 | Управленческие расходы | decimal | 1000.00 |
+| 46 | Прибыль от продаж | decimal | 1000.00 |
+| 47 | Проценты к получению | decimal | 1000.00 |
+| 48 | Проценты к уплате | decimal | 1000.00 |
+| 49 | Прочие доходы | decimal | 1000.00 |
+| 50 | Прочие расходы | decimal | 1000.00 |
+| 51 | Прибыль до налогообложения | decimal | 1000.00 |
+| 52 | Текущий налог на прибыль | decimal | 1000.00 |
+| 53 | Чистая прибыль | decimal | 1000.00 |
+| 54 | EBITDA | decimal | 1000.00 |
+| 55 | Амортизация | decimal | 1000.00 |
+| 56 | Оборотный капитал | decimal | 1000.00 |
+| 57 | Чистый долг | decimal | 1000.00 |
+| 58 | Баланс (актив) - прошлый период | decimal | 1000.00 |
+| 59 | Баланс (пассив) - прошлый период | decimal | 1000.00 |
+| 60 | Выручка - прошлый период | decimal | 1000.00 |
+| 61 | Чистая прибыль - прошлый период | decimal | 1000.00 |
+
+### Б.4. Пример заполнения формы Ф-3 (Кадры и оборудование)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Средняя численность работников | decimal | 1000.00 |
+| 5 | Производственный персонал | decimal | 1000.00 |
+| 6 | Инженерно-технические работники | decimal | 1000.00 |
+| 7 | Административный персонал | decimal | 1000.00 |
+| 8 | Всего оборудования | int | 120 |
+| 9 | Отечественное оборудование | int | 90 |
+| 10 | Импортное оборудование | int | 30 |
+| 11 | Оборудование до 5 лет | int | 25 |
+| 12 | Оборудование 5-10 лет | int | 40 |
+| 13 | Оборудование 10-15 лет | int | 30 |
+| 14 | Оборудование 15-20 лет | int | 15 |
+| 15 | Оборудование свыше 20 лет | int | 10 |
+| 16 | Физический износ, % | decimal | 0.85 |
+| 17 | Коэффициент загрузки | decimal | 0.85 |
+| 18 | Средняя сменность работы | decimal | 0.85 |
+| 19 | Потребность в оборудовании | int | 10 |
+| 20 | Потребность в кадрах | int | 10 |
+
+### Б.5. Пример заполнения формы Ф-4 (Сводные финансовые данные)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Выручка (РСБУ) | decimal | 1000.00 |
+| 5 | Выручка (МСФО) | decimal | 1000.00 |
+| 6 | Выручка прошлого года (РСБУ) | decimal | 1000.00 |
+| 7 | Выручка прошлого года (МСФО) | decimal | 1000.00 |
+| 8 | Чистая прибыль (РСБУ) | decimal | 1000.00 |
+| 9 | Чистая прибыль (МСФО) | decimal | 1000.00 |
+| 10 | Валовая прибыль (РСБУ) | decimal | 1000.00 |
+| 11 | Операционная прибыль (РСБУ) | decimal | 1000.00 |
+| 12 | EBITDA (РСБУ) | decimal | 1000.00 |
+| 13 | EBITDA (МСФО) | decimal | 1000.00 |
+| 14 | Кредиты и займы (РСБУ) | decimal | 1000.00 |
+| 15 | Кредиты и займы (МСФО) | decimal | 1000.00 |
+| 16 | Чистый долг (РСБУ) | decimal | 1000.00 |
+| 17 | Чистый долг (МСФО) | decimal | 1000.00 |
+| 18 | Долг/EBITDA | decimal | 1000.00 |
+| 19 | Активы (РСБУ) | decimal | 1000.00 |
+| 20 | Активы (МСФО) | decimal | 1000.00 |
+| 21 | Собственный капитал (РСБУ) | decimal | 1000.00 |
+| 22 | Собственный капитал (МСФО) | decimal | 1000.00 |
+| 23 | ROE | decimal | 1000.00 |
+| 24 | ROA | decimal | 1000.00 |
+| 25 | ROS | decimal | 1000.00 |
+| 26 | CAPEX | decimal | 1000.00 |
+| 27 | Затраты на НИОКР | decimal | 1000.00 |
+| 28 | Выплаченные дивиденды | decimal | 1000.00 |
+| 29 | Дивидендная доходность | decimal | 1000.00 |
+
+### Б.6. Пример заполнения формы Ф-5 (Инвентаризация оборудования)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Идентификационный код | str | Пример |
+| 5 | Инвентарный номер | str | Пример |
+| 6 | Наименование оборудования | str | Станок токарный |
+| 7 | Модель | str | СТ-16К20 |
+| 8 | Производитель | str | АО "Машзавод" |
+| 9 | Страна происхождения | str | Россия |
+| 10 | Отечественное производство | bool | да |
+| 11 | Год выпуска | int | 2018 |
+| 12 | Наличие ЧПУ | bool | да |
+| 13 | Тип оборудования | str | Токарное |
+| 14 | Категория оборудования | str | Металлообработка |
+| 15 | Дата ввода в эксплуатацию | date | 2020-06-01 |
+| 16 | Местонахождение | str | Цех №1 |
+| 17 | Производственный участок | str | Участок обработки |
+| 18 | Коэффициент использования | decimal | 0.85 |
+| 19 | Фактический износ, % | decimal | 1000.00 |
+| 20 | В рабочем состоянии | bool | да |
+| 21 | Требует ремонта | bool | да |
+| 22 | Требует замены | bool | да |
+| 23 | Первоначальная стоимость | decimal | 1000.00 |
+| 24 | Остаточная стоимость | decimal | 1000.00 |
+| 25 | Примечания | str | Без замечаний |
+
+### Б.7. Пример заполнения формы Ф-6 (Возрастная структура оборудования)
+| № | Заголовок | Тип | Пример значения |
+|---|-----------|-----|-----------------|
+| 0 | Наименование организации | str | АО "Пример" |
+| 1 | ОКПО | str | 12345678 |
+| 2 | ОГРН | str | 1027700132195 |
+| 3 | ИНН | str | 7701234567 |
+| 4 | Код строки | str | 010 |
+| 5 | Категория оборудования | str | Металлообработка |
+| 6 | Всего оборудования | int | 120 |
+| 7 | Отечественное оборудование | int | 90 |
+| 8 | Импортное оборудование | int | 30 |
+| 9 | До 5 лет | int | 25 |
+| 10 | 5-10 лет | int | 40 |
+| 11 | 10-15 лет | int | 30 |
+| 12 | 15-20 лет | int | 15 |
+| 13 | Свыше 20 лет | int | 10 |
+| 14 | С ЧПУ всего | int | 60 |
+| 15 | С ЧПУ до 5 лет | int | 15 |
+| 16 | С ЧПУ 5-10 лет | int | 20 |
+| 17 | С ЧПУ 10-15 лет | int | 15 |
+| 18 | С ЧПУ 15-20 лет | int | 7 |
+| 19 | С ЧПУ свыше 20 лет | int | 3 |
+| 20 | Средняя сменность работы | decimal | 0.85 |
+| 21 | Коэффициент загрузки | decimal | 0.85 |
+| 22 | Физический износ, % | decimal | 0.85 |
+| 23 | Рабочие места без оборудования | int | 10 |
+| 24 | Оборудование к замене | int | 10 |
+
+
+---
+
+## ПРИЛОЖЕНИЕ В (СПРАВОЧНОЕ). СПРАВОЧНИК ПОКАЗАТЕЛЕЙ
+
+### В.1. Общие положения
+Справочник показателей предназначен для унифицированной интерпретации полей базы данных и отчётных колонок. Для каждого показателя приведено наименование и краткое пояснение.
+
+### В.2. Показатели формы Ф-1 (Выпуск продукции)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `military_output_actual` | Выпуск военной продукции (факт.) | Значение показателя «Выпуск военной продукции (факт.)», передаваемое в форме Ф-1. |
+| `military_domestic_actual` | Военная на внутренний рынок (факт.) | Значение показателя «Военная на внутренний рынок (факт.)», передаваемое в форме Ф-1. |
+| `military_export_actual` | Военная на экспорт (факт.) | Значение показателя «Военная на экспорт (факт.)», передаваемое в форме Ф-1. |
+| `civilian_output_actual` | Выпуск гражданской продукции (факт.) | Значение показателя «Выпуск гражданской продукции (факт.)», передаваемое в форме Ф-1. |
+| `civilian_domestic_actual` | Гражданская на внутренний рынок (факт.) | Значение показателя «Гражданская на внутренний рынок (факт.)», передаваемое в форме Ф-1. |
+| `civilian_export_actual` | Гражданская на экспорт (факт.) | Значение показателя «Гражданская на экспорт (факт.)», передаваемое в форме Ф-1. |
+| `hightech_output_actual` | Высокотехнологичная продукция (факт.) | Значение показателя «Высокотехнологичная продукция (факт.)», передаваемое в форме Ф-1. |
+| `hightech_domestic_actual` | Высокотехнологичная на внутренний рынок (факт.) | Значение показателя «Высокотехнологичная на внутренний рынок (факт.)», передаваемое в форме Ф-1. |
+| `hightech_export_actual` | Высокотехнологичная на экспорт (факт.) | Значение показателя «Высокотехнологичная на экспорт (факт.)», передаваемое в форме Ф-1. |
+| `rd_volume_actual` | Объём НИОКР (факт.) | Значение показателя «Объём НИОКР (факт.)», передаваемое в форме Ф-1. |
+| `rd_defense_actual` | НИОКР в интересах обороны (факт.) | Значение показателя «НИОКР в интересах обороны (факт.)», передаваемое в форме Ф-1. |
+| `military_output_fixed` | Выпуск военной продукции (фикс.) | Значение показателя «Выпуск военной продукции (фикс.)», передаваемое в форме Ф-1. |
+| `military_domestic_fixed` | Военная на внутренний рынок (фикс.) | Значение показателя «Военная на внутренний рынок (фикс.)», передаваемое в форме Ф-1. |
+| `military_export_fixed` | Военная на экспорт (фикс.) | Значение показателя «Военная на экспорт (фикс.)», передаваемое в форме Ф-1. |
+| `civilian_output_fixed` | Выпуск гражданской продукции (фикс.) | Значение показателя «Выпуск гражданской продукции (фикс.)», передаваемое в форме Ф-1. |
+| `civilian_domestic_fixed` | Гражданская на внутренний рынок (фикс.) | Значение показателя «Гражданская на внутренний рынок (фикс.)», передаваемое в форме Ф-1. |
+| `civilian_export_fixed` | Гражданская на экспорт (фикс.) | Значение показателя «Гражданская на экспорт (фикс.)», передаваемое в форме Ф-1. |
+| `hightech_output_fixed` | Высокотехнологичная продукция (фикс.) | Значение показателя «Высокотехнологичная продукция (фикс.)», передаваемое в форме Ф-1. |
+| `hightech_domestic_fixed` | Высокотехнологичная на внутренний рынок (фикс.) | Значение показателя «Высокотехнологичная на внутренний рынок (фикс.)», передаваемое в форме Ф-1. |
+| `hightech_export_fixed` | Высокотехнологичная на экспорт (фикс.) | Значение показателя «Высокотехнологичная на экспорт (фикс.)», передаваемое в форме Ф-1. |
+| `rd_volume_fixed` | Объём НИОКР (фикс.) | Значение показателя «Объём НИОКР (фикс.)», передаваемое в форме Ф-1. |
+| `rd_defense_fixed` | НИОКР в интересах обороны (фикс.) | Значение показателя «НИОКР в интересах обороны (фикс.)», передаваемое в форме Ф-1. |
+| `avg_employees` | Средняя численность работников | Значение показателя «Средняя численность работников», передаваемое в форме Ф-1. |
+| `avg_payroll_employees` | Среднесписочная численность | Значение показателя «Среднесписочная численность», передаваемое в форме Ф-1. |
+| `payroll_fund` | Фонд начисленной зарплаты | Значение показателя «Фонд начисленной зарплаты», передаваемое в форме Ф-1. |
+| `salary_arrears` | Просроченная задолженность по ЗП | Значение показателя «Просроченная задолженность по ЗП», передаваемое в форме Ф-1. |
+
+### В.3. Показатели формы Ф-2 (Бухгалтерский баланс)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `intangible_assets` | Нематериальные активы | Значение показателя «Нематериальные активы», передаваемое в форме Ф-2. |
+| `rd_results` | Результаты исследований и разработок | Значение показателя «Результаты исследований и разработок», передаваемое в форме Ф-2. |
+| `intangible_search_assets` | Нематериальные поисковые активы | Значение показателя «Нематериальные поисковые активы», передаваемое в форме Ф-2. |
+| `tangible_search_assets` | Материальные поисковые активы | Значение показателя «Материальные поисковые активы», передаваемое в форме Ф-2. |
+| `fixed_assets` | Основные средства | Значение показателя «Основные средства», передаваемое в форме Ф-2. |
+| `profitable_investments` | Доходные вложения в материальные ценности | Значение показателя «Доходные вложения в материальные ценности», передаваемое в форме Ф-2. |
+| `financial_investments_non_current` | Финансовые вложения (внеоборотные) | Значение показателя «Финансовые вложения (внеоборотные)», передаваемое в форме Ф-2. |
+| `deferred_tax_assets` | Отложенные налоговые активы | Значение показателя «Отложенные налоговые активы», передаваемое в форме Ф-2. |
+| `other_non_current_assets` | Прочие внеоборотные активы | Значение показателя «Прочие внеоборотные активы», передаваемое в форме Ф-2. |
+| `total_non_current_assets` | Итого внеоборотные активы | Значение показателя «Итого внеоборотные активы», передаваемое в форме Ф-2. |
+| `inventories` | Запасы | Значение показателя «Запасы», передаваемое в форме Ф-2. |
+| `vat_on_acquired_assets` | НДС по приобретённым ценностям | Значение показателя «НДС по приобретённым ценностям», передаваемое в форме Ф-2. |
+| `receivables` | Дебиторская задолженность | Значение показателя «Дебиторская задолженность», передаваемое в форме Ф-2. |
+| `financial_investments_current` | Финансовые вложения (оборотные) | Значение показателя «Финансовые вложения (оборотные)», передаваемое в форме Ф-2. |
+| `cash_and_equivalents` | Денежные средства и эквиваленты | Значение показателя «Денежные средства и эквиваленты», передаваемое в форме Ф-2. |
+| `other_current_assets` | Прочие оборотные активы | Значение показателя «Прочие оборотные активы», передаваемое в форме Ф-2. |
+| `total_current_assets` | Итого оборотные активы | Значение показателя «Итого оборотные активы», передаваемое в форме Ф-2. |
+| `total_assets` | Баланс (актив) | Значение показателя «Баланс (актив)», передаваемое в форме Ф-2. |
+| `authorized_capital` | Уставный капитал | Значение показателя «Уставный капитал», передаваемое в форме Ф-2. |
+| `own_shares_bought_back` | Собственные акции, выкупленные у акционеров | Значение показателя «Собственные акции, выкупленные у акционеров», передаваемое в форме Ф-2. |
+| `revaluation_of_non_current_assets` | Переоценка внеоборотных активов | Значение показателя «Переоценка внеоборотных активов», передаваемое в форме Ф-2. |
+| `additional_capital` | Добавочный капитал | Значение показателя «Добавочный капитал», передаваемое в форме Ф-2. |
+| `reserve_capital` | Резервный капитал | Значение показателя «Резервный капитал», передаваемое в форме Ф-2. |
+| `retained_earnings` | Нераспределённая прибыль | Значение показателя «Нераспределённая прибыль», передаваемое в форме Ф-2. |
+| `total_equity` | Итого капитал и резервы | Значение показателя «Итого капитал и резервы», передаваемое в форме Ф-2. |
+| `borrowings_non_current` | Заёмные средства (долгосрочные) | Значение показателя «Заёмные средства (долгосрочные)», передаваемое в форме Ф-2. |
+| `deferred_tax_liabilities` | Отложенные налоговые обязательства | Значение показателя «Отложенные налоговые обязательства», передаваемое в форме Ф-2. |
+| `estimated_liabilities_non_current` | Оценочные обязательства (долгосрочные) | Значение показателя «Оценочные обязательства (долгосрочные)», передаваемое в форме Ф-2. |
+| `other_liabilities_non_current` | Прочие обязательства (долгосрочные) | Значение показателя «Прочие обязательства (долгосрочные)», передаваемое в форме Ф-2. |
+| `total_non_current_liabilities` | Итого долгосрочные обязательства | Значение показателя «Итого долгосрочные обязательства», передаваемое в форме Ф-2. |
+| `borrowings_current` | Заёмные средства (краткосрочные) | Значение показателя «Заёмные средства (краткосрочные)», передаваемое в форме Ф-2. |
+| `payables` | Кредиторская задолженность | Значение показателя «Кредиторская задолженность», передаваемое в форме Ф-2. |
+| `deferred_income` | Доходы будущих периодов | Значение показателя «Доходы будущих периодов», передаваемое в форме Ф-2. |
+| `estimated_liabilities_current` | Оценочные обязательства (краткосрочные) | Значение показателя «Оценочные обязательства (краткосрочные)», передаваемое в форме Ф-2. |
+| `other_liabilities_current` | Прочие обязательства (краткосрочные) | Значение показателя «Прочие обязательства (краткосрочные)», передаваемое в форме Ф-2. |
+| `total_current_liabilities` | Итого краткосрочные обязательства | Значение показателя «Итого краткосрочные обязательства», передаваемое в форме Ф-2. |
+| `total_liabilities` | Баланс (пассив) | Значение показателя «Баланс (пассив)», передаваемое в форме Ф-2. |
+| `revenue` | Выручка | Значение показателя «Выручка», передаваемое в форме Ф-2. |
+| `cost_of_sales` | Себестоимость продаж | Значение показателя «Себестоимость продаж», передаваемое в форме Ф-2. |
+| `gross_profit` | Валовая прибыль | Значение показателя «Валовая прибыль», передаваемое в форме Ф-2. |
+| `selling_expenses` | Коммерческие расходы | Значение показателя «Коммерческие расходы», передаваемое в форме Ф-2. |
+| `administrative_expenses` | Управленческие расходы | Значение показателя «Управленческие расходы», передаваемое в форме Ф-2. |
+| `profit_from_sales` | Прибыль от продаж | Значение показателя «Прибыль от продаж», передаваемое в форме Ф-2. |
+| `interest_receivable` | Проценты к получению | Значение показателя «Проценты к получению», передаваемое в форме Ф-2. |
+| `interest_payable` | Проценты к уплате | Значение показателя «Проценты к уплате», передаваемое в форме Ф-2. |
+| `other_income` | Прочие доходы | Значение показателя «Прочие доходы», передаваемое в форме Ф-2. |
+| `other_expenses` | Прочие расходы | Значение показателя «Прочие расходы», передаваемое в форме Ф-2. |
+| `profit_before_tax` | Прибыль до налогообложения | Значение показателя «Прибыль до налогообложения», передаваемое в форме Ф-2. |
+| `income_tax` | Текущий налог на прибыль | Значение показателя «Текущий налог на прибыль», передаваемое в форме Ф-2. |
+| `net_profit` | Чистая прибыль | Значение показателя «Чистая прибыль», передаваемое в форме Ф-2. |
+| `ebitda` | EBITDA | Значение показателя «EBITDA», передаваемое в форме Ф-2. |
+| `depreciation` | Амортизация | Значение показателя «Амортизация», передаваемое в форме Ф-2. |
+| `working_capital` | Оборотный капитал | Значение показателя «Оборотный капитал», передаваемое в форме Ф-2. |
+| `net_debt` | Чистый долг | Значение показателя «Чистый долг», передаваемое в форме Ф-2. |
+| `total_assets_prev` | Баланс (актив) - прошлый период | Значение показателя «Баланс (актив) - прошлый период», передаваемое в форме Ф-2. |
+| `total_liabilities_prev` | Баланс (пассив) - прошлый период | Значение показателя «Баланс (пассив) - прошлый период», передаваемое в форме Ф-2. |
+| `revenue_prev` | Выручка - прошлый период | Значение показателя «Выручка - прошлый период», передаваемое в форме Ф-2. |
+| `net_profit_prev` | Чистая прибыль - прошлый период | Значение показателя «Чистая прибыль - прошлый период», передаваемое в форме Ф-2. |
+
+### В.4. Показатели формы Ф-3 (Кадры и оборудование)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `avg_employees` | Средняя численность работников | Значение показателя «Средняя численность работников», передаваемое в форме Ф-3. |
+| `production_workers` | Производственный персонал | Значение показателя «Производственный персонал», передаваемое в форме Ф-3. |
+| `engineering_workers` | Инженерно-технические работники | Значение показателя «Инженерно-технические работники», передаваемое в форме Ф-3. |
+| `administrative_workers` | Административный персонал | Значение показателя «Административный персонал», передаваемое в форме Ф-3. |
+| `total_equipment` | Всего оборудования | Значение показателя «Всего оборудования», передаваемое в форме Ф-3. |
+| `domestic_equipment` | Отечественное оборудование | Значение показателя «Отечественное оборудование», передаваемое в форме Ф-3. |
+| `imported_equipment` | Импортное оборудование | Значение показателя «Импортное оборудование», передаваемое в форме Ф-3. |
+| `equipment_age_under_5` | Оборудование до 5 лет | Значение показателя «Оборудование до 5 лет», передаваемое в форме Ф-3. |
+| `equipment_age_5_10` | Оборудование 5-10 лет | Значение показателя «Оборудование 5-10 лет», передаваемое в форме Ф-3. |
+| `equipment_age_10_15` | Оборудование 10-15 лет | Значение показателя «Оборудование 10-15 лет», передаваемое в форме Ф-3. |
+| `equipment_age_15_20` | Оборудование 15-20 лет | Значение показателя «Оборудование 15-20 лет», передаваемое в форме Ф-3. |
+| `equipment_age_over_20` | Оборудование свыше 20 лет | Значение показателя «Оборудование свыше 20 лет», передаваемое в форме Ф-3. |
+| `physical_wear_percent` | Физический износ, % | Значение показателя «Физический износ, %», передаваемое в форме Ф-3. |
+| `utilization_rate` | Коэффициент загрузки | Значение показателя «Коэффициент загрузки», передаваемое в форме Ф-3. |
+| `avg_shift_work` | Средняя сменность работы | Значение показателя «Средняя сменность работы», передаваемое в форме Ф-3. |
+| `equipment_needed` | Потребность в оборудовании | Значение показателя «Потребность в оборудовании», передаваемое в форме Ф-3. |
+| `workers_needed` | Потребность в кадрах | Значение показателя «Потребность в кадрах», передаваемое в форме Ф-3. |
+
+### В.5. Показатели формы Ф-4 (Сводные финансовые данные)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `revenue_rsbu` | Выручка (РСБУ) | Значение показателя «Выручка (РСБУ)», передаваемое в форме Ф-4. |
+| `revenue_ifrs` | Выручка (МСФО) | Значение показателя «Выручка (МСФО)», передаваемое в форме Ф-4. |
+| `revenue_prev_rsbu` | Выручка прошлого года (РСБУ) | Значение показателя «Выручка прошлого года (РСБУ)», передаваемое в форме Ф-4. |
+| `revenue_prev_ifrs` | Выручка прошлого года (МСФО) | Значение показателя «Выручка прошлого года (МСФО)», передаваемое в форме Ф-4. |
+| `net_profit_rsbu` | Чистая прибыль (РСБУ) | Значение показателя «Чистая прибыль (РСБУ)», передаваемое в форме Ф-4. |
+| `net_profit_ifrs` | Чистая прибыль (МСФО) | Значение показателя «Чистая прибыль (МСФО)», передаваемое в форме Ф-4. |
+| `gross_profit_rsbu` | Валовая прибыль (РСБУ) | Значение показателя «Валовая прибыль (РСБУ)», передаваемое в форме Ф-4. |
+| `operating_profit_rsbu` | Операционная прибыль (РСБУ) | Значение показателя «Операционная прибыль (РСБУ)», передаваемое в форме Ф-4. |
+| `ebitda_rsbu` | EBITDA (РСБУ) | Значение показателя «EBITDA (РСБУ)», передаваемое в форме Ф-4. |
+| `ebitda_ifrs` | EBITDA (МСФО) | Значение показателя «EBITDA (МСФО)», передаваемое в форме Ф-4. |
+| `loans_rsbu` | Кредиты и займы (РСБУ) | Значение показателя «Кредиты и займы (РСБУ)», передаваемое в форме Ф-4. |
+| `loans_ifrs` | Кредиты и займы (МСФО) | Значение показателя «Кредиты и займы (МСФО)», передаваемое в форме Ф-4. |
+| `net_debt_rsbu` | Чистый долг (РСБУ) | Значение показателя «Чистый долг (РСБУ)», передаваемое в форме Ф-4. |
+| `net_debt_ifrs` | Чистый долг (МСФО) | Значение показателя «Чистый долг (МСФО)», передаваемое в форме Ф-4. |
+| `debt_to_ebitda` | Долг/EBITDA | Значение показателя «Долг/EBITDA», передаваемое в форме Ф-4. |
+| `total_assets_rsbu` | Активы (РСБУ) | Значение показателя «Активы (РСБУ)», передаваемое в форме Ф-4. |
+| `total_assets_ifrs` | Активы (МСФО) | Значение показателя «Активы (МСФО)», передаваемое в форме Ф-4. |
+| `equity_rsbu` | Собственный капитал (РСБУ) | Значение показателя «Собственный капитал (РСБУ)», передаваемое в форме Ф-4. |
+| `equity_ifrs` | Собственный капитал (МСФО) | Значение показателя «Собственный капитал (МСФО)», передаваемое в форме Ф-4. |
+| `roe` | ROE | Значение показателя «ROE», передаваемое в форме Ф-4. |
+| `roa` | ROA | Значение показателя «ROA», передаваемое в форме Ф-4. |
+| `ros` | ROS | Значение показателя «ROS», передаваемое в форме Ф-4. |
+| `capex` | CAPEX | Значение показателя «CAPEX», передаваемое в форме Ф-4. |
+| `rd_expenses` | Затраты на НИОКР | Значение показателя «Затраты на НИОКР», передаваемое в форме Ф-4. |
+| `dividends_paid` | Выплаченные дивиденды | Значение показателя «Выплаченные дивиденды», передаваемое в форме Ф-4. |
+| `dividend_yield` | Дивидендная доходность | Значение показателя «Дивидендная доходность», передаваемое в форме Ф-4. |
+
+### В.6. Показатели формы Ф-5 (Инвентаризация оборудования)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `equipment_id` | Идентификационный код | Значение показателя «Идентификационный код», передаваемое в форме Ф-5. |
+| `inventory_number` | Инвентарный номер | Значение показателя «Инвентарный номер», передаваемое в форме Ф-5. |
+| `name` | Наименование оборудования | Значение показателя «Наименование оборудования», передаваемое в форме Ф-5. |
+| `model` | Модель | Значение показателя «Модель», передаваемое в форме Ф-5. |
+| `manufacturer` | Производитель | Значение показателя «Производитель», передаваемое в форме Ф-5. |
+| `country_origin` | Страна происхождения | Значение показателя «Страна происхождения», передаваемое в форме Ф-5. |
+| `is_domestic` | Отечественное производство | Значение показателя «Отечественное производство», передаваемое в форме Ф-5. |
+| `year_manufacture` | Год выпуска | Значение показателя «Год выпуска», передаваемое в форме Ф-5. |
+| `has_cnc` | Наличие ЧПУ | Значение показателя «Наличие ЧПУ», передаваемое в форме Ф-5. |
+| `equipment_type` | Тип оборудования | Значение показателя «Тип оборудования», передаваемое в форме Ф-5. |
+| `equipment_category` | Категория оборудования | Значение показателя «Категория оборудования», передаваемое в форме Ф-5. |
+| `commissioning_date` | Дата ввода в эксплуатацию | Значение показателя «Дата ввода в эксплуатацию», передаваемое в форме Ф-5. |
+| `location` | Местонахождение | Значение показателя «Местонахождение», передаваемое в форме Ф-5. |
+| `production_site` | Производственный участок | Значение показателя «Производственный участок», передаваемое в форме Ф-5. |
+| `utilization_rate` | Коэффициент использования | Значение показателя «Коэффициент использования», передаваемое в форме Ф-5. |
+| `physical_wear_percent` | Фактический износ, % | Значение показателя «Фактический износ, %», передаваемое в форме Ф-5. |
+| `is_operational` | В рабочем состоянии | Значение показателя «В рабочем состоянии», передаваемое в форме Ф-5. |
+| `requires_repair` | Требует ремонта | Значение показателя «Требует ремонта», передаваемое в форме Ф-5. |
+| `requires_replacement` | Требует замены | Значение показателя «Требует замены», передаваемое в форме Ф-5. |
+| `initial_cost` | Первоначальная стоимость | Значение показателя «Первоначальная стоимость», передаваемое в форме Ф-5. |
+| `residual_value` | Остаточная стоимость | Значение показателя «Остаточная стоимость», передаваемое в форме Ф-5. |
+| `notes` | Примечания | Значение показателя «Примечания», передаваемое в форме Ф-5. |
+
+### В.7. Показатели формы Ф-6 (Возрастная структура оборудования)
+| Поле модели | Показатель | Пояснение |
+|------------|-----------|----------|
+| `organization_name` | Наименование организации | Полное наименование организации в соответствии с отчётной формой. |
+| `okpo` | ОКПО | Код организации по ОКПО, используется для идентификации в отчётности. |
+| `ogrn` | ОГРН | Основной государственный регистрационный номер организации. |
+| `inn` | ИНН | Идентификационный номер налогоплательщика организации. |
+| `row_code` | Код строки | Значение показателя «Код строки», передаваемое в форме Ф-6. |
+| `category` | Категория оборудования | Значение показателя «Категория оборудования», передаваемое в форме Ф-6. |
+| `total_equipment` | Всего оборудования | Значение показателя «Всего оборудования», передаваемое в форме Ф-6. |
+| `domestic_equipment` | Отечественное оборудование | Значение показателя «Отечественное оборудование», передаваемое в форме Ф-6. |
+| `imported_equipment` | Импортное оборудование | Значение показателя «Импортное оборудование», передаваемое в форме Ф-6. |
+| `age_under_5` | До 5 лет | Значение показателя «До 5 лет», передаваемое в форме Ф-6. |
+| `age_5_10` | 5-10 лет | Значение показателя «5-10 лет», передаваемое в форме Ф-6. |
+| `age_10_15` | 10-15 лет | Значение показателя «10-15 лет», передаваемое в форме Ф-6. |
+| `age_15_20` | 15-20 лет | Значение показателя «15-20 лет», передаваемое в форме Ф-6. |
+| `age_over_20` | Свыше 20 лет | Значение показателя «Свыше 20 лет», передаваемое в форме Ф-6. |
+| `cnc_total` | С ЧПУ всего | Значение показателя «С ЧПУ всего», передаваемое в форме Ф-6. |
+| `cnc_under_5` | С ЧПУ до 5 лет | Значение показателя «С ЧПУ до 5 лет», передаваемое в форме Ф-6. |
+| `cnc_5_10` | С ЧПУ 5-10 лет | Значение показателя «С ЧПУ 5-10 лет», передаваемое в форме Ф-6. |
+| `cnc_10_15` | С ЧПУ 10-15 лет | Значение показателя «С ЧПУ 10-15 лет», передаваемое в форме Ф-6. |
+| `cnc_15_20` | С ЧПУ 15-20 лет | Значение показателя «С ЧПУ 15-20 лет», передаваемое в форме Ф-6. |
+| `cnc_over_20` | С ЧПУ свыше 20 лет | Значение показателя «С ЧПУ свыше 20 лет», передаваемое в форме Ф-6. |
+| `avg_shift_work` | Средняя сменность работы | Значение показателя «Средняя сменность работы», передаваемое в форме Ф-6. |
+| `utilization_rate` | Коэффициент загрузки | Значение показателя «Коэффициент загрузки», передаваемое в форме Ф-6. |
+| `physical_wear_percent` | Физический износ, % | Значение показателя «Физический износ, %», передаваемое в форме Ф-6. |
+| `workplaces_without_equipment` | Рабочие места без оборудования | Значение показателя «Рабочие места без оборудования», передаваемое в форме Ф-6. |
+| `equipment_to_replace` | Оборудование к замене | Значение показателя «Оборудование к замене», передаваемое в форме Ф-6. |
+