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:
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user