Corp/backend/api/v1/commerce/serializers.py
Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

473 lines
16 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.

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