Corp/backend/commerce/tasks/__init__.py

226 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from datetime import timedelta
from celery import shared_task
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from commerce.integrations import sync_registered_source
from commerce.integrations.moysklad import MoySkladError
from commerce.models import IntegrationConnection, IntegrationStatus, IntegrationSyncRun
from commerce.realtime import send_connection_update
from commerce.services import sync_moysklad_connection
from commerce.services.credentials import CredentialError
logger = logging.getLogger(__name__)
def _next_sync_at(connection, *, now=None):
if not connection.auto_sync:
return None
return (now or timezone.now()) + timedelta(minutes=connection.sync_interval_minutes)
def enqueue_connection_sync(connection):
"""Create and publish one run while preventing overlapping scans."""
with transaction.atomic():
locked = IntegrationConnection.objects.select_for_update().get(pk=connection.pk)
if locked.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}:
return None
run = IntegrationSyncRun.objects.create(connection=locked)
locked.status = IntegrationStatus.QUEUED
locked.task_id = ""
locked.last_error = ""
locked.next_sync_at = _next_sync_at(locked)
locked.save(
update_fields=[
"status",
"task_id",
"last_error",
"next_sync_at",
"updated_at",
]
)
send_connection_update(locked, run=run)
try:
async_result = sync_moysklad_run.delay(str(run.pk))
except Exception:
retry_at = timezone.now() + timedelta(minutes=5)
IntegrationSyncRun.objects.filter(pk=run.pk).update(
status="error",
error="Не удалось поставить синхронизацию в очередь.",
finished_at=timezone.now(),
)
IntegrationConnection.objects.filter(pk=connection.pk).update(
status=IntegrationStatus.ERROR,
last_error="Не удалось поставить синхронизацию в очередь.",
next_sync_at=retry_at,
)
connection.refresh_from_db()
run.refresh_from_db()
send_connection_update(connection, run=run)
raise
task_id = async_result.id or ""
IntegrationSyncRun.objects.filter(pk=run.pk).update(task_id=task_id)
IntegrationConnection.objects.filter(pk=connection.pk).update(task_id=task_id)
run.task_id = task_id
return run
@shared_task(name="commerce.sync_source")
def sync_source(source_code: str, entity_type: str):
results = sync_registered_source(source_code, entity_type=entity_type)
return {
"processed": len(results),
"created": sum(result.created for result in results),
"changed": sum(result.changed for result in results),
}
@shared_task(bind=True, name="commerce.sync_moysklad_run")
def sync_moysklad_run(self, run_id: str):
run = IntegrationSyncRun.objects.select_related("connection__source").get(pk=run_id)
connection = run.connection
started_at = timezone.now()
run.status = "running"
run.started_at = started_at
run.task_id = self.request.id or run.task_id
run.error = ""
run.save(update_fields=["status", "started_at", "task_id", "error"])
connection.status = IntegrationStatus.SYNCING
connection.last_sync_started_at = started_at
connection.last_error = ""
connection.save(
update_fields=["status", "last_sync_started_at", "last_error", "updated_at"]
)
send_connection_update(connection, run=run)
def report_progress(current_stats):
now = timezone.now()
IntegrationSyncRun.objects.filter(pk=run.pk).update(
processed=current_stats.processed,
created_products=current_stats.created_products,
matched_products=current_stats.matched_products,
stock_snapshots=current_stats.stock_snapshots,
images=current_stats.images,
image_errors=current_stats.image_errors,
sales=current_stats.sales,
)
IntegrationConnection.objects.filter(pk=connection.pk).update(updated_at=now)
run.refresh_from_db()
connection.refresh_from_db()
send_connection_update(connection, run=run)
try:
stats = sync_moysklad_connection(
connection,
progress_callback=report_progress,
).to_dict()
except Exception as exc:
logger.exception(
"MoySklad synchronization failed",
extra={
"event": "commerce.connection.sync_failed",
"run_id": run_id,
"connection_id": str(connection.pk),
"provider": connection.provider,
"error_type": type(exc).__name__,
},
)
if isinstance(exc, (MoySkladError, CredentialError)):
message = str(exc)
else:
message = "Внутренняя ошибка синхронизации."
finished_at = timezone.now()
run.status = "error"
run.error = message
run.finished_at = finished_at
run.save(update_fields=["status", "error", "finished_at"])
connection.status = IntegrationStatus.ERROR
connection.last_error = message
connection.last_sync_finished_at = finished_at
connection.next_sync_at = _next_sync_at(connection, now=finished_at)
connection.save(
update_fields=[
"status",
"last_error",
"last_sync_finished_at",
"next_sync_at",
"updated_at",
]
)
send_connection_update(connection, run=run)
raise
finished_at = timezone.now()
run.status = "success"
run.processed = stats["processed"]
run.created_products = stats["created_products"]
run.matched_products = stats["matched_products"]
run.stock_snapshots = stats["stock_snapshots"]
run.images = stats["images"]
run.image_errors = stats["image_errors"]
run.sales = stats["sales"]
run.finished_at = finished_at
run.save(
update_fields=[
"status",
"processed",
"created_products",
"matched_products",
"stock_snapshots",
"images",
"image_errors",
"sales",
"finished_at",
]
)
connection.status = IntegrationStatus.READY
connection.last_stats = stats
connection.last_error = ""
connection.last_sync_finished_at = finished_at
connection.next_sync_at = _next_sync_at(connection, now=finished_at)
connection.save(
update_fields=[
"status",
"last_stats",
"last_error",
"last_sync_finished_at",
"next_sync_at",
"updated_at",
]
)
send_connection_update(connection, run=run)
return stats
@shared_task(name="commerce.dispatch_due_connection_syncs")
def dispatch_due_connection_syncs():
now = timezone.now()
stale_cutoff = now - timedelta(minutes=45)
IntegrationConnection.objects.filter(
auto_sync=True,
status__in=[IntegrationStatus.QUEUED, IntegrationStatus.SYNCING],
updated_at__lte=stale_cutoff,
).update(
status=IntegrationStatus.ERROR,
last_error="Предыдущая синхронизация была прервана и будет перезапущена.",
next_sync_at=now,
updated_at=now,
)
due_ids = list(
IntegrationConnection.objects.filter(
auto_sync=True,
status__in=[IntegrationStatus.READY, IntegrationStatus.ERROR],
)
.filter(Q(next_sync_at__isnull=True) | Q(next_sync_at__lte=now))
.values_list("pk", flat=True)
)
queued = 0
for connection in IntegrationConnection.objects.filter(pk__in=due_ids):
if enqueue_connection_sync(connection) is not None:
queued += 1
return {"queued": queued}