from decimal import Decimal import pytest from django.core.files.base import ContentFile from django.urls import reverse from django.utils import timezone from rest_framework import status from commerce.models import ( Cost, Product, ProductBinding, ProductImage, Sale, SaleLine, SourceSystem, StockSnapshot, ) pytestmark = pytest.mark.django_db def test_commerce_api_requires_authentication(api_client): response = api_client.get(reverse("api:v1:commerce:product-list")) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_employee_can_read_but_cannot_create_product(api_client, user_factory): user = user_factory() Product.objects.create(sku="FISHKA", name="Фишка") api_client.force_authenticate(user=user) list_response = api_client.get(reverse("api:v1:commerce:product-list")) create_response = api_client.post( reverse("api:v1:commerce:product-list"), {"sku": "NEW", "name": "Новый товар", "kind": "product"}, format="json", ) assert list_response.status_code == status.HTTP_200_OK assert list_response.data["count"] == 1 assert create_response.status_code == status.HTTP_403_FORBIDDEN def test_product_search_matches_unicode_substring_without_case(api_client, user_factory): user = user_factory() Product.objects.create(sku="SEA-001", name="Автомат «Бродяги морей»") Product.objects.create(sku="OTHER-001", name="Другой товар") api_client.force_authenticate(user=user) response = api_client.get( reverse("api:v1:commerce:product-list"), {"search": "бродяг"}, ) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 assert response.data["results"][0]["name"] == "Автомат «Бродяги морей»" def test_staff_can_create_product(api_client, user_factory): user = user_factory(staff=True) api_client.force_authenticate(user=user) response = api_client.post( reverse("api:v1:commerce:product-list"), {"sku": "NEW", "name": "Новый товар", "kind": "product"}, format="json", ) assert response.status_code == status.HTTP_201_CREATED assert Product.objects.filter(sku="NEW").exists() def test_product_api_exposes_local_image_urls(api_client, user_factory): user = user_factory() source = SourceSystem.objects.create(code="moysklad", name="МойСклад") product = Product.objects.create(sku="PHOTO", name="Товар с фото") image = ProductImage( product=product, source=source, external_id="image-1", filename="photo.jpg", is_primary=True, ) image.file.save("image-1.jpg", ContentFile(b"image"), save=False) image.thumbnail.save("image-1.webp", ContentFile(b"thumbnail"), save=False) image.save() api_client.force_authenticate(user=user) response = api_client.get(reverse("api:v1:commerce:product-list")) assert response.status_code == status.HTTP_200_OK serialized = response.data["results"][0]["images"][0] assert serialized["is_primary"] is True assert serialized["url"].endswith(".jpg") assert serialized["thumbnail_url"].endswith(".webp") def test_deleting_source_in_use_returns_conflict(api_client, user_factory): user = user_factory(staff=True) source = SourceSystem.objects.create(code="moysklad", name="МойСклад") product = Product.objects.create(sku="MS-001", name="Товар") ProductBinding.objects.create( product=product, source=source, external_id="external-1", ) api_client.force_authenticate(user=user) response = api_client.delete( reverse("api:v1:commerce:source-detail", args=[source.pk]) ) assert response.status_code == status.HTTP_409_CONFLICT assert response.data["code"] == "source_in_use" assert response.data["protected_count"] == 1 assert SourceSystem.objects.filter(pk=source.pk).exists() def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory): user = user_factory(staff=True) source = SourceSystem.objects.create(code="moysklad", name="МойСклад") product = Product.objects.create(sku="FISHKA", name="Фишка") api_client.force_authenticate(user=user) sale_response = api_client.post( reverse("api:v1:commerce:sale-list"), { "source": source.pk, "external_id": "order-1", "occurred_at": timezone.now().isoformat(), }, format="json", ) assert sale_response.status_code == status.HTTP_201_CREATED line_response = api_client.post( reverse("api:v1:commerce:saleline-list"), { "sale": sale_response.data["id"], "product": product.pk, "external_id": "line-1", "quantity": "2", "gross_amount": "500.00", "discount_amount": "50.00", "cost_amount": "200.00", }, format="json", ) assert line_response.status_code == status.HTTP_201_CREATED cost_response = api_client.post( reverse("api:v1:commerce:cost-list"), { "source": source.pk, "product": product.pk, "cost_type": "commission", "amount": "25.00", "occurred_at": timezone.now().isoformat(), }, format="json", ) assert cost_response.status_code == status.HTTP_201_CREATED, cost_response.data stock_response = api_client.post( reverse("api:v1:commerce:stocksnapshot-list"), { "source": source.pk, "product": product.pk, "warehouse_external_id": "main", "quantity": "10", "reserved": "2", "captured_at": timezone.now().isoformat(), }, format="json", ) assert stock_response.status_code == status.HTTP_201_CREATED assert SaleLine.objects.filter(pk=line_response.data["id"]).exists() assert Cost.objects.filter(pk=cost_response.data["id"]).exists() assert StockSnapshot.objects.filter(pk=stock_response.data["id"]).exists() def test_management_searches_costs_and_stock_by_product_or_warehouse( api_client, user_factory ): user = user_factory(staff=True) source = SourceSystem.objects.create(code="moysklad", name="МойСклад") product = Product.objects.create(sku="SEARCH-42", name="Товар для поиска") Cost.objects.create( source=source, product=product, external_id="cost-42", channel="marketplace", cost_type="commission", amount=Decimal("10"), occurred_at=timezone.now(), ) StockSnapshot.objects.create( source=source, product=product, warehouse_external_id="warehouse-42", warehouse_name="Главный склад", quantity=Decimal("5"), captured_at=timezone.now(), ) api_client.force_authenticate(user=user) cost_response = api_client.get( reverse("api:v1:commerce:cost-list"), {"search": "SEARCH-42"} ) stock_response = api_client.get( reverse("api:v1:commerce:stocksnapshot-list"), {"search": "Главный склад"}, ) assert cost_response.status_code == status.HTTP_200_OK assert cost_response.data["count"] == 1 assert stock_response.status_code == status.HTTP_200_OK assert stock_response.data["count"] == 1 def test_product_analytics_endpoint(api_client, user_factory): user = user_factory() source = SourceSystem.objects.create(code="insales", name="InSales") product = Product.objects.create(sku="FISHKA", name="Фишка") sale = Sale.objects.create( source=source, external_id="order-1", occurred_at=timezone.now(), channel="site", ) SaleLine.objects.create( sale=sale, product=product, external_id="line-1", quantity=Decimal("1"), gross_amount=Decimal("500"), cost_amount=Decimal("200"), ) api_client.force_authenticate(user=user) response = api_client.get( reverse("api:v1:commerce:product-analytics", args=[product.pk]) ) assert response.status_code == status.HTTP_200_OK assert response.data["product_id"] == product.pk assert response.data["monetary"][0]["net_revenue"] == Decimal("500") assert response.data["monetary"][0]["contribution_margin"] == Decimal("300") def test_staff_can_merge_imported_product_into_canonical(api_client, user_factory): user = user_factory(staff=True) source = SourceSystem.objects.create(code="moysklad", name="МойСклад") target = Product.objects.create(sku="CANONICAL", name="Основной товар") imported = Product.objects.create(sku="MS-001", name="Импортированный товар") binding = imported.bindings.create( source=source, external_id="external-1", external_sku="MS-001", ) api_client.force_authenticate(user=user) response = api_client.post( reverse("api:v1:commerce:product-merge", args=[target.pk]), {"source_product_ids": [str(imported.pk)]}, format="json", ) assert response.status_code == status.HTTP_200_OK assert response.data["merged_products"] == 1 assert response.data["moved_bindings"] == 1 binding.refresh_from_db() assert binding.product == target assert not Product.objects.filter(pk=imported.pk).exists()