279 lines
9.4 KiB
Python
279 lines
9.4 KiB
Python
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()
|