From 9eb41f6fd408dee68fea61bbcc49371ef851569a Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Sun, 19 Jul 2026 13:32:56 +0200 Subject: [PATCH] feat: address QA analytics and report upload feedback --- input/demo-periods/Ф-1_2022-12.xlsx | Bin 0 -> 11644 bytes input/demo-periods/Ф-1_2023-12.xlsx | Bin 0 -> 11656 bytes input/demo-periods/Ф-1_2024-12.xlsx | Bin 0 -> 11665 bytes .../migrations/0003_report_upload_history.py | 98 +++++++ src/apps/core/models.py | 15 + src/apps/core/report_upload_history.py | 271 +++++++++++++++++ src/apps/core/report_uploads.py | 65 ++++- src/apps/form_1/api.py | 4 +- src/apps/form_2/api.py | 4 +- src/apps/form_3/api.py | 4 +- src/apps/form_4/api.py | 4 +- src/apps/form_5/api.py | 4 +- src/apps/form_6/api.py | 4 +- src/apps/organization/analytics_services.py | 276 ++++++++++++++---- src/apps/organization/analytics_views.py | 5 +- src/apps/organization/api.py | 5 +- src/apps/organization/contract_serializers.py | 19 ++ .../commands/delete_test_companies.py | 3 + src/apps/user/views.py | 3 +- src/core/api_v1_urls.py | 6 + src/settings/base.py | 6 +- src/settings/test.py | 3 +- tests/apps/forms/test_report_uploads_api.py | 70 +++++ tests/apps/organization/factories.py | 1 + tests/apps/organization/test_analytics_api.py | 111 ++++++- tests/apps/organization/test_api.py | 19 ++ .../test_delete_test_companies_command.py | 9 +- tests/apps/user/test_views.py | 11 + 28 files changed, 938 insertions(+), 82 deletions(-) create mode 100644 input/demo-periods/Ф-1_2022-12.xlsx create mode 100644 input/demo-periods/Ф-1_2023-12.xlsx create mode 100644 input/demo-periods/Ф-1_2024-12.xlsx create mode 100644 src/apps/core/migrations/0003_report_upload_history.py create mode 100644 src/apps/core/report_upload_history.py diff --git a/input/demo-periods/Ф-1_2022-12.xlsx b/input/demo-periods/Ф-1_2022-12.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..104d6610a45e11e00c944640044ab9716c2da129 GIT binary patch literal 11644 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 literal 0 HcmV?d00001 diff --git a/input/demo-periods/Ф-1_2023-12.xlsx b/input/demo-periods/Ф-1_2023-12.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d573aa43e483bbbaa2091836b283dbdb795f32db GIT binary patch literal 11656 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

