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}