473 lines
16 KiB
Python
473 lines
16 KiB
Python
from datetime import timedelta
|
||
|
||
from django.utils import timezone
|
||
from drf_spectacular.utils import extend_schema_field
|
||
from rest_framework import serializers
|
||
|
||
from commerce.models import (
|
||
Cost,
|
||
IntegrationConnection,
|
||
IntegrationProvider,
|
||
IntegrationSyncRun,
|
||
Product,
|
||
ProductBinding,
|
||
ProductImage,
|
||
Sale,
|
||
SaleLine,
|
||
SourceSystem,
|
||
StockSnapshot,
|
||
)
|
||
from commerce.services.credentials import encrypt_credentials
|
||
|
||
|
||
class SourceSystemSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = SourceSystem
|
||
fields = [
|
||
"id",
|
||
"code",
|
||
"name",
|
||
"is_active",
|
||
"metadata",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at"]
|
||
|
||
|
||
class IntegrationSyncRunSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = IntegrationSyncRun
|
||
fields = [
|
||
"id",
|
||
"connection",
|
||
"status",
|
||
"task_id",
|
||
"stage",
|
||
"stage_processed",
|
||
"stage_total",
|
||
"last_progress_at",
|
||
"processed",
|
||
"created_products",
|
||
"matched_products",
|
||
"stock_snapshots",
|
||
"images",
|
||
"image_errors",
|
||
"sales",
|
||
"costs",
|
||
"error",
|
||
"started_at",
|
||
"finished_at",
|
||
"created_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class IntegrationConnectionSerializer(serializers.ModelSerializer):
|
||
provider_name = serializers.CharField(source="get_provider_display", read_only=True)
|
||
source = SourceSystemSerializer(read_only=True)
|
||
credentials_configured = serializers.SerializerMethodField()
|
||
token = serializers.CharField(write_only=True, required=False, allow_blank=False)
|
||
login = serializers.CharField(write_only=True, required=False, allow_blank=False)
|
||
password = serializers.CharField(
|
||
write_only=True, required=False, allow_blank=False, trim_whitespace=False
|
||
)
|
||
recent_runs = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = IntegrationConnection
|
||
fields = [
|
||
"id",
|
||
"source",
|
||
"provider",
|
||
"provider_name",
|
||
"auth_type",
|
||
"credentials_configured",
|
||
"token",
|
||
"login",
|
||
"password",
|
||
"status",
|
||
"account_name",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"last_sync_started_at",
|
||
"last_sync_finished_at",
|
||
"last_stats",
|
||
"task_id",
|
||
"auto_sync",
|
||
"sync_interval_minutes",
|
||
"next_sync_at",
|
||
"recent_runs",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = [
|
||
"status",
|
||
"account_name",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"last_sync_started_at",
|
||
"last_sync_finished_at",
|
||
"last_stats",
|
||
"task_id",
|
||
"next_sync_at",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
validators = []
|
||
|
||
def get_credentials_configured(self, obj) -> bool:
|
||
return bool(obj.encrypted_credentials)
|
||
|
||
@extend_schema_field(IntegrationSyncRunSerializer(many=True))
|
||
def get_recent_runs(self, obj):
|
||
return IntegrationSyncRunSerializer(obj.sync_runs.all()[:5], many=True).data
|
||
|
||
def validate_provider(self, value):
|
||
if value not in {
|
||
IntegrationProvider.MOYSKLAD,
|
||
IntegrationProvider.WILDBERRIES,
|
||
}:
|
||
raise serializers.ValidationError("Этот сервис пока не поддерживается.")
|
||
if self.instance and value != self.instance.provider:
|
||
raise serializers.ValidationError("Провайдер подключения изменить нельзя.")
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
auth_type = attrs.get("auth_type", getattr(self.instance, "auth_type", "token"))
|
||
provider = attrs.get("provider", getattr(self.instance, "provider", None))
|
||
if provider == IntegrationProvider.WILDBERRIES and auth_type != "token":
|
||
raise serializers.ValidationError(
|
||
{
|
||
"auth_type": "Wildberries поддерживает авторизацию только по API-токену."
|
||
}
|
||
)
|
||
has_new_credentials = any(
|
||
key in attrs for key in ("token", "login", "password")
|
||
)
|
||
if self.instance is None or has_new_credentials:
|
||
if auth_type == "token" and not attrs.get("token"):
|
||
raise serializers.ValidationError({"token": "Укажите токен."})
|
||
if auth_type == "basic":
|
||
errors = {}
|
||
if not attrs.get("login"):
|
||
errors["login"] = "Укажите логин."
|
||
if not attrs.get("password"):
|
||
errors["password"] = "Укажите пароль."
|
||
if errors:
|
||
raise serializers.ValidationError(errors)
|
||
return attrs
|
||
|
||
def _credentials(self, validated_data):
|
||
auth_type = validated_data.get(
|
||
"auth_type", getattr(self.instance, "auth_type", "token")
|
||
)
|
||
if auth_type == "token":
|
||
token = validated_data.pop("token", None)
|
||
validated_data.pop("login", None)
|
||
validated_data.pop("password", None)
|
||
return {"token": token} if token else None
|
||
login = validated_data.pop("login", None)
|
||
password = validated_data.pop("password", None)
|
||
validated_data.pop("token", None)
|
||
return {"login": login, "password": password} if login and password else None
|
||
|
||
def create(self, validated_data):
|
||
credentials = self._credentials(validated_data)
|
||
provider = validated_data["provider"]
|
||
if IntegrationConnection.objects.filter(provider=provider).exists():
|
||
raise serializers.ValidationError(
|
||
{"provider": "Этот сервис уже подключён."}
|
||
)
|
||
source, _ = SourceSystem.objects.get_or_create(
|
||
code=provider,
|
||
defaults={"name": IntegrationProvider(provider).label},
|
||
)
|
||
if provider == IntegrationProvider.WILDBERRIES:
|
||
validated_data.setdefault("sync_interval_minutes", 60)
|
||
return IntegrationConnection.objects.create(
|
||
source=source,
|
||
created_by=self.context["request"].user,
|
||
encrypted_credentials=encrypt_credentials(credentials or {}),
|
||
**validated_data,
|
||
)
|
||
|
||
def update(self, instance, validated_data):
|
||
previous_auth_type = instance.auth_type
|
||
credentials = self._credentials(validated_data)
|
||
if credentials:
|
||
instance.encrypted_credentials = encrypt_credentials(credentials)
|
||
for field, value in validated_data.items():
|
||
setattr(instance, field, value)
|
||
credentials_changed = credentials or instance.auth_type != previous_auth_type
|
||
if credentials_changed:
|
||
instance.status = "disconnected"
|
||
instance.last_error = ""
|
||
if "auto_sync" in validated_data or "sync_interval_minutes" in validated_data:
|
||
instance.next_sync_at = (
|
||
timezone.now() + timedelta(minutes=instance.sync_interval_minutes)
|
||
if instance.auto_sync
|
||
else None
|
||
)
|
||
instance.save()
|
||
return instance
|
||
|
||
|
||
class ProductBindingSerializer(serializers.ModelSerializer):
|
||
source_code = serializers.CharField(source="source.code", read_only=True)
|
||
|
||
class Meta:
|
||
model = ProductBinding
|
||
fields = [
|
||
"id",
|
||
"product",
|
||
"source",
|
||
"source_code",
|
||
"external_id",
|
||
"external_sku",
|
||
"metadata",
|
||
"is_primary",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at"]
|
||
|
||
|
||
class ProductImageSerializer(serializers.ModelSerializer):
|
||
url = serializers.ImageField(source="file", read_only=True)
|
||
thumbnail_url = serializers.ImageField(source="thumbnail", read_only=True)
|
||
|
||
class Meta:
|
||
model = ProductImage
|
||
fields = [
|
||
"id",
|
||
"url",
|
||
"thumbnail_url",
|
||
"filename",
|
||
"title",
|
||
"source_size",
|
||
"position",
|
||
"is_primary",
|
||
"source_updated_at",
|
||
]
|
||
|
||
|
||
class ProductSerializer(serializers.ModelSerializer):
|
||
bindings = ProductBindingSerializer(many=True, read_only=True)
|
||
images = ProductImageSerializer(many=True, read_only=True)
|
||
variants_count = serializers.IntegerField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Product
|
||
fields = [
|
||
"id",
|
||
"parent",
|
||
"kind",
|
||
"sku",
|
||
"name",
|
||
"barcode",
|
||
"description",
|
||
"is_active",
|
||
"variants_count",
|
||
"bindings",
|
||
"images",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = ["id", "created_at", "updated_at"]
|
||
|
||
|
||
class ProductMergeSerializer(serializers.Serializer):
|
||
source_product_ids = serializers.PrimaryKeyRelatedField(
|
||
queryset=Product.objects.all(),
|
||
many=True,
|
||
source="source_products",
|
||
)
|
||
|
||
def validate_source_product_ids(self, value):
|
||
target = self.context["target"]
|
||
filtered = [product for product in value if product.pk != target.pk]
|
||
if not filtered:
|
||
raise serializers.ValidationError("Выберите хотя бы один другой товар.")
|
||
return filtered
|
||
|
||
|
||
class SaleLineSerializer(serializers.ModelSerializer):
|
||
net_amount = serializers.DecimalField(
|
||
max_digits=18, decimal_places=2, read_only=True
|
||
)
|
||
|
||
class Meta:
|
||
model = SaleLine
|
||
fields = [
|
||
"id",
|
||
"sale",
|
||
"product",
|
||
"external_id",
|
||
"external_product_id",
|
||
"external_sku",
|
||
"name",
|
||
"quantity",
|
||
"gross_amount",
|
||
"discount_amount",
|
||
"net_amount",
|
||
"cost_amount",
|
||
"metadata",
|
||
]
|
||
|
||
def validate(self, attrs):
|
||
gross = attrs.get("gross_amount", getattr(self.instance, "gross_amount", 0))
|
||
discount = attrs.get(
|
||
"discount_amount", getattr(self.instance, "discount_amount", 0)
|
||
)
|
||
if discount > gross:
|
||
raise serializers.ValidationError(
|
||
{"discount_amount": "Скидка не может превышать сумму позиции."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class SaleSerializer(serializers.ModelSerializer):
|
||
source_code = serializers.CharField(source="source.code", read_only=True)
|
||
lines = SaleLineSerializer(many=True, read_only=True)
|
||
|
||
def validate(self, attrs):
|
||
project = attrs.get("project", getattr(self.instance, "project", None))
|
||
source = attrs.get("source", getattr(self.instance, "source", None))
|
||
if project and source and project.source_id != source.pk:
|
||
raise serializers.ValidationError(
|
||
{"project": "Проект относится к другому источнику."}
|
||
)
|
||
return attrs
|
||
|
||
class Meta:
|
||
model = Sale
|
||
fields = [
|
||
"id",
|
||
"source",
|
||
"source_code",
|
||
"source_record",
|
||
"external_id",
|
||
"number",
|
||
"event_type",
|
||
"channel",
|
||
"customer_external_id",
|
||
"customer_name",
|
||
"project",
|
||
"status",
|
||
"counts_as_sale",
|
||
"currency",
|
||
"occurred_at",
|
||
"source_updated_at",
|
||
"lines",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class CostSerializer(serializers.ModelSerializer):
|
||
source_code = serializers.CharField(source="source.code", read_only=True)
|
||
|
||
class Meta:
|
||
model = Cost
|
||
fields = [
|
||
"id",
|
||
"source",
|
||
"source_code",
|
||
"product",
|
||
"sale_line",
|
||
"project",
|
||
"external_id",
|
||
"channel",
|
||
"cost_type",
|
||
"amount",
|
||
"currency",
|
||
"occurred_at",
|
||
"metadata",
|
||
"created_at",
|
||
]
|
||
validators = []
|
||
|
||
def validate(self, attrs):
|
||
product = attrs.get("product", getattr(self.instance, "product", None))
|
||
sale_line = attrs.get("sale_line", getattr(self.instance, "sale_line", None))
|
||
project = attrs.get("project", getattr(self.instance, "project", None))
|
||
source = attrs.get("source", getattr(self.instance, "source", None))
|
||
if project and source and project.source_id != source.pk:
|
||
raise serializers.ValidationError(
|
||
{"project": "Проект относится к другому источнику."}
|
||
)
|
||
if (
|
||
project
|
||
and sale_line
|
||
and sale_line.sale.project_id not in [None, project.pk]
|
||
):
|
||
raise serializers.ValidationError(
|
||
{"project": "Проект не совпадает с проектом продажи."}
|
||
)
|
||
if product is None and sale_line is None and project is None:
|
||
raise serializers.ValidationError(
|
||
"Нужно указать товар, позицию продажи или проект."
|
||
)
|
||
external_id = attrs.get(
|
||
"external_id", getattr(self.instance, "external_id", "")
|
||
)
|
||
if external_id:
|
||
source = attrs.get("source", getattr(self.instance, "source", None))
|
||
cost_type = attrs.get(
|
||
"cost_type", getattr(self.instance, "cost_type", None)
|
||
)
|
||
duplicate = Cost.objects.filter(
|
||
source=source,
|
||
external_id=external_id,
|
||
cost_type=cost_type,
|
||
)
|
||
if self.instance:
|
||
duplicate = duplicate.exclude(pk=self.instance.pk)
|
||
if duplicate.exists():
|
||
raise serializers.ValidationError(
|
||
{"external_id": "Расход с таким внешним ID уже существует."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class StockSnapshotSerializer(serializers.ModelSerializer):
|
||
source_code = serializers.CharField(source="source.code", read_only=True)
|
||
available = serializers.DecimalField(
|
||
max_digits=18, decimal_places=4, read_only=True
|
||
)
|
||
|
||
class Meta:
|
||
model = StockSnapshot
|
||
fields = [
|
||
"id",
|
||
"product",
|
||
"source",
|
||
"source_code",
|
||
"warehouse_external_id",
|
||
"warehouse_name",
|
||
"quantity",
|
||
"reserved",
|
||
"available",
|
||
"in_transit",
|
||
"captured_at",
|
||
"metadata",
|
||
"created_at",
|
||
]
|
||
|
||
|
||
class ProductMetricsQuerySerializer(serializers.Serializer):
|
||
date_from = serializers.DateTimeField(required=False)
|
||
date_to = serializers.DateTimeField(required=False)
|
||
|
||
def validate(self, attrs):
|
||
if (
|
||
attrs.get("date_from")
|
||
and attrs.get("date_to")
|
||
and attrs["date_from"] > attrs["date_to"]
|
||
):
|
||
raise serializers.ValidationError(
|
||
{"date_to": "Дата окончания должна быть не раньше даты начала."}
|
||
)
|
||
return attrs
|