diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py index 2ff2be2..e3c154e 100644 --- a/src/apps/parsers/services.py +++ b/src/apps/parsers/services.py @@ -802,6 +802,9 @@ class GenericParserRecordService(BulkOperationsMixin, BaseService[GenericParserR external_id__in=unique_records.keys(), ).values_list("external_id", flat=True) ) + registry_lookup = RegistryOrganizationResolver.build_lookup( + [(record.inn, record.ogrn) for record in unique_records.values()] + ) instances = [ cls.model( load_batch=batch_id, @@ -816,6 +819,11 @@ class GenericParserRecordService(BulkOperationsMixin, BaseService[GenericParserR status=record.status, url=record.url, payload=record.payload, + registry_organization_id=RegistryOrganizationResolver.resolve_organization_id( + lookup=registry_lookup, + inn=record.inn, + ogrn=record.ogrn, + ), ) for external_id, record in unique_records.items() if external_id not in existing_external_ids diff --git a/src/apps/parsers/source_registry.py b/src/apps/parsers/source_registry.py index 9e20141..8843a8c 100644 --- a/src/apps/parsers/source_registry.py +++ b/src/apps/parsers/source_registry.py @@ -234,7 +234,12 @@ PARSER_SOURCES: dict[str, ParserSourceDescriptor] = { status="implemented", upstream_url="https://kad.arbitr.ru/", access_method="official_search_api", - parser_strategy="kad_arbitr_search", + parser_strategy="checko_legal_cases_by_inn_ogrn", + source_notes=( + "Поиск дел выполняется по ИНН/ОГРН организаций из реестров; " + "если реестр пустой, используются ИНН из уже загруженных источников. " + "Checko отдаёт карточки со ссылками на КАД Арбитр." + ), api_route="arbitration/cases", ), "fedresurs_bankruptcy": ParserSourceDescriptor( diff --git a/src/apps/parsers/tasks.py b/src/apps/parsers/tasks.py index 20b99a1..71fff2f 100644 --- a/src/apps/parsers/tasks.py +++ b/src/apps/parsers/tasks.py @@ -10,13 +10,15 @@ import logging import shutil import time import uuid +from dataclasses import dataclass from datetime import datetime +from decimal import Decimal from pathlib import Path from apps.core.services import BackgroundJobService from apps.core.tasks import PeriodicTask as CorePeriodicTask from apps.parsers.clients.base import HTTPClientError -from apps.parsers.clients.checko import CheckoClient, CompanyRequest +from apps.parsers.clients.checko import CheckoClient, CompanyRequest, LegalCasesRequest from apps.parsers.clients.checko.exceptions import CheckoError from apps.parsers.clients.common import GenericParserItem, StructuredDataClient from apps.parsers.clients.minpromtorg import ( @@ -27,7 +29,16 @@ from apps.parsers.clients.minpromtorg import ( from apps.parsers.clients.proverki import ProverkiClient from apps.parsers.clients.trudvsem import TrudvsemClient from apps.parsers.clients.zakupki import ZakupkiClient -from apps.parsers.models import ParserLoadLog +from apps.parsers.models import ( + FinancialReport, + GenericParserRecord, + IndustrialCertificateRecord, + IndustrialProductRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + ProcurementRecord, +) from apps.parsers.services import ( FNSReportService, GenericParserRecordService, @@ -56,6 +67,7 @@ STRUCTURED_SOURCE_OPTIONS = { "fedresurs_bankruptcy": {"timeout": 30}, } FEDRESURS_CHECKO_FALLBACK_LIMIT = 100 +ARBITRATION_CHECKO_LIMIT = 100 PARSER_STALE_LOAD_MAX_AGE_MINUTES = 90 PARSER_SOFT_TIME_LIMIT_SECONDS = 15 * 60 PARSER_TIME_LIMIT_SECONDS = 20 * 60 @@ -392,6 +404,320 @@ def _fedresurs_external_id( return f"checko-fedresurs:{digest}" +@dataclass(frozen=True) +class ArbitrationSubject: + """Организация, по которой можно запросить арбитражные дела.""" + + inn: str + ogrn: str + name: str + registry_organization_id: int | None = None + + +def _normalize_identifier(value) -> str: + """Вернуть только цифровой идентификатор или пустую строку.""" + if value is None: + return "" + value = str(value).strip() + return value if value.isdigit() else "" + + +def _arbitration_subject_key(subject: ArbitrationSubject) -> str: + """Стабильный ключ субъекта для дедупликации запросов.""" + return subject.inn or subject.ogrn + + +def _add_arbitration_subject( + subjects: dict[str, ArbitrationSubject], + *, + inn, + ogrn, + name, + registry_organization_id: int | None = None, + limit: int, +) -> None: + """Добавить subject, если по нему есть ИНН/ОГРН и лимит не исчерпан.""" + if len(subjects) >= limit: + return + + subject = ArbitrationSubject( + inn=_normalize_identifier(inn), + ogrn=_normalize_identifier(ogrn), + name=str(name or "").strip(), + registry_organization_id=registry_organization_id, + ) + key = _arbitration_subject_key(subject) + if key: + subjects.setdefault(key, subject) + + +def _extend_arbitration_subjects_from_model( + subjects: dict[str, ArbitrationSubject], + *, + model, + limit: int, + inn_field: str | None, + ogrn_field: str | None, + name_field: str | None, +) -> None: + """Добрать организации из уже загруженных parser-таблиц.""" + if len(subjects) >= limit: + return + + fields = [ + field + for field in (inn_field, ogrn_field, name_field, "registry_organization_id") + if field + ] + queryset = model.objects.all().order_by("-updated_at") + if inn_field: + queryset = queryset.exclude(**{inn_field: ""}) + elif ogrn_field: + queryset = queryset.exclude(**{ogrn_field: ""}) + + # Берем небольшой запас: в источниках часто несколько строк на один ИНН. + row_limit = max((limit - len(subjects)) * 5, 10) + for row in queryset.values(*fields)[:row_limit]: + _add_arbitration_subject( + subjects, + inn=row.get(inn_field) if inn_field else "", + ogrn=row.get(ogrn_field) if ogrn_field else "", + name=row.get(name_field) if name_field else "", + registry_organization_id=row.get("registry_organization_id"), + limit=limit, + ) + if len(subjects) >= limit: + return + + +def _arbitration_subjects(limit: int) -> list[ArbitrationSubject]: + """Собрать организации для арбитражного lookup.""" + subjects: dict[str, ArbitrationSubject] = {} + + for organization in Organization.objects.order_by("pn_name").values( + "id", + "mn_inn", + "mn_ogrn", + "pn_name", + )[:limit]: + _add_arbitration_subject( + subjects, + inn=organization["mn_inn"], + ogrn=organization["mn_ogrn"], + name=organization["pn_name"], + registry_organization_id=organization["id"], + limit=limit, + ) + + fallbacks = ( + (ManufacturerRecord, "inn", "ogrn", "full_legal_name"), + (IndustrialCertificateRecord, "inn", "ogrn", "organisation_name"), + (IndustrialProductRecord, "inn", "ogrn", "full_organisation_name"), + (InspectionRecord, "inn", "ogrn", "organisation_name"), + (ProcurementRecord, "customer_inn", "customer_ogrn", "customer_name"), + (GenericParserRecord, "inn", "ogrn", "organisation_name"), + (FinancialReport, None, "ogrn", None), + ) + for model, inn_field, ogrn_field, name_field in fallbacks: + _extend_arbitration_subjects_from_model( + subjects, + model=model, + inn_field=inn_field, + ogrn_field=ogrn_field, + name_field=name_field, + limit=limit, + ) + if len(subjects) >= limit: + break + + return list(subjects.values()) + + +def _resolve_arbitration_limit(limit: int | None) -> int: + if limit is None: + limit = getattr(settings, "ARBITRATION_CHECKO_LIMIT", ARBITRATION_CHECKO_LIMIT) + try: + resolved = int(limit) + except (TypeError, ValueError): + resolved = ARBITRATION_CHECKO_LIMIT + return max(resolved, 0) + + +def _fetch_checko_arbitration_records( + *, + limit: int | None, + proxies: list[str] | None, +) -> list[GenericParserItem]: + """Получить арбитражные дела по ИНН/ОГРН через Checko legal-cases API.""" + api_key = getattr(settings, "CHECKO_API_KEY", "") + if not api_key: + raise ParserSourceSkipped("CHECKO_API_KEY is empty; arbitration parser skipped") + + resolved_limit = _resolve_arbitration_limit(limit) + if resolved_limit <= 0: + logger.info("Arbitration Checko parser is disabled by limit=%s", limit) + return [] + + subjects = _arbitration_subjects(resolved_limit) + if not subjects: + raise ParserSourceSkipped( + "no registry organizations or parser identifiers found for arbitration" + ) + + checko_proxies = ( + proxies if getattr(settings, "CHECKO_USE_RUNTIME_PROXIES", False) else None + ) + client = CheckoClient(api_key=api_key, proxies=checko_proxies, timeout=30) + records: list[GenericParserItem] = [] + failed_lookups = 0 + for subject in subjects: + try: + request = LegalCasesRequest( + inn=subject.inn or None, + ogrn=subject.ogrn or None, + limit=100, + ) + for legal_case in client.iter_legal_cases(request): + records.append(_checko_arbitration_item(legal_case, subject=subject)) + except CheckoError as exc: + failed_lookups += 1 + logger.info( + "Checko arbitration lookup skipped for subject=%s: %s", + _arbitration_subject_key(subject), + exc, + ) + + if failed_lookups == len(subjects) and not records: + raise ParserSourceSkipped("Checko arbitration lookups failed for all subjects") + + logger.info( + "Fetched %d arbitration records through Checko for %d subjects", + len(records), + len(subjects), + ) + return records + + +def _checko_arbitration_item(case, *, subject: ArbitrationSubject) -> GenericParserItem: + """Преобразовать дело Checko в generic record.""" + case_number = getattr(case, "case_number", "") or "" + filing_date = getattr(case, "filing_date", "") or "" + role = _case_role_for_subject(case, subject) + external_id = _arbitration_external_id( + subject_key=_arbitration_subject_key(subject), + case_number=case_number, + filing_date=filing_date, + role=role, + ) + title = case_number or getattr(case, "category", "") or "Арбитражное дело" + amount = _decimal_or_none(getattr(case, "claim_amount", None)) + return GenericParserItem( + source="arbitration", + external_id=external_id, + inn=subject.inn, + ogrn=subject.ogrn, + organisation_name=subject.name, + title=title, + record_date=filing_date or getattr(case, "result_date", "") or "", + amount=amount, + status=getattr(case, "status", "") or "", + url=getattr(case, "url", "") or "", + payload={ + "provider": "checko", + "declared_source": "КАД Арбитр", + "target": { + "inn": subject.inn, + "ogrn": subject.ogrn, + "name": subject.name, + "registry_organization_id": subject.registry_organization_id, + "role": role, + }, + "case_number": case_number, + "court_name": getattr(case, "court_name", None), + "type": getattr(case, "type", None), + "category": getattr(case, "category", None), + "status": getattr(case, "status", None), + "filing_date": filing_date, + "result_date": getattr(case, "result_date", None), + "claim_amount": getattr(case, "claim_amount", None), + "awarded_amount": getattr(case, "awarded_amount", None), + "plaintiffs": _case_parties_payload(getattr(case, "plaintiffs", ())), + "defendants": _case_parties_payload(getattr(case, "defendants", ())), + "third_parties": _case_parties_payload(getattr(case, "third_parties", ())), + "instances": _case_instances_payload(getattr(case, "instances", ())), + "url": getattr(case, "url", None), + }, + ) + + +def _decimal_or_none(value) -> Decimal | None: + if value in (None, ""): + return None + try: + return Decimal(str(value)) + except Exception: + return None + + +def _case_role_for_subject(case, subject: ArbitrationSubject) -> str: + party_groups = ( + ("plaintiff", getattr(case, "plaintiffs", ())), + ("defendant", getattr(case, "defendants", ())), + ("third_party", getattr(case, "third_parties", ())), + ) + for role, parties in party_groups: + for party in parties or (): + if _party_matches_subject(party, subject): + return role + return "unknown" + + +def _party_matches_subject(party, subject: ArbitrationSubject) -> bool: + party_inn = _normalize_identifier(getattr(party, "inn", None)) + party_ogrn = _normalize_identifier(getattr(party, "ogrn", None)) + return bool( + (subject.inn and party_inn == subject.inn) + or (subject.ogrn and party_ogrn == subject.ogrn) + ) + + +def _case_parties_payload(parties) -> list[dict]: + return [ + { + "name": getattr(party, "name", None), + "inn": getattr(party, "inn", None), + "ogrn": getattr(party, "ogrn", None), + "role": getattr(party, "role", None), + } + for party in parties or () + ] + + +def _case_instances_payload(instances) -> list[dict]: + return [ + { + "number": getattr(instance, "number", None), + "court_name": getattr(instance, "court_name", None), + "judge": getattr(instance, "judge", None), + "result": getattr(instance, "result", None), + "date": getattr(instance, "date", None), + } + for instance in instances or () + ] + + +def _arbitration_external_id( + *, + subject_key: str, + case_number: str, + filing_date: str, + role: str, +) -> str: + raw = f"{subject_key}:{case_number}:{filing_date}:{role}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + return f"checko-arbitration:{digest}" + + @shared_task(bind=True, base=CorePeriodicTask) def sync_ru_proxies(self) -> dict[str, int | str]: # noqa: ARG001 """Периодически загружать RU-прокси из Proxy-Tools.""" @@ -1860,23 +2186,37 @@ def parse_arbitration_cases( *, file_url: str | None = None, file_path: str | None = None, + limit: int | None = None, proxies: list[str] | None = None, requested_by_id: int | None = None, ) -> dict: - """Парсинг выдачи КАД/арбитража в generic storage.""" + """Парсинг арбитражных дел по организациям в generic storage.""" proxies = _resolve_proxies(proxies) + if file_url or file_path: + + def fetch_records(): + return _fetch_structured_records( + source_key="arbitration", + file_url=file_url, + file_path=file_path, + proxies=proxies, + ) + + else: + + def fetch_records(): + return _fetch_checko_arbitration_records( + limit=limit, + proxies=proxies, + ) + return _run_generic_parser( self, source_key="arbitration", source=ParserLoadLog.Source.ARBITRATION, task_name="apps.parsers.tasks.parse_arbitration_cases", requested_by_id=requested_by_id, - fetch_records=lambda: _fetch_structured_records( - source_key="arbitration", - file_url=file_url, - file_path=file_path, - proxies=proxies, - ), + fetch_records=fetch_records, ) diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index e40b446..af77437 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, + FNSZipUploadSerializer, GenericParserRecordSerializer, IndustrialCertificateSerializer, IndustrialProductSerializer, @@ -172,6 +173,13 @@ TRUDVSEM_PARAMS = { "requested_by_id", } GENERIC_FILE_PARAMS = {"file_url", "file_path", "proxies", "requested_by_id"} +ARBITRATION_PARAMS = { + "file_url", + "file_path", + "limit", + "proxies", + "requested_by_id", +} PAGE_PARAM = openapi.Parameter( "page", openapi.IN_QUERY, @@ -936,6 +944,28 @@ class FNSReportUploadView(APIView): }, ) def post(self, request): # noqa + uploaded_file = request.FILES.get("file") + if uploaded_file and uploaded_file.name.lower().endswith(".zip"): + serializer = FNSZipUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = FNSUploadService.queue_zip_archive( + archive_file=serializer.validated_data["file"], + requested_by_id=request.user.id, + ) + except ValueError as exc: + raise ValidationError({"file": 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, + ) + serializer = FNSFileUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -1307,6 +1337,8 @@ class ParserLoadLogExportView(APIView): def _allowed_task_params(source_key: str) -> set[str]: if source_key in EXISTING_TASK_PARAMS: return EXISTING_TASK_PARAMS[source_key] + if source_key == "arbitration": + return ARBITRATION_PARAMS if source_key == "trudvsem": return TRUDVSEM_PARAMS return GENERIC_FILE_PARAMS diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index f19a08b..8422c89 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -850,6 +850,7 @@ +
@@ -870,6 +871,7 @@
Запрос уйдет в /api/v1/registers/upload/ +
@@ -1987,9 +1989,16 @@ const formData = new FormData(event.target); const file = formData.get("file"); if (!file || !file.name) return; - await apiUpload("/api/v1/registers/upload/", formData); - event.target.querySelector('input[type="file"]').value = ""; - await refreshDashboard(); + $("registryUploadStatus").textContent = "Загрузка..."; + try { + await apiUpload("/api/v1/registers/upload/", formData); + event.target.querySelector('input[type="file"]').value = ""; + $("registryUploadStatus").textContent = "Файл загружен"; + await refreshDashboard(); + showMainTab("uploads"); + } catch (error) { + $("registryUploadStatus").textContent = errorMessage(error); + } }); $("backupExportForm").addEventListener("submit", async (event) => { @@ -2121,9 +2130,20 @@ formData.append("file", fileInput.files[0]); const source = sourceByKey(target.dataset.upload); const uploadUrl = source?.upload_url || `/api/v1/parsers/upload/${target.dataset.upload}/`; - await apiUpload(uploadUrl, formData); - fileInput.value = ""; - await refreshDashboard(); + $("manualUploadStatus").classList.remove("hidden"); + $("manualUploadStatus").textContent = "Загрузка..."; + try { + const result = await apiUpload(uploadUrl, formData); + fileInput.value = ""; + const queued = result.queued ?? result.data?.queued; + $("manualUploadStatus").textContent = queued !== undefined + ? `Файл принят: queued=${queued}, skipped=${result.skipped ?? 0}, invalid=${result.invalid ?? 0}` + : "Файл загружен"; + await refreshDashboard(); + showMainTab("uploads"); + } catch (error) { + $("manualUploadStatus").textContent = errorMessage(error); + } } const recordRow = target.closest("[data-record-id]"); if (recordRow) { diff --git a/tests/apps/parsers/test_tasks.py b/tests/apps/parsers/test_tasks.py index 2eb8e8f..240c397 100644 --- a/tests/apps/parsers/test_tasks.py +++ b/tests/apps/parsers/test_tasks.py @@ -31,6 +31,7 @@ from apps.parsers.clients.proverki.client import ProverkiClientError from apps.parsers.clients.zakupki import ZakupkiClientError from apps.parsers.models import ( FinancialReport, + GenericParserRecord, IndustrialCertificateRecord, IndustrialProductRecord, InspectionRecord, @@ -66,6 +67,7 @@ from registers.models import Organization from tests.apps.parsers.factories import ( InspectionRecordFactory, + ManufacturerRecordFactory, ParserLoadLogFactory, ProcurementRecordFactory, ProxyFactory, @@ -246,6 +248,124 @@ class GenericSourceFetchTestCase(TestCase): self.assertEqual(records[0].record_date, "2026-04-01") self.assertEqual(records[0].payload["provider"], "checko") + @override_settings(CHECKO_API_KEY="test-key", ARBITRATION_CHECKO_LIMIT=10) + def test_arbitration_fetches_checko_legal_cases_for_registry_organizations(self): + organization = Organization.objects.create( + pn_name='ООО "Арбитраж"', + mn_ogrn=1027700000001, + mn_inn=7701000002, + in_kpp=770101001, + mn_okpo="12345678", + ) + + class _CheckoClient: + instances = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.requests = [] + self.instances.append(self) + + def iter_legal_cases(self, request): + self.requests.append(request) + return iter( + [ + SimpleNamespace( + case_number="А40-1/2026", + court_name="АС города Москвы", + type="civil", + category="Взыскание задолженности", + status="Рассмотрено", + filing_date="2026-04-01", + result_date="2026-04-20", + claim_amount=150000, + awarded_amount=100000, + plaintiffs=( + SimpleNamespace( + name=organization.pn_name, + inn=str(organization.mn_inn), + ogrn=str(organization.mn_ogrn), + role="plaintiff", + ), + ), + defendants=(), + third_parties=(), + instances=(), + url="https://kad.arbitr.ru/Card/1", + ) + ] + ) + + with patch.object(parser_tasks, "CheckoClient", _CheckoClient): + result = parser_tasks.parse_arbitration_cases(limit=10, proxies=[]) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["saved"], 1) + record = GenericParserRecord.objects.get( + source=ParserLoadLog.Source.ARBITRATION + ) + self.assertEqual(record.registry_organization_id, organization.id) + self.assertEqual(record.inn, str(organization.mn_inn)) + self.assertEqual(record.ogrn, str(organization.mn_ogrn)) + self.assertEqual(record.amount, 150000) + self.assertEqual(record.payload["provider"], "checko") + self.assertEqual(record.payload["target"]["role"], "plaintiff") + self.assertEqual(_CheckoClient.instances[0].requests[0].inn, record.inn) + + @override_settings(CHECKO_API_KEY="test-key") + def test_arbitration_uses_loaded_parser_identifiers_when_registry_empty(self): + manufacturer = ManufacturerRecordFactory( + inn="7701000003", + ogrn="1027700000002", + full_legal_name='ООО "Источник"', + ) + + class _CheckoClient: + def __init__(self, **_kwargs): + return + + def iter_legal_cases(self, request): + self.request = request + return iter( + [ + SimpleNamespace( + case_number="А40-2/2026", + court_name="АС города Москвы", + type=None, + category=None, + status="Принято", + filing_date="2026-04-02", + result_date=None, + claim_amount=None, + awarded_amount=None, + plaintiffs=(), + defendants=( + SimpleNamespace( + name=manufacturer.full_legal_name, + inn=manufacturer.inn, + ogrn=manufacturer.ogrn, + role="defendant", + ), + ), + third_parties=(), + instances=(), + url="https://kad.arbitr.ru/Card/2", + ) + ] + ) + + with patch.object(parser_tasks, "CheckoClient", _CheckoClient): + records = parser_tasks._fetch_checko_arbitration_records( + limit=1, + proxies=[], + ) + + self.assertEqual(len(records), 1) + self.assertEqual(records[0].source, "arbitration") + self.assertEqual(records[0].inn, manufacturer.inn) + self.assertEqual(records[0].ogrn, manufacturer.ogrn) + self.assertEqual(records[0].payload["target"]["role"], "defendant") + @override_settings(CHECKO_API_KEY="") def test_fedresurs_skips_when_official_blocked_and_fallback_empty(self): with patch.object( diff --git a/tests/apps/parsers/test_views.py b/tests/apps/parsers/test_views.py index 072051e..927477e 100644 --- a/tests/apps/parsers/test_views.py +++ b/tests/apps/parsers/test_views.py @@ -5,6 +5,7 @@ from __future__ import annotations import io import os import tempfile +import zipfile from unittest.mock import Mock, patch from apps.parsers.models import ( @@ -50,6 +51,14 @@ def _build_fns_excel_bytes() -> bytes: return buf.getvalue() +def _build_fns_zip_bytes(file_map: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_name, content in file_map.items(): + archive.writestr(file_name, content) + return buf.getvalue() + + def _create_procurement_record() -> ProcurementRecord: return ProcurementRecord.objects.create( load_batch=fake.random_int(min=1, max=1000), @@ -411,6 +420,34 @@ class ParsersViewSetTest(APITestCase): self.assertEqual(response.data["success"], True) self.assertIn("message", response.data) + def test_fns_upload_accepts_zip_payload(self): + self.client.force_authenticate(self.admin) + external_id = _digits(5) + ogrn = _digits(13) + filename = f"fin_{external_id}_{ogrn}.xlsx" + upload = SimpleUploadedFile( + "fns_reports.zip", + _build_fns_zip_bytes({filename: _build_fns_excel_bytes()}), + content_type="application/zip", + ) + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir = os.path.join(tmpdir, "watch") + processed_dir = os.path.join(tmpdir, "processed") + failed_dir = os.path.join(tmpdir, "failed") + with self.settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + url = reverse("api_v1:fns:fns-upload") + response = self.client.post(url, {"file": upload}, format="multipart") + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["queued"], 1) + self.assertEqual(response.data["skipped"], 0) + self.assertEqual(response.data["invalid"], 0) + self.assertEqual(FinancialReport.objects.count(), 1) + def test_parsing_settings_get_and_patch(self): self.client.force_authenticate(self.admin) url = reverse("api_v1:parsing:parsing-settings")