333 lines
12 KiB
Python
333 lines
12 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,
|
|
test_wildberries_connection,
|
|
)
|
|
from commerce.services.credentials import CredentialError
|
|
from commerce.integrations.moysklad import MoySkladError
|
|
from commerce.integrations.wildberries import WildberriesError
|
|
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:
|
|
if connection.provider == "wildberries":
|
|
context = test_wildberries_connection(connection)
|
|
elif connection.provider == "moysklad":
|
|
context = test_moysklad_connection(connection)
|
|
else:
|
|
return Response(
|
|
{"detail": "Неподдерживаемый провайдер интеграции."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except (MoySkladError, WildberriesError, 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"],
|
|
}
|
|
if context.get("tin"):
|
|
connection.source.metadata["tin"] = context["tin"]
|
|
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"]
|