feat(forms): unify F2-F6 upload contracts and add shared serializers
This commit is contained in:
216
src/apps/core/upload_contracts.py
Normal file
216
src/apps/core/upload_contracts.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user