From 8ed3e1175cf220342f75b5e4219f7122639eccf9 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 17 Feb 2026 09:26:08 +0100 Subject: [PATCH] Add initial implementations for forms and organization apps with serializers, factories, and admin configurations --- docker-compose.prod.yml | 121 ++ sitecustomize.py | 10 + src/apps/core/admin.py | 152 ++ src/apps/core/excel.py | 486 +++++ src/apps/form_1/__init__.py | 0 src/apps/form_1/admin.py | 131 ++ src/apps/form_1/api.py | 153 ++ src/apps/form_1/apps.py | 11 + src/apps/form_1/migrations/0001_initial.py | 66 + src/apps/form_1/migrations/__init__.py | 0 src/apps/form_1/models.py | 252 +++ src/apps/form_1/serializers.py | 141 ++ src/apps/form_1/services.py | 162 ++ src/apps/form_1/tasks.py | 55 + src/apps/form_1/urls.py | 13 + src/apps/form_2/__init__.py | 1 + src/apps/form_2/admin.py | 86 + src/apps/form_2/api.py | 109 + src/apps/form_2/apps.py | 11 + src/apps/form_2/migrations/0001_initial.py | 98 + src/apps/form_2/migrations/__init__.py | 0 src/apps/form_2/models.py | 304 +++ src/apps/form_2/serializers.py | 91 + src/apps/form_2/services.py | 188 ++ src/apps/form_2/tasks.py | 46 + src/apps/form_2/urls.py | 15 + src/apps/form_3/__init__.py | 1 + src/apps/form_3/admin.py | 48 + src/apps/form_3/api.py | 106 + src/apps/form_3/apps.py | 11 + src/apps/form_3/migrations/0001_initial.py | 57 + src/apps/form_3/migrations/__init__.py | 0 src/apps/form_3/models.py | 129 ++ src/apps/form_3/serializers.py | 89 + src/apps/form_3/services.py | 144 ++ src/apps/form_3/tasks.py | 42 + src/apps/form_3/urls.py | 15 + src/apps/form_4/__init__.py | 1 + src/apps/form_4/admin.py | 45 + src/apps/form_4/api.py | 58 + src/apps/form_4/apps.py | 11 + src/apps/form_4/migrations/0001_initial.py | 66 + src/apps/form_4/migrations/__init__.py | 0 src/apps/form_4/models.py | 171 ++ src/apps/form_4/serializers.py | 57 + src/apps/form_4/services.py | 119 ++ src/apps/form_4/tasks.py | 19 + src/apps/form_4/urls.py | 13 + src/apps/form_5/__init__.py | 1 + src/apps/form_5/admin.py | 45 + src/apps/form_5/api.py | 58 + src/apps/form_5/apps.py | 11 + src/apps/form_5/migrations/0001_initial.py | 70 + src/apps/form_5/migrations/__init__.py | 0 src/apps/form_5/models.py | 155 ++ src/apps/form_5/serializers.py | 58 + src/apps/form_5/services.py | 114 ++ src/apps/form_5/tasks.py | 19 + src/apps/form_5/urls.py | 13 + src/apps/form_6/__init__.py | 1 + src/apps/form_6/admin.py | 39 + src/apps/form_6/api.py | 58 + src/apps/form_6/apps.py | 11 + src/apps/form_6/migrations/0001_initial.py | 65 + src/apps/form_6/migrations/__init__.py | 0 src/apps/form_6/models.py | 148 ++ src/apps/form_6/serializers.py | 58 + src/apps/form_6/services.py | 112 + src/apps/form_6/tasks.py | 19 + src/apps/form_6/urls.py | 13 + src/apps/organization/__init__.py | 0 src/apps/organization/admin.py | 37 + src/apps/organization/api.py | 57 + src/apps/organization/apps.py | 11 + .../organization/migrations/0001_initial.py | 45 + src/apps/organization/migrations/__init__.py | 0 src/apps/organization/models.py | 83 + src/apps/organization/serializers.py | 41 + src/apps/organization/services.py | 107 + src/apps/organization/urls.py | 12 + src/apps/user/admin.py | 181 ++ .../0002_remove_firstname_lastname.py | 27 + .../user/migrations/0003_alter_user_groups.py | 19 + .../user/migrations/0004_alter_user_groups.py | 19 + src/input/fns/fin_0000605_1027700169089.xlsx | Bin 0 -> 12049 bytes tests/apps/core/test_excel.py | 172 ++ tests/apps/form_1/__init__.py | 1 + tests/apps/form_1/factories.py | 34 + tests/apps/form_1/test_models.py | 48 + tests/apps/form_1/test_services.py | 87 + tests/apps/form_2/__init__.py | 1 + tests/apps/form_2/factories.py | 33 + tests/apps/form_2/test_models.py | 43 + tests/apps/form_2/test_services.py | 79 + tests/apps/form_3/__init__.py | 1 + tests/apps/form_3/factories.py | 34 + tests/apps/form_3/test_models.py | 42 + tests/apps/form_3/test_services.py | 79 + tests/apps/form_4/__init__.py | 1 + tests/apps/form_4/factories.py | 39 + tests/apps/form_4/test_models.py | 43 + tests/apps/form_4/test_services.py | 79 + tests/apps/form_5/__init__.py | 1 + tests/apps/form_5/factories.py | 44 + tests/apps/form_5/test_models.py | 48 + tests/apps/form_5/test_services.py | 79 + tests/apps/form_6/__init__.py | 1 + tests/apps/form_6/factories.py | 43 + tests/apps/form_6/test_models.py | 47 + tests/apps/form_6/test_services.py | 80 + tests/apps/organization/__init__.py | 1 + tests/apps/organization/factories.py | 25 + tests/apps/organization/test_models.py | 52 + tests/apps/organization/test_services.py | 52 + tests/conftest.py | 10 + tests/utils/__init__.py | 5 + tests/utils/fixtures.py | 241 +++ tests/utils/http_server.py | 134 ++ ТЕХНИЧЕСКОЕ_ОПИСАНИЕ.md | 1801 +++++++++++++++++ 119 files changed, 9091 insertions(+) create mode 100644 docker-compose.prod.yml create mode 100644 sitecustomize.py create mode 100644 src/apps/core/admin.py create mode 100644 src/apps/core/excel.py create mode 100644 src/apps/form_1/__init__.py create mode 100644 src/apps/form_1/admin.py create mode 100644 src/apps/form_1/api.py create mode 100644 src/apps/form_1/apps.py create mode 100644 src/apps/form_1/migrations/0001_initial.py create mode 100644 src/apps/form_1/migrations/__init__.py create mode 100644 src/apps/form_1/models.py create mode 100644 src/apps/form_1/serializers.py create mode 100644 src/apps/form_1/services.py create mode 100644 src/apps/form_1/tasks.py create mode 100644 src/apps/form_1/urls.py create mode 100644 src/apps/form_2/__init__.py create mode 100644 src/apps/form_2/admin.py create mode 100644 src/apps/form_2/api.py create mode 100644 src/apps/form_2/apps.py create mode 100644 src/apps/form_2/migrations/0001_initial.py create mode 100644 src/apps/form_2/migrations/__init__.py create mode 100644 src/apps/form_2/models.py create mode 100644 src/apps/form_2/serializers.py create mode 100644 src/apps/form_2/services.py create mode 100644 src/apps/form_2/tasks.py create mode 100644 src/apps/form_2/urls.py create mode 100644 src/apps/form_3/__init__.py create mode 100644 src/apps/form_3/admin.py create mode 100644 src/apps/form_3/api.py create mode 100644 src/apps/form_3/apps.py create mode 100644 src/apps/form_3/migrations/0001_initial.py create mode 100644 src/apps/form_3/migrations/__init__.py create mode 100644 src/apps/form_3/models.py create mode 100644 src/apps/form_3/serializers.py create mode 100644 src/apps/form_3/services.py create mode 100644 src/apps/form_3/tasks.py create mode 100644 src/apps/form_3/urls.py create mode 100644 src/apps/form_4/__init__.py create mode 100644 src/apps/form_4/admin.py create mode 100644 src/apps/form_4/api.py create mode 100644 src/apps/form_4/apps.py create mode 100644 src/apps/form_4/migrations/0001_initial.py create mode 100644 src/apps/form_4/migrations/__init__.py create mode 100644 src/apps/form_4/models.py create mode 100644 src/apps/form_4/serializers.py create mode 100644 src/apps/form_4/services.py create mode 100644 src/apps/form_4/tasks.py create mode 100644 src/apps/form_4/urls.py create mode 100644 src/apps/form_5/__init__.py create mode 100644 src/apps/form_5/admin.py create mode 100644 src/apps/form_5/api.py create mode 100644 src/apps/form_5/apps.py create mode 100644 src/apps/form_5/migrations/0001_initial.py create mode 100644 src/apps/form_5/migrations/__init__.py create mode 100644 src/apps/form_5/models.py create mode 100644 src/apps/form_5/serializers.py create mode 100644 src/apps/form_5/services.py create mode 100644 src/apps/form_5/tasks.py create mode 100644 src/apps/form_5/urls.py create mode 100644 src/apps/form_6/__init__.py create mode 100644 src/apps/form_6/admin.py create mode 100644 src/apps/form_6/api.py create mode 100644 src/apps/form_6/apps.py create mode 100644 src/apps/form_6/migrations/0001_initial.py create mode 100644 src/apps/form_6/migrations/__init__.py create mode 100644 src/apps/form_6/models.py create mode 100644 src/apps/form_6/serializers.py create mode 100644 src/apps/form_6/services.py create mode 100644 src/apps/form_6/tasks.py create mode 100644 src/apps/form_6/urls.py create mode 100644 src/apps/organization/__init__.py create mode 100644 src/apps/organization/admin.py create mode 100644 src/apps/organization/api.py create mode 100644 src/apps/organization/apps.py create mode 100644 src/apps/organization/migrations/0001_initial.py create mode 100644 src/apps/organization/migrations/__init__.py create mode 100644 src/apps/organization/models.py create mode 100644 src/apps/organization/serializers.py create mode 100644 src/apps/organization/services.py create mode 100644 src/apps/organization/urls.py create mode 100644 src/apps/user/admin.py create mode 100644 src/apps/user/migrations/0002_remove_firstname_lastname.py create mode 100644 src/apps/user/migrations/0003_alter_user_groups.py create mode 100644 src/apps/user/migrations/0004_alter_user_groups.py create mode 100644 src/input/fns/fin_0000605_1027700169089.xlsx create mode 100644 tests/apps/core/test_excel.py create mode 100644 tests/apps/form_1/__init__.py create mode 100644 tests/apps/form_1/factories.py create mode 100644 tests/apps/form_1/test_models.py create mode 100644 tests/apps/form_1/test_services.py create mode 100644 tests/apps/form_2/__init__.py create mode 100644 tests/apps/form_2/factories.py create mode 100644 tests/apps/form_2/test_models.py create mode 100644 tests/apps/form_2/test_services.py create mode 100644 tests/apps/form_3/__init__.py create mode 100644 tests/apps/form_3/factories.py create mode 100644 tests/apps/form_3/test_models.py create mode 100644 tests/apps/form_3/test_services.py create mode 100644 tests/apps/form_4/__init__.py create mode 100644 tests/apps/form_4/factories.py create mode 100644 tests/apps/form_4/test_models.py create mode 100644 tests/apps/form_4/test_services.py create mode 100644 tests/apps/form_5/__init__.py create mode 100644 tests/apps/form_5/factories.py create mode 100644 tests/apps/form_5/test_models.py create mode 100644 tests/apps/form_5/test_services.py create mode 100644 tests/apps/form_6/__init__.py create mode 100644 tests/apps/form_6/factories.py create mode 100644 tests/apps/form_6/test_models.py create mode 100644 tests/apps/form_6/test_services.py create mode 100644 tests/apps/organization/__init__.py create mode 100644 tests/apps/organization/factories.py create mode 100644 tests/apps/organization/test_models.py create mode 100644 tests/apps/organization/test_services.py create mode 100644 tests/conftest.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/fixtures.py create mode 100644 tests/utils/http_server.py create mode 100644 ТЕХНИЧЕСКОЕ_ОПИСАНИЕ.md 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 0000000000000000000000000000000000000000..2df2a0dc48d8d545638e0cb4e08af95d1560bea5 GIT binary patch literal 12049 zcmcI~1y~&0wk8llLU0N07QAt7EChFVr*Rr-G*}3h-~@Mv;L-$_1b26LJGc`_c%7Vc zZ|=#QH{Z;B^Wf{Mu3mfnd+lYlYS&VhdxVG!2M6~QF6JXpA8*8=w+nVf4i5*10Xuux z0o)y&ZA~2;3^wDHM(VN0gr;v(swd3K7aN7yGiUv^DI^5#RZ zU=p#3ubcH=#)UQ4F%w?yA5&yH%(vLBF9_)`xVfhtWM_k}Y|+Dt3C@CX) zCyWNMZR*M`Wpi?5!~68>np(aj^2`Mpb49qk`*Qg@1;lw6e(CsdJ=-tB;6Rdq8Ffkm zIK>6bWc~s8C{=6ofq;J49>I`X%|qto1@>fZZ8zSv2tl)p~}z zCz`+kfm2>{{+Q6H=1p_-;|ax{iixSoOV@G-k(UK+s;|P?&^-h&O4(dC0jN=}gJw4L zh&?M~eSx!wNF9Nt{*BwpPdlyY6^u^~850lX2qkREAm?FX3?WQ2f-8dM#)v&rk5clW zZenz?bAz~@zIZF^b$SCSoMZzHEaO^LZZMb6l9PYt9aCxZOn zZV?S8V=_!)OO%+s$n#H-(7Ym>;Q zo17om%WJW{nq@e$c!`|9NbVP92~)~NQGE|KrRr#&lCF(7fw$fa9)&l3pNV+ z66C(bxCrk#iKBq0Lf^?uw}`%=SF8SkU`Q`|YAHo0;t>DDuBEaA3+i-(c2J4`4RN3M zOomYm{{8V&&#Z{(&P1Khe%Q$`j@xbzLsI{U%|yqD(sHk>P>ikMEqoEOFyrn}4qj;y z_;z}8Rr2+sZsmTvV<7XG%-6%)kB>x&ru}eZ0HZ>2g*@sSVASm4{q4IWD!v- zQA>+geFISTWNj&|=PsmUE49#ycLp#%RN(Cd@pSdt$M%M}Lh4=HUY|L7-0fd1Y;M&( zMB{et^`W?5GYqd7kox+j1u8C@dHcCv2cR6RGz%;N^IiQsz4&fVmcCt1sVuKuY+`&@ zHQe@ae`xAh*tlK$`gLkAd9lgcr}^vNiP!tn{foP%jnkuThw|kXk&Fx=86fJct`5W9 z54b2S)EzbkP!d;Cy8L?d`sQJCyT8c&wKL>)ZSNtoVwq&6O<-sXnJmh$UKsf8^fD^y z4C42&r@6jz!SA+jRya}>J5qI?Kbstt?h~0V(!-iyx+Cn?%ILOchF@ZuDD2k2=(c61 zRbo0L?AF5QwqeFwVk-EDZ$E3Q>uDx*q47Qem5@~@apME!KtmY!h={p>dllXH=)=XX zlpFDG2)^w)hR%pV>3b1#;h%(Wi(R2Nd(jU+>EGqF65K}~FE`#NqTj+sd48vSfT5g* z-0!eXcaooFHfiJd_4*x|D%}4fXm*YS|3x>dWqkmNe;?4Ct{>4jf}x=OKzT2+De`l! zzb5%9#4kek4&lFmZ(%GLw*aHWhqIsfBM%1`WdnY<{}S&vPT0C&kVF-pFp;QuzK4wr zMHY7VUY&XDAz-+RWCHni_g*Y+)nTul#zN>>vU>`NMSP%64EGld>6RC$V}YaRN>)OY zYE*!CgYOoxl5rYdR3}9PGHM89K19;U>nfb@E^G`_`V^+* zN}g#$9@VHQ`uItd_oDEfZX_XWq}j+fiZqH?*Gf`Rj)sQ% zqM%g5WMuc@9;6=Il;E0^jBT>24x_ssGjEe0w$qBT)5<+$gQicJ3zlLJ+j=lE^Vj|y zhn;N4!5rm=S5M71P>;Y?{G zXNk&yivfR7yeNlnRFkmN87BuT=`JXnO9By8-8%f+jgs2{AtoYz@J zUrUK!&|4*2GsnRVfiy)f$b!}34*JQWLmo9%qi|KKJ+R81zhzPjsz`}A%bvf{+!#Qc zej-S{!r-lAantHWy~3Hfqp;dNI3xrn5V`~oGS`pRCd4GFJbP?G05dU=>%gyp=+e;I^&bF*~aZ&o#zVL1;ax?uy(^32ODRx>-O5` zLG7+gFCKb9UhQ{wSmoMNC@70mX_}u@{q%Mt_ksmAm>r`WKY&nzN$Wh8*tQod^>@=e zJ#4hWMlhXt*9M|hf`Dbb{nxhbdYvVqKGEs#k8V^tnKV)D+P%6{s|}qSdlbxgr%@%l zuldg%jm%<>~Zl3Bu*WXK3oRH1jDf{TbWaK1aY$)hGr>iX1*+<*9hp6AX^%2Kp4 zm%~CVVv}M4)s1AfXhu81^^3vuAihpIR4l@~1gNy+$luC&Mtbg=j<;xSzv;CdTdc@~ zvIUj*Bb-YmQk{F*q(JvMT(y_2$FVLnq)?;>PvF{48Ofo3%)%eQ>}c!nj#m%;?1i!@ z^#uZrT(AUx7+);qcZYiLu`$7pJoKY~l&bf`qwRFpqdC~$3!CD5Kc_!l)>{>RmXYeZXyvAM|G5-Mv$Y00(rwLs zcGc+L^tfvOT#MvpX)O4tiMP@RRgib5zUaV0|1H0#aes$Dzo&UW)I=}ahHZ~!U4k*d z&Q2X?naXhb`Sr?yokxgAOJ9S1OWm5#hDh3b4ae-uU?`JB1n6j7n0)p#KX{XT zf$)}5Ax=!1dWFMCE>-O|)E*YL6LG+_O5QV}^F9l;C+OpY%N*A?(3YiFn+?c`sstFp z4Mn}@Bx;V=p&6vcp4? zF?YV{ZRVrXbgT#sd35ev(f=7tby%{R;;-N_P!Bf6S!TeSpQ|~|brN<3 zUf;77xeTx>NRr-Ng7*+-lZ6m5otzua#7iJ!1rMn*#GV7|#rh{;irMZ(Su z=fF3-U>7~{MnUn$bjqa`$|YgCr3U_ckFo;bTff_Xh1Rp1dF@XomrhT zJLM<+;b=#j%T$0^8fVr*K)839HzO@Wl+RJPcj1YQ;n|zqnB!!n56mYRJKJPYg7q3! z#$d9dGe=|a)?MN7IWxmR=#C3jrd?$|`o= zYjaApNU4)|x5*)oAm>~ZBS_oB#KWX|ovAfNl?U~eurxi+9NWiaXG^@oo~f?W(PAvw zYU!&M1aa7|C9AVoL>Or(V**X#ezK@H7reF`rR;;w)IQz0$Hm91-|AUSv9a_Q4E%IT zhq6em|CniR+k%wWaIsO3tz^AQWg~xmm~li>K6;i)H$Hwmz`jGT^AFWxbe=NgV@!wr znl-vGwBuWoyb9kURWc0q`us6t)2=7E{?AwmMqtTeTxW>WA5$qblyel8EN)HAktm{w z7EU6`3pCFbmxHgvwNkUvfxPGSI{TVgBP?~wo%kEmsd@qEgh7I(K$3eBcWqCegNrr0 zw`}+)r&a?|Z*-{14hb|kf*v0cq6=7TZ^lWyyii)W0t@o}!4Cf-Pt#~i zareAc5UXBiSl+4(IzCUc1W+cmW?M2b<`}niFkYrbvQ?_JuVe2Xq861pi%p?+zDa@c zq+q*GECa1|pK%AlCaj(_-P8tW`K&{F2OZE;y+$e$9*%P^U4wRv1qdB_JE9q`H;qlf zbgoZ{@kDdG;bjIIoz*5#phZGpp#I@|`>Q(Xo&Gu~=NRdDxv|Ne?k<@%p}zhuA47M9 zbzK)jlGbU>NA@9!*#^r(jO*tWm`jXoBU25cy8e2>@yRrJ_2TuLeJ5VN;z38r_0bHpR})OG7-4s6Gwr?Z+Rs8AV%sTtuD}K* z1apn9tFMnJI%J^5;w{{kq1{ql0I_CWfN(cV81gO-CN^p5F)8N8b^TLQ=;DP5Jd=z(TY~I z)U0gOLlh8_$~^fdCt?I3FH}2n*UQk3?L5fek8}~uAY&)B`Hs2@Dw*h`%24=9q=isV z@h}^3R;K$On$0!{Z*0Fu6kQ8KQsid!q!E1vk%|qWeh!xZqZe}qKbW$-;=Uq{NZ8Ks3HYIDUw_cZ9Zg#lAOu$Y0(}YkZ_+)BQXR zvC?X7er4jz$f@G09>$YLb~3b_db6*43;C}fxxZ^Fd!b`f`ZAVo_L({BbbWg)-1~gH zy2CwX<|`y1kT_~p#Wyb>t@E2I>3WcSk72O;iuQ=?kr5@*m9LhZ?{Y_>-MINolLAe%2q{PljP_c*{4s z#%o}ey20pOY_B!cOx*cu2-onHp@5SyC%?1k%e>@Tt*Rbz-MQrH2{HHQbpQ$W19f^& z!qZ2n7HLgNCGD`#j11lZ#{3*rWS*}a}Pr1n3}^2nQeIZfnqN|CTpT) zSr#(CA>aLcsa~K{8*UrxDZ%dccrIp-&>`=G&bY1fGygzofE&^)0EyW8p_&AMq?N(> zhzs+J2N8&m@(^m;xsy|=iDCRTc`{WlMS&(s5K$NN< zAyaOVt{)q#rT@+uzk*YM_(YU-naB|~^GXbB61G^P{QIoXf>W$&1)KRb=@{jw`P<5l zTbsSiuP8xF^J2 zC&b--jQdi9;|CYDsa;I;LQ0|@GeIy^&c9HCrr9+|8~ECmuF8aqN%OtM9RVI!NW{_&q*O%RC7-gb}q) zOpV9reHlyrpGT+$NbV6u&}GC1DpO~e@vde|_N#)&?4PjVy zkpp3Di}Rvc?^8L#mB35zBp_yLFwH zX`Sa{yoPs$eB47#L`YKRp~=O;r;EjG#pP5JY;YY=x1ApJirS^d!-06Lch#y3niKP7 zEY$c1cbDE2Q|$Cl;#88Axns?`&W|Aym04q*V?XsC1Yv%L)~$6 z!3uq7c9zCJo$F?<*{iTg6@VYxgC%{Kr78Oe_9bZjlc*}=t;}*(Z zFQ6@lNqkxWHyf4~FS*^S`7VIQ@s;KrQZ|kNE2OQ0B!0~z4Qhb$_<468*J>&QFGSlzd#0>cZmH$J+fFJO7OGHWd`#_JM z;O$nM@b|l(HdqZTJ}QzKerxo7=T&odq0IrLnico^M{jFOjF16xG9|;6ZyroaEC4@< z=iSEEal`cAw21%#@uS93cQRSBQ&) z{m+eOl?CuyQQZ6I)fH&frKi=1T+GTP_QjgE>$$99s&jI)&#M$3T4sqBc7zp&)A%_d z_a;LqC8qhq`A^?0y!~wYwx0{X364a;;`)o@s@&F)Y>~FLb3vJfgq2b;+(Cp&#)eg+ zrFX6#nmAX7e1f+bk@gWuGKe2kl_xbE=!6+)5JL7TW>J$@;>2eybVRdTAp>cZ`skI@ zqCY7yEnl_4E;ezU(@3z-Q!KGDA@o=!<02G7csV;h6gX~IYAwPF6Cujybf&>% zYDCV|jI=i>=P2Ql`Le7M90^gB@4_EbbBA-A>1+j!5gCb7V7|om&A!9G|Da==IUzGa z(<_{=4J42sU$RX1xdI)^9WD8_-b{LF>aqZs0Ye4Vd-DBEEJE78^_3SMJePV~V`YLx zcaZChk+^~97>TU$mKh@j8S0EooN8)YPn2ZC&bKbkWUY}`gmeuJ(kfOD_cw~p`faSg zo7~D2sq1(P#1EK9B)$OwTo0(z z0ca1OEA9%#h8&QljE5ZVSROjhuWu>8y9RJSVYoZK&+z*=pY*U?@g%j{M#-IeW*_0( zz-b@t*fz9FUa~I0V7-U+N}|b&FAQ9{#VaIgwV^QJfT*Wy!edRlt$qrZapE>!XaUvP z>K2CYcB&lis-jv&)XP|THVv!IZuT*~z{GLe1}PJ3c9w?4ciE zxi_!odj<@NO$Cs)?7-tY`{qk2Mlg>%6$y zPxhkVnBB(6-z=@3hWV_@edVH6EsMILC#rw1k?m&|jybLWvPv4kk zpP}JIT;s65nccHpZ?alaZclYv!`uGc;{?u?U&9WT$UqR0#Ft~8;6nG$j`bb#SoWcK zbmyPvk>l9M_nC3Hn(m!ggrol&zpm#}D_w2s2;c4$gBDH{@qU7n{ZsLGPp}Jn2@UvW z<8KE&5(e;`XErIG*}PzUrXMgTzfZrcGm)e|n%T-)ok>P}m&YBs2(ls}L*Mw!5P-ZM z-m)qIyb}#)kKszGSz8H~!-5NrJVST}|KU=`IYj)(kLo@yv=($(-tl?nt>NUY5#OZo z3-vZ-vHJuLXm>}+M`t_LQ}w#-$-bkElZArM2PWj{%7qHdyM5h}aTGN)UNc`Cx<@HC z$sD2sKQicIQ0+?VyeG1hl^aST+7S)~SdDE>~4wg}MPQyR05=J7d9r{5i$O#HL+BQVq4W?pnI}I2NEnZH@ zGPT&tZQ6wq8-5^JZy)-zl_bA$F$Qz!y!=35G+9yv4gm3eRNlPogHA8QTI*v(52Ze0 zZ3^0IJ}5|*U;$o)sOk}|vAq~AyzzAnkJc#BpcaGZOgAa{)PYw8c}k$WZ4;Rn_GYqT zc}GtQ;tf^iMhO0meo}dzeErxV#8jSFM6BSCW9pi&N$B$u{8x{iTqB#9to`JGZ5}l- zBhBp-XNnCMTiiq}Mm86`xVon-qt3f(=iXqK{6qRZqI|w~o=+%D>4%oNM3vDiJyj00 z*6$lh4`mx8x*=@E%$jZWw}G$ICN76`s45%C;jMIVrkf6N5kE|gsBor51DNGG{s@U? zuo-{@xbSNu@Xb7PatiaR-C(}w{O+-hC^yi}%oM7dnfX7(@6^IOop`jBO^P-(8n|O)Lp{bgg;B_hB)( zIg{0j)N31hp+_n;S>--+ zLm$v(TKh1k#m2j_J1OP8pH!r-?MO$)P>fAgy{u<>L715|7iyEu!3Wo>dTzBY>HJ2+ zZ0^GqwipHaz!%FLjS6vR>jcFI_4sV|c7C@r={M@qc~eK*am3j}=qlXUn@Vg@ST zd?%x65K))o`nQZrMP!M@m0Nph@?6*|p&tIqui*-?T%v^&+X?{TVpR;7u0Pe9>59|cjQfoj5^IW%$hAL7j$&1?C}e%PdPP%6$Y?B0ly$;qo7veXkRVu&AR0}7J z@kUG&g2kt&q};?&6pDPglu#6(VPpZ~%sRp)F63&yq~OmzCcQN#*9{b4flk@s@m`7h za0Z#@Y;=}Zk;}ZZ3zOXb`t|lhcpLc{0*h+8EV@>k;e{mxU%J|#nHGheyj!r zVFRei8lQ!|jyBS=4JtpuHel{Uuc0qYmrI|I4`ubXlpfk}o?ASaAsnizsu7m-Y^dbv zEy=^1aitWTFN(@c9!xSStwNd-%Q8le6iJVNi@Y8@nN#cdC6doX(G}7wN^&B@{N_xl zf#X`_U$q$A&*rFdBt)GGSi$?_qXMhN98Jt@O)NnG2*}yZ+6)8%*n?b5%uQTO0A>!( zAQnehK@4(sv4#=2IZZfB%y`YYdARwxOh6!hZXOeMQ+87>4t5g`PF_w{M~J<-nd5O& z8&Cw+vq9koa)wwtfQ6{oS=p#4KwvWmb6Bw_MCIyY!NNx+A`JX7!HKNf0020;I@_^2I9meD%>W=fkUa>Y;5cRZ3THXGb@n235zut;$i|e1O0{nZw|;$GK3W-Di|iEg@d!b zi3{u!CfdK$2SeidZ&gPn3^X?rFmnc(xHvcqkEV>aj<$@w9SazH!#bJ=yJ&=c^#{V1 z`R_5X@jn;v=MNxr3fKY!Ts$2?LR9~vXmN_aQkSF>=3wLCVPRuu;owte=M!M#65!-w z<>cV!V`Jyh{ek&69AV&3v;NY$@ZY^7gtHp67}iMkGsMWJruYzcA^q5}NZGe|`tV=hFc%E1m|=Vs$H=i#+r4^OIpNoc{rZfa(3!NbpC&c$WU%l-c@;om&_?^^rsQpA5q_%H7NZ)xSP^>Ksv zIC=SaO?b`uIJivA{#+m6KfMF;Z&RmN4RHt(C?M0hK&Cut``k-Rd;u_@j1aw>;DCr3O{9?=g2WApB^ zRy(~=g*3+F-4{`{sVuhoS@djP$LOQI$C?9O#F3h3h`KX=B-6*P7bBYD7L()7IF@0(BBour4T>>J{3?RV`rfOdOj9N6k?yzDPLCIHp9jifjcUZr9{VU}oPE9LNG@wB(TpDJ z9u0Nk#D0!Q8i5w3i8?uzXv&ig1Y;X_mBH(c;p8(hs9di;=V2FHmBoi@buKhV%k1Ur zz5$X)u;xYjQz$FwVX&t!iWVYCO&z90xoanc?Z2L>llb&9f3sAx6V&i*8znE&z>+)Z zzfTVfV39bCSWUVSr+h$P&PYe?xsAnP!9yy1PyB!_Xu z<3n-8Ut))*4vF7W^t_6-eG$WhUdewdjyI7xs>BWmLjdV?zsN8>_H)6+8{x-{do3FJ zeTDPD4n64J0HLMdn2i2>gx?c0v*kLV_l&8~L0JwS0r%&g#y__n!~E?({(eg;zcc^d z#ruo%4EFWMrpdp1dVgpAy^-=4Ycb5yf6e>9v{n9&^81F^FBE!Myx{&DqRA1d?5 literal 0 HcmV?d00001 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. | +