feat(parsers): implement arbitration and upload fixes
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 19s
CI/CD Pipeline / Build and Push Images (push) Successful in 6s
CI/CD Pipeline / Internal Notify (push) Successful in 1s
CI/CD Pipeline / Deploy Dev in Dokploy (push) Successful in 2s

This commit is contained in:
2026-04-29 00:52:12 +02:00
parent db824bf4fa
commit 8e29b9902d
7 changed files with 578 additions and 16 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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

View File

@@ -850,6 +850,7 @@
</div>
<span id="uploadCount" class="badge"></span>
</div>
<div id="manualUploadStatus" class="empty-state hidden"></div>
<div id="uploadSources" class="source-list"></div>
</section>
@@ -870,6 +871,7 @@
<div class="row">
<button type="submit">Загрузить организации ОПК</button>
<span class="muted">Запрос уйдет в <code>/api/v1/registers/upload/</code></span>
<span id="registryUploadStatus" class="muted"></span>
</div>
</form>
<div id="registrySummary" class="source-list"></div>
@@ -1987,9 +1989,16 @@
const formData = new FormData(event.target);
const file = formData.get("file");
if (!file || !file.name) return;
$("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);
$("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) {

View File

@@ -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(

View File

@@ -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")