diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5717a2b..bdb5690 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,50 @@
---
+## [0.3.0] - 2026-01-27
+
+### Добавлено
+
+#### Парсер zakupki.gov.ru (`apps.parsers.clients.zakupki`)
+- **ZakupkiClient** — клиент для получения данных о закупках:
+ - Интеграция через SOAP API (FTP закрыт с 01.01.2025)
+ - Методы: `getDocsByOrgRegionRequest`, `getDocsByReestrNumberRequest`
+ - Парсинг XML/ZIP архивов с поддержкой множественных кодировок (UTF-8, Windows-1251)
+ - Поддержка прокси-серверов
+ - Маппинг 80+ регионов РФ
+
+- **Модель ProcurementRecord** (`models.py`):
+ - 18 полей: номер закупки, ИНН/КПП/ОГРН заказчика, НМЦ, тип закона (44-ФЗ/223-ФЗ), статус
+ - Поля региона, года, месяца для фильтрации
+ - `load_batch` для отслеживания пакетной загрузки
+ - 3 индекса для оптимизации запросов
+
+- **Сервисный слой** (`services.py`):
+ - `ProcurementService` — сохранение, поиск, отслеживание загрузок
+ - `ParserLoadLogService` — логирование результатов парсинга
+ - Bulk-операции с chunking и обработкой дубликатов
+
+- **Celery задачи** (`tasks.py`):
+ - `parse_procurements` — загрузка по региону/году/месяцу с BackgroundJob tracking
+ - `sync_procurements` — синхронизация помесячно с автопродолжением
+
+- **Админка** (`admin.py`):
+ - Цветовая индикация статусов
+ - Поиск по номеру закупки, ИНН, ОГРН, названию заказчика
+ - Фильтры: тип закона, статус, регион, batch, дата создания
+ - Read-only режим
+
+#### Тестирование
+- 71 тест (66 unit + 5 E2E)
+- `ProcurementRecordFactory` с Faker("ru_RU")
+- E2E тесты с реальными HTTP-запросами (активация: `RUN_E2E_TESTS=1`)
+- Покрытие: клиент, сервисы, задачи
+
+### Требования для работы
+- Токен SOAP API (получается через Госуслуги на `https://zakupki.gov.ru/pmd/auth/welcome`)
+
+---
+
## [0.2.0] - 2026-01-21
### Добавлено
diff --git a/pyproject.toml b/pyproject.toml
index 5ed9f1a..1bfa31c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,6 +50,7 @@ dependencies = [
"openpyxl>=3.1.5",
"django-jazzmin>=2.6.2",
"playwright>=1.57.0",
+ "pylint>=3.0",
]
[project.optional-dependencies]
diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py
index 1a8741c..cc8f9bd 100644
--- a/src/apps/parsers/admin.py
+++ b/src/apps/parsers/admin.py
@@ -7,6 +7,7 @@ from apps.parsers.models import (
InspectionRecord,
ManufacturerRecord,
ParserLoadLog,
+ ProcurementRecord,
Proxy,
)
from django.contrib import admin
@@ -385,3 +386,137 @@ class InspectionRecordAdmin(admin.ModelAdmin):
def has_change_permission(self, request, obj=None):
"""Запретить редактирование записей."""
return False
+
+
+@admin.register(ProcurementRecord)
+class ProcurementRecordAdmin(admin.ModelAdmin):
+ """Admin для государственных закупок."""
+
+ list_display = [
+ "purchase_number",
+ "purchase_name_short",
+ "customer_inn",
+ "customer_name_short",
+ "max_price",
+ "law_type",
+ "status_badge",
+ "publish_date",
+ "load_batch",
+ ]
+ list_filter = [
+ "law_type",
+ "status",
+ "region_code",
+ "load_batch",
+ "created_at",
+ ]
+ search_fields = [
+ "purchase_number",
+ "purchase_name",
+ "customer_inn",
+ "customer_ogrn",
+ "customer_name",
+ ]
+ readonly_fields = ["created_at", "updated_at", "load_batch"]
+ ordering = ["-created_at"]
+ list_per_page = 100
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (
+ "Закупка",
+ {
+ "fields": (
+ "purchase_number",
+ "purchase_name",
+ "purchase_object_info",
+ "law_type",
+ "status",
+ )
+ },
+ ),
+ (
+ "Заказчик",
+ {
+ "fields": (
+ "customer_name",
+ "customer_inn",
+ "customer_kpp",
+ "customer_ogrn",
+ )
+ },
+ ),
+ (
+ "Финансы",
+ {"fields": ("max_price", "currency_code", "placement_method")},
+ ),
+ (
+ "Сроки",
+ {"fields": ("publish_date", "end_date")},
+ ),
+ (
+ "Дополнительно",
+ {"fields": ("region_code", "href"), "classes": ("collapse",)},
+ ),
+ (
+ "Системное",
+ {
+ "fields": (
+ "load_batch",
+ "data_year",
+ "data_month",
+ "created_at",
+ "updated_at",
+ ),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def purchase_name_short(self, obj):
+ """Сокращённое наименование закупки."""
+ name = obj.purchase_name or ""
+ return name[:50] + "..." if len(name) > 50 else name
+
+ purchase_name_short.short_description = "Наименование"
+ purchase_name_short.admin_order_field = "purchase_name"
+
+ def customer_name_short(self, obj):
+ """Сокращённое наименование заказчика."""
+ name = obj.customer_name or ""
+ return name[:30] + "..." if len(name) > 30 else name
+
+ customer_name_short.short_description = "Заказчик"
+ customer_name_short.admin_order_field = "customer_name"
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ status = obj.status or ""
+ status_lower = status.lower()
+
+ if "опублик" in status_lower or "подача" in status_lower:
+ color = "#28a745"
+ elif "завершен" in status_lower or "состоял" in status_lower:
+ color = "#17a2b8"
+ elif "отменен" in status_lower or "не состоял" in status_lower:
+ color = "#dc3545"
+ else:
+ color = "#6c757d"
+
+ return format_html(
+ '{}',
+ color,
+ status[:20] if len(status) > 20 else status,
+ )
+
+ status_badge.short_description = "Статус"
+ status_badge.admin_order_field = "status"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
diff --git a/src/apps/parsers/clients/__init__.py b/src/apps/parsers/clients/__init__.py
index e2cb352..946f3f6 100644
--- a/src/apps/parsers/clients/__init__.py
+++ b/src/apps/parsers/clients/__init__.py
@@ -13,10 +13,12 @@ from apps.parsers.clients.minpromtorg import (
ManufacturesClient,
)
from apps.parsers.clients.proverki import ProverkiClient
+from apps.parsers.clients.zakupki import ZakupkiClient
__all__ = [
"BaseHTTPClient",
"IndustrialProductionClient",
"ManufacturesClient",
"ProverkiClient",
+ "ZakupkiClient",
]
diff --git a/src/apps/parsers/clients/zakupki/__init__.py b/src/apps/parsers/clients/zakupki/__init__.py
new file mode 100644
index 0000000..c43ae34
--- /dev/null
+++ b/src/apps/parsers/clients/zakupki/__init__.py
@@ -0,0 +1,863 @@
+"""
+Клиент для парсинга данных с zakupki.gov.ru.
+
+Источник: Единая информационная система в сфере закупок (ЕИС).
+
+Стратегия получения данных:
+1. SOAP API через int44.zakupki.gov.ru (основной метод с 01.01.2025)
+2. Парсинг XML файлов из архивов
+
+Примечание:
+ FTP доступ закрыт с 1 января 2025 года.
+ Для работы требуется токен, который можно получить через Госуслуги.
+"""
+
+import io
+import logging
+import re
+import uuid
+import zipfile
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from datetime import datetime
+from xml.etree import ( # noqa: S314 - XML parsing with proper error handling
+ ElementTree as ET,
+)
+
+from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
+from apps.parsers.clients.zakupki.schemas import Procurement, ProcurementPlan
+
+logger = logging.getLogger(__name__)
+
+# SOAP API конфигурация
+SOAP_API_URL = "https://int44.zakupki.gov.ru/eis-integration/services/getDocsIP"
+SOAP_NAMESPACE = "http://zakupki.gov.ru/fz44/get-docs-ip/ws"
+
+# HTTP конфигурация (fallback для прямых ссылок)
+DEFAULT_HOST = "zakupki.gov.ru"
+
+# Типы подсистем
+SUBSYSTEM_TYPES = {
+ "44": "PRIZ", # 44-ФЗ Закупки
+ "223": "OOS223", # 223-ФЗ
+}
+
+# Типы документов для 44-ФЗ
+DOCUMENT_TYPES_44 = {
+ "notification": "epNotificationEF2020", # Извещения электронного аукциона
+ "notification_ok": "epNotificationOK2020", # Открытый конкурс
+ "notification_zk": "epNotificationZK2020", # Запрос котировок
+ "contract": "contract", # Контракты
+}
+
+
+class ZakupkiClientError(HTTPClientError):
+ """Ошибка клиента zakupki.gov.ru."""
+
+ pass
+
+
+@dataclass
+class ZakupkiClient:
+ """
+ Клиент для получения данных о закупках с zakupki.gov.ru.
+
+ Полностью изолирован от Django. Все настройки передаются через конструктор.
+
+ Стратегия работы:
+ 1. Отправляет SOAP запрос на int44.zakupki.gov.ru
+ 2. Получает URL архива с данными
+ 3. Скачивает и парсит XML файлы из архива
+
+ Использование:
+ client = ZakupkiClient(token="your-token-from-gosuslugi")
+ procurements = client.fetch_procurements(region_code="77", year=2025)
+
+ for proc in procurements:
+ print(proc.purchase_number, proc.customer_inn)
+
+ Примечание:
+ Для работы требуется токен, который можно получить на:
+ https://zakupki.gov.ru/pmd/auth/welcome
+ через авторизацию в Госуслугах.
+ """
+
+ token: str | None = None # Токен для SOAP API (обязателен для работы)
+ proxies: list[str] | None = None
+ host: str = DEFAULT_HOST
+ timeout: int = 120
+ _http_client: BaseHTTPClient | None = field(default=None, repr=False)
+
+ def __post_init__(self) -> None:
+ """Инициализация клиента."""
+ self._http_client = None
+
+ @property
+ def http_client(self) -> BaseHTTPClient:
+ """Ленивая инициализация HTTP клиента."""
+ if self._http_client is None:
+ self._http_client = BaseHTTPClient(
+ base_url=f"https://{self.host}",
+ proxies=self.proxies,
+ timeout=self.timeout,
+ headers={
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ ),
+ "Accept": "application/json, application/xml, text/html, */*",
+ "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
+ },
+ )
+ return self._http_client
+
+ def fetch_procurements(
+ self,
+ *,
+ region_code: str | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ file_url: str | None = None,
+ law_type: str = "44",
+ reestr_number: str | None = None,
+ document_type: str = "notification",
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """
+ Получить список закупок.
+
+ Args:
+ region_code: Код региона (например, "77" для Москвы)
+ year: Год данных
+ month: Месяц (опционально)
+ file_url: Прямая ссылка на файл данных (HTTP URL)
+ law_type: Тип закона ("44" или "223")
+ reestr_number: Реестровый номер закупки (для точечного запроса)
+ document_type: Тип документа (notification, contract, etc.)
+ progress_callback: Callback для отчёта о прогрессе (percent, message)
+
+ Returns:
+ Список закупок
+
+ Raises:
+ ZakupkiClientError: При ошибке получения данных
+ """
+ logger.info(
+ "Fetching procurements (region=%s, year=%s, month=%s, law=%s-FZ)",
+ region_code,
+ year,
+ month,
+ law_type,
+ )
+
+ if progress_callback:
+ progress_callback(0, "Инициализация...")
+
+ try:
+ # Если передан прямой HTTP URL - скачиваем через HTTP
+ if file_url and file_url.startswith("http"):
+ return self._download_and_parse_http(file_url, progress_callback)
+
+ # Если есть токен - используем SOAP API
+ if self.token:
+ return self._fetch_via_soap(
+ region_code=region_code,
+ year=year,
+ month=month,
+ day=None,
+ law_type=law_type,
+ reestr_number=reestr_number,
+ document_type=document_type,
+ progress_callback=progress_callback,
+ )
+
+ # Без токена - пробуем HTTP fallback (может не работать)
+ logger.warning("No token provided, trying HTTP fallback")
+ return self._fetch_via_http(
+ region_code=region_code,
+ year=year,
+ month=month,
+ law_type=law_type,
+ progress_callback=progress_callback,
+ )
+
+ except HTTPClientError:
+ raise
+ except Exception as e:
+ logger.error("Error fetching procurements: %s", e)
+ raise ZakupkiClientError(f"Failed to fetch procurements: {e}") from e
+
+ def _fetch_via_soap( # noqa: C901
+ self,
+ *,
+ region_code: str | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ day: int | None = None,
+ law_type: str = "44",
+ reestr_number: str | None = None,
+ document_type: str = "notification",
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """Загрузка данных через SOAP API."""
+ if not self.token:
+ raise ZakupkiClientError("Token is required for SOAP API access")
+
+ if progress_callback:
+ progress_callback(5, "Формирование SOAP запроса...")
+
+ # Определяем метод и параметры запроса
+ if reestr_number:
+ # Запрос по реестровому номеру
+ soap_request = self._build_soap_request_by_reestr_number(
+ reestr_number=reestr_number,
+ law_type=law_type,
+ )
+ elif region_code:
+ # Запрос по региону
+ soap_request = self._build_soap_request_by_region(
+ region_code=region_code,
+ law_type=law_type,
+ document_type=document_type,
+ year=year,
+ month=month,
+ day=day,
+ )
+ else:
+ raise ZakupkiClientError("Either region_code or reestr_number is required")
+
+ if progress_callback:
+ progress_callback(10, "Отправка запроса к API...")
+
+ # Отправляем SOAP запрос
+ try:
+ response = self.http_client.post(
+ SOAP_API_URL,
+ data=soap_request.encode("utf-8"),
+ headers={
+ "Content-Type": "text/xml; charset=utf-8",
+ "SOAPAction": "",
+ },
+ )
+ except HTTPClientError as e:
+ logger.error("SOAP request failed: %s", e)
+ raise ZakupkiClientError(f"SOAP request failed: {e}") from e
+
+ if progress_callback:
+ progress_callback(30, "Обработка ответа...")
+
+ # Парсим ответ и получаем URL архива
+ archive_url = self._parse_soap_response(response)
+
+ if not archive_url:
+ logger.warning("No archive URL in SOAP response")
+ if progress_callback:
+ progress_callback(100, "Данные не найдены")
+ return []
+
+ if progress_callback:
+ progress_callback(40, "Скачивание архива...")
+
+ # Скачиваем архив (с токеном в заголовке!)
+ try:
+ archive_content = self.http_client.download_file(
+ archive_url,
+ headers={"individualPerson_token": self.token},
+ )
+ except HTTPClientError as e:
+ logger.error("Failed to download archive: %s", e)
+ raise ZakupkiClientError(f"Failed to download archive: {e}") from e
+
+ if progress_callback:
+ progress_callback(70, "Парсинг данных...")
+
+ # Парсим архив
+ procurements = self._parse_archive_content(archive_content, archive_url)
+
+ if progress_callback:
+ progress_callback(95, f"Загружено {len(procurements)} закупок")
+
+ logger.info("Total fetched %d procurements via SOAP", len(procurements))
+ return procurements
+
+ def _build_soap_request_by_reestr_number(
+ self,
+ reestr_number: str,
+ law_type: str = "44",
+ ) -> str:
+ """Построить SOAP запрос по реестровому номеру."""
+ request_id = str(uuid.uuid4())
+ created_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+ subsystem_type = SUBSYSTEM_TYPES.get(law_type, "PRIZ")
+
+ return f"""
+
+
+ {self.token}
+
+
+
+
+ {request_id}
+ {created_time}
+ PROD
+
+
+ {subsystem_type}
+ {reestr_number}
+
+
+
+"""
+
+ def _build_soap_request_by_region(
+ self,
+ region_code: str,
+ law_type: str = "44",
+ document_type: str = "notification",
+ year: int | None = None,
+ month: int | None = None,
+ day: int | None = None,
+ ) -> str:
+ """Построить SOAP запрос по региону."""
+ request_id = str(uuid.uuid4())
+ created_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+ subsystem_type = SUBSYSTEM_TYPES.get(law_type, "PRIZ")
+ doc_type = DOCUMENT_TYPES_44.get(document_type, "epNotificationEF2020")
+
+ # Формируем дату для запроса
+ if year and month and day:
+ date_str = f"{year:04d}-{month:02d}-{day:02d}"
+ period_xml = f"{date_str}"
+ elif year and month:
+ # Берём последний день месяца как точную дату
+ # (API не поддерживает диапазоны напрямую)
+ date_str = f"{year:04d}-{month:02d}-01"
+ period_xml = f"{date_str}"
+ elif year:
+ date_str = f"{year:04d}-01-01"
+ period_xml = f"{date_str}"
+ else:
+ # Сегодняшняя дата
+ date_str = datetime.now().strftime("%Y-%m-%d")
+ period_xml = f"{date_str}"
+
+ # ВАЖНО: порядок тегов критичен для SOAP!
+ return f"""
+
+
+ {self.token}
+
+
+
+
+ {request_id}
+ {created_time}
+ PROD
+
+
+ {region_code}
+ {subsystem_type}
+ {doc_type}
+
+ {period_xml}
+
+
+
+
+"""
+
+ def _parse_soap_response(self, response_content: bytes) -> str | None:
+ """Извлечь URL архива из SOAP ответа."""
+ try:
+ xml_str = response_content.decode("utf-8")
+ root = ET.fromstring(xml_str) # noqa: S314
+
+ # Ищем archiveUrl в ответе
+ # Структура: soap:Envelope/soap:Body/ns2:*Response/dataInfo/archiveUrl
+ for elem in root.iter():
+ if elem.tag.endswith("archiveUrl") and elem.text:
+ logger.info("Found archive URL: %s", elem.text)
+ return elem.text.strip()
+
+ # Проверяем на ошибки
+ for elem in root.iter():
+ if "fault" in elem.tag.lower() or "error" in elem.tag.lower():
+ error_text = elem.text or ET.tostring(elem, encoding="unicode")
+ logger.error("SOAP error: %s", error_text)
+ raise ZakupkiClientError(f"SOAP error: {error_text}")
+
+ logger.warning("No archiveUrl found in SOAP response")
+ return None
+
+ except ET.ParseError as e:
+ logger.error("Failed to parse SOAP response: %s", e)
+ raise ZakupkiClientError(f"Invalid SOAP response: {e}") from e
+
+ def _fetch_via_http(
+ self,
+ *,
+ region_code: str | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ law_type: str = "44",
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """Загрузка данных через HTTP (fallback, может не работать)."""
+ plans = self._discover_data_files(
+ region_code=region_code,
+ year=year,
+ month=month,
+ law_type=law_type,
+ )
+
+ if not plans:
+ logger.warning(
+ "No data files found for region=%s, year=%s", region_code, year
+ )
+ return []
+
+ if progress_callback:
+ progress_callback(10, f"Найдено {len(plans)} файлов данных")
+
+ all_procurements = []
+ for i, plan in enumerate(plans):
+ if progress_callback:
+ progress = 10 + (i * 80) // len(plans)
+ progress_callback(progress, f"Загрузка {plan.file_name}...")
+
+ procurements = self._download_and_parse_http(plan.file_url, None)
+ all_procurements.extend(procurements)
+ logger.info(
+ "Parsed %d procurements from %s", len(procurements), plan.file_name
+ )
+
+ if progress_callback:
+ progress_callback(95, f"Загружено {len(all_procurements)} закупок")
+
+ logger.info("Total fetched %d procurements via HTTP", len(all_procurements))
+ return all_procurements
+
+ def _discover_data_files(
+ self,
+ *,
+ region_code: str | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ law_type: str = "44",
+ ) -> list[ProcurementPlan]:
+ """Найти доступные файлы данных для указанного периода (HTTP fallback)."""
+ plans = []
+
+ if not region_code or not year:
+ return plans
+
+ fz_suffix = f"fz{law_type}"
+
+ if month:
+ file_name = f"notifications_{region_code}_{year}{month:02d}_{fz_suffix}.zip"
+ file_url = (
+ f"https://{self.host}/opendata/download/"
+ f"notifications/{region_code}/{year}/{month:02d}/{fz_suffix}.zip"
+ )
+ else:
+ file_name = f"notifications_{region_code}_{year}_{fz_suffix}.zip"
+ file_url = (
+ f"https://{self.host}/opendata/download/"
+ f"notifications/{region_code}/{year}/{fz_suffix}.zip"
+ )
+
+ plans.append(
+ ProcurementPlan(
+ region_code=region_code,
+ year=year,
+ month=month,
+ file_url=file_url,
+ file_name=file_name,
+ file_format="zip",
+ )
+ )
+
+ logger.info(
+ "Discovered %d data files for region=%s, year=%s, month=%s",
+ len(plans),
+ region_code,
+ year,
+ month,
+ )
+ return plans
+
+ def _download_and_parse_http(
+ self,
+ file_url: str,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """Скачать файл через HTTP и распарсить его содержимое."""
+ logger.info("Downloading: %s", file_url)
+
+ if progress_callback:
+ progress_callback(20, f"Скачивание {file_url}...")
+
+ try:
+ content = self.http_client.download_file(file_url)
+ except HTTPClientError as e:
+ logger.warning("Failed to download %s: %s", file_url, e)
+ return []
+
+ logger.info("Downloaded %d bytes", len(content))
+ return self._parse_archive_content(content, file_url)
+
+ def _parse_archive_content(
+ self,
+ content: bytes,
+ source_name: str,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """Определить тип файла и распарсить содержимое."""
+ # Определяем тип файла по содержимому
+ is_zip = content[:4] == b"PK\x03\x04"
+ is_xml = content[:5] == b" list[Procurement]:
+ """Распаковать ZIP архив и распарсить XML файлы внутри."""
+ procurements = []
+
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
+ xml_files = [
+ name for name in zf.namelist() if name.lower().endswith(".xml")
+ ]
+
+ if not xml_files:
+ logger.warning("No XML files found in ZIP archive")
+ return []
+
+ logger.info("Found %d XML files in archive", len(xml_files))
+
+ for i, xml_name in enumerate(xml_files):
+ if progress_callback:
+ progress = 30 + (i * 60) // len(xml_files)
+ progress_callback(progress, f"Парсинг {xml_name}...")
+
+ xml_content = zf.read(xml_name)
+ file_procurements = self._parse_xml_content(xml_content, None)
+ procurements.extend(file_procurements)
+
+ return procurements
+
+ def _parse_xml_content( # noqa: C901
+ self,
+ content: bytes,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """Распарсить XML содержимое файла закупок."""
+ procurements = []
+
+ try:
+ # Пробуем разные кодировки
+ for encoding in ["utf-8", "windows-1251", "cp1251"]:
+ try:
+ xml_str = content.decode(encoding)
+ break
+ except UnicodeDecodeError:
+ continue
+ else:
+ xml_str = content.decode("utf-8", errors="replace")
+
+ # Очистка невалидных XML символов
+ xml_str = self._sanitize_xml(xml_str)
+
+ root = ET.fromstring(xml_str) # noqa: S314
+
+ # Определяем namespace
+ ns = {}
+ root_tag = root.tag
+ if root_tag.startswith("{"):
+ ns_uri = root_tag[1 : root_tag.index("}")]
+ ns["ns"] = ns_uri
+ logger.debug("Detected XML namespace: %s", ns_uri)
+
+ # Ищем записи о закупках
+ procurement_tags = [
+ ".//ns:notification" if ns else None,
+ ".//ns:purchaseNotice" if ns else None,
+ ".//ns:fcsNotification" if ns else None,
+ ".//notification",
+ ".//purchaseNotice",
+ ".//fcsNotification",
+ ".//notificationOK",
+ ".//notificationEF",
+ ".//notificationZK",
+ ".//notificationEP",
+ ".//record",
+ ".//item",
+ ]
+
+ records = []
+ for tag in procurement_tags:
+ if tag is None:
+ continue
+ try:
+ if ns and tag.startswith(".//ns:"):
+ found = root.findall(tag, ns)
+ else:
+ found = root.findall(tag)
+ if found:
+ records = found
+ logger.info("Found %d records with tag %s", len(found), tag)
+ break
+ except Exception as e:
+ logger.debug("Tag %s search failed: %s", tag, e)
+ continue
+
+ if not records:
+ records = list(root)
+ logger.debug("Using %d root children as records", len(records))
+
+ for record in records:
+ procurement = self._parse_xml_record(record, ns.get("ns"))
+ if procurement:
+ procurements.append(procurement)
+
+ except ET.ParseError as e:
+ logger.error("XML parse error: %s", e)
+ raise ZakupkiClientError(f"Failed to parse XML: {e}") from e
+
+ return procurements
+
+ def _sanitize_xml(self, xml_str: str) -> str:
+ """Очистить XML строку от невалидных символов."""
+ # Удаляем недопустимые XML символы
+ illegal_xml_chars_re = re.compile(
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f]"
+ )
+ xml_str = illegal_xml_chars_re.sub("", xml_str)
+
+ # Заменяем неэкранированные амперсанды
+ xml_str = re.sub(
+ r"&(?!(?:amp|lt|gt|apos|quot|#\d+|#x[0-9a-fA-F]+);)", "&", xml_str
+ )
+
+ return xml_str
+
+ def _parse_xml_record( # noqa: C901
+ self, element: ET.Element, ns_uri: str | None = None
+ ) -> Procurement | None:
+ """Преобразовать XML элемент в объект Procurement."""
+ try:
+
+ def find_child(tag_name: str) -> ET.Element | None:
+ """Найти дочерний элемент с учётом namespace."""
+ if ns_uri:
+ child = element.find(f"{{{ns_uri}}}{tag_name}")
+ if child is not None:
+ return child
+ return element.find(tag_name)
+
+ def get_text(tag_names: list[str]) -> str:
+ """Получить текст элемента."""
+ for name in tag_names:
+ if name in element.attrib:
+ return element.attrib[name].strip()
+
+ for name in tag_names:
+ child = find_child(name)
+ if child is not None:
+ if child.text:
+ return child.text.strip()
+ for sub in child:
+ if sub.text:
+ return sub.text.strip()
+
+ return ""
+
+ def get_nested_text(parent_tags: list[str], child_tags: list[str]) -> str:
+ """Получить текст из вложенного элемента."""
+ for parent_tag in parent_tags:
+ parent = find_child(parent_tag)
+ if parent is not None:
+ for child_tag in child_tags:
+ if ns_uri:
+ child = parent.find(f"{{{ns_uri}}}{child_tag}")
+ else:
+ child = parent.find(child_tag)
+ if child is not None and child.text:
+ return child.text.strip()
+ return ""
+
+ purchase_number = get_text(
+ [
+ "purchaseNumber",
+ "regNum",
+ "registrationNumber",
+ "notificationNumber",
+ "number",
+ ]
+ )
+
+ purchase_name = get_text(
+ [
+ "purchaseObjectInfo",
+ "name",
+ "objectInfo",
+ "subject",
+ "purchaseName",
+ ]
+ )
+
+ customer_inn = get_nested_text(
+ ["customer", "organizationInfo", "organization", "responsibleOrg"],
+ ["INN", "inn"],
+ )
+ customer_kpp = get_nested_text(
+ ["customer", "organizationInfo", "organization"],
+ ["KPP", "kpp"],
+ )
+ customer_ogrn = get_nested_text(
+ ["customer", "organizationInfo", "organization"],
+ ["OGRN", "ogrn"],
+ )
+ customer_name = get_nested_text(
+ ["customer", "organizationInfo", "organization", "responsibleOrg"],
+ ["fullName", "shortName", "name", "organizationName"],
+ )
+
+ max_price = get_nested_text(
+ ["lot", "lotData", "contractConditions"],
+ ["maxPrice", "maxContractPrice", "initialSum", "sum"],
+ )
+ if not max_price:
+ max_price = get_text(["maxPrice", "initialSum"])
+
+ currency_code = get_nested_text(
+ ["lot", "lotData", "currency"],
+ ["code", "currencyCode"],
+ )
+ if not currency_code:
+ currency_code = "RUB"
+
+ placement_method = get_nested_text(
+ ["placingWay", "purchaseMethod"],
+ ["name", "methodName", "code"],
+ )
+ if not placement_method:
+ placement_method = get_text(["placingWay", "purchaseMethod", "epName"])
+
+ publish_date = get_text(
+ ["publishDate", "docPublishDate", "createDate", "publishDTInEIS"]
+ )
+ end_date = get_text(
+ ["endDate", "submissionCloseDate", "applicationEndDate", "endDT"]
+ )
+
+ status = get_text(
+ ["state", "status", "notificationStatus", "currentStatus"]
+ )
+
+ law_type = ""
+ href_val = get_text(["href", "url", "link"])
+ if "44" in element.tag or "fcs" in element.tag.lower():
+ law_type = "44-FZ"
+ elif "223" in element.tag:
+ law_type = "223-FZ"
+
+ purchase_object_info = get_text(
+ ["purchaseObjectInfo", "objectInfo", "description"]
+ )
+
+ procurement = Procurement(
+ purchase_number=purchase_number,
+ purchase_name=purchase_name,
+ customer_inn=customer_inn,
+ customer_kpp=customer_kpp,
+ customer_ogrn=customer_ogrn,
+ customer_name=customer_name,
+ max_price=max_price,
+ currency_code=currency_code,
+ placement_method=placement_method,
+ publish_date=publish_date,
+ end_date=end_date,
+ status=status,
+ law_type=law_type,
+ purchase_object_info=purchase_object_info,
+ href=href_val,
+ )
+
+ if not procurement.purchase_number and not procurement.customer_inn:
+ logger.debug(
+ "Empty procurement from element %s, attribs: %s",
+ element.tag,
+ list(element.attrib.keys())[:5],
+ )
+ return None
+
+ return procurement
+
+ except Exception as e:
+ logger.warning("Failed to parse XML record: %s", e)
+ return None
+
+ def fetch_procurement_plans(
+ self, region_code: str, year: int
+ ) -> list[ProcurementPlan]:
+ """Получить список доступных файлов закупок за год."""
+ return self._discover_data_files(region_code=region_code, year=year)
+
+ def fetch_by_reestr_number(
+ self,
+ reestr_number: str,
+ law_type: str = "44",
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Procurement]:
+ """
+ Получить данные по реестровому номеру закупки.
+
+ Args:
+ reestr_number: Реестровый номер (например, "0888200000224000038")
+ law_type: Тип закона ("44" или "223")
+ progress_callback: Callback для отчёта о прогрессе
+
+ Returns:
+ Список закупок (обычно одна)
+ """
+ return self.fetch_procurements(
+ reestr_number=reestr_number,
+ law_type=law_type,
+ progress_callback=progress_callback,
+ )
+
+ def close(self) -> None:
+ """Закрыть клиент и освободить ресурсы."""
+ if self._http_client is not None:
+ self._http_client.close()
+ self._http_client = None
+
+ def __enter__(self) -> "ZakupkiClient":
+ """Поддержка context manager."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Закрытие при выходе из context manager."""
+ self.close()
diff --git a/src/apps/parsers/clients/zakupki/schemas.py b/src/apps/parsers/clients/zakupki/schemas.py
new file mode 100644
index 0000000..041f0e3
--- /dev/null
+++ b/src/apps/parsers/clients/zakupki/schemas.py
@@ -0,0 +1,94 @@
+"""
+Dataclass схемы для данных zakupki.gov.ru.
+
+Эти классы представляют данные о государственных закупках, возвращаемые клиентом.
+Они не зависят от Django ORM и используются как DTO.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Procurement:
+ """
+ Государственная закупка из ЕИС zakupki.gov.ru.
+
+ Источник: Единая информационная система в сфере закупок.
+
+ Содержит данные о закупках по 44-ФЗ и 223-ФЗ.
+ """
+
+ purchase_number: str
+ """Реестровый номер закупки."""
+
+ purchase_name: str
+ """Наименование закупки."""
+
+ customer_inn: str
+ """ИНН заказчика."""
+
+ customer_kpp: str
+ """КПП заказчика."""
+
+ customer_ogrn: str
+ """ОГРН заказчика."""
+
+ customer_name: str
+ """Наименование заказчика."""
+
+ max_price: str
+ """Начальная (максимальная) цена контракта."""
+
+ currency_code: str
+ """Код валюты (RUB, USD и т.д.)."""
+
+ placement_method: str
+ """Способ определения поставщика."""
+
+ publish_date: str
+ """Дата публикации извещения."""
+
+ end_date: str
+ """Дата окончания подачи заявок."""
+
+ status: str
+ """Статус закупки."""
+
+ law_type: str
+ """Тип закона (44-ФЗ, 223-ФЗ)."""
+
+ purchase_object_info: str = ""
+ """Информация об объекте закупки."""
+
+ href: str = ""
+ """Ссылка на страницу закупки."""
+
+
+@dataclass(frozen=True)
+class ProcurementPlan:
+ """
+ План загрузки закупок на определённый период.
+
+ Содержит метаданные о файле с данными.
+ """
+
+ region_code: str
+ """Код региона."""
+
+ year: int
+ """Год данных."""
+
+ month: int | None
+ """Месяц (если данные помесячные)."""
+
+ file_url: str
+ """URL файла с данными."""
+
+ file_name: str
+ """Имя файла."""
+
+ records_count: int = 0
+ """Количество записей (если известно)."""
+
+ file_format: str = "xml"
+ """Формат файла (xml, csv)."""
diff --git a/src/apps/parsers/migrations/0006_add_procurement_model.py b/src/apps/parsers/migrations/0006_add_procurement_model.py
new file mode 100644
index 0000000..5ecea36
--- /dev/null
+++ b/src/apps/parsers/migrations/0006_add_procurement_model.py
@@ -0,0 +1,67 @@
+# Generated by Django 3.2.25 on 2026-01-27 11:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0005_add_inspection_fz248_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ProcurementRecord',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')),
+ ('purchase_number', models.CharField(db_index=True, help_text='Реестровый номер закупки', max_length=100, verbose_name='реестровый номер')),
+ ('purchase_name', models.TextField(help_text='Наименование закупки', verbose_name='наименование закупки')),
+ ('customer_inn', models.CharField(db_index=True, help_text='ИНН заказчика', max_length=20, verbose_name='ИНН заказчика')),
+ ('customer_kpp', models.CharField(blank=True, help_text='КПП заказчика', max_length=20, verbose_name='КПП заказчика')),
+ ('customer_ogrn', models.CharField(blank=True, db_index=True, help_text='ОГРН заказчика', max_length=20, verbose_name='ОГРН заказчика')),
+ ('customer_name', models.TextField(help_text='Наименование заказчика', verbose_name='наименование заказчика')),
+ ('max_price', models.CharField(blank=True, help_text='Начальная (максимальная) цена контракта', max_length=50, verbose_name='НМЦ')),
+ ('currency_code', models.CharField(default='RUB', help_text='Код валюты', max_length=10, verbose_name='валюта')),
+ ('placement_method', models.CharField(blank=True, help_text='Способ определения поставщика', max_length=255, verbose_name='способ определения')),
+ ('publish_date', models.CharField(blank=True, help_text='Дата публикации извещения', max_length=30, verbose_name='дата публикации')),
+ ('end_date', models.CharField(blank=True, help_text='Дата окончания подачи заявок', max_length=30, verbose_name='дата окончания')),
+ ('status', models.CharField(blank=True, help_text='Статус закупки', max_length=100, verbose_name='статус')),
+ ('law_type', models.CharField(blank=True, db_index=True, help_text='Тип закона (44-ФЗ, 223-ФЗ)', max_length=20, verbose_name='тип закона')),
+ ('purchase_object_info', models.TextField(blank=True, help_text='Информация об объекте закупки', verbose_name='объект закупки')),
+ ('href', models.URLField(blank=True, help_text='Ссылка на страницу закупки', max_length=500, verbose_name='ссылка')),
+ ('region_code', models.CharField(blank=True, db_index=True, help_text='Код региона', max_length=10, verbose_name='код региона')),
+ ('data_year', models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Год, за который загружены данные', null=True, verbose_name='год данных')),
+ ('data_month', models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Месяц, за который загружены данные', null=True, verbose_name='месяц данных')),
+ ],
+ options={
+ 'verbose_name': 'закупка',
+ 'verbose_name_plural': 'закупки',
+ 'db_table': 'parsers_procurement',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AlterField(
+ model_name='parserloadlog',
+ name='source',
+ field=models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей'), ('inspections', 'Единый реестр проверок'), ('procurements', 'Государственные закупки')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник'),
+ ),
+ migrations.AddIndex(
+ model_name='procurementrecord',
+ index=models.Index(fields=['customer_inn', 'purchase_number'], name='parsers_pro_custome_8e0271_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='procurementrecord',
+ index=models.Index(fields=['load_batch', 'customer_inn'], name='parsers_pro_load_ba_ca8e7f_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='procurementrecord',
+ index=models.Index(fields=['law_type', 'data_year', 'data_month'], name='parsers_pro_law_typ_5a53c9_idx'),
+ ),
+ migrations.AddConstraint(
+ model_name='procurementrecord',
+ constraint=models.UniqueConstraint(fields=('purchase_number',), name='unique_procurement_purchase_number'),
+ ),
+ ]
diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py
index 0cb8c33..7f9b0d9 100644
--- a/src/apps/parsers/models.py
+++ b/src/apps/parsers/models.py
@@ -20,6 +20,7 @@ class ParserLoadLog(TimestampMixin, models.Model):
INDUSTRIAL = "industrial", _("Промышленное производство")
MANUFACTURES = "manufactures", _("Реестр производителей")
INSPECTIONS = "inspections", _("Единый реестр проверок")
+ PROCUREMENTS = "procurements", _("Государственные закупки")
batch_id = models.PositiveIntegerField(
_("ID пакета"),
@@ -361,3 +362,147 @@ class InspectionRecord(TimestampMixin, models.Model):
def __str__(self) -> str:
org_name = self.organisation_name[:50] if self.organisation_name else ""
return f"{self.registration_number} - {org_name}"
+
+
+class ProcurementRecord(TimestampMixin, models.Model):
+ """
+ Государственная закупка из ЕИС zakupki.gov.ru.
+
+ Данные загружаются из Единой информационной системы в сфере закупок.
+ Поддерживает закупки по 44-ФЗ и 223-ФЗ.
+ """
+
+ load_batch = models.PositiveIntegerField(
+ _("ID пакета загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+ purchase_number = models.CharField(
+ _("реестровый номер"),
+ max_length=100,
+ db_index=True,
+ help_text=_("Реестровый номер закупки"),
+ )
+ purchase_name = models.TextField(
+ _("наименование закупки"),
+ help_text=_("Наименование закупки"),
+ )
+ customer_inn = models.CharField(
+ _("ИНН заказчика"),
+ max_length=20,
+ db_index=True,
+ help_text=_("ИНН заказчика"),
+ )
+ customer_kpp = models.CharField(
+ _("КПП заказчика"),
+ max_length=20,
+ blank=True,
+ help_text=_("КПП заказчика"),
+ )
+ customer_ogrn = models.CharField(
+ _("ОГРН заказчика"),
+ max_length=20,
+ db_index=True,
+ blank=True,
+ help_text=_("ОГРН заказчика"),
+ )
+ customer_name = models.TextField(
+ _("наименование заказчика"),
+ help_text=_("Наименование заказчика"),
+ )
+ max_price = models.CharField(
+ _("НМЦ"),
+ max_length=50,
+ blank=True,
+ help_text=_("Начальная (максимальная) цена контракта"),
+ )
+ currency_code = models.CharField(
+ _("валюта"),
+ max_length=10,
+ default="RUB",
+ help_text=_("Код валюты"),
+ )
+ placement_method = models.CharField(
+ _("способ определения"),
+ max_length=255,
+ blank=True,
+ help_text=_("Способ определения поставщика"),
+ )
+ publish_date = models.CharField(
+ _("дата публикации"),
+ max_length=30,
+ blank=True,
+ help_text=_("Дата публикации извещения"),
+ )
+ end_date = models.CharField(
+ _("дата окончания"),
+ max_length=30,
+ blank=True,
+ help_text=_("Дата окончания подачи заявок"),
+ )
+ status = models.CharField(
+ _("статус"),
+ max_length=100,
+ blank=True,
+ help_text=_("Статус закупки"),
+ )
+ law_type = models.CharField(
+ _("тип закона"),
+ max_length=20,
+ blank=True,
+ db_index=True,
+ help_text=_("Тип закона (44-ФЗ, 223-ФЗ)"),
+ )
+ purchase_object_info = models.TextField(
+ _("объект закупки"),
+ blank=True,
+ help_text=_("Информация об объекте закупки"),
+ )
+ href = models.URLField(
+ _("ссылка"),
+ blank=True,
+ max_length=500,
+ help_text=_("Ссылка на страницу закупки"),
+ )
+ region_code = models.CharField(
+ _("код региона"),
+ max_length=10,
+ blank=True,
+ db_index=True,
+ help_text=_("Код региона"),
+ )
+ data_year = models.PositiveSmallIntegerField(
+ _("год данных"),
+ db_index=True,
+ null=True,
+ blank=True,
+ help_text=_("Год, за который загружены данные"),
+ )
+ data_month = models.PositiveSmallIntegerField(
+ _("месяц данных"),
+ db_index=True,
+ null=True,
+ blank=True,
+ help_text=_("Месяц, за который загружены данные"),
+ )
+
+ class Meta:
+ db_table = "parsers_procurement"
+ verbose_name = _("закупка")
+ verbose_name_plural = _("закупки")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["customer_inn", "purchase_number"]),
+ models.Index(fields=["load_batch", "customer_inn"]),
+ models.Index(fields=["law_type", "data_year", "data_month"]),
+ ]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["purchase_number"],
+ name="unique_procurement_purchase_number",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ name = self.purchase_name[:50] if self.purchase_name else ""
+ return f"{self.purchase_number} - {name}"
diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py
index e47893f..e728817 100644
--- a/src/apps/parsers/services.py
+++ b/src/apps/parsers/services.py
@@ -9,11 +9,13 @@ import logging
from apps.core.services import BaseService, BulkOperationsMixin
from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
from apps.parsers.clients.proverki.schemas import Inspection
+from apps.parsers.clients.zakupki.schemas import Procurement
from apps.parsers.models import (
IndustrialCertificateRecord,
InspectionRecord,
ManufacturerRecord,
ParserLoadLog,
+ ProcurementRecord,
Proxy,
)
from django.db import transaction
@@ -534,3 +536,198 @@ class InspectionService(BulkOperationsMixin, BaseService[InspectionRecord]):
if batch_id:
qs = qs.filter(load_batch=batch_id)
return qs
+
+
+class ProcurementService(BulkOperationsMixin, BaseService[ProcurementRecord]):
+ """
+ Сервис для управления данными о государственных закупках.
+
+ Отвечает за:
+ - Массовое сохранение закупок из парсера
+ - Поиск закупок по ИНН/номеру/региону
+ """
+
+ model = ProcurementRecord
+
+ @classmethod
+ @transaction.atomic
+ def save_procurements(
+ cls,
+ procurements: list[Procurement],
+ batch_id: int,
+ *,
+ region_code: str | None = None,
+ data_year: int | None = None,
+ data_month: int | None = None,
+ chunk_size: int = 500,
+ ) -> int:
+ """
+ Сохранить список закупок из парсера.
+
+ Преобразует dataclass объекты в Django модели и сохраняет чанками.
+ Дубликаты по purchase_number пропускаются (ignore_conflicts).
+
+ Args:
+ procurements: Список закупок из клиента
+ batch_id: ID пакета загрузки
+ region_code: Код региона
+ data_year: Год, за который загружены данные
+ data_month: Месяц, за который загружены данные
+ chunk_size: Размер чанка для bulk_create
+
+ Returns:
+ Количество новых сохранённых записей
+ """
+ if not procurements:
+ logger.warning("No procurements to save")
+ return 0
+
+ logger.info(
+ "Saving %d procurements (batch_id=%d, region=%s, year=%s, month=%s)",
+ len(procurements),
+ batch_id,
+ region_code,
+ data_year,
+ data_month,
+ )
+
+ instances = [
+ cls.model(
+ load_batch=batch_id,
+ purchase_number=proc.purchase_number,
+ purchase_name=proc.purchase_name,
+ customer_inn=proc.customer_inn,
+ customer_kpp=proc.customer_kpp,
+ customer_ogrn=proc.customer_ogrn,
+ customer_name=proc.customer_name,
+ max_price=proc.max_price,
+ currency_code=proc.currency_code,
+ placement_method=proc.placement_method,
+ publish_date=proc.publish_date,
+ end_date=proc.end_date,
+ status=proc.status,
+ law_type=proc.law_type,
+ purchase_object_info=proc.purchase_object_info,
+ href=proc.href,
+ region_code=region_code or "",
+ data_year=data_year,
+ data_month=data_month,
+ )
+ for proc in procurements
+ ]
+
+ saved_count = cls.bulk_create_chunked(
+ instances,
+ chunk_size=chunk_size,
+ ignore_conflicts=True, # Skip duplicates by purchase_number
+ )
+ logger.info("Saved %d procurements", saved_count)
+
+ return saved_count
+
+ @classmethod
+ def get_last_loaded_period(
+ cls, region_code: str | None = None, law_type: str | None = None
+ ) -> tuple[int | None, int | None]:
+ """
+ Получить последний загруженный период (год, месяц).
+
+ Args:
+ region_code: Фильтр по региону
+ law_type: Фильтр по типу закона (44-FZ, 223-FZ)
+
+ Returns:
+ Кортеж (year, month) или (None, None) если данных нет
+ """
+ qs = cls.model.objects.exclude(data_year__isnull=True)
+
+ if region_code:
+ qs = qs.filter(region_code=region_code)
+ if law_type:
+ qs = qs.filter(law_type=law_type)
+
+ last_record = (
+ qs.order_by("-data_year", "-data_month")
+ .values("data_year", "data_month")
+ .first()
+ )
+
+ if last_record:
+ return last_record["data_year"], last_record["data_month"]
+ return None, None
+
+ @classmethod
+ def has_data_for_period(
+ cls,
+ year: int,
+ month: int,
+ region_code: str | None = None,
+ law_type: str | None = None,
+ ) -> bool:
+ """
+ Проверить, есть ли данные за указанный период.
+
+ Args:
+ year: Год
+ month: Месяц
+ region_code: Фильтр по региону
+ law_type: Фильтр по типу закона
+
+ Returns:
+ True если данные есть
+ """
+ qs = cls.model.objects.filter(data_year=year, data_month=month)
+
+ if region_code:
+ qs = qs.filter(region_code=region_code)
+ if law_type:
+ qs = qs.filter(law_type=law_type)
+
+ return qs.exists()
+
+ @classmethod
+ def find_by_inn(cls, inn: str, batch_id: int | None = None):
+ """
+ Найти закупки по ИНН заказчика.
+
+ Args:
+ inn: ИНН заказчика
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(customer_inn=inn)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
+
+ @classmethod
+ def find_by_purchase_number(cls, purchase_number: str):
+ """Найти закупки по реестровому номеру."""
+ return cls.filter(purchase_number=purchase_number)
+
+ @classmethod
+ def find_by_region(cls, region_code: str, batch_id: int | None = None):
+ """
+ Найти закупки по региону.
+
+ Args:
+ region_code: Код региона
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(region_code=region_code)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
+
+ @classmethod
+ def find_by_customer_name(cls, customer_name: str, batch_id: int | None = None):
+ """
+ Найти закупки по наименованию заказчика.
+
+ Args:
+ customer_name: Наименование заказчика (частичное совпадение)
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(customer_name__icontains=customer_name)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
diff --git a/src/apps/parsers/tasks.py b/src/apps/parsers/tasks.py
index 46f08ce..4b6edd4 100644
--- a/src/apps/parsers/tasks.py
+++ b/src/apps/parsers/tasks.py
@@ -14,12 +14,14 @@ from apps.parsers.clients.minpromtorg import (
ManufacturesClient,
)
from apps.parsers.clients.proverki import ProverkiClient
+from apps.parsers.clients.zakupki import ZakupkiClient
from apps.parsers.models import ParserLoadLog
from apps.parsers.services import (
IndustrialCertificateService,
InspectionService,
ManufacturerService,
ParserLoadLogService,
+ ProcurementService,
ProxyService,
)
from celery import shared_task
@@ -578,3 +580,331 @@ def sync_inspections( # noqa: C901
"status": "failed",
"error": str(e),
}
+
+
+@shared_task(bind=True)
+def parse_procurements(
+ self,
+ *,
+ region_code: str | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ file_url: str | None = None,
+ law_type: str = "44",
+ proxies: list[str] | None = None,
+) -> dict:
+ """
+ Задача парсинга данных о государственных закупках с zakupki.gov.ru.
+
+ Args:
+ region_code: Код региона (например, "77" для Москвы)
+ year: Год данных
+ month: Месяц (опционально)
+ file_url: Прямая ссылка на файл данных (опционально)
+ law_type: Тип закона ("44" или "223")
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, берётся из БД.
+
+ Returns:
+ Результат: batch_id, saved, status
+ """
+ source = ParserLoadLog.Source.PROCUREMENTS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting procurements parsing "
+ "(task_id=%s, batch_id=%d, region=%s, year=%s, month=%s, law=%s-FZ, proxies=%d)",
+ task_id,
+ batch_id,
+ region_code,
+ year,
+ month,
+ law_type,
+ len(proxies) if proxies else 0,
+ )
+
+ # Создаём запись BackgroundJob для отслеживания прогресса
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.parse_procurements",
+ meta={
+ "source": source,
+ "batch_id": batch_id,
+ "region_code": region_code,
+ "year": year,
+ "month": month,
+ "law_type": law_type,
+ },
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация парсера...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ def progress_callback(percent: int, message: str) -> None:
+ """Callback для обновления прогресса."""
+ job.update_progress(percent, message)
+
+ try:
+ # Парсинг данных
+ job.update_progress(10, "Загрузка данных с zakupki.gov.ru...")
+ with ZakupkiClient(proxies=proxies) as client:
+ procurements = client.fetch_procurements(
+ region_code=region_code,
+ year=year,
+ month=month,
+ file_url=file_url,
+ law_type=law_type,
+ progress_callback=progress_callback,
+ )
+
+ # Сохранение в БД
+ job.update_progress(80, f"Сохранение {len(procurements)} закупок...")
+ saved_count = ProcurementService.save_procurements(
+ procurements,
+ batch_id=batch_id,
+ region_code=region_code,
+ data_year=year,
+ data_month=month,
+ )
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=saved_count,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(result={"batch_id": batch_id, "saved": saved_count})
+
+ logger.info(
+ "Procurements parsing completed (batch_id=%d, saved=%d)",
+ batch_id,
+ saved_count,
+ )
+
+ return {
+ "batch_id": batch_id,
+ "saved": saved_count,
+ "status": "success",
+ }
+
+ except Exception as e:
+ logger.error("Procurements parsing failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "saved": 0,
+ "status": "failed",
+ "error": str(e),
+ }
+
+
+@shared_task(bind=True)
+def sync_procurements(
+ self,
+ *,
+ region_code: str,
+ law_type: str = "44",
+ proxies: list[str] | None = None,
+) -> dict:
+ """
+ Синхронизация данных о закупках с zakupki.gov.ru.
+
+ Логика работы:
+ 1. Проверяет последнюю загруженную дату в БД для региона
+ 2. Если данных нет - начинает с 01.01.2025
+ 3. Загружает месяц за месяцем до текущего
+
+ Args:
+ region_code: Код региона (обязательный)
+ law_type: Тип закона ("44" или "223")
+ proxies: Список прокси-серверов (опционально)
+
+ Returns:
+ Результат синхронизации
+ """
+ source = ParserLoadLog.Source.PROCUREMENTS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting procurements sync (task_id=%s, batch_id=%d, region=%s, law=%s-FZ)",
+ task_id,
+ batch_id,
+ region_code,
+ law_type,
+ )
+
+ # Создаём запись BackgroundJob
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.sync_procurements",
+ meta={
+ "source": source,
+ "batch_id": batch_id,
+ "region_code": region_code,
+ "law_type": law_type,
+ },
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация синхронизации...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ current_year = datetime.now().year
+ current_month = datetime.now().month
+ total_saved = 0
+ results = []
+
+ try:
+ with ZakupkiClient(proxies=proxies) as client:
+ # Определяем начальную точку
+ last_year, last_month = ProcurementService.get_last_loaded_period(
+ region_code=region_code,
+ law_type=f"{law_type}-FZ",
+ )
+
+ if last_year and last_month:
+ # Начинаем со следующего месяца после последнего загруженного
+ start_year, start_month = _get_next_month(last_year, last_month)
+ logger.info(
+ "Continuing from %d/%d (last loaded: %d/%d)",
+ start_year,
+ start_month,
+ last_year,
+ last_month,
+ )
+ else:
+ # Начинаем с дефолтной даты
+ start_year, start_month = DEFAULT_START_YEAR, DEFAULT_START_MONTH
+ logger.info(
+ "No data in DB, starting from %d/%d",
+ start_year,
+ start_month,
+ )
+
+ # Загружаем месяц за месяцем
+ year, month = start_year, start_month
+ empty_months_count = 0
+
+ while year < current_year or (
+ year == current_year and month <= current_month
+ ):
+ # Прекращаем если 2 месяца подряд нет данных
+ if empty_months_count >= 2:
+ logger.info("Stopping after %d empty months", empty_months_count)
+ break
+
+ job.update_progress(
+ 20 + (60 * ((year - start_year) * 12 + month - start_month) // 24),
+ f"Загрузка за {month:02d}/{year}...",
+ )
+
+ try:
+ procurements = client.fetch_procurements(
+ region_code=region_code,
+ year=year,
+ month=month,
+ law_type=law_type,
+ )
+
+ if procurements:
+ saved = ProcurementService.save_procurements(
+ procurements,
+ batch_id=batch_id,
+ region_code=region_code,
+ data_year=year,
+ data_month=month,
+ )
+ total_saved += saved
+ results.append(
+ {
+ "year": year,
+ "month": month,
+ "fetched": len(procurements),
+ "saved": saved,
+ }
+ )
+ empty_months_count = 0
+ logger.info(
+ "%d/%d: fetched %d, saved %d",
+ year,
+ month,
+ len(procurements),
+ saved,
+ )
+ else:
+ empty_months_count += 1
+ logger.info(
+ "%d/%d: no data found (empty_count=%d)",
+ year,
+ month,
+ empty_months_count,
+ )
+
+ except Exception as e:
+ logger.warning("%d/%d: error - %s", year, month, str(e))
+ empty_months_count += 1
+
+ # Переходим к следующему месяцу
+ year, month = _get_next_month(year, month)
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=total_saved,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(
+ result={
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "results": results,
+ }
+ )
+
+ logger.info("Procurements sync completed (total_saved=%d)", total_saved)
+
+ return {
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "status": "success",
+ "results": results,
+ }
+
+ except Exception as e:
+ logger.error("Procurements sync failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "status": "failed",
+ "error": str(e),
+ }
diff --git a/src/apps/parsers/tests/__init__.py b/src/apps/parsers/tests/__init__.py
new file mode 100644
index 0000000..c3bfa03
--- /dev/null
+++ b/src/apps/parsers/tests/__init__.py
@@ -0,0 +1,8 @@
+"""
+Тесты для приложения parsers.
+
+Содержит:
+- Unit-тесты клиентов (ZakupkiClient и др.)
+- Unit-тесты сервисов (ProcurementService и др.)
+- E2E тесты с реальной загрузкой данных
+"""
diff --git a/src/apps/parsers/tests/factories.py b/src/apps/parsers/tests/factories.py
new file mode 100644
index 0000000..1209b53
--- /dev/null
+++ b/src/apps/parsers/tests/factories.py
@@ -0,0 +1,173 @@
+"""
+Фабрики для тестов приложения parsers.
+
+Использует factory_boy + Faker для генерации тестовых данных.
+"""
+
+import factory
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ ProcurementRecord,
+ Proxy,
+)
+from faker import Faker
+
+fake = Faker("ru_RU")
+
+
+class ProxyFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели Proxy."""
+
+ class Meta:
+ model = Proxy
+
+ address = factory.LazyAttribute(
+ lambda _: f"http://{fake.ipv4()}:{fake.port_number()}"
+ )
+ is_active = True
+ fail_count = 0
+ description = factory.LazyAttribute(lambda _: fake.sentence(nb_words=3))
+
+
+class ParserLoadLogFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели ParserLoadLog."""
+
+ class Meta:
+ model = ParserLoadLog
+
+ batch_id = factory.Sequence(lambda n: n + 1)
+ source = ParserLoadLog.Source.PROCUREMENTS
+ records_count = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=1000))
+ status = "success"
+ error_message = ""
+
+
+class ProcurementRecordFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели ProcurementRecord."""
+
+ class Meta:
+ model = ProcurementRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ purchase_number = factory.LazyAttribute(
+ lambda _: f"{fake.random_number(digits=19, fix_len=True)}"
+ )
+ purchase_name = factory.LazyAttribute(lambda _: fake.sentence(nb_words=6))
+ customer_inn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=10, fix_len=True))
+ )
+ customer_kpp = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=9, fix_len=True))
+ )
+ customer_ogrn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=13, fix_len=True))
+ )
+ customer_name = factory.LazyAttribute(lambda _: fake.company())
+ max_price = factory.LazyAttribute(
+ lambda _: str(fake.random_int(min=10000, max=10000000))
+ )
+ currency_code = "RUB"
+ placement_method = factory.LazyAttribute(
+ lambda _: fake.random_element(
+ ["Электронный аукцион", "Открытый конкурс", "Запрос котировок"]
+ )
+ )
+ publish_date = factory.LazyAttribute(lambda _: fake.date())
+ end_date = factory.LazyAttribute(lambda _: fake.date())
+ status = factory.LazyAttribute(
+ lambda _: fake.random_element(
+ ["Подача заявок", "Работа комиссии", "Завершена", "Отменена"]
+ )
+ )
+ law_type = factory.LazyAttribute(lambda _: fake.random_element(["44-FZ", "223-FZ"]))
+ purchase_object_info = factory.LazyAttribute(lambda _: fake.text(max_nb_chars=200))
+ href = factory.LazyAttribute(lambda _: fake.url())
+ region_code = factory.LazyAttribute(
+ lambda _: str(fake.random_int(min=1, max=99)).zfill(2)
+ )
+ data_year = factory.LazyAttribute(lambda _: fake.random_int(min=2020, max=2025))
+ data_month = factory.LazyAttribute(lambda _: fake.random_int(min=1, max=12))
+
+
+class IndustrialCertificateRecordFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели IndustrialCertificateRecord."""
+
+ class Meta:
+ model = IndustrialCertificateRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ issue_date = factory.LazyAttribute(lambda _: fake.date())
+ certificate_number = factory.LazyAttribute(
+ lambda _: f"CERT-{fake.random_number(digits=10, fix_len=True)}"
+ )
+ expiry_date = factory.LazyAttribute(lambda _: fake.date())
+ certificate_file_url = factory.LazyAttribute(lambda _: fake.url())
+ organisation_name = factory.LazyAttribute(lambda _: fake.company())
+ inn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=10, fix_len=True))
+ )
+ ogrn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=13, fix_len=True))
+ )
+
+
+class ManufacturerRecordFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели ManufacturerRecord."""
+
+ class Meta:
+ model = ManufacturerRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ full_legal_name = factory.LazyAttribute(lambda _: fake.company())
+ inn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=10, fix_len=True))
+ )
+ ogrn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=13, fix_len=True))
+ )
+ address = factory.LazyAttribute(lambda _: fake.address())
+
+
+class InspectionRecordFactory(factory.django.DjangoModelFactory):
+ """Фабрика для модели InspectionRecord."""
+
+ class Meta:
+ model = InspectionRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ registration_number = factory.LazyAttribute(
+ lambda _: f"REG-{fake.random_number(digits=15, fix_len=True)}"
+ )
+ inn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=10, fix_len=True))
+ )
+ ogrn = factory.LazyAttribute(
+ lambda _: str(fake.random_number(digits=13, fix_len=True))
+ )
+ organisation_name = factory.LazyAttribute(lambda _: fake.company())
+ control_authority = factory.LazyAttribute(
+ lambda _: fake.random_element(
+ ["Роспотребнадзор", "Ростехнадзор", "МЧС России", "Росприроднадзор"]
+ )
+ )
+ inspection_type = factory.LazyAttribute(
+ lambda _: fake.random_element(["Плановая", "Внеплановая"])
+ )
+ inspection_form = factory.LazyAttribute(
+ lambda _: fake.random_element(["Документарная", "Выездная"])
+ )
+ start_date = factory.LazyAttribute(lambda _: fake.date())
+ end_date = factory.LazyAttribute(lambda _: fake.date())
+ status = factory.LazyAttribute(
+ lambda _: fake.random_element(["Завершена", "В процессе", "Отменена"])
+ )
+ legal_basis = factory.LazyAttribute(
+ lambda _: fake.random_element(["ФЗ-294", "ФЗ-248"])
+ )
+ result = factory.LazyAttribute(lambda _: fake.text(max_nb_chars=100))
+ is_federal_law_248 = False
+ data_year = factory.LazyAttribute(lambda _: fake.random_int(min=2020, max=2025))
+ data_month = factory.LazyAttribute(lambda _: fake.random_int(min=1, max=12))
diff --git a/src/apps/parsers/tests/test_e2e.py b/src/apps/parsers/tests/test_e2e.py
new file mode 100644
index 0000000..dfb292a
--- /dev/null
+++ b/src/apps/parsers/tests/test_e2e.py
@@ -0,0 +1,284 @@
+"""
+E2E тесты для парсера zakupki.gov.ru.
+
+Тесты с реальной загрузкой данных.
+
+Для запуска E2E тестов с реальными данными:
+ RUN_E2E_TESTS=1 uv run python manage.py test apps.parsers.tests.test_e2e
+
+Тесты пропускаются по умолчанию, чтобы не нагружать внешние API
+в обычных тестовых прогонах.
+"""
+
+import os
+import unittest
+
+from apps.parsers.clients.zakupki import ZakupkiClient
+from apps.parsers.models import ParserLoadLog, ProcurementRecord
+from apps.parsers.services import ParserLoadLogService, ProcurementService
+from django.test import TestCase, override_settings
+
+# Флаг для запуска E2E тестов
+RUN_E2E_TESTS = os.environ.get("RUN_E2E_TESTS", "").lower() in ("1", "true", "yes")
+
+
+@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable")
+class ZakupkiClientE2ETestCase(TestCase):
+ """
+ E2E тесты клиента ZakupkiClient.
+
+ Выполняют реальные HTTP запросы к zakupki.gov.ru.
+ """
+
+ def setUp(self):
+ """Подготовка."""
+ self.client = ZakupkiClient(timeout=60)
+
+ def tearDown(self):
+ """Очистка."""
+ self.client.close()
+
+ def test_fetch_procurement_plans(self):
+ """Получение списка файлов для загрузки."""
+ plans = self.client.fetch_procurement_plans(region_code="77", year=2025)
+
+ self.assertIsInstance(plans, list)
+ # Планы должны быть сгенерированы даже без реальных данных
+ self.assertGreater(len(plans), 0)
+
+ for plan in plans:
+ self.assertEqual(plan.region_code, "77")
+ self.assertEqual(plan.year, 2025)
+
+ def test_fetch_procurements_with_invalid_region(self):
+ """Загрузка с несуществующим регионом возвращает пустой список."""
+ # Используем несуществующий код региона
+ procurements = self.client.fetch_procurements(
+ region_code="99",
+ year=2025,
+ month=1,
+ )
+
+ # Должен вернуть пустой список, а не упасть
+ self.assertIsInstance(procurements, list)
+
+ def test_fetch_with_progress_callback(self):
+ """Тест callback для отслеживания прогресса."""
+ progress_updates = []
+
+ def progress_callback(percent: int, message: str) -> None:
+ progress_updates.append({"percent": percent, "message": message})
+
+ self.client.fetch_procurements(
+ region_code="77",
+ year=2025,
+ month=1,
+ progress_callback=progress_callback,
+ )
+
+ # Должен быть хотя бы один вызов callback
+ self.assertGreater(len(progress_updates), 0)
+ # Первый вызов должен быть с 0%
+ self.assertEqual(progress_updates[0]["percent"], 0)
+
+
+@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable")
+class ProcurementServiceE2ETestCase(TestCase):
+ """
+ E2E тесты сервиса ProcurementService.
+
+ Тестирует полный цикл: загрузка → парсинг → сохранение в БД.
+ """
+
+ def test_full_load_cycle(self):
+ """Полный цикл загрузки данных."""
+ # Подготовка
+ source = ParserLoadLog.Source.PROCUREMENTS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ # Загрузка данных
+ with ZakupkiClient(timeout=60) as client:
+ procurements = client.fetch_procurements(
+ region_code="77",
+ year=2025,
+ month=1,
+ )
+
+ # Сохранение в БД
+ if procurements:
+ saved_count = ProcurementService.save_procurements(
+ procurements,
+ batch_id=batch_id,
+ region_code="77",
+ data_year=2025,
+ data_month=1,
+ )
+
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=saved_count,
+ )
+
+ # Проверки
+ self.assertGreater(saved_count, 0)
+ self.assertEqual(ProcurementRecord.objects.count(), saved_count)
+
+ # Проверяем что данные корректны
+ record = ProcurementRecord.objects.first()
+ self.assertIsNotNone(record.purchase_number)
+ self.assertEqual(record.region_code, "77")
+ self.assertEqual(record.data_year, 2025)
+ self.assertEqual(record.data_month, 1)
+ self.assertEqual(record.load_batch, batch_id)
+
+ # Проверяем лог
+ load_log.refresh_from_db()
+ self.assertEqual(load_log.status, "success")
+ self.assertEqual(load_log.records_count, saved_count)
+ else:
+ # Если данных нет - это тоже валидный результат
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=0,
+ )
+
+
+@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable")
+@override_settings(
+ CELERY_TASK_ALWAYS_EAGER=True,
+ CELERY_TASK_EAGER_PROPAGATES=True,
+)
+class ProcurementTasksE2ETestCase(TestCase):
+ """
+ E2E тесты Celery задач.
+
+ Запускает реальные задачи в синхронном режиме.
+ """
+
+ def test_parse_procurements_task(self):
+ """Тест задачи parse_procurements."""
+ import uuid
+
+ from apps.parsers.tasks import parse_procurements
+
+ # Запуск задачи синхронно через .apply() с явным task_id
+ # (CELERY_TASK_ALWAYS_EAGER=True, но self.request.id = None без apply)
+ task_id = str(uuid.uuid4())
+ async_result = parse_procurements.apply(
+ kwargs={
+ "region_code": "77",
+ "year": 2025,
+ "month": 1,
+ "law_type": "44",
+ },
+ task_id=task_id,
+ )
+ result = async_result.result
+
+ # Проверки
+ self.assertIn("batch_id", result)
+ self.assertIn("status", result)
+ self.assertIn("saved", result)
+
+ # Статус должен быть success или failed (не упасть с исключением)
+ self.assertIn(result["status"], ["success", "failed"])
+
+ if result["status"] == "success":
+ # Если успех - проверяем что batch_id корректен
+ self.assertGreater(result["batch_id"], 0)
+
+
+class ZakupkiClientOfflineTestCase(TestCase):
+ """
+ Тесты клиента без реальных HTTP запросов.
+
+ Эти тесты выполняются всегда и проверяют
+ обработку ошибок и edge cases.
+ """
+
+ def test_client_handles_connection_error(self):
+ """Клиент корректно обрабатывает ошибки соединения."""
+ # Используем невалидный хост
+ client = ZakupkiClient(host="invalid.host.local", timeout=5)
+
+ # Должен вернуть пустой список, а не упасть
+ procurements = client.fetch_procurements(
+ region_code="77",
+ year=2025,
+ file_url="http://invalid.host.local/data.xml",
+ )
+
+ self.assertEqual(procurements, [])
+ client.close()
+
+ def test_client_with_empty_response(self):
+ """Клиент обрабатывает пустой ответ."""
+ client = ZakupkiClient()
+
+ # Пустой XML
+ procurements = client._parse_xml_content(
+ b'',
+ None,
+ )
+
+ self.assertEqual(procurements, [])
+ client.close()
+
+
+class ProxyIntegrationTestCase(TestCase):
+ """
+ Тесты интеграции с прокси.
+
+ Проверяет что клиент корректно использует прокси из БД.
+ """
+
+ def test_client_accepts_proxy_list(self):
+ """Клиент принимает список прокси."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = ZakupkiClient(proxies=proxies)
+
+ self.assertEqual(client.proxies, proxies)
+ client.close()
+
+ def test_client_works_without_proxies(self):
+ """Клиент работает без прокси."""
+ client = ZakupkiClient()
+
+ self.assertIsNone(client.proxies)
+ client.close()
+
+ def test_proxy_service_integration(self):
+ """Интеграция с ProxyService."""
+ from apps.parsers.services import ProxyService
+ from apps.parsers.tests.factories import ProxyFactory
+
+ # Создаём активные прокси
+ ProxyFactory.create_batch(3, is_active=True)
+ ProxyFactory.create_batch(2, is_active=False)
+
+ # Получаем активные прокси
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ self.assertIsNotNone(proxies)
+ self.assertEqual(len(proxies), 3)
+
+ # Создаём клиент с этими прокси
+ client = ZakupkiClient(proxies=proxies)
+ self.assertEqual(len(client.proxies), 3)
+ client.close()
+
+ def test_proxy_service_returns_none_when_empty(self):
+ """ProxyService возвращает None если нет активных прокси."""
+ from apps.parsers.services import ProxyService
+
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ self.assertIsNone(proxies)
diff --git a/src/apps/parsers/tests/test_procurement_service.py b/src/apps/parsers/tests/test_procurement_service.py
new file mode 100644
index 0000000..6d41c28
--- /dev/null
+++ b/src/apps/parsers/tests/test_procurement_service.py
@@ -0,0 +1,319 @@
+"""
+Unit-тесты для ProcurementService.
+
+Тестирует сервис для работы с данными о закупках.
+"""
+
+from apps.parsers.clients.zakupki.schemas import Procurement
+from apps.parsers.models import ProcurementRecord
+from apps.parsers.services import ProcurementService
+from apps.parsers.tests.factories import ProcurementRecordFactory
+from django.test import TestCase
+
+
+class ProcurementServiceSaveTestCase(TestCase):
+ """Тесты метода save_procurements."""
+
+ def test_save_empty_list(self):
+ """Сохранение пустого списка возвращает 0."""
+ saved = ProcurementService.save_procurements([], batch_id=1)
+ self.assertEqual(saved, 0)
+
+ def test_save_single_procurement(self):
+ """Сохранение одной закупки."""
+ procurement = Procurement(
+ purchase_number="1234567890123456789",
+ purchase_name="Test procurement",
+ customer_inn="1234567890",
+ customer_kpp="123456789",
+ customer_ogrn="1234567890123",
+ customer_name="Test Organization",
+ max_price="1000000",
+ currency_code="RUB",
+ placement_method="Electronic auction",
+ publish_date="2025-01-15",
+ end_date="2025-02-15",
+ status="Published",
+ law_type="44-FZ",
+ purchase_object_info="Test object",
+ href="https://zakupki.gov.ru/test",
+ )
+
+ saved = ProcurementService.save_procurements(
+ [procurement],
+ batch_id=1,
+ region_code="77",
+ data_year=2025,
+ data_month=1,
+ )
+
+ self.assertEqual(saved, 1)
+ self.assertEqual(ProcurementRecord.objects.count(), 1)
+
+ record = ProcurementRecord.objects.first()
+ self.assertEqual(record.purchase_number, "1234567890123456789")
+ self.assertEqual(record.customer_inn, "1234567890")
+ self.assertEqual(record.region_code, "77")
+ self.assertEqual(record.data_year, 2025)
+ self.assertEqual(record.data_month, 1)
+
+ def test_save_multiple_procurements(self):
+ """Сохранение нескольких закупок."""
+ procurements = [
+ Procurement(
+ purchase_number=f"{i:019d}",
+ purchase_name=f"Procurement {i}",
+ customer_inn=f"{i:010d}",
+ customer_kpp="",
+ customer_ogrn="",
+ customer_name=f"Organization {i}",
+ max_price=str(1000000 * i),
+ currency_code="RUB",
+ placement_method="",
+ publish_date="2025-01-01",
+ end_date="",
+ status="",
+ law_type="44-FZ",
+ )
+ for i in range(1, 6)
+ ]
+
+ saved = ProcurementService.save_procurements(procurements, batch_id=1)
+
+ self.assertEqual(saved, 5)
+ self.assertEqual(ProcurementRecord.objects.count(), 5)
+
+ def test_save_ignores_duplicates(self):
+ """Дубликаты по purchase_number пропускаются."""
+ # Создаём существующую запись
+ ProcurementRecordFactory(purchase_number="1234567890123456789")
+ initial_count = ProcurementRecord.objects.count()
+
+ # Пытаемся сохранить с тем же номером
+ procurement = Procurement(
+ purchase_number="1234567890123456789",
+ purchase_name="Duplicate",
+ customer_inn="9999999999",
+ customer_kpp="",
+ customer_ogrn="",
+ customer_name="Duplicate Org",
+ max_price="500000",
+ currency_code="RUB",
+ placement_method="",
+ publish_date="2025-01-01",
+ end_date="",
+ status="",
+ law_type="44-FZ",
+ )
+
+ ProcurementService.save_procurements([procurement], batch_id=2)
+
+ # Дубликат пропущен - количество записей не изменилось
+ self.assertEqual(ProcurementRecord.objects.count(), initial_count)
+ # Оригинальная запись не была перезаписана
+ original = ProcurementRecord.objects.get(purchase_number="1234567890123456789")
+ self.assertNotEqual(original.customer_inn, "9999999999")
+
+ def test_save_with_chunking(self):
+ """Сохранение большого количества записей чанками."""
+ procurements = [
+ Procurement(
+ purchase_number=f"{i:019d}",
+ purchase_name=f"Procurement {i}",
+ customer_inn=f"{i:010d}",
+ customer_kpp="",
+ customer_ogrn="",
+ customer_name=f"Organization {i}",
+ max_price=str(1000000),
+ currency_code="RUB",
+ placement_method="",
+ publish_date="2025-01-01",
+ end_date="",
+ status="",
+ law_type="44-FZ",
+ )
+ for i in range(1, 101) # 100 записей
+ ]
+
+ saved = ProcurementService.save_procurements(
+ procurements, batch_id=1, chunk_size=25
+ )
+
+ self.assertEqual(saved, 100)
+ self.assertEqual(ProcurementRecord.objects.count(), 100)
+
+
+class ProcurementServiceFindTestCase(TestCase):
+ """Тесты методов поиска."""
+
+ def setUp(self):
+ """Подготовка тестовых данных."""
+ self.record1 = ProcurementRecordFactory(
+ purchase_number="1111111111111111111",
+ customer_inn="1111111111",
+ customer_name="First Organization",
+ region_code="77",
+ load_batch=1,
+ )
+ self.record2 = ProcurementRecordFactory(
+ purchase_number="2222222222222222222",
+ customer_inn="2222222222",
+ customer_name="Second Organization",
+ region_code="77",
+ load_batch=1,
+ )
+ self.record3 = ProcurementRecordFactory(
+ purchase_number="3333333333333333333",
+ customer_inn="1111111111", # Тот же ИНН что и у первого
+ customer_name="Third Organization",
+ region_code="50",
+ load_batch=2,
+ )
+
+ def test_find_by_inn(self):
+ """Поиск по ИНН."""
+ results = ProcurementService.find_by_inn("1111111111")
+ self.assertEqual(results.count(), 2)
+
+ def test_find_by_inn_with_batch(self):
+ """Поиск по ИНН с фильтром по batch."""
+ results = ProcurementService.find_by_inn("1111111111", batch_id=1)
+ self.assertEqual(results.count(), 1)
+ self.assertEqual(results.first().purchase_number, "1111111111111111111")
+
+ def test_find_by_purchase_number(self):
+ """Поиск по номеру закупки."""
+ results = ProcurementService.find_by_purchase_number("2222222222222222222")
+ self.assertEqual(results.count(), 1)
+ self.assertEqual(results.first().customer_inn, "2222222222")
+
+ def test_find_by_region(self):
+ """Поиск по региону."""
+ results = ProcurementService.find_by_region("77")
+ self.assertEqual(results.count(), 2)
+
+ def test_find_by_region_with_batch(self):
+ """Поиск по региону с фильтром по batch."""
+ results = ProcurementService.find_by_region("77", batch_id=1)
+ self.assertEqual(results.count(), 2)
+
+ def test_find_by_customer_name(self):
+ """Поиск по названию заказчика (частичное совпадение)."""
+ results = ProcurementService.find_by_customer_name("Organization")
+ self.assertEqual(results.count(), 3)
+
+ results = ProcurementService.find_by_customer_name("First")
+ self.assertEqual(results.count(), 1)
+
+
+class ProcurementServicePeriodTestCase(TestCase):
+ """Тесты методов работы с периодами."""
+
+ def test_get_last_loaded_period_empty(self):
+ """Если данных нет, возвращается (None, None)."""
+ year, month = ProcurementService.get_last_loaded_period()
+ self.assertIsNone(year)
+ self.assertIsNone(month)
+
+ def test_get_last_loaded_period(self):
+ """Получение последнего загруженного периода."""
+ ProcurementRecordFactory(data_year=2024, data_month=6)
+ ProcurementRecordFactory(data_year=2025, data_month=1)
+ ProcurementRecordFactory(data_year=2024, data_month=12)
+
+ year, month = ProcurementService.get_last_loaded_period()
+
+ self.assertEqual(year, 2025)
+ self.assertEqual(month, 1)
+
+ def test_get_last_loaded_period_by_region(self):
+ """Получение последнего периода с фильтром по региону."""
+ ProcurementRecordFactory(data_year=2025, data_month=3, region_code="77")
+ ProcurementRecordFactory(data_year=2025, data_month=6, region_code="50")
+
+ year, month = ProcurementService.get_last_loaded_period(region_code="77")
+
+ self.assertEqual(year, 2025)
+ self.assertEqual(month, 3)
+
+ def test_get_last_loaded_period_by_law_type(self):
+ """Получение последнего периода с фильтром по типу закона."""
+ ProcurementRecordFactory(data_year=2025, data_month=3, law_type="44-FZ")
+ ProcurementRecordFactory(data_year=2025, data_month=6, law_type="223-FZ")
+
+ year, month = ProcurementService.get_last_loaded_period(law_type="44-FZ")
+
+ self.assertEqual(year, 2025)
+ self.assertEqual(month, 3)
+
+ def test_has_data_for_period_true(self):
+ """Проверка наличия данных за период - есть данные."""
+ ProcurementRecordFactory(data_year=2025, data_month=1)
+
+ result = ProcurementService.has_data_for_period(2025, 1)
+
+ self.assertTrue(result)
+
+ def test_has_data_for_period_false(self):
+ """Проверка наличия данных за период - нет данных."""
+ ProcurementRecordFactory(data_year=2025, data_month=1)
+
+ result = ProcurementService.has_data_for_period(2025, 2)
+
+ self.assertFalse(result)
+
+ def test_has_data_for_period_with_filters(self):
+ """Проверка наличия данных с фильтрами."""
+ ProcurementRecordFactory(
+ data_year=2025, data_month=1, region_code="77", law_type="44-FZ"
+ )
+
+ # С правильными фильтрами - есть
+ self.assertTrue(
+ ProcurementService.has_data_for_period(
+ 2025, 1, region_code="77", law_type="44-FZ"
+ )
+ )
+
+ # С неправильным регионом - нет
+ self.assertFalse(
+ ProcurementService.has_data_for_period(2025, 1, region_code="50")
+ )
+
+
+class ProcurementServiceBaseMethodsTestCase(TestCase):
+ """Тесты базовых методов от BaseService."""
+
+ def test_count(self):
+ """Подсчёт записей."""
+ ProcurementRecordFactory.create_batch(5)
+ self.assertEqual(ProcurementService.count(), 5)
+
+ def test_exists(self):
+ """Проверка существования."""
+ record = ProcurementRecordFactory()
+ self.assertTrue(
+ ProcurementService.exists(purchase_number=record.purchase_number)
+ )
+ self.assertFalse(ProcurementService.exists(purchase_number="nonexistent"))
+
+ def test_get_by_id(self):
+ """Получение по ID."""
+ record = ProcurementRecordFactory()
+ found = ProcurementService.get_by_id(record.pk)
+ self.assertEqual(found.pk, record.pk)
+
+ def test_get_all(self):
+ """Получение всех записей."""
+ ProcurementRecordFactory.create_batch(3)
+ all_records = ProcurementService.get_all()
+ self.assertEqual(all_records.count(), 3)
+
+ def test_filter(self):
+ """Фильтрация записей."""
+ ProcurementRecordFactory(law_type="44-FZ")
+ ProcurementRecordFactory(law_type="44-FZ")
+ ProcurementRecordFactory(law_type="223-FZ")
+
+ filtered = ProcurementService.filter(law_type="44-FZ")
+ self.assertEqual(filtered.count(), 2)
diff --git a/src/apps/parsers/tests/test_tasks.py b/src/apps/parsers/tests/test_tasks.py
new file mode 100644
index 0000000..b78f330
--- /dev/null
+++ b/src/apps/parsers/tests/test_tasks.py
@@ -0,0 +1,339 @@
+"""
+Unit-тесты для Celery задач парсера zakupki.gov.ru.
+
+Тестирует задачи parse_procurements и sync_procurements.
+"""
+
+from unittest.mock import MagicMock, patch
+
+from apps.parsers.clients.zakupki.schemas import Procurement
+from apps.parsers.models import ParserLoadLog, ProcurementRecord
+from apps.parsers.tests.factories import ParserLoadLogFactory, ProcurementRecordFactory
+from django.test import TestCase, override_settings
+
+
+@override_settings(
+ CELERY_TASK_ALWAYS_EAGER=True,
+ CELERY_TASK_EAGER_PROPAGATES=True,
+)
+class ParseProcurementsTaskTestCase(TestCase):
+ """Тесты задачи parse_procurements."""
+
+ def _create_mock_procurement(self, number: str = "1234567890123456789"):
+ """Создать мок закупки."""
+ return Procurement(
+ purchase_number=number,
+ purchase_name="Test Procurement",
+ customer_inn="1234567890",
+ customer_kpp="123456789",
+ customer_ogrn="1234567890123",
+ customer_name="Test Organization",
+ max_price="1000000",
+ currency_code="RUB",
+ placement_method="Electronic auction",
+ publish_date="2025-01-01",
+ end_date="2025-02-01",
+ status="Published",
+ law_type="44-FZ",
+ )
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_parse_procurements_success(self, mock_create_job, mock_client_class):
+ """Успешный парсинг закупок."""
+ from apps.parsers.tasks import parse_procurements
+
+ # Настройка моков
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ mock_client.fetch_procurements.return_value = [
+ self._create_mock_procurement("1111111111111111111"),
+ self._create_mock_procurement("2222222222222222222"),
+ ]
+
+ # Запуск задачи
+ result = parse_procurements(
+ region_code="77",
+ year=2025,
+ month=1,
+ law_type="44",
+ )
+
+ # Проверки
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["saved"], 2)
+ self.assertIn("batch_id", result)
+
+ # Проверяем что BackgroundJob был обновлён
+ mock_job.mark_started.assert_called_once()
+ mock_job.complete.assert_called_once()
+
+ # Проверяем сохранение в БД
+ self.assertEqual(ProcurementRecord.objects.count(), 2)
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_parse_procurements_failure(self, mock_create_job, mock_client_class):
+ """Обработка ошибки при парсинге."""
+ from apps.parsers.tasks import parse_procurements
+
+ # Настройка моков
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ mock_client.fetch_procurements.side_effect = Exception("Network error")
+
+ # Запуск задачи
+ result = parse_procurements(
+ region_code="77",
+ year=2025,
+ month=1,
+ )
+
+ # Проверки
+ self.assertEqual(result["status"], "failed")
+ self.assertIn("error", result)
+
+ # Проверяем что BackgroundJob был помечен как failed
+ mock_job.fail.assert_called_once()
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_parse_procurements_empty_result(self, mock_create_job, mock_client_class):
+ """Парсинг без результатов."""
+ from apps.parsers.tasks import parse_procurements
+
+ # Настройка моков
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ mock_client.fetch_procurements.return_value = []
+
+ # Запуск задачи
+ result = parse_procurements(
+ region_code="77",
+ year=2025,
+ month=1,
+ )
+
+ # Проверки
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["saved"], 0)
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_parse_procurements_with_file_url(self, mock_create_job, mock_client_class):
+ """Парсинг по прямой ссылке."""
+ from apps.parsers.tasks import parse_procurements
+
+ # Настройка моков
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ mock_client.fetch_procurements.return_value = [self._create_mock_procurement()]
+
+ # Запуск задачи с прямой ссылкой
+ result = parse_procurements(
+ file_url="http://example.com/data.xml",
+ )
+
+ # Проверки
+ self.assertEqual(result["status"], "success")
+
+ # Проверяем что клиент был вызван с file_url
+ mock_client.fetch_procurements.assert_called_once()
+ call_kwargs = mock_client.fetch_procurements.call_args.kwargs
+ self.assertEqual(call_kwargs["file_url"], "http://example.com/data.xml")
+
+
+@override_settings(
+ CELERY_TASK_ALWAYS_EAGER=True,
+ CELERY_TASK_EAGER_PROPAGATES=True,
+)
+class SyncProcurementsTaskTestCase(TestCase):
+ """Тесты задачи sync_procurements."""
+
+ def _create_mock_procurement(self, number: str):
+ """Создать мок закупки."""
+ return Procurement(
+ purchase_number=number,
+ purchase_name="Test",
+ customer_inn="1234567890",
+ customer_kpp="",
+ customer_ogrn="",
+ customer_name="Test Org",
+ max_price="1000000",
+ currency_code="RUB",
+ placement_method="",
+ publish_date="2025-01-01",
+ end_date="",
+ status="",
+ law_type="44-FZ",
+ )
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_sync_starts_from_default_date(self, mock_create_job, mock_client_class):
+ """Синхронизация начинается с дефолтной даты если нет данных."""
+ from apps.parsers.tasks import sync_procurements
+
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ # Возвращаем пустой список для всех месяцев, чтобы быстро завершиться
+ mock_client.fetch_procurements.return_value = []
+
+ result = sync_procurements(region_code="77", law_type="44")
+
+ self.assertEqual(result["status"], "success")
+ # Клиент должен был быть вызван
+ self.assertTrue(mock_client.fetch_procurements.called)
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_sync_continues_from_last_loaded(self, mock_create_job, mock_client_class):
+ """Синхронизация продолжается с последнего загруженного месяца."""
+ from apps.parsers.tasks import sync_procurements
+
+ # Создаём существующие данные за январь
+ ProcurementRecordFactory(
+ data_year=2025,
+ data_month=1,
+ region_code="77",
+ law_type="44-FZ",
+ )
+
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ mock_client.fetch_procurements.return_value = []
+
+ result = sync_procurements(region_code="77", law_type="44")
+
+ self.assertEqual(result["status"], "success")
+
+ # Проверяем что первый вызов был для февраля (следующий месяц)
+ if mock_client.fetch_procurements.called:
+ first_call_kwargs = mock_client.fetch_procurements.call_args_list[0].kwargs
+ # Должен начать с февраля 2025
+ self.assertEqual(first_call_kwargs.get("year"), 2025)
+ self.assertEqual(first_call_kwargs.get("month"), 2)
+
+ @patch("apps.parsers.tasks.ZakupkiClient")
+ @patch("apps.parsers.tasks.BackgroundJobService.create_job")
+ def test_sync_stops_after_empty_months(self, mock_create_job, mock_client_class):
+ """Синхронизация останавливается после 2 пустых месяцев подряд."""
+ from apps.parsers.tasks import sync_procurements
+
+ mock_job = MagicMock()
+ mock_create_job.return_value = mock_job
+
+ mock_client = MagicMock()
+ mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client)
+ mock_client_class.return_value.__exit__ = MagicMock(return_value=False)
+ # Первый месяц - есть данные, потом 2 пустых
+ mock_client.fetch_procurements.side_effect = [
+ [self._create_mock_procurement("1111111111111111111")],
+ [], # Пустой
+ [], # Пустой - должен остановиться
+ ]
+
+ result = sync_procurements(region_code="77", law_type="44")
+
+ self.assertEqual(result["status"], "success")
+ # Должен был вызваться 3 раза (1 с данными + 2 пустых)
+ self.assertEqual(mock_client.fetch_procurements.call_count, 3)
+
+
+class ParserLoadLogServiceTestCase(TestCase):
+ """Тесты ParserLoadLogService для закупок."""
+
+ def test_get_next_batch_id_new_source(self):
+ """Получение следующего batch_id для нового источника."""
+ from apps.parsers.services import ParserLoadLogService
+
+ batch_id = ParserLoadLogService.get_next_batch_id(
+ ParserLoadLog.Source.PROCUREMENTS
+ )
+
+ self.assertEqual(batch_id, 1)
+
+ def test_get_next_batch_id_increments(self):
+ """Batch ID увеличивается при каждом вызове."""
+ from apps.parsers.services import ParserLoadLogService
+
+ ParserLoadLogFactory(source=ParserLoadLog.Source.PROCUREMENTS, batch_id=5)
+
+ batch_id = ParserLoadLogService.get_next_batch_id(
+ ParserLoadLog.Source.PROCUREMENTS
+ )
+
+ self.assertEqual(batch_id, 6)
+
+ def test_create_load_log(self):
+ """Создание записи лога загрузки."""
+ from apps.parsers.services import ParserLoadLogService
+
+ log = ParserLoadLogService.create_load_log(
+ source=ParserLoadLog.Source.PROCUREMENTS,
+ batch_id=1,
+ status="in_progress",
+ )
+
+ self.assertIsNotNone(log.pk)
+ self.assertEqual(log.source, ParserLoadLog.Source.PROCUREMENTS)
+ self.assertEqual(log.batch_id, 1)
+ self.assertEqual(log.status, "in_progress")
+
+ def test_update_load_log(self):
+ """Обновление записи лога."""
+ from apps.parsers.services import ParserLoadLogService
+
+ log = ParserLoadLogFactory(
+ source=ParserLoadLog.Source.PROCUREMENTS,
+ status="in_progress",
+ )
+
+ ParserLoadLogService.update(
+ log,
+ status="success",
+ records_count=100,
+ )
+
+ log.refresh_from_db()
+ self.assertEqual(log.status, "success")
+ self.assertEqual(log.records_count, 100)
+
+ def test_mark_failed(self):
+ """Пометка лога как failed."""
+ from apps.parsers.services import ParserLoadLogService
+
+ log = ParserLoadLogFactory(
+ source=ParserLoadLog.Source.PROCUREMENTS,
+ status="in_progress",
+ )
+
+ ParserLoadLogService.mark_failed(log, "Test error message")
+
+ log.refresh_from_db()
+ self.assertEqual(log.status, "failed")
+ self.assertEqual(log.error_message, "Test error message")
diff --git a/src/apps/parsers/tests/test_zakupki_client.py b/src/apps/parsers/tests/test_zakupki_client.py
new file mode 100644
index 0000000..b7ea3cb
--- /dev/null
+++ b/src/apps/parsers/tests/test_zakupki_client.py
@@ -0,0 +1,404 @@
+"""
+Unit-тесты для ZakupkiClient.
+
+Тестирует клиент для парсинга данных с zakupki.gov.ru.
+Использует моки для HTTP запросов.
+"""
+
+import io
+import zipfile
+from unittest.mock import patch
+
+from apps.parsers.clients.zakupki import ZakupkiClient, ZakupkiClientError
+from apps.parsers.clients.zakupki.schemas import Procurement, ProcurementPlan
+from django.test import SimpleTestCase
+
+
+class ZakupkiClientInitTestCase(SimpleTestCase):
+ """Тесты инициализации клиента."""
+
+ def test_init_default(self):
+ """Клиент создаётся с настройками по умолчанию."""
+ client = ZakupkiClient()
+ self.assertEqual(client.host, "zakupki.gov.ru")
+ self.assertEqual(client.timeout, 120)
+ self.assertIsNone(client.proxies)
+
+ def test_init_with_proxies(self):
+ """Клиент создаётся с прокси."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = ZakupkiClient(proxies=proxies)
+ self.assertEqual(client.proxies, proxies)
+
+ def test_init_with_custom_timeout(self):
+ """Клиент создаётся с кастомным таймаутом."""
+ client = ZakupkiClient(timeout=60)
+ self.assertEqual(client.timeout, 60)
+
+ def test_context_manager(self):
+ """Клиент поддерживает context manager."""
+ with ZakupkiClient() as client:
+ self.assertIsInstance(client, ZakupkiClient)
+
+
+class ZakupkiClientDiscoverFilesTestCase(SimpleTestCase):
+ """Тесты метода _discover_data_files."""
+
+ def test_discover_files_with_region_and_year(self):
+ """Поиск файлов с регионом и годом."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(region_code="77", year=2025)
+
+ self.assertEqual(len(plans), 1)
+ self.assertIsInstance(plans[0], ProcurementPlan)
+ self.assertEqual(plans[0].region_code, "77")
+ self.assertEqual(plans[0].year, 2025)
+ self.assertIsNone(plans[0].month)
+
+ def test_discover_files_with_month(self):
+ """Поиск файлов с указанием месяца."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(region_code="77", year=2025, month=3)
+
+ self.assertEqual(len(plans), 1)
+ self.assertEqual(plans[0].month, 3)
+ # URL содержит год и месяц
+ self.assertIn("2025", plans[0].file_url)
+ self.assertIn("03", plans[0].file_url)
+
+ def test_discover_files_empty_without_region(self):
+ """Без региона возвращается пустой список."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(year=2025)
+ self.assertEqual(plans, [])
+
+ def test_discover_files_empty_without_year(self):
+ """Без года возвращается пустой список."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(region_code="77")
+ self.assertEqual(plans, [])
+
+ def test_discover_files_law_type_44(self):
+ """Поиск файлов по 44-ФЗ."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(region_code="77", year=2025, law_type="44")
+
+ self.assertEqual(len(plans), 1)
+ self.assertIn("fz44", plans[0].file_url)
+
+ def test_discover_files_law_type_223(self):
+ """Поиск файлов по 223-ФЗ."""
+ client = ZakupkiClient()
+ plans = client._discover_data_files(region_code="77", year=2025, law_type="223")
+
+ self.assertEqual(len(plans), 1)
+ self.assertIn("fz223", plans[0].file_url)
+
+
+class ZakupkiClientParseXMLTestCase(SimpleTestCase):
+ """Тесты парсинга XML."""
+
+ def setUp(self):
+ """Подготовка тестовых данных."""
+ self.client = ZakupkiClient()
+
+ # Минимальный валидный XML с закупкой
+ self.valid_xml = b"""
+
+
+ 0123456789012345678
+ Test procurement
+
+ 1234567890
+ 123456789
+ 1234567890123
+ Test Organization
+
+
+ 1000000
+
+ RUB
+
+
+
+ Electronic auction
+
+ 2025-01-15
+ 2025-02-15
+ Published
+
+
+ """
+
+ self.empty_xml = b"""
+
+ """
+
+ self.invalid_xml = b"not xml content"
+
+ def test_parse_xml_valid(self):
+ """Парсинг валидного XML."""
+ procurements = self.client._parse_xml_content(self.valid_xml, None)
+
+ self.assertEqual(len(procurements), 1)
+ proc = procurements[0]
+ self.assertIsInstance(proc, Procurement)
+ self.assertEqual(proc.purchase_number, "0123456789012345678")
+ self.assertEqual(proc.customer_inn, "1234567890")
+ self.assertEqual(proc.customer_name, "Test Organization")
+ self.assertEqual(proc.max_price, "1000000")
+
+ def test_parse_xml_empty(self):
+ """Парсинг пустого XML возвращает пустой список."""
+ procurements = self.client._parse_xml_content(self.empty_xml, None)
+ self.assertEqual(procurements, [])
+
+ def test_parse_xml_invalid(self):
+ """Невалидный XML вызывает исключение."""
+ with self.assertRaises(ZakupkiClientError):
+ self.client._parse_xml_content(self.invalid_xml, None)
+
+ def test_parse_xml_with_namespace(self):
+ """Парсинг XML с namespace."""
+ xml_with_ns = b"""
+
+
+ 9876543210123456789
+
+ 9876543210
+ NS Organization
+
+
+
+ """
+ procurements = self.client._parse_xml_content(xml_with_ns, None)
+ # Парсер должен обработать или вернуть пустой список
+ # (зависит от реализации обработки namespace)
+ self.assertIsInstance(procurements, list)
+
+ def test_parse_xml_windows1251_encoding(self):
+ """Парсинг XML в кодировке Windows-1251."""
+ xml_cp1251 = """
+
+
+ 1111111111111111111
+
+ 1111111111
+ Тестовая Организация
+
+
+
+ """.encode("windows-1251")
+
+ procurements = self.client._parse_xml_content(xml_cp1251, None)
+ self.assertEqual(len(procurements), 1)
+ self.assertEqual(procurements[0].customer_name, "Тестовая Организация")
+
+
+class ZakupkiClientParseZIPTestCase(SimpleTestCase):
+ """Тесты парсинга ZIP архивов."""
+
+ def setUp(self):
+ """Подготовка тестовых данных."""
+ self.client = ZakupkiClient()
+
+ def _create_zip_with_xml(self, xml_content: bytes, filename: str = "data.xml"):
+ """Создать ZIP архив с XML файлом."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr(filename, xml_content)
+ return buffer.getvalue()
+
+ def test_parse_zip_with_xml(self):
+ """Парсинг ZIP архива с XML файлом."""
+ xml_content = b"""
+
+
+ 1234567890123456789
+
+ 1234567890
+ ZIP Test Org
+
+
+
+ """
+ zip_content = self._create_zip_with_xml(xml_content)
+
+ procurements = self.client._parse_zip_archive(zip_content, None)
+
+ self.assertEqual(len(procurements), 1)
+ self.assertEqual(procurements[0].purchase_number, "1234567890123456789")
+
+ def test_parse_zip_empty(self):
+ """Парсинг пустого ZIP архива."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w"):
+ pass # Пустой архив
+ zip_content = buffer.getvalue()
+
+ procurements = self.client._parse_zip_archive(zip_content, None)
+ self.assertEqual(procurements, [])
+
+ def test_parse_zip_multiple_xml_files(self):
+ """Парсинг ZIP с несколькими XML файлами."""
+ xml1 = b"""
+
+ 1111111111111111111
+ 1111111111Org1
+
+ """
+
+ xml2 = b"""
+
+ 2222222222222222222
+ 2222222222Org2
+
+ """
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as zf:
+ zf.writestr("file1.xml", xml1)
+ zf.writestr("file2.xml", xml2)
+ zip_content = buffer.getvalue()
+
+ procurements = self.client._parse_zip_archive(zip_content, None)
+
+ self.assertEqual(len(procurements), 2)
+ numbers = {p.purchase_number for p in procurements}
+ self.assertIn("1111111111111111111", numbers)
+ self.assertIn("2222222222222222222", numbers)
+
+
+class ZakupkiClientFetchTestCase(SimpleTestCase):
+ """Тесты метода fetch_procurements с моками."""
+
+ def setUp(self):
+ """Подготовка тестовых данных."""
+ # Отключаем FTP для использования HTTP логики в тестах
+ # Без токена клиент использует HTTP fallback
+ self.client = ZakupkiClient()
+
+ @patch.object(ZakupkiClient, "_download_and_parse_http")
+ @patch.object(ZakupkiClient, "_discover_data_files")
+ def test_fetch_with_region_and_year(self, mock_discover, mock_download):
+ """Загрузка закупок по региону и году."""
+ mock_discover.return_value = [
+ ProcurementPlan(
+ region_code="77",
+ year=2025,
+ month=None,
+ file_url="http://test.url/data.zip",
+ file_name="data.zip",
+ )
+ ]
+ mock_download.return_value = [
+ Procurement(
+ purchase_number="1234567890123456789",
+ purchase_name="Test",
+ customer_inn="1234567890",
+ customer_kpp="123456789",
+ customer_ogrn="1234567890123",
+ customer_name="Test Org",
+ max_price="1000000",
+ currency_code="RUB",
+ placement_method="Auction",
+ publish_date="2025-01-01",
+ end_date="2025-02-01",
+ status="Published",
+ law_type="44-FZ",
+ )
+ ]
+
+ procurements = self.client.fetch_procurements(region_code="77", year=2025)
+
+ self.assertEqual(len(procurements), 1)
+ mock_discover.assert_called_once()
+ mock_download.assert_called_once()
+
+ @patch.object(ZakupkiClient, "_download_and_parse_http")
+ def test_fetch_with_direct_url(self, mock_download):
+ """Загрузка закупок по прямой ссылке."""
+ mock_download.return_value = [
+ Procurement(
+ purchase_number="9999999999999999999",
+ purchase_name="Direct URL Test",
+ customer_inn="9999999999",
+ customer_kpp="",
+ customer_ogrn="",
+ customer_name="Direct Org",
+ max_price="500000",
+ currency_code="RUB",
+ placement_method="",
+ publish_date="2025-01-01",
+ end_date="",
+ status="",
+ law_type="",
+ )
+ ]
+
+ procurements = self.client.fetch_procurements(
+ file_url="http://direct.url/data.xml"
+ )
+
+ self.assertEqual(len(procurements), 1)
+ self.assertEqual(procurements[0].purchase_number, "9999999999999999999")
+ mock_download.assert_called_once()
+
+ @patch.object(ZakupkiClient, "_discover_data_files")
+ def test_fetch_no_files_found(self, mock_discover):
+ """Возвращает пустой список если файлы не найдены."""
+ mock_discover.return_value = []
+
+ procurements = self.client.fetch_procurements(region_code="77", year=2025)
+
+ self.assertEqual(procurements, [])
+
+ def test_fetch_progress_callback(self):
+ """Тест callback для прогресса."""
+ progress_calls = []
+
+ def callback(percent, message):
+ progress_calls.append((percent, message))
+
+ with patch.object(ZakupkiClient, "_discover_data_files", return_value=[]):
+ self.client.fetch_procurements(
+ region_code="77", year=2025, progress_callback=callback
+ )
+
+ # Должен быть вызван хотя бы один раз
+ self.assertGreater(len(progress_calls), 0)
+ self.assertEqual(progress_calls[0][0], 0) # Начало с 0%
+
+
+class ZakupkiClientSanitizeXMLTestCase(SimpleTestCase):
+ """Тесты метода _sanitize_xml."""
+
+ def setUp(self):
+ """Подготовка."""
+ self.client = ZakupkiClient()
+
+ def test_sanitize_removes_control_chars(self):
+ """Удаляет управляющие символы."""
+ dirty_xml = "Test\x00\x01\x02"
+ clean_xml = self.client._sanitize_xml(dirty_xml)
+
+ self.assertNotIn("\x00", clean_xml)
+ self.assertNotIn("\x01", clean_xml)
+ self.assertNotIn("\x02", clean_xml)
+
+ def test_sanitize_escapes_ampersands(self):
+ """Экранирует неэкранированные амперсанды."""
+ dirty_xml = "Test & Company"
+ clean_xml = self.client._sanitize_xml(dirty_xml)
+
+ self.assertIn("&", clean_xml)
+
+ def test_sanitize_keeps_valid_entities(self):
+ """Сохраняет валидные XML сущности."""
+ valid_xml = "& < > ""
+ clean_xml = self.client._sanitize_xml(valid_xml)
+
+ self.assertIn("&", clean_xml)
+ self.assertIn("<", clean_xml)
+ self.assertIn(">", clean_xml)
+ self.assertIn(""", clean_xml)