From 903312670c7bd91edcf9041da1b3e4fccd4cac91 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 14 Apr 2026 10:56:29 +0200 Subject: [PATCH] feat(forms): unify F2-F6 upload contracts and add shared serializers --- docs/backend-endpoints-implementation-plan.md | 17 +- src/apps/core/upload_contracts.py | 216 ++++++++++++++++++ src/apps/form_2/api.py | 42 ++-- src/apps/form_2/serializers.py | 53 +---- src/apps/form_3/api.py | 40 ++-- src/apps/form_3/serializers.py | 52 +---- src/apps/form_4/api.py | 39 +++- src/apps/form_4/serializers.py | 43 +--- src/apps/form_5/api.py | 35 ++- src/apps/form_5/serializers.py | 43 +--- src/apps/form_6/api.py | 34 ++- src/apps/form_6/serializers.py | 43 +--- tests/apps/forms/test_upload_contracts_api.py | 211 +++++++++++++++++ 13 files changed, 606 insertions(+), 262 deletions(-) create mode 100644 src/apps/core/upload_contracts.py create mode 100644 tests/apps/forms/test_upload_contracts_api.py diff --git a/docs/backend-endpoints-implementation-plan.md b/docs/backend-endpoints-implementation-plan.md index 51621d3..e5c0e53 100644 --- a/docs/backend-endpoints-implementation-plan.md +++ b/docs/backend-endpoints-implementation-plan.md @@ -21,7 +21,7 @@ - [x] **Pass 0 — Инициация:** создана отдельная ветка от `dev` и зафиксирован общий план в репозитории. - [x] **Pass 1 — Discovery & контрактный каркас:** подготовка сериализаторов контрактов, уточнение форматов, матрица соответствий. - [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management. -- [ ] **Pass 3 — Формы:** выравнивание upload F-2…F-6. +- [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6. - [ ] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. - [ ] **Pass 5 — Внешние контуры:** industrial/prosecutor/procurements/arbitration/security registries. - [ ] **Pass 6 — Финализация:** OpenAPI + массовое тестирование + smoke. @@ -40,13 +40,18 @@ - доработан `GET /api/v1/users/admin/users/` под контракт импорта. - переведены поля `corporation_scope`/`corporation_scope_label` на скалярный контракт в `organizations` list/detail. - добавлены метрики `progress_message`, `result`, `error`, `started_at`, `completed_at`, `duration`, `is_successful` для user-management. +- **Pass 3 — Формы (2026-04-14): завершён** + - введены общие upload-сериализаторы и payload helpers для F-2…F-6. + - выровнен request/response контракт upload-эндпоинтов (sync/async, report-период, `upload_id`, `job_id`). + - добавлена единая схема ошибок валидации multipart. + - добавлены contract tests на upload endpoints F-2…F-6. --- ## Риск-оценка перед стартом - Некоторые поля периодов требуют согласования формата валидации (`report_period_display`/`report_half_year`). - Для `/information-security-registry-entries/` в кодовой базе нет модели и данных — потребуется новая модель/миграция или адаптер. -- Отдельное решение по async upload: при каком пороге или условиях всегда возвращать `queued`. +- Отдельное решение по async upload: порог `1MB` определяет фоновую обработку. - Нужно подтвердить, где `organization`/`profile` поля могут быть `null`. --- @@ -224,10 +229,10 @@ - [x] Добавить/проверить `/api/v1/dictionaries/corporation-scopes/`. ### Pass 3. Формы -- [ ] Унифицировать upload-схемы Ф-2…Ф-6 (request). -- [ ] Унифицировать upload-ответы Ф-2…Ф-6 (response). -- [ ] Зафиксировать async/pending статус и `job_id`. -- [ ] Добавить общий error serializer для валидации multipart. +- [x] Унифицировать upload-схемы Ф-2…Ф-6 (request). +- [x] Унифицировать upload-ответы Ф-2…Ф-6 (response). +- [x] Зафиксировать async/pending статус и `job_id`. +- [x] Добавить общий error serializer для валидации multipart. ### Pass 4. Аналитика - [ ] Финализировать `financial-summary` и добавить расчёты deltas/period. diff --git a/src/apps/core/upload_contracts.py b/src/apps/core/upload_contracts.py new file mode 100644 index 0000000..eb0e669 --- /dev/null +++ b/src/apps/core/upload_contracts.py @@ -0,0 +1,216 @@ +"""Shared upload serializers and response helpers for form upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from django.utils import timezone +from rest_framework import serializers, status +from rest_framework.response import Response + +MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024 +ALLOWED_UPLOAD_EXTENSIONS = (".xlsx", ".xls") + +_ROMAN_NUMBERS = { + 1: "I", + 2: "II", + 3: "III", + 4: "IV", +} + + +def _normalize_extension(name: str) -> str: + return name.rsplit(".", 1)[-1].lower() if "." in name else "" + + +def _validate_uploaded_file(value: Any) -> None: + if not hasattr(value, "name") or not value.name: + raise serializers.ValidationError("Файл не выбран") + + extension = "." + _normalize_extension(value.name) + if extension not in ALLOWED_UPLOAD_EXTENSIONS: + raise serializers.ValidationError("Неподдерживаемый формат файла") + + if value.size > MAX_UPLOAD_SIZE_BYTES: + raise serializers.ValidationError("Размер файла превышает 50MB") + + +def report_quarter_display(report_year: int, report_quarter: int) -> str: + return f"{_ROMAN_NUMBERS.get(report_quarter, report_quarter)} квартал {report_year}" + + +def report_half_year_display(report_year: int, report_half_year: int) -> str: + return f"{_ROMAN_NUMBERS.get(report_half_year, report_half_year)} полугодие {report_year}" + + +def report_annual_display(report_year: int) -> str: + return f"{report_year} год" + + +def build_upload_success_payload( + *, + form: str, + report_year: int, + status: str, + report_quarter: int | None = None, + report_half_year: int | None = None, + result: dict[str, Any] | None = None, + job_id: str | None = None, + created_at: datetime | None = None, +) -> dict[str, Any]: + created = created_at or timezone.now() + payload: dict[str, Any] = { + "upload_id": str(uuid4()), + "form": form, + "report_year": report_year, + "status": status, + "created_at": created.isoformat(), + } + + if report_half_year is not None: + payload["report_half_year"] = report_half_year + payload["report_period_display"] = report_half_year_display( + report_year=report_year, + report_half_year=report_half_year, + ) + elif report_quarter is not None: + payload["report_quarter"] = report_quarter + payload["report_period_display"] = report_quarter_display( + report_year=report_year, + report_quarter=report_quarter, + ) + else: + payload["report_period_display"] = report_annual_display(report_year=report_year) + + if result is not None: + payload["result"] = result + + if job_id is not None: + payload["job_id"] = job_id + + return payload + + +def build_upload_validation_payload(errors: dict[str, Any]) -> dict[str, Any]: + details: list[dict[str, Any]] = [] + + def _collect(field: str, value: Any) -> None: + if isinstance(value, dict): + for nested_field, nested_value in value.items(): + nested_name = f"{field}.{nested_field}" if field else nested_field + _collect(nested_name, nested_value) + return + + if isinstance(value, list): + for item in value: + _collect(field, item) + return + + code = getattr(value, "code", None) or "invalid" + details.append( + { + "field": field, + "code": code, + "message": str(value), + } + ) + + for field, value in errors.items(): + target_field = "non_field_errors" if field == "non_field_errors" else field + _collect(target_field, value) + + return { + "error_code": "validation_error", + "error_message": "Validation failed", + "details": details, + } + + +def build_upload_error_payload( + *, + error_code: str, + error_message: str, + details: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "error_code": error_code, + "error_message": error_message, + "details": details or [], + } + + +def build_upload_error_response( + *, + error_code: str, + error_message: str, + details: list[dict[str, Any]] | None = None, + status_code: int = status.HTTP_400_BAD_REQUEST, +) -> Response: + return Response( + build_upload_error_payload( + error_code=error_code, + error_message=error_message, + details=details, + ), + status=status_code, + ) + + +def build_upload_validation_response(errors: dict[str, Any]) -> Response: + return Response( + build_upload_validation_payload(errors), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class BaseUploadSerializer(serializers.Serializer): + file = serializers.FileField(help_text="Excel файл (.xlsx, .xls)") + report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") + + def validate_file(self, value): + _validate_uploaded_file(value) + return value + + +class UploadQuarterSerializer(BaseUploadSerializer): + report_quarter = serializers.IntegerField( + min_value=1, + max_value=4, + required=True, + help_text="Отчетный квартал от 1 до 4", + ) + + +class UploadHalfYearSerializer(BaseUploadSerializer): + report_half_year = serializers.IntegerField( + min_value=1, + max_value=2, + required=True, + help_text="Полугодие отчета: 1 или 2", + ) + + +class UploadAnnualSerializer(BaseUploadSerializer): + """Upload payload without period quarter (годовая форма).""" + + +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 UploadParseResultSerializer(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/api.py b/src/apps/form_2/api.py index 371d8b1..06985c3 100644 --- a/src/apps/form_2/api.py +++ b/src/apps/form_2/api.py @@ -8,6 +8,11 @@ API формы Ф-2. import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_2.models import FormF2Record from apps.form_2.serializers import ( @@ -42,7 +47,9 @@ class FormF2UploadView(APIView): def post(self, request): """Загрузка и обработка файла.""" serializer = FormF2UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] @@ -59,11 +66,13 @@ class FormF2UploadView(APIView): ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь на обработку", - "task_id": task.id, - }, + build_upload_success_payload( + form="f2", + report_year=report_year, + report_quarter=report_quarter, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -77,20 +86,21 @@ class FormF2UploadView(APIView): result_serializer = FormF2ParseResultSerializer(result) return Response( - { - "success": True, - "data": result_serializer.data, - }, + build_upload_success_payload( + form="f2", + report_year=report_year, + report_quarter=report_quarter, + status="done", + result=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, + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_2/serializers.py b/src/apps/form_2/serializers.py index da9e0fd..3da59c3 100644 --- a/src/apps/form_2/serializers.py +++ b/src/apps/form_2/serializers.py @@ -8,6 +8,10 @@ """ from apps.form_2.models import FormF2Record +from apps.core.upload_contracts import ( + UploadQuarterSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -51,52 +55,9 @@ class FormF2RecordListSerializer(serializers.ModelSerializer): ] -class FormF2UploadSerializer(serializers.Serializer): - """Сериализатор загрузки файла формы Ф-2.""" - - file = serializers.FileField(help_text="Excel файл формы Ф-2 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - 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 FormF2UploadSerializer(UploadQuarterSerializer): + """Загрузка файла формы Ф-2 (квартальная отчетность).""" -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): +class FormF2ParseResultSerializer(UploadParseResultSerializer): """Сериализатор результата парсинга.""" - - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py index c7c1ccf..6705ea4 100644 --- a/src/apps/form_3/api.py +++ b/src/apps/form_3/api.py @@ -8,6 +8,11 @@ API формы Ф-3. import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_3.models import FormF3Record from apps.form_3.serializers import ( @@ -41,7 +46,9 @@ class FormF3UploadView(APIView): def post(self, request): """Загрузка и обработка файла.""" serializer = FormF3UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] @@ -57,11 +64,12 @@ class FormF3UploadView(APIView): ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь на обработку", - "task_id": task.id, - }, + build_upload_success_payload( + form="f3", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -74,20 +82,20 @@ class FormF3UploadView(APIView): result_serializer = FormF3ParseResultSerializer(result) return Response( - { - "success": True, - "data": result_serializer.data, - }, + build_upload_success_payload( + form="f3", + report_year=report_year, + status="done", + result=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, + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_3/serializers.py b/src/apps/form_3/serializers.py index 0aa6d51..1d2fd05 100644 --- a/src/apps/form_3/serializers.py +++ b/src/apps/form_3/serializers.py @@ -8,6 +8,10 @@ """ from apps.form_3.models import FormF3Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -50,51 +54,9 @@ class FormF3RecordListSerializer(serializers.ModelSerializer): ] -class FormF3UploadSerializer(serializers.Serializer): - """Сериализатор загрузки файла формы Ф-3.""" - - file = serializers.FileField(help_text="Excel файл формы Ф-3 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - 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 FormF3UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-3 (годовая отчетность).""" -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): +class FormF3ParseResultSerializer(UploadParseResultSerializer): """Сериализатор результата парсинга.""" - - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py index 1b5c0e7..6832732 100644 --- a/src/apps/form_4/api.py +++ b/src/apps/form_4/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_4.models import FormF4Record from apps.form_4.serializers import ( @@ -27,10 +32,13 @@ class FormF4UploadView(APIView): def post(self, request): serializer = FormF4UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] - report_quarter = serializer.validated_data.get("report_quarter") + report_half_year = serializer.validated_data.get("report_half_year") + report_quarter = report_half_year if file.size > BACKGROUND_THRESHOLD: task = process_form_f4_file.delay( @@ -40,11 +48,13 @@ class FormF4UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f4", + report_year=report_year, + report_half_year=report_half_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +65,21 @@ class FormF4UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF4ParseResultSerializer(result).data} + build_upload_success_payload( + form="f4", + report_year=report_year, + report_half_year=report_half_year, + status="done", + result=FormF4ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-4") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_4/serializers.py b/src/apps/form_4/serializers.py index b902dd3..4c22e08 100644 --- a/src/apps/form_4/serializers.py +++ b/src/apps/form_4/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-4.""" from apps.form_4.models import FormF4Record +from apps.core.upload_contracts import ( + UploadHalfYearSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -39,40 +43,9 @@ class FormF4RecordListSerializer(serializers.ModelSerializer): ] -class FormF4UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-4 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - 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 FormF4UploadSerializer(UploadHalfYearSerializer): + """Загрузка файла формы Ф-4 (полугодовая отчетность).""" -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) +class FormF4ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py index c228710..b183704 100644 --- a/src/apps/form_5/api.py +++ b/src/apps/form_5/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_5.models import FormF5Record from apps.form_5.serializers import ( @@ -27,9 +32,12 @@ class FormF5UploadView(APIView): def post(self, request): serializer = FormF5UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] + report_quarter = serializer.validated_data.get("report_quarter") if file.size > BACKGROUND_THRESHOLD: @@ -40,11 +48,12 @@ class FormF5UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f5", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +64,20 @@ class FormF5UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF5ParseResultSerializer(result).data} + build_upload_success_payload( + form="f5", + report_year=report_year, + status="done", + result=FormF5ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-5") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_5/serializers.py b/src/apps/form_5/serializers.py index 773b611..5bcd954 100644 --- a/src/apps/form_5/serializers.py +++ b/src/apps/form_5/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-5.""" from apps.form_5.models import FormF5Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -41,40 +45,9 @@ class FormF5RecordListSerializer(serializers.ModelSerializer): ] -class FormF5UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-5 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - 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 FormF5UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-5 (годовая отчетность).""" -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) +class FormF5ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py index ab87c33..7029c6c 100644 --- a/src/apps/form_6/api.py +++ b/src/apps/form_6/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_6.models import FormF6Record from apps.form_6.serializers import ( @@ -27,7 +32,9 @@ class FormF6UploadView(APIView): def post(self, request): serializer = FormF6UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") @@ -40,11 +47,12 @@ class FormF6UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f6", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +63,20 @@ class FormF6UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF6ParseResultSerializer(result).data} + build_upload_success_payload( + form="f6", + report_year=report_year, + status="done", + result=FormF6ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-6") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_6/serializers.py b/src/apps/form_6/serializers.py index 304f35c..8a647ab 100644 --- a/src/apps/form_6/serializers.py +++ b/src/apps/form_6/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-6.""" from apps.form_6.models import FormF6Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -40,40 +44,9 @@ class FormF6RecordListSerializer(serializers.ModelSerializer): ] -class FormF6UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-6 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - 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 FormF6UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-6 (годовая отчетность).""" -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) +class FormF6ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/tests/apps/forms/test_upload_contracts_api.py b/tests/apps/forms/test_upload_contracts_api.py new file mode 100644 index 0000000..cbb4d27 --- /dev/null +++ b/tests/apps/forms/test_upload_contracts_api.py @@ -0,0 +1,211 @@ +"""Contract tests for F-2…F-6 upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.user.factories import UserFactory + + +@override_settings(ROOT_URLCONF="core.urls") +class FormUploadContractsApiTest(APITestCase): + """Contract tests for multipart upload endpoints Ф-2 ... Ф-6.""" + + BACKGROUND_THRESHOLD_PLUS = 1024 * 1024 + 1 + RESULT_PAYLOAD = { + "batch_id": 101, + "loaded_count": 2, + "skipped_count": 1, + "errors": [], + } + CASES = { + "f2": { + "url": "/api/v1/forms/f2/upload/", + "form": "f2", + "payload": {"report_year": 2026, "report_quarter": 1}, + "period_field": "report_quarter", + "period_value": 1, + "report_period_display": "I квартал 2026", + "parse_target": "apps.form_2.api.parse_form_f2_file", + "task_target": "apps.form_2.api.process_form_f2_file", + }, + "f3": { + "url": "/api/v1/forms/f3/upload/", + "form": "f3", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_3.api.parse_form_f3_file", + "task_target": "apps.form_3.api.process_form_f3_file", + }, + "f4": { + "url": "/api/v1/forms/f4/upload/", + "form": "f4", + "payload": {"report_year": 2026, "report_half_year": 1}, + "period_field": "report_half_year", + "period_value": 1, + "report_period_display": "I полугодие 2026", + "parse_target": "apps.form_4.api.parse_form_f4_file", + "task_target": "apps.form_4.api.process_form_f4_file", + }, + "f5": { + "url": "/api/v1/forms/f5/upload/", + "form": "f5", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_5.api.parse_form_f5_file", + "task_target": "apps.form_5.api.process_form_f5_file", + }, + "f6": { + "url": "/api/v1/forms/f6/upload/", + "form": "f6", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_6.api.parse_form_f6_file", + "task_target": "apps.form_6.api.process_form_f6_file", + }, + } + + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def _build_payload(self, base_payload: dict[str, int], file_size: int) -> dict: + payload = dict(base_payload) + payload["file"] = SimpleUploadedFile( + name="report.xlsx", + content=b"0" * file_size, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + return payload + + def _assert_base_success_contract( + self, + response_data: dict, + case: dict, + status_code: str, + ): + self.assertEqual(response_data["form"], case["form"]) + self.assertEqual(response_data["report_year"], 2026) + self.assertEqual(response_data["status"], status_code) + self.assertEqual( + response_data["report_period_display"], + case["report_period_display"], + ) + datetime.fromisoformat(response_data["created_at"]) + self.assertIn("upload_id", response_data) + if case["period_field"]: + self.assertEqual(response_data[case["period_field"]], case["period_value"]) + else: + self.assertNotIn("report_quarter", response_data) + self.assertNotIn("report_half_year", response_data) + + def test_upload_validation_error_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + response = self.client.post( + case["url"], + case["payload"], + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error_code"], "validation_error") + self.assertEqual(response.data["error_message"], "Validation failed") + self.assertIsInstance(response.data["details"], list) + self.assertTrue(response.data["details"]) + self.assertTrue( + any(item["field"] == "file" for item in response.data["details"]) + ) + + def test_upload_async_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + task_mock = SimpleNamespace( + delay=Mock(return_value=SimpleNamespace(id="background-task-id")), + ) + with ( + patch(case["parse_target"]) as parse_mock, + patch(case["task_target"], task_mock), + ): + response = self.client.post( + case["url"], + self._build_payload( + case["payload"], + self.BACKGROUND_THRESHOLD_PLUS, + ), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["status"], "queued") + self.assertIn("job_id", response.data) + self.assertEqual(response.data["job_id"], "background-task-id") + self.assertNotIn("result", response.data) + self._assert_base_success_contract( + response.data, + case, + status_code="queued", + ) + parse_mock.assert_not_called() + task_mock.delay.assert_called_once() + + def test_upload_sync_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + task_mock = SimpleNamespace( + delay=Mock(return_value=SimpleNamespace(id="should-not-use")), + ) + with patch( + case["parse_target"], + return_value=self.RESULT_PAYLOAD, + ) as parse_mock, patch(case["task_target"], task_mock): + response = self.client.post( + case["url"], + self._build_payload(case["payload"], file_size=256), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "done") + self.assertIn("result", response.data) + self.assertNotIn("job_id", response.data) + self.assertEqual(response.data["result"], self.RESULT_PAYLOAD) + self._assert_base_success_contract( + response.data, + case, + status_code="done", + ) + parse_mock.assert_called_once() + task_mock.delay.assert_not_called() + + def test_upload_processing_error_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + with patch( + case["parse_target"], + side_effect=RuntimeError("parse failed"), + ) as parse_mock: + response = self.client.post( + case["url"], + self._build_payload(case["payload"], file_size=256), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error_code"], "processing_error") + self.assertEqual(response.data["error_message"], "parse failed") + self.assertEqual(response.data["details"], []) + parse_mock.assert_called_once()