Corp/backend/api/v1/commerce/viewsets.py
2026-08-18 09:28:58 +03:00

321 lines
11 KiB
Python

import logging
from django.utils import timezone
from datetime import timedelta
from django.db.models.deletion import ProtectedError
from django.db.models import Count
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework import status
from rest_framework.response import Response
from commerce.models import (
Cost,
IntegrationConnection,
IntegrationStatus,
IntegrationSyncRun,
Product,
ProductBinding,
Sale,
SaleLine,
SourceSystem,
StockSnapshot,
)
from commerce.services import (
get_product_metrics,
merge_products,
test_moysklad_connection,
)
from commerce.services.credentials import CredentialError
from commerce.integrations.moysklad import MoySkladError
from commerce.search import UnicodeCasefoldSearchFilter
from commerce.tasks import enqueue_connection_sync
from .permissions import CommerceAccessPermission
from .serializers import (
CostSerializer,
IntegrationConnectionSerializer,
IntegrationSyncRunSerializer,
ProductBindingSerializer,
ProductMergeSerializer,
ProductMetricsQuerySerializer,
ProductSerializer,
SaleSerializer,
SaleLineSerializer,
SourceSystemSerializer,
StockSnapshotSerializer,
)
logger = logging.getLogger(__name__)
class IntegrationConnectionViewSet(viewsets.ModelViewSet):
queryset = IntegrationConnection.objects.select_related("source")
serializer_class = IntegrationConnectionSerializer
permission_classes = [CommerceAccessPermission]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
@action(detail=True, methods=["post"])
def test(self, request, pk=None):
connection = self.get_object()
try:
context = test_moysklad_connection(connection)
except (MoySkladError, CredentialError) as exc:
logger.warning(
"Commerce connection test failed",
extra={
"event": "commerce.connection.test_failed",
"connection_id": str(connection.pk),
"provider": connection.provider,
"error_type": type(exc).__name__,
"failure_reason": str(exc),
},
)
connection.status = IntegrationStatus.ERROR
connection.last_error = str(exc)
connection.last_tested_at = timezone.now()
connection.save(
update_fields=[
"status",
"last_error",
"last_tested_at",
"updated_at",
]
)
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
connection.status = IntegrationStatus.READY
connection.account_name = context["account_name"]
connection.last_error = ""
connection.last_tested_at = timezone.now()
connection.next_sync_at = (
timezone.now() + timedelta(minutes=connection.sync_interval_minutes)
if connection.auto_sync
else None
)
connection.source.metadata = {
**connection.source.metadata,
"account_id": context["account_id"],
}
connection.source.save(update_fields=["metadata", "updated_at"])
connection.save(
update_fields=[
"status",
"account_name",
"last_error",
"last_tested_at",
"next_sync_at",
"updated_at",
]
)
logger.info(
"Commerce connection test succeeded",
extra={
"event": "commerce.connection.test_succeeded",
"connection_id": str(connection.pk),
"provider": connection.provider,
},
)
return Response(self.get_serializer(connection).data)
@action(detail=True, methods=["post"])
def sync(self, request, pk=None):
connection = self.get_object()
if connection.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}:
return Response(
{"detail": "Синхронизация уже выполняется."},
status=status.HTTP_409_CONFLICT,
)
run = enqueue_connection_sync(connection)
if run is None:
return Response(
{"detail": "Синхронизация уже выполняется."},
status=status.HTTP_409_CONFLICT,
)
return Response(
IntegrationSyncRunSerializer(run).data,
status=status.HTTP_202_ACCEPTED,
)
class IntegrationSyncRunViewSet(viewsets.ReadOnlyModelViewSet):
queryset = IntegrationSyncRun.objects.select_related("connection")
serializer_class = IntegrationSyncRunSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["connection", "status"]
class SourceSystemViewSet(viewsets.ModelViewSet):
queryset = SourceSystem.objects.all()
serializer_class = SourceSystemSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["code", "name"]
ordering_fields = ["code", "name", "created_at"]
ordering = ["name"]
def destroy(self, request, *args, **kwargs):
source = self.get_object()
try:
self.perform_destroy(source)
except ProtectedError as exc:
protected_count = len(exc.protected_objects)
logger.info(
"Commerce source deletion rejected because it is in use",
extra={
"event": "commerce.source.delete_conflict",
"source_id": source.pk,
"source_code": source.code,
"protected_count": protected_count,
},
)
return Response(
{
"code": "source_in_use",
"detail": (
"Источник используется подключением или импортированными "
"данными. Сначала удалите связанные записи."
),
"protected_count": protected_count,
},
status=status.HTTP_409_CONFLICT,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [
DjangoFilterBackend,
UnicodeCasefoldSearchFilter,
filters.OrderingFilter,
]
filterset_fields = ["kind", "is_active", "parent"]
search_fields = [
"sku__unicode_casefold__contains",
"name__unicode_casefold__contains",
"barcode__unicode_casefold__contains",
"bindings__external_sku__unicode_casefold__contains",
]
ordering_fields = ["sku", "name", "created_at", "updated_at"]
ordering = ["name", "sku"]
def get_queryset(self):
return (
super()
.get_queryset()
.select_related("parent")
.prefetch_related("bindings__source", "images")
.annotate(variants_count=Count("variants", distinct=True))
.distinct()
)
@action(detail=True, methods=["get"])
def analytics(self, request, pk=None):
query = ProductMetricsQuerySerializer(data=request.query_params)
query.is_valid(raise_exception=True)
product = self.get_object()
return Response(get_product_metrics(product, **query.validated_data))
@action(detail=True, methods=["post"])
def merge(self, request, pk=None):
target = self.get_object()
serializer = ProductMergeSerializer(
data=request.data,
context={"target": target},
)
serializer.is_valid(raise_exception=True)
result = merge_products(
target=target,
source_products=serializer.validated_data["source_products"],
)
refreshed = self.get_queryset().get(pk=result.target.pk)
return Response(
{
"product": ProductSerializer(refreshed).data,
"merged_products": result.merged_products,
"moved_bindings": result.moved_bindings,
}
)
class ProductBindingViewSet(viewsets.ModelViewSet):
queryset = ProductBinding.objects.select_related("product", "source")
serializer_class = ProductBindingSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["product", "source", "is_primary"]
search_fields = ["external_id", "external_sku", "product__sku", "product__name"]
class SaleViewSet(viewsets.ModelViewSet):
queryset = Sale.objects.select_related("source", "source_record").prefetch_related(
"lines__product"
)
serializer_class = SaleSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
"source",
"event_type",
"channel",
"status",
"counts_as_sale",
"currency",
]
search_fields = ["external_id", "number", "lines__external_sku", "lines__name"]
ordering_fields = ["occurred_at", "created_at", "updated_at"]
ordering = ["-occurred_at"]
class SaleLineViewSet(viewsets.ModelViewSet):
queryset = SaleLine.objects.select_related("sale", "product", "sale__source")
serializer_class = SaleLineSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["sale", "product"]
search_fields = ["external_id", "external_product_id", "external_sku", "name"]
class CostViewSet(viewsets.ModelViewSet):
queryset = Cost.objects.select_related("source", "product", "sale_line")
serializer_class = CostSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = ["source", "product", "channel", "cost_type", "currency"]
search_fields = ["external_id", "channel", "product__sku", "product__name"]
ordering_fields = ["occurred_at", "amount", "created_at"]
ordering = ["-occurred_at"]
class StockSnapshotViewSet(viewsets.ModelViewSet):
queryset = StockSnapshot.objects.select_related("source", "product")
serializer_class = StockSnapshotSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = ["source", "product", "warehouse_external_id"]
search_fields = [
"warehouse_external_id",
"warehouse_name",
"product__sku",
"product__name",
]
ordering_fields = ["captured_at", "quantity", "created_at"]
ordering = ["-captured_at"]