diff --git a/src/apps/parsers/fns_upload.py b/src/apps/parsers/fns_upload.py index 616695f..8bb8a50 100644 --- a/src/apps/parsers/fns_upload.py +++ b/src/apps/parsers/fns_upload.py @@ -137,6 +137,55 @@ class FNSUploadService: return FNSUploadResult(queued=1, skipped=0, invalid=0, task_ids=[task.id]) + @classmethod + def queue_server_path( + cls, + *, + server_path: str, + requested_by_id: int | None, + ) -> FNSUploadResult: + """Queue an FNS file that already exists on the worker-visible disk.""" + from apps.parsers.tasks import process_fns_zip_archive + + path = cls._validate_server_path(server_path) + if path.suffix.lower() == ".zip": + task_name = "apps.parsers.tasks.process_fns_zip_archive" + task = process_fns_zip_archive + upload_type = "zip_server_path" + else: + task_name = "apps.parsers.tasks.process_fns_file" + task = process_fns_file + upload_type = "file_server_path" + + task_id = str(uuid.uuid4()) + try: + BackgroundJobService.create_job( + task_id=task_id, + task_name=task_name, + user_id=requested_by_id, + meta={ + "source": ParserLoadLog.Source.FNS_REPORTS, + "file": path.name, + "server_path": str(path), + "upload_type": upload_type, + }, + ) + async_result = task.apply_async( + args=[str(path)], + kwargs={"requested_by_id": requested_by_id}, + task_id=task_id, + ) + except Exception: + BackgroundJob.objects.filter(task_id=task_id).delete() + raise + + return FNSUploadResult( + queued=1, + skipped=0, + invalid=0, + task_ids=[async_result.id], + ) + @classmethod def queue_zip_archive_path( cls, @@ -240,6 +289,50 @@ class FNSUploadService: archive_file.seek(0) return archive_path + @classmethod + def _validate_server_path(cls, server_path: str) -> Path: + raw_path = Path(server_path) + if not raw_path.is_absolute(): + raise ValueError("Путь к файлу должен быть абсолютным") + + path = raw_path.resolve(strict=False) + allowed_roots = cls._allowed_server_path_roots() + if not any(cls._is_relative_to(path, root) for root in allowed_roots): + allowed = ", ".join(str(root) for root in allowed_roots) + raise ValueError(f"Путь должен находиться внутри: {allowed}") + + suffix = path.suffix.lower() + if suffix == ".zip": + return path + if suffix in {".xlsx", ".xlsm"} and FNS_XLSX_FILENAME_RE.match(path.name): + return path + raise ValueError( + "Поддерживаются ZIP архивы или Excel файлы формата " "fin_{id}_{ogrn}.xlsx" + ) + + @staticmethod + def _allowed_server_path_roots() -> list[Path]: + roots = [ + Path(settings.FNS_WATCH_DIRECTORY), + Path(settings.FNS_WATCH_DIRECTORY) / "archives", + ] + configured_archive_dir = getattr( + settings, + "FNS_ARCHIVE_UPLOAD_DIRECTORY", + None, + ) + if configured_archive_dir: + roots.append(Path(configured_archive_dir)) + return list(dict.fromkeys(root.resolve(strict=False) for root in roots)) + + @staticmethod + def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + @classmethod def _queue_file_bytes( cls, diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py index 81a2a1b..22e3800 100644 --- a/src/apps/parsers/serializers.py +++ b/src/apps/parsers/serializers.py @@ -367,6 +367,14 @@ class FNSZipUploadSerializer(serializers.Serializer): return value +class FNSServerPathUploadSerializer(serializers.Serializer): + """Сериализатор для запуска обработки файла, уже лежащего на сервере.""" + + server_path = serializers.CharField( + help_text="Абсолютный путь к ZIP/XLSX внутри FNS watch directory", + ) + + class FNSFileUploadSuccessSerializer(serializers.Serializer): """Ответ одиночной загрузки FNS в формате frontend.""" diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 7a46de1..db5e2b2 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -31,6 +31,7 @@ from apps.parsers.serializers import ( FinancialReportSerializer, FNSFileUploadSerializer, FNSFileUploadSuccessSerializer, + FNSServerPathUploadSerializer, FNSZipUploadSerializer, GenericParserRecordSerializer, IndustrialCertificateSerializer, @@ -74,7 +75,7 @@ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.exceptions import ValidationError -from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -904,7 +905,7 @@ class FNSReportUploadView(APIView): на обработку через Celery. """ - parser_classes = [MultiPartParser] + parser_classes = [MultiPartParser, JSONParser] permission_classes = [IsAdminUser] @swagger_auto_schema( @@ -923,8 +924,18 @@ class FNSReportUploadView(APIView): name="file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, - required=True, - description="Файл для загрузки (fin_*.xlsx)", + required=False, + description="Файл для загрузки (fin_*.xlsx или ZIP архив)", + ), + openapi.Parameter( + name="server_path", + in_=openapi.IN_FORM, + type=openapi.TYPE_STRING, + required=False, + description=( + "Абсолютный путь к fin_*.xlsx или ZIP на диске worker-а " + "внутри FNS watch directory" + ), ), ], consumes=["multipart/form-data"], @@ -956,6 +967,27 @@ class FNSReportUploadView(APIView): }, ) def post(self, request): # noqa + if "server_path" in request.data: + serializer = FNSServerPathUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = FNSUploadService.queue_server_path( + server_path=serializer.validated_data["server_path"], + requested_by_id=request.user.id, + ) + except ValueError as exc: + raise ValidationError({"server_path": str(exc)}) from exc + + return Response( + { + "queued": result.queued, + "skipped": result.skipped, + "invalid": result.invalid, + "task_ids": result.task_ids, + }, + status=status.HTTP_202_ACCEPTED, + ) + uploaded_file = request.FILES.get("file") if uploaded_file and uploaded_file.name.lower().endswith(".zip"): serializer = FNSZipUploadSerializer(data=request.data) diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index 8a951b2..c9c22ed 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -1538,6 +1538,12 @@ + ${source.key === "fns_financial" ? ` +