L*xx#l5zqkkG7(Nu+NwEwu65A%n%QO0KJc3QRCg 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 Q5b&=b|JMb+{P^bmKLfLI2><{9 literal 0 HcmV?d00001 diff --git a/input/demo-periods/Ф-1_2024-12.xlsx b/input/demo-periods/Ф-1_2024-12.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7e6973c56f5052732622864a94eb7e74cef26728 GIT binary patch literal 11665 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 aQ~!U{UO^fX>TMTruP@)%3;aOy=KVkVe`j3) literal 0 HcmV?d00001 diff --git a/src/apps/core/migrations/0003_report_upload_history.py b/src/apps/core/migrations/0003_report_upload_history.py new file mode 100644 index 0000000..6a753c8 --- /dev/null +++ b/src/apps/core/migrations/0003_report_upload_history.py @@ -0,0 +1,98 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("core", "0002_report_upload")] + + operations = [ + migrations.AddField( + model_name="reportupload", + name="report_year", + field=models.PositiveSmallIntegerField( + blank=True, db_index=True, null=True + ), + ), + migrations.AddField( + model_name="reportupload", + name="report_month", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="reportupload", + name="report_quarter", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="reportupload", + name="report_half_year", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="reportupload", + name="corporation_scope", + field=models.CharField(blank=True, db_index=True, max_length=32), + ), + migrations.AddField( + model_name="reportupload", + name="status_message", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="reportupload", + name="job_id", + field=models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="reportupload", + name="records_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="reportupload", + name="loaded_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="reportupload", + name="skipped_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="reportupload", + name="errors_count", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="reportupload", + name="validation_errors", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="reportupload", + name="started_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="reportupload", + name="completed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="reportupload", + name="status", + field=models.CharField( + choices=[ + ("queued", "Ожидает обработки"), + ("processing", "Обрабатывается"), + ("success", "Обработан"), + ("partial", "Обработан частично"), + ("failed", "Ошибка"), + ], + db_index=True, + default="processing", + max_length=16, + ), + ), + ] diff --git a/src/apps/core/models.py b/src/apps/core/models.py index abaa961..882f6ef 100644 --- a/src/apps/core/models.py +++ b/src/apps/core/models.py @@ -28,6 +28,7 @@ class ReportUploadStatus(models.TextChoices): QUEUED = "queued", _("Ожидает обработки") PROCESSING = "processing", _("Обрабатывается") SUCCESS = "success", _("Обработан") + PARTIAL = "partial", _("Обработан частично") FAILED = "failed", _("Ошибка") @@ -39,6 +40,11 @@ class ReportUpload(TimestampMixin, models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) form = models.CharField(max_length=2, choices=FORM_CHOICES, db_index=True) load_batch = models.PositiveIntegerField(null=True, blank=True, db_index=True) + report_year = models.PositiveSmallIntegerField(null=True, blank=True, db_index=True) + report_month = models.PositiveSmallIntegerField(null=True, blank=True) + report_quarter = models.PositiveSmallIntegerField(null=True, blank=True) + report_half_year = models.PositiveSmallIntegerField(null=True, blank=True) + corporation_scope = models.CharField(max_length=32, blank=True, db_index=True) original_file = models.FileField(upload_to=report_upload_path, max_length=500) file_name = models.CharField(max_length=255) content_type = models.CharField(max_length=127) @@ -50,6 +56,15 @@ class ReportUpload(TimestampMixin, models.Model): default=ReportUploadStatus.PROCESSING, db_index=True, ) + status_message = models.TextField(blank=True) + job_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) + records_count = models.PositiveIntegerField(default=0) + loaded_count = models.PositiveIntegerField(default=0) + skipped_count = models.PositiveIntegerField(default=0) + errors_count = models.PositiveIntegerField(default=0) + validation_errors = models.JSONField(default=list, blank=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) uploaded_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, diff --git a/src/apps/core/report_upload_history.py b/src/apps/core/report_upload_history.py new file mode 100644 index 0000000..cf23602 --- /dev/null +++ b/src/apps/core/report_upload_history.py @@ -0,0 +1,271 @@ +"""Read projection for report upload history.""" + +from __future__ import annotations + +from collections import defaultdict +from uuid import NAMESPACE_URL, uuid5 + +from apps.core.models import ReportUpload, ReportUploadStatus +from apps.core.report_uploads import ALLOWED_FORMS, FORM_RECORD_MODELS +from apps.core.upload_contracts import ( + report_annual_display, + report_half_year_display, + report_month_display, + report_quarter_display, +) +from apps.organization.models import CorporationScope, Organization +from apps.organization.scope_utils import SCOPE_LABELS +from django.apps import apps +from django.db.models import Count, Max, Q +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + + +class ReportUploadHistoryQuerySerializer(serializers.Serializer): + form = serializers.ChoiceField(choices=sorted(ALLOWED_FORMS), required=False) + corporation_scope = serializers.ChoiceField( + choices=CorporationScope.values, required=False + ) + organization_id = serializers.UUIDField(required=False) + organization_inn = serializers.CharField(required=False) + search = serializers.CharField(required=False, allow_blank=True) + report_year = serializers.IntegerField(min_value=2000, required=False) + report_month = serializers.IntegerField(min_value=1, max_value=12, required=False) + report_quarter = serializers.IntegerField(min_value=1, max_value=4, required=False) + report_half_year = serializers.IntegerField( + min_value=1, max_value=2, required=False + ) + status = serializers.ChoiceField(choices=ReportUploadStatus.values, required=False) + upload_id = serializers.UUIDField(required=False) + load_batch = serializers.IntegerField(min_value=1, required=False) + created_from = serializers.DateTimeField(required=False) + created_to = serializers.DateTimeField(required=False) + ordering = serializers.ChoiceField( + choices=( + "created_at", + "-created_at", + "status", + "-status", + "form", + "-form", + "report_year", + "-report_year", + "load_batch", + "-load_batch", + ), + default="-created_at", + ) + page = serializers.IntegerField(min_value=1, required=False) + page_size = serializers.IntegerField(min_value=1, max_value=100, required=False) + + def validate(self, attrs): + form = attrs.get("form") + if attrs.get("report_month") is not None and form not in {None, "f1"}: + raise serializers.ValidationError( + {"report_month": "Параметр доступен только для формы Ф-1"} + ) + if attrs.get("report_half_year") is not None and form not in {None, "f4"}: + raise serializers.ValidationError( + {"report_half_year": "Параметр доступен только для формы Ф-4"} + ) + return attrs + + +class ReportUploadHistoryPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +def _period_display(upload: ReportUpload) -> str | None: + if upload.report_year is None: + return None + if upload.report_month is not None: + return report_month_display(upload.report_year, upload.report_month) + if upload.report_half_year is not None: + return report_half_year_display(upload.report_year, upload.report_half_year) + if upload.report_quarter is not None: + return report_quarter_display(upload.report_year, upload.report_quarter) + return report_annual_display(upload.report_year) + + +def _uploaded_by(upload: ReportUpload) -> dict[str, str] | None: + user = upload.uploaded_by + if user is None: + return None + profile = getattr(user, "profile", None) + name = profile.full_name if profile is not None else user.username + return {"id": str(user.id), "name": name} + + +def _record_projection(uploads: list[ReportUpload]): + projection: dict[tuple[str, int], list[dict[str, object]]] = defaultdict(list) + organization_ids: set[object] = set() + + for form in ALLOWED_FORMS: + batches = { + upload.load_batch + for upload in uploads + if upload.form == form and upload.load_batch is not None + } + if not batches: + continue + app_label, model_name = FORM_RECORD_MODELS[form] + record_model = apps.get_model(app_label, model_name) + records = list( + record_model.objects.filter(load_batch__in=batches) + .values("load_batch", "organization_id") + .annotate( + records_count=Count("id"), + active_records_count=Count("id", filter=Q(is_active_version=True)), + superseded_by_batch=Max("superseded_by_batch"), + ) + ) + for record in records: + organization_ids.add(record["organization_id"]) + projection[(form, record["load_batch"])].append(record) + + return projection, Organization.objects.in_bulk(organization_ids) + + +def _download_available(upload: ReportUpload) -> bool: + return bool( + upload.load_batch is not None + and upload.original_file + and upload.original_file.storage.exists(upload.original_file.name) + ) + + +def _row( + upload: ReportUpload, + record: dict[str, object] | None, + organizations: dict[object, Organization], +) -> dict[str, object]: + organization = organizations.get(record["organization_id"]) if record else None + scopes = organization.get_corporation_scopes() if organization else [] + corporation_scope = upload.corporation_scope or (scopes[0] if scopes else None) + available = _download_available(upload) + row_id = ( + uuid5(NAMESPACE_URL, f"{upload.id}:{organization.id}") + if organization is not None + else upload.id + ) + records_count = int(record["records_count"]) if record else upload.records_count + return { + "id": str(row_id), + "upload_id": str(upload.id), + "form": upload.form, + "form_display": upload.get_form_display(), + "load_batch": upload.load_batch, + "file_name": upload.file_name, + "file_size": upload.file_size, + "content_type": upload.content_type, + "corporation_scope": corporation_scope, + "corporation_scope_label": SCOPE_LABELS.get(corporation_scope), + "organization_id": str(organization.id) if organization else None, + "organization_name": organization.name if organization else None, + "organization_inn": organization.inn if organization else None, + "report_year": upload.report_year, + "report_month": upload.report_month, + "report_quarter": upload.report_quarter, + "report_half_year": upload.report_half_year, + "report_period_display": _period_display(upload), + "status": upload.status, + "status_message": upload.status_message or None, + "job_id": upload.job_id, + "records_count": records_count, + "loaded_count": records_count if record else upload.loaded_count, + "skipped_count": upload.skipped_count, + "errors_count": upload.errors_count, + "is_active_version": (bool(record["active_records_count"]) if record else None), + "superseded_by_batch": record["superseded_by_batch"] if record else None, + "created_at": upload.created_at, + "started_at": upload.started_at, + "completed_at": upload.completed_at, + "updated_at": upload.updated_at, + "uploaded_by": _uploaded_by(upload), + "download_available": available, + "download_url": ( + f"/api/v1/forms/{upload.form}/uploads/{upload.load_batch}/download/" + if available + else None + ), + "validation_errors": upload.validation_errors, + } + + +def _matches(row: dict[str, object], filters: dict[str, object]) -> bool: + direct_fields = ( + "corporation_scope", + "organization_id", + "organization_inn", + "report_year", + "report_month", + "report_quarter", + "report_half_year", + ) + if any( + field in filters and str(row.get(field)) != str(filters[field]) + for field in direct_fields + ): + return False + search = str(filters.get("search") or "").casefold() + if search and not any( + search in str(row.get(field) or "").casefold() + for field in ("organization_name", "organization_inn", "file_name") + ): + return False + return True + + +class ReportUploadHistoryView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = ReportUploadHistoryPagination + + @swagger_auto_schema( + tags=["Формы отчетности"], + operation_summary="История загрузок форм отчетности", + query_serializer=ReportUploadHistoryQuerySerializer, + responses={200: openapi.Response("Пагинированная история загрузок")}, + ) + def get(self, request): + serializer = ReportUploadHistoryQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + filters = serializer.validated_data + + queryset = ReportUpload.objects.select_related("uploaded_by__profile") + if not request.user.is_staff: + queryset = queryset.filter(uploaded_by=request.user) + for field in ("form", "status", "load_batch"): + if field in filters: + queryset = queryset.filter(**{field: filters[field]}) + if "upload_id" in filters: + queryset = queryset.filter(pk=filters["upload_id"]) + if "created_from" in filters: + queryset = queryset.filter(created_at__gte=filters["created_from"]) + if "created_to" in filters: + queryset = queryset.filter(created_at__lte=filters["created_to"]) + + uploads = list(queryset) + projection, organizations = _record_projection(uploads) + rows = [ + _row(upload, record, organizations) + for upload in uploads + for record in (projection.get((upload.form, upload.load_batch)) or [None]) + ] + rows = [row for row in rows if _matches(row, filters)] + + ordering = filters["ordering"] + reverse = ordering.startswith("-") + field = ordering.lstrip("-") + rows.sort( + key=lambda row: (row.get(field) is not None, row.get(field), row["id"]), + reverse=reverse, + ) + paginator = self.pagination_class() + page = paginator.paginate_queryset(rows, request, view=self) + return paginator.get_paginated_response(page) diff --git a/src/apps/core/report_uploads.py b/src/apps/core/report_uploads.py index bee21f9..82bca9e 100644 --- a/src/apps/core/report_uploads.py +++ b/src/apps/core/report_uploads.py @@ -10,8 +10,10 @@ from uuid import UUID from apps.core.models import ReportUpload, ReportUploadStatus from apps.core.response import api_error_response +from django.apps import apps from django.core.files.base import ContentFile from django.http import FileResponse +from django.utils import timezone from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status @@ -19,6 +21,9 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView ALLOWED_FORMS = frozenset(f"f{number}" for number in range(1, 7)) +FORM_RECORD_MODELS = { + f"f{number}": (f"form_{number}", f"FormF{number}Record") for number in range(1, 7) +} EXCEL_CONTENT_TYPES = { ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -29,7 +34,17 @@ class ReportUploadService: """Own lifecycle transitions for a persisted report upload.""" @staticmethod - def create(*, uploaded_file, form: str, user=None) -> ReportUpload: + def create( + *, + uploaded_file, + form: str, + user=None, + report_year: int | None = None, + report_month: int | None = None, + report_quarter: int | None = None, + report_half_year: int | None = None, + corporation_scope: str = "", + ) -> ReportUpload: if form not in ALLOWED_FORMS: raise ValueError(f"Unsupported report form: {form}") @@ -47,6 +62,12 @@ class ReportUploadService: file_size=len(content), file_hash=hashlib.sha256(content).hexdigest(), status=ReportUploadStatus.PROCESSING, + report_year=report_year, + report_month=report_month, + report_quarter=report_quarter, + report_half_year=report_half_year, + corporation_scope=corporation_scope, + started_at=timezone.now(), uploaded_by=user if getattr(user, "is_authenticated", False) else None, ) report_upload.original_file.save( @@ -68,27 +89,57 @@ class ReportUploadService: raise ValueError("Parser result does not contain batch_id") report_upload = ReportUpload.objects.get(pk=upload_id) + app_label, model_name = FORM_RECORD_MODELS[report_upload.form] + record_model = apps.get_model(app_label, model_name) + records_count = record_model.objects.filter(load_batch=batch_id).count() report_upload.load_batch = int(batch_id) report_upload.status = ReportUploadStatus.SUCCESS - report_upload.save(update_fields=["load_batch", "status", "updated_at"]) + report_upload.status_message = "" + report_upload.records_count = records_count + report_upload.loaded_count = records_count + report_upload.completed_at = timezone.now() + report_upload.save( + update_fields=[ + "load_batch", + "status", + "status_message", + "records_count", + "loaded_count", + "completed_at", + "updated_at", + ] + ) return report_upload @staticmethod - def mark_failed(upload_id: UUID | str) -> None: + def mark_failed( + upload_id: UUID | str, + *, + message: str = "Не удалось обработать файл", + validation_errors: list[dict[str, object]] | None = None, + ) -> None: ReportUpload.objects.filter(pk=upload_id).update( - status=ReportUploadStatus.FAILED + status=ReportUploadStatus.FAILED, + status_message=message, + validation_errors=validation_errors or [], + errors_count=len(validation_errors or []), + completed_at=timezone.now(), ) @staticmethod - def mark_queued(upload_id: UUID | str) -> None: + def mark_queued(upload_id: UUID | str, *, job_id: str | None = None) -> None: ReportUpload.objects.filter(pk=upload_id).update( - status=ReportUploadStatus.QUEUED + status=ReportUploadStatus.QUEUED, + status_message="Файл принят и ожидает обработки", + job_id=job_id, ) @staticmethod def mark_processing(upload_id: UUID | str) -> None: ReportUpload.objects.filter(pk=upload_id).update( - status=ReportUploadStatus.PROCESSING + status=ReportUploadStatus.PROCESSING, + status_message="Файл принят и обрабатывается", + started_at=timezone.now(), ) diff --git a/src/apps/form_1/api.py b/src/apps/form_1/api.py index c2f1d6a..fde890d 100644 --- a/src/apps/form_1/api.py +++ b/src/apps/form_1/api.py @@ -145,6 +145,8 @@ class FormF1UploadView(APIView): uploaded_file=file, form="f1", user=request.user, + report_year=report_year, + report_month=report_month, ) # Определяем размер файла для выбора режима обработки @@ -181,7 +183,6 @@ class FormF1UploadView(APIView): ) # Для больших файлов - фоновая обработка - ReportUploadService.mark_queued(report_upload.id) # Сохраняем файл во временное хранилище file_path = f"uploads/form_f1/{file.name}" saved_path = default_storage.save(file_path, file) @@ -194,6 +195,7 @@ class FormF1UploadView(APIView): report_month=report_month, report_upload_id=str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py index 2a385df..f6c3783 100644 --- a/src/apps/form_2/api.py +++ b/src/apps/form_2/api.py @@ -70,11 +70,12 @@ class FormF2UploadView(APIView): uploaded_file=file, form="f2", user=request.user, + report_year=report_year, + report_quarter=report_quarter, ) # Для больших файлов - фоновая обработка if file.size > BACKGROUND_THRESHOLD: - ReportUploadService.mark_queued(report_upload.id) file_content = file.read() task = process_form_f2_file.delay( file_content, @@ -83,6 +84,7 @@ class FormF2UploadView(APIView): report_quarter, str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py index ac8c8ab..0fcb4c5 100644 --- a/src/apps/form_3/api.py +++ b/src/apps/form_3/api.py @@ -69,10 +69,11 @@ class FormF3UploadView(APIView): uploaded_file=file, form="f3", user=request.user, + report_year=report_year, + report_quarter=report_quarter, ) if file.size > BACKGROUND_THRESHOLD: - ReportUploadService.mark_queued(report_upload.id) file_content = file.read() task = process_form_f3_file.delay( file_content, @@ -81,6 +82,7 @@ class FormF3UploadView(APIView): report_quarter, str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py index 5252efc..f6e745c 100644 --- a/src/apps/form_4/api.py +++ b/src/apps/form_4/api.py @@ -54,10 +54,11 @@ class FormF4UploadView(APIView): uploaded_file=file, form="f4", user=request.user, + report_year=report_year, + report_half_year=report_half_year, ) if file.size > BACKGROUND_THRESHOLD: - ReportUploadService.mark_queued(report_upload.id) task = process_form_f4_file.delay( file.read(), file.name, @@ -66,6 +67,7 @@ class FormF4UploadView(APIView): None, str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( form="f4", diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py index e7e4784..67a5ebd 100644 --- a/src/apps/form_5/api.py +++ b/src/apps/form_5/api.py @@ -55,10 +55,11 @@ class FormF5UploadView(APIView): uploaded_file=file, form="f5", user=request.user, + report_year=report_year, + report_quarter=report_quarter, ) if file.size > BACKGROUND_THRESHOLD: - ReportUploadService.mark_queued(report_upload.id) task = process_form_f5_file.delay( file.read(), file.name, @@ -66,6 +67,7 @@ class FormF5UploadView(APIView): report_quarter, str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( form="f5", diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py index 3a35539..9da0a79 100644 --- a/src/apps/form_6/api.py +++ b/src/apps/form_6/api.py @@ -54,10 +54,11 @@ class FormF6UploadView(APIView): uploaded_file=file, form="f6", user=request.user, + report_year=report_year, + report_quarter=report_quarter, ) if file.size > BACKGROUND_THRESHOLD: - ReportUploadService.mark_queued(report_upload.id) task = process_form_f6_file.delay( file.read(), file.name, @@ -65,6 +66,7 @@ class FormF6UploadView(APIView): report_quarter, str(report_upload.id), ) + ReportUploadService.mark_queued(report_upload.id, job_id=task.id) return Response( build_upload_success_payload( form="f6", diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index fdfe6ca..1a88986 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -77,6 +77,16 @@ def _delta_percent(current, previous) -> float: ) +def _direction(current, previous) -> str: + current_value = _dec(current) + previous_value = _dec(previous) + if current_value > previous_value: + return "up" + if current_value < previous_value: + return "down" + return "unchanged" + + def _period_rank(report_quarter: int | None) -> int: return 5 if report_quarter is None else report_quarter @@ -216,14 +226,30 @@ class OrganizationAnalyticsService: *, organization: Organization, report_year: int, - report_quarter: int | None, + report_quarter: int | None = None, ) -> dict[str, object]: f2_records = cls._f2_records(organization) f1_records = cls._f1_records(organization) - current_f2 = cls._require_record( - _pick_record(f2_records, report_year, report_quarter), - entity="Financial summary", - ) + current_f2 = _pick_record(f2_records, report_year, report_quarter) + if current_f2 is None: + empty_metric = { + "amount": 0, + "previous_amount": 0, + "delta_percent": 0.0, + } + return { + "organization_id": str(organization.id), + "report_period": { + "year": report_year, + "quarter": report_quarter, + }, + "data_available": False, + "message": "Данные отсутствуют", + "revenue": empty_metric, + "net_profit": empty_metric.copy(), + "taxes_paid": empty_metric.copy(), + "insurance_contributions": empty_metric.copy(), + } current_f1 = _pick_record(f1_records, report_year, report_quarter) previous_f2 = _pick_record(f2_records, report_year - 1, report_quarter) previous_f1 = _pick_record(f1_records, report_year - 1, report_quarter) @@ -252,6 +278,8 @@ class OrganizationAnalyticsService: "year": report_year, "quarter": report_quarter, }, + "data_available": True, + "message": None, "revenue": { "amount": _amount(current_f2.revenue), "previous_amount": _amount(revenue_previous), @@ -279,7 +307,12 @@ class OrganizationAnalyticsService: @staticmethod def _economics_metric_groups() -> dict[str, tuple[str, ...]]: return { - "efficiency": ("revenue", "ebitda", "net_profit"), + "efficiency": ( + "revenue", + "ebitda", + "net_profit", + "revenue_per_employee", + ), "profitability": ("gross_profit", "net_profit", "operating_profit"), "debt": ("net_debt", "loans", "assets"), "investment": ("capex", "rd_expenses", "assets"), @@ -298,6 +331,7 @@ class OrganizationAnalyticsService: "assets": "rub_thousands", "capex": "rub_thousands", "rd_expenses": "rub_thousands", + "revenue_per_employee": "rub_per_employee", } @staticmethod @@ -341,6 +375,17 @@ class OrganizationAnalyticsService: primary, fallback = field_map.get(metric, (ZERO, ZERO)) return _dec(primary or fallback) + @classmethod + def _economics_display_value(cls, metric: str, f2, f4, f1) -> int: + if metric == "revenue_per_employee": + employees = _dec(getattr(f1, "avg_payroll_employees", ZERO)) + if employees == ZERO: + employees = _dec(getattr(f1, "avg_employees", ZERO)) + if employees == ZERO: + return 0 + return _amount(cls._economics_metric_value("revenue", f2, f4) / employees) + return _amount_thousands(cls._economics_metric_value(metric, f2, f4)) + @classmethod def get_economics( cls, @@ -356,46 +401,58 @@ class OrganizationAnalyticsService: f4_by_year = _best_records_by_year( cls._f4_records(organization), from_year, to_year ) + f1_by_year = _best_records_by_year( + cls._f1_records(organization), from_year, to_year + ) - periods = sorted(set(f2_by_year) | set(f4_by_year)) + periods = sorted(set(f2_by_year) | set(f4_by_year) | set(f1_by_year)) if not periods: - latest_year = max( - filter( - None, - ( - _latest_report_year(cls._f2_records(organization)), - _latest_report_year(cls._f4_records(organization)), - ), - ), - default=None, - ) - if latest_year is None: - raise NotFoundError(message="Economics data is not available") - - f2_by_year = _best_records_by_year( - cls._f2_records(organization), latest_year, latest_year - ) - f4_by_year = _best_records_by_year( - cls._f4_records(organization), latest_year, latest_year - ) - periods = sorted(set(f2_by_year) | set(f4_by_year)) + return { + "organization_id": str(organization.id), + "group": group, + "periods": [], + "data_available": False, + "message": "Данные отсутствуют", + "kpis": {}, + "series": [], + "ratios": [], + "ratio_normatives": ECONOMICS_RATIO_NORMATIVES, + } metric_units = cls._economics_metric_units() selected_metrics = cls._economics_metric_groups()[group] last_period = periods[-1] + previous_period = periods[-2] if len(periods) > 1 else None + + def metric_value(metric: str, period: int | None) -> int: + if period is None: + return 0 + return cls._economics_display_value( + metric, + f2_by_year.get(period), + f4_by_year.get(period), + f1_by_year.get(period), + ) return { "organization_id": str(organization.id), "group": group, "periods": periods, + "data_available": True, + "message": None, "kpis": { metric: { - "value": _amount_thousands( - cls._economics_metric_value( - metric, - f2_by_year.get(last_period), - f4_by_year.get(last_period), - ) + "value": metric_value(metric, last_period), + "previous_value": metric_value(metric, previous_period), + "delta_value": metric_value(metric, last_period) + - metric_value(metric, previous_period), + "delta_percent": _delta_percent( + metric_value(metric, last_period), + metric_value(metric, previous_period), + ), + "direction": _direction( + metric_value(metric, last_period), + metric_value(metric, previous_period), ), "unit": metric_units[metric], } @@ -408,13 +465,7 @@ class OrganizationAnalyticsService: "points": [ { "period": report_year, - "value": _amount_thousands( - cls._economics_metric_value( - metric, - f2_by_year.get(report_year), - f4_by_year.get(report_year), - ) - ), + "value": metric_value(metric, report_year), } for report_year in periods ], @@ -462,6 +513,9 @@ class OrganizationAnalyticsService: current_f1 = _pick_record(cls._f1_records(organization), report_year) years = list(range(report_year - history_years + 1, report_year + 1)) history_records = _best_records_by_year(f3_records, years[0], years[-1]) + history_f1_records = _best_records_by_year( + cls._f1_records(organization), years[0], years[-1] + ) average_employees = int(_dec(current_f3.avg_employees)) under_30 = int(average_employees * 0.29) @@ -494,6 +548,43 @@ class OrganizationAnalyticsService: { "year": year, "average_employees": int(_dec(history_records[year].avg_employees)), + "avg_payroll_employees": int( + _dec( + getattr( + history_f1_records.get(year), + "avg_payroll_employees", + ZERO, + ) + ) + ), + "average_age": _weighted_average_age( + [ + { + "age_group": "under_30", + "employees_count": int( + _dec(history_records[year].avg_employees) + * Decimal("0.29") + ), + }, + { + "age_group": "30_50", + "employees_count": int( + _dec(history_records[year].avg_employees) + * Decimal("0.49") + ), + }, + { + "age_group": "over_50", + "employees_count": int( + _dec(history_records[year].avg_employees) + * Decimal("0.22") + ), + }, + ] + ), + "payroll_fund": _amount( + getattr(history_f1_records.get(year), "payroll_fund", ZERO) + ), } for year in years if year in history_records @@ -592,6 +683,61 @@ class OrganizationAnalyticsService: }, ] + bucket_fields = { + "under_5_years": "age_under_5", + "5_10_years": "age_5_10", + "10_15_years": "age_10_15", + "15_20_years": "age_15_20", + "over_20_years": "age_over_20", + } + total_age_units = sum(item["units_count"] for item in age_distribution) + for item in age_distribution: + bucket = item["bucket"] + if f6_records: + bucket_units_by_record = [ + int(_dec(getattr(record, bucket_fields[bucket], ZERO))) + for record in f6_records + ] + imported_equipment = sum( + round( + int(_dec(record.imported_equipment)) + * units + / max(1, int(_dec(record.total_equipment))) + ) + for record, units in zip( + f6_records, bucket_units_by_record, strict=True + ) + ) + weighted_units = sum(bucket_units_by_record) + utilization_rate = sum( + _dec(record.utilization_rate) * units + for record, units in zip( + f6_records, bucket_units_by_record, strict=True + ) + ) / max(1, weighted_units) + weighted_wear = sum( + _dec(record.physical_wear_percent) * units + for record, units in zip( + f6_records, bucket_units_by_record, strict=True + ) + ) / max(1, weighted_units) + else: + imported_equipment = round( + int(_dec(current_f3.imported_equipment)) + * item["units_count"] + / max(1, total_age_units) + ) + utilization_rate = _dec(current_f3.utilization_rate) + weighted_wear = _dec(current_f3.physical_wear_percent) + item.update( + { + "imported_equipment": imported_equipment, + "utilization_rate": round(float(utilization_rate / 100), 2), + "lease_share_itn_percent": None, + "weighted_wear_percent": _ratio(weighted_wear), + } + ) + if f6_records: category_rows = [ { @@ -661,6 +807,7 @@ class OrganizationAnalyticsService: "report_year": report_year, "summary": { "total_equipment": int(_dec(current_f3.total_equipment)), + "machine_tools_and_equipment": int(_dec(current_f3.total_equipment)), "domestic_equipment": int(_dec(current_f3.domestic_equipment)), "imported_equipment": int(_dec(current_f3.imported_equipment)), "physical_wear_percent": _ratio(current_f3.physical_wear_percent), @@ -1185,7 +1332,7 @@ class DashboardAnalyticsService: def get_dashboard( cls, *, corporation_scope: str | None = None ) -> dict[str, object]: - queryset = Organization.objects.all() + queryset = Organization.objects.filter(mostovik_uid__isnull=False) if corporation_scope: queryset = filter_queryset_by_scopes(queryset, [corporation_scope]) @@ -1234,23 +1381,38 @@ class DashboardAnalyticsService: 1 for org in cluster_organizations if not org.bankruptcy_messages_found ) + cluster_years = sorted( + { + year + for organization in cluster_organizations + for year in f3_best_by_organization.get(str(organization.id), {}) + } + ) + from_year = None + to_year = None growth_values = [] - for organization in cluster_organizations: - organization_f3_by_year = f3_best_by_organization.get( - str(organization.id), {} - ) - report_years = sorted(organization_f3_by_year) - if len(report_years) < 2: - continue - previous_record = organization_f3_by_year[report_years[-2]] - current_record = organization_f3_by_year[report_years[-1]] - if current_record is None or previous_record is None: - continue - growth_values.append( - _delta_percent( - current_record.avg_employees, previous_record.avg_employees + year_pairs = list(zip(cluster_years, cluster_years[1:], strict=False)) + for candidate_from, candidate_to in reversed(year_pairs): + candidate_growth_values = [] + for organization in cluster_organizations: + organization_f3_by_year = f3_best_by_organization.get( + str(organization.id), {} ) - ) + previous_record = organization_f3_by_year.get(candidate_from) + current_record = organization_f3_by_year.get(candidate_to) + if current_record is None or previous_record is None: + continue + candidate_growth_values.append( + _delta_percent( + current_record.avg_employees, + previous_record.avg_employees, + ) + ) + if candidate_growth_values: + from_year = candidate_from + to_year = candidate_to + growth_values = candidate_growth_values + break distribution_by_cluster.append( { @@ -1270,6 +1432,8 @@ class DashboardAnalyticsService: headcount_growth_by_cluster.append( { "cluster": cluster_code, + "from_year": from_year, + "to_year": to_year, "growth_percent": round(sum(growth_values) / len(growth_values), 1) if growth_values else 0.0, diff --git a/src/apps/organization/analytics_views.py b/src/apps/organization/analytics_views.py index af38be5..251111e 100644 --- a/src/apps/organization/analytics_views.py +++ b/src/apps/organization/analytics_views.py @@ -39,7 +39,10 @@ class OrganizationAnalyticsBaseView(APIView): permission_classes = [IsAuthenticated] def get_organization(self, organization_id) -> Organization: - return get_object_or_404(Organization, id=organization_id) + return get_object_or_404( + Organization.objects.filter(mostovik_uid__isnull=False), + id=organization_id, + ) class AnalyticsDashboardView(APIView): diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py index daa1c49..1715715 100644 --- a/src/apps/organization/api.py +++ b/src/apps/organization/api.py @@ -69,14 +69,15 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): """ ViewSet для просмотра организаций. - Только чтение - организации создаются автоматически при загрузке форм. + Только чтение. В каталог попадают организации, подтвержденные обменным + пакетом Моставика; строки форм сами по себе каталог не расширяют. Эндпоинты: GET /organizations/ - список организаций GET /organizations/{id}/ - детали организации """ - queryset = Organization.objects.all() + queryset = Organization.objects.filter(mostovik_uid__isnull=False) serializer_class = OrganizationCatalogDetailSerializer permission_classes = [IsAuthenticated] filterset_class = OrganizationFilter diff --git a/src/apps/organization/contract_serializers.py b/src/apps/organization/contract_serializers.py index e739d5d..24311e1 100644 --- a/src/apps/organization/contract_serializers.py +++ b/src/apps/organization/contract_serializers.py @@ -33,6 +33,8 @@ class FinancialSummaryReportPeriodSerializer(serializers.Serializer): class FinancialSummaryResponseSerializer(serializers.Serializer): organization_id = serializers.UUIDField() report_period = FinancialSummaryReportPeriodSerializer() + data_available = serializers.BooleanField() + message = serializers.CharField(allow_null=True) revenue = FinancialSummaryMetricSerializer() net_profit = FinancialSummaryMetricSerializer() taxes_paid = FinancialSummaryMetricSerializer() @@ -41,6 +43,10 @@ class FinancialSummaryResponseSerializer(serializers.Serializer): class EconomicsKpiSerializer(serializers.Serializer): value = serializers.IntegerField() + previous_value = serializers.IntegerField() + delta_value = serializers.IntegerField() + delta_percent = serializers.FloatField() + direction = serializers.ChoiceField(choices=("up", "down", "unchanged")) unit = serializers.CharField() @@ -48,6 +54,7 @@ class EconomicsKpisSerializer(serializers.Serializer): revenue = EconomicsKpiSerializer(required=False) ebitda = EconomicsKpiSerializer(required=False) net_profit = EconomicsKpiSerializer(required=False) + revenue_per_employee = EconomicsKpiSerializer(required=False) gross_profit = EconomicsKpiSerializer(required=False) operating_profit = EconomicsKpiSerializer(required=False) net_debt = EconomicsKpiSerializer(required=False) @@ -87,6 +94,8 @@ class EconomicsResponseSerializer(serializers.Serializer): organization_id = serializers.UUIDField() group = serializers.CharField() periods = serializers.ListField(child=serializers.IntegerField()) + data_available = serializers.BooleanField() + message = serializers.CharField(allow_null=True) kpis = EconomicsKpisSerializer() series = EconomicsMetricSeriesSerializer(many=True) ratios = EconomicsRatioSerializer(many=True) @@ -111,6 +120,9 @@ class PersonnelAgeDistributionSerializer(serializers.Serializer): class PersonnelHistorySerializer(serializers.Serializer): year = serializers.IntegerField() average_employees = serializers.IntegerField() + avg_payroll_employees = serializers.IntegerField() + average_age = serializers.FloatField() + payroll_fund = serializers.IntegerField() class PersonnelResponseSerializer(serializers.Serializer): @@ -124,6 +136,7 @@ class PersonnelResponseSerializer(serializers.Serializer): class EquipmentSummarySerializer(serializers.Serializer): total_equipment = serializers.IntegerField() + machine_tools_and_equipment = serializers.IntegerField() domestic_equipment = serializers.IntegerField() imported_equipment = serializers.IntegerField() physical_wear_percent = serializers.FloatField() @@ -139,6 +152,10 @@ class EquipmentSummarySerializer(serializers.Serializer): class EquipmentAgeDistributionSerializer(serializers.Serializer): bucket = serializers.CharField() units_count = serializers.IntegerField() + imported_equipment = serializers.IntegerField() + utilization_rate = serializers.FloatField() + lease_share_itn_percent = serializers.FloatField(allow_null=True) + weighted_wear_percent = serializers.FloatField() class EquipmentCategorySerializer(serializers.Serializer): @@ -320,6 +337,8 @@ class DashboardExecutorsSerializer(serializers.Serializer): class DashboardHeadcountGrowthSerializer(serializers.Serializer): cluster = serializers.CharField() + from_year = serializers.IntegerField(allow_null=True) + to_year = serializers.IntegerField(allow_null=True) growth_percent = serializers.FloatField() diff --git a/src/apps/organization/management/commands/delete_test_companies.py b/src/apps/organization/management/commands/delete_test_companies.py index b98571a..083d424 100644 --- a/src/apps/organization/management/commands/delete_test_companies.py +++ b/src/apps/organization/management/commands/delete_test_companies.py @@ -27,6 +27,9 @@ TEST_COMPANY_IDENTITIES = tuple( for index in range(1, TEST_COMPANY_COUNT + 1) ) DEMO_REPORT_FILE_NAMES = ( + "Ф-1_2022-12.xlsx", + "Ф-1_2023-12.xlsx", + "Ф-1_2024-12.xlsx", "Ф-1_2025-09.xlsx", "Ф-1_2025-12.xlsx", "Ф-1_2026-03.xlsx", diff --git a/src/apps/user/views.py b/src/apps/user/views.py index b613835..6b7d11e 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -11,6 +11,7 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.tokens import RefreshToken from .serializers import ( @@ -333,7 +334,7 @@ class TokenRefreshView(APIView): return Response( {"access": str(refresh.access_token), "refresh": str(refresh)} ) - except Exception: + except TokenError: return Response( {"error": "Неверный refresh token"}, status=status.HTTP_401_UNAUTHORIZED ) diff --git a/src/core/api_v1_urls.py b/src/core/api_v1_urls.py index 305b0d6..705b263 100644 --- a/src/core/api_v1_urls.py +++ b/src/core/api_v1_urls.py @@ -4,6 +4,7 @@ API v1 URL configuration. Все API эндпоинты версионированы под /api/v1/ """ +from apps.core.report_upload_history import ReportUploadHistoryView from apps.core.report_uploads import ReportUploadDownloadView from apps.core.views import ( BackgroundJobListView, @@ -35,6 +36,11 @@ urlpatterns = [ path("forms/f4/", include("apps.form_4.urls")), path("forms/f5/", include("apps.form_5.urls")), path("forms/f6/", include("apps.form_6.urls")), + path( + "forms/uploads/history/", + ReportUploadHistoryView.as_view(), + name="report-upload-history", + ), path( "forms//uploads//download/", ReportUploadDownloadView.as_view(), diff --git a/src/settings/base.py b/src/settings/base.py index c0d9496..2599a80 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -268,8 +268,10 @@ REST_FRAMEWORK = { SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), - "ROTATE_REFRESH_TOKENS": True, - "BLACKLIST_AFTER_ROTATION": True, + # The frontend can issue concurrent refresh requests. Reusing one refresh token + # avoids a race where the first response blacklists the token used by the second. + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, "UPDATE_LAST_LOGIN": True, "ALGORITHM": "HS256", "VERIFYING_KEY": None, diff --git a/src/settings/test.py b/src/settings/test.py index 5ea45ec..7d6ef01 100644 --- a/src/settings/test.py +++ b/src/settings/test.py @@ -107,5 +107,6 @@ SIMPLE_JWT = { **globals().get("SIMPLE_JWT", {}), "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), - "ROTATE_REFRESH_TOKENS": True, + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, } diff --git a/tests/apps/forms/test_report_uploads_api.py b/tests/apps/forms/test_report_uploads_api.py index 3d5f371..05f8ef2 100644 --- a/tests/apps/forms/test_report_uploads_api.py +++ b/tests/apps/forms/test_report_uploads_api.py @@ -11,6 +11,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from tests.apps.form_1.factories import FormF1RecordFactory +from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -32,6 +34,8 @@ class ReportUploadApiTest(APITestCase): ), form="f1", user=self.user, + report_year=2026, + report_month=9, ) def test_service_persists_original_bytes_and_finalizes_batch(self): @@ -101,3 +105,69 @@ class ReportUploadApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_410_GONE) self.assertEqual(response.data["errors"][0]["code"], "source_file_gone") + + def test_history_returns_upload_before_records_are_parsed(self): + report_upload = self._create_upload() + ReportUploadService.mark_queued(report_upload.id, job_id="job-42") + self.client.force_authenticate(self.user) + + response = self.client.get("/api/v1/forms/uploads/history/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + row = response.data["results"][0] + self.assertEqual(row["upload_id"], str(report_upload.id)) + self.assertEqual(row["form"], "f1") + self.assertEqual(row["report_year"], 2026) + self.assertEqual(row["report_month"], 9) + self.assertEqual(row["status"], "queued") + self.assertEqual(row["job_id"], "job-42") + self.assertIsNone(row["organization_id"]) + self.assertFalse(row["download_available"]) + + def test_history_projects_successful_upload_per_organization(self): + organization = OrganizationFactory.create( + name="Тестовая компания 1", + inn="7709000010", + gk_code="rosatom", + gk_name="Госкорпорация Росатом", + ) + report_upload = self._create_upload() + FormF1RecordFactory.create( + organization=organization, + load_batch=42, + report_year=2026, + report_month=9, + ) + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + self.client.force_authenticate(self.user) + + response = self.client.get( + "/api/v1/forms/uploads/history/", + { + "corporation_scope": "rosatom", + "organization_inn": organization.inn, + "report_year": 2026, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + row = response.data["results"][0] + self.assertEqual(row["organization_id"], str(organization.id)) + self.assertEqual(row["organization_name"], organization.name) + self.assertEqual(row["organization_inn"], organization.inn) + self.assertEqual(row["corporation_scope"], "rosatom") + self.assertEqual(row["report_period_display"], "Сентябрь 2026") + self.assertEqual(row["records_count"], 1) + self.assertTrue(row["download_available"]) + self.assertEqual(row["download_url"], "/api/v1/forms/f1/uploads/42/download/") + + def test_history_does_not_expose_another_users_uploads(self): + self._create_upload() + self.client.force_authenticate(UserFactory.create_user()) + + response = self.client.get("/api/v1/forms/uploads/history/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) diff --git a/tests/apps/organization/factories.py b/tests/apps/organization/factories.py index 72e9d14..3c6f940 100644 --- a/tests/apps/organization/factories.py +++ b/tests/apps/organization/factories.py @@ -33,6 +33,7 @@ class OrganizationFactory(factory.django.DjangoModelFactory): ogrn = factory.LazyAttribute(lambda _: fake.numerify("#############")) kpp = factory.LazyAttribute(lambda _: fake.numerify("#########")) okpo = factory.LazyAttribute(lambda _: fake.numerify("########")) + mostovik_uid = factory.Faker("uuid4") registration_date = factory.LazyAttribute(lambda _: fake.date_this_century()) legal_address = factory.LazyAttribute(lambda _: fake.address().replace("\n", ", ")) activity_type = factory.LazyAttribute(lambda _: fake.job()) diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index ce4a918..8f3ada8 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -194,6 +194,32 @@ class OrganizationAnalyticsApiTest(APITestCase): set(response.data["insurance_contributions"]), {"amount", "previous_amount", "delta_percent"}, ) + self.assertTrue(response.data["data_available"]) + self.assertIsNone(response.data["message"]) + + def test_financial_summary_returns_explicit_empty_state(self): + organization = OrganizationFactory.create() + + response = self.client.get( + f"/api/v1/organizations/{organization.id}/analytics/financial-summary/" + "?report_year=2026&report_quarter=3" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["data_available"]) + self.assertEqual(response.data["message"], "Данные отсутствуют") + self.assertEqual(response.data["revenue"]["amount"], 0) + + def test_financial_summary_allows_annual_request_without_quarter(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/financial-summary/" + "?report_year=2026" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["report_period"], {"year": 2026, "quarter": None} + ) def test_economics_contract(self): response = self.client.get( @@ -207,14 +233,29 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.data["periods"], [2025, 2026]) self.assertEqual( response.data["kpis"].keys(), - {"revenue", "ebitda", "net_profit"}, + {"revenue", "ebitda", "net_profit", "revenue_per_employee"}, ) + self.assertGreater(response.data["kpis"]["revenue_per_employee"]["value"], 0) + self.assertEqual( + response.data["kpis"]["revenue_per_employee"]["unit"], + "rub_per_employee", + ) + for kpi in response.data["kpis"].values(): + self.assertIn("previous_value", kpi) + self.assertIn("delta_value", kpi) + self.assertIn("delta_percent", kpi) + self.assertIn("direction", kpi) self.assertIn("series", response.data) for series in response.data["series"]: self.assertIn("metric", series) self.assertIn("unit", series) self.assertIn("points", series) - self.assertEqual(series["unit"], "rub_thousands") + expected_unit = ( + "rub_per_employee" + if series["metric"] == "revenue_per_employee" + else "rub_thousands" + ) + self.assertEqual(series["unit"], expected_unit) self.assertEqual(len(series["points"]), 2) self.assertEqual(set(series["points"][0]), {"period", "value"}) @@ -234,15 +275,17 @@ class OrganizationAnalyticsApiTest(APITestCase): ) ) - def test_economics_uses_latest_available_year_when_requested_range_is_empty(self): + def test_economics_respects_requested_range_when_it_is_empty(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/economics/" "?group=efficiency&from_year=2022&to_year=2024" ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["periods"], [2026]) - self.assertEqual(response.data["series"][0]["points"][0]["period"], 2026) + self.assertEqual(response.data["periods"], []) + self.assertEqual(response.data["kpis"], {}) + self.assertFalse(response.data["data_available"]) + self.assertEqual(response.data["message"], "Данные отсутствуют") def test_personnel_contract(self): personnel_response = self.client.get( @@ -259,6 +302,16 @@ class OrganizationAnalyticsApiTest(APITestCase): 1050, ) self.assertEqual(len(personnel_response.data["history"]), 2) + self.assertEqual( + set(personnel_response.data["history"][0]), + { + "year", + "average_employees", + "avg_payroll_employees", + "average_age", + "payroll_fund", + }, + ) self.assertEqual(len(personnel_response.data["age_distribution"]), 3) self.assertIn("average_age", personnel_response.data) self.assertEqual( @@ -307,6 +360,7 @@ class OrganizationAnalyticsApiTest(APITestCase): set(response.data["summary"]), { "total_equipment", + "machine_tools_and_equipment", "domestic_equipment", "imported_equipment", "physical_wear_percent", @@ -329,6 +383,17 @@ class OrganizationAnalyticsApiTest(APITestCase): response.data["age_distribution"][0]["bucket"], "under_5_years" ) self.assertEqual(len(response.data["age_distribution"]), 5) + self.assertEqual( + set(response.data["age_distribution"][0]), + { + "bucket", + "units_count", + "imported_equipment", + "utilization_rate", + "lease_share_itn_percent", + "weighted_wear_percent", + }, + ) self.assertGreaterEqual(len(response.data["categories"]), 1) self.assertEqual(len(response.data["dynamics_series"]), 3) self.assertEqual( @@ -608,6 +673,42 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual( response.data["headcount_growth_by_cluster"][0]["growth_percent"], 50.0 ) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["from_year"], 2023 + ) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["to_year"], 2024 + ) + + def test_dashboard_growth_ignores_latest_year_without_comparable_history(self): + comparable = OrganizationFactory.create( + cluster="space", + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + latest_only = OrganizationFactory.create( + cluster="space", + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + FormF3RecordFactory.create( + organization=comparable, report_year=2023, avg_employees=100 + ) + FormF3RecordFactory.create( + organization=comparable, report_year=2024, avg_employees=120 + ) + FormF3RecordFactory.create( + organization=latest_only, report_year=2025, avg_employees=200 + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + growth = response.data["headcount_growth_by_cluster"][0] + self.assertEqual(growth["from_year"], 2023) + self.assertEqual(growth["to_year"], 2024) + self.assertEqual(growth["growth_percent"], 20.0) def test_analytics_query_validation(self): response = self.client.get( diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index fcfa9bd..85e69be 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -43,6 +43,25 @@ class OrganizationApiTest(APITestCase): self.assertEqual(response.data["results"][0]["registry_category_label"], "ОПК") self.assertEqual(response.data["results"][0]["short_name"], "АО «Альфа»") + def test_catalog_hides_form_only_organization_until_mostovik_confirms_it(self): + organization = OrganizationFactory.create(mostovik_uid=None) + + list_response = self.client.get("/api/v1/organizations/") + detail_response = self.client.get(f"/api/v1/organizations/{organization.id}/") + + self.assertEqual(list_response.status_code, status.HTTP_200_OK) + self.assertEqual(list_response.data["count"], 0) + self.assertEqual(detail_response.status_code, status.HTTP_404_NOT_FOUND) + + organization.mostovik_uid = "11111111-1111-4111-8111-111111111111" + organization.save(update_fields=["mostovik_uid", "updated_at"]) + + list_response = self.client.get("/api/v1/organizations/") + detail_response = self.client.get(f"/api/v1/organizations/{organization.id}/") + + self.assertEqual(list_response.data["count"], 1) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + def test_detail_includes_active_registries(self): organization = OrganizationFactory.create( short_name="АО «Бета»", diff --git a/tests/apps/organization/test_delete_test_companies_command.py b/tests/apps/organization/test_delete_test_companies_command.py index a4dca6b..252af66 100644 --- a/tests/apps/organization/test_delete_test_companies_command.py +++ b/tests/apps/organization/test_delete_test_companies_command.py @@ -50,6 +50,10 @@ class DeleteTestCompaniesCommandTest(TestCase): uploaded_file=SimpleUploadedFile("Ф-1_2025-09.xlsx", b"demo"), form="f1", ) + historical_demo_upload = ReportUploadService.create( + uploaded_file=SimpleUploadedFile("Ф-1_2022-12.xlsx", b"demo-history"), + form="f1", + ) demo_file_name = demo_upload.original_file.name demo_storage = demo_upload.original_file.storage unrelated_upload = ReportUploadService.create( @@ -64,7 +68,7 @@ class DeleteTestCompaniesCommandTest(TestCase): self.assertEqual(payload["organizations_deleted"], 2) self.assertEqual(payload["related_objects_deleted"], 2) - self.assertEqual(payload["report_uploads_deleted"], 1) + self.assertEqual(payload["report_uploads_deleted"], 2) self.assertFalse(Organization.objects.filter(pk__in=[first.pk, second.pk])) self.assertTrue( Organization.objects.filter(pk=same_pattern_but_not_test_identity.pk) @@ -75,6 +79,9 @@ class DeleteTestCompaniesCommandTest(TestCase): self.assertFalse(FormF1Record.objects.exists()) self.assertFalse(IndustrialProduct.objects.exists()) self.assertFalse(ReportUpload.objects.filter(pk=demo_upload.pk).exists()) + self.assertFalse( + ReportUpload.objects.filter(pk=historical_demo_upload.pk).exists() + ) self.assertFalse(demo_storage.exists(demo_file_name)) self.assertTrue(ReportUpload.objects.filter(pk=unrelated_upload.pk).exists()) diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index abf8d7e..153e05e 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -398,6 +398,17 @@ class TokenRefreshViewTest(APITestCase): # New refresh token should be different # Refresh token may be the same or different depending on implementation + def test_same_refresh_token_can_handle_parallel_refresh_requests(self): + data = {"refresh": self.tokens["refresh"]} + + first_response = self.client.post(self.refresh_url, data, format="json") + second_response = self.client.post(self.refresh_url, data, format="json") + + self.assertEqual(first_response.status_code, status.HTTP_200_OK) + self.assertEqual(second_response.status_code, status.HTTP_200_OK) + self.assertEqual(first_response.data["refresh"], self.tokens["refresh"]) + self.assertEqual(second_response.data["refresh"], self.tokens["refresh"]) + def test_refresh_token_invalid(self): """Test token refresh fails with invalid refresh token""" data = {"refresh": fake.pystr(min_chars=20, max_chars=50)}