feat(parsers): добавлен парсер zakupki.gov.ru с SOAP API интеграцией

Реализована полная интеграция с ЕИС Закупки через SOAP API
(FTP доступ закрыт с 01.01.2025).

Добавлено:
- ZakupkiClient с поддержкой SOAP методов getDocsByOrgRegionRequest
  и getDocsByReestrNumberRequest
- Модель ProcurementRecord (18 полей, 3 индекса)
- ProcurementService и ParserLoadLogService для бизнес-логики
- Celery задачи parse_procurements и sync_procurements
- Админка с цветовой индикацией статусов и фильтрами
- 71 тест (unit + E2E с RUN_E2E_TESTS=1)

Требования: токен SOAP API через Госуслуги

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
2026-01-27 16:01:28 +01:00
parent 199d871923
commit c6483d8427
16 changed files with 3405 additions and 0 deletions

View File

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