323 lines
10 KiB
Python
323 lines
10 KiB
Python
from decimal import Decimal
|
|
from unittest.mock import Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
|
|
from commerce.integrations.wildberries import WildberriesClient
|
|
from commerce.models import (
|
|
Cost,
|
|
CostType,
|
|
IntegrationConnection,
|
|
IntegrationStatus,
|
|
Product,
|
|
ProductBinding,
|
|
Sale,
|
|
SaleEventType,
|
|
SourceRecord,
|
|
SourceSystem,
|
|
)
|
|
from commerce.services.credentials import encrypt_credentials
|
|
from commerce.services.wildberries_sync import (
|
|
normalize_wildberries_finance_row,
|
|
sync_wildberries_connection,
|
|
)
|
|
from commerce.tasks import sync_moysklad_run
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
def test_wildberries_client_uses_bare_token_and_current_finance_endpoint(settings):
|
|
settings.WILDBERRIES_FINANCE_API_BASE_URL = "https://finance.example"
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
return httpx.Response(204, request=request)
|
|
|
|
client = WildberriesClient(credentials={"token": " Bearer wb-token "})
|
|
client.client.close()
|
|
client.client = httpx.Client(
|
|
headers=client.client.headers,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
try:
|
|
assert (
|
|
client.financial_report(date_from="2026-08-01", date_to="2026-08-18") == []
|
|
)
|
|
finally:
|
|
client.close()
|
|
|
|
assert requests[0].headers["Authorization"] == "wb-token"
|
|
assert requests[0].url.path == "/api/finance/v1/sales-reports/detailed"
|
|
assert requests[0].read()
|
|
assert '"rrdId":0' in requests[0].content.decode().replace(" ", "")
|
|
|
|
|
|
def test_wildberries_finance_ping_validates_finance_category(settings):
|
|
settings.WILDBERRIES_FINANCE_API_BASE_URL = "https://finance.example"
|
|
|
|
def handler(request):
|
|
return httpx.Response(
|
|
200, json={"TS": "2026-08-18", "Status": "OK"}, request=request
|
|
)
|
|
|
|
client = WildberriesClient(credentials={"token": "wb-token"})
|
|
client.client.close()
|
|
client.client = httpx.Client(
|
|
headers=client.client.headers,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
try:
|
|
assert client.finance_ping() is None
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_wildberries_finance_row_creates_sale_binding_and_expenses():
|
|
source = SourceSystem.objects.create(code="wildberries", name="Wildberries")
|
|
connection = IntegrationConnection.objects.create(
|
|
source=source,
|
|
provider="wildberries",
|
|
encrypted_credentials="unused",
|
|
)
|
|
product = Product.objects.create(
|
|
sku="CANONICAL-1",
|
|
barcode="460000000001",
|
|
name="Канонический товар",
|
|
)
|
|
row = {
|
|
"rrdId": 1232610467,
|
|
"reportId": 1234567,
|
|
"createDate": "2026-08-18",
|
|
"saleDt": "2026-08-17T12:30:00Z",
|
|
"rrDate": "2026-08-18",
|
|
"srid": "sale-row-1",
|
|
"docTypeName": "Продажа",
|
|
"nmId": 7654321,
|
|
"vendorCode": "WB-ART-1",
|
|
"sku": "460000000001",
|
|
"techSize": "42",
|
|
"title": "Товар WB",
|
|
"quantity": "2",
|
|
"retailAmount": "367.50",
|
|
"ppvzSalesCommission": "55.12",
|
|
"acquiringFee": "14.89",
|
|
"deliveryAmount": 99,
|
|
"deliveryService": "20",
|
|
"rebillLogisticCost": "1.35",
|
|
"paidStorage": "10",
|
|
"paidAcceptance": "5",
|
|
"deduction": "2",
|
|
"penalty": "7",
|
|
"cashbackCommissionChange": "0.50",
|
|
"additionalPayment": "50",
|
|
"currency": "RUB",
|
|
}
|
|
|
|
matched, costs, unmatched, counts_as_sale = normalize_wildberries_finance_row(
|
|
connection=connection,
|
|
row=row,
|
|
)
|
|
|
|
assert matched is True
|
|
assert unmatched is False
|
|
assert counts_as_sale is True
|
|
assert costs == 10
|
|
binding = ProductBinding.objects.get(source=source, external_id="7654321")
|
|
assert binding.product == product
|
|
sale = Sale.objects.get(source=source, external_id="financial:1232610467")
|
|
assert sale.event_type == SaleEventType.SALE
|
|
assert sale.counts_as_sale is True
|
|
assert sale.occurred_at.isoformat() == "2026-08-17T12:30:00+00:00"
|
|
assert timezone.localdate(sale.source_updated_at).isoformat() == "2026-08-18"
|
|
assert sale.channel == "wildberries"
|
|
line = sale.lines.get()
|
|
assert line.product == product
|
|
assert line.quantity == 2
|
|
assert line.gross_amount == Decimal("367.50")
|
|
assert line.metadata["barcode"] == "460000000001"
|
|
assert line.metadata["size"] == "42"
|
|
assert set(line.costs.values_list("cost_type", flat=True)) == {
|
|
CostType.COMMISSION,
|
|
CostType.ACQUIRING,
|
|
CostType.DELIVERY,
|
|
CostType.STORAGE,
|
|
CostType.OTHER,
|
|
}
|
|
assert line.costs.get(external_id="1232610467:delivery").amount == Decimal("20")
|
|
assert line.costs.get(external_id="1232610467:rebill_logistics").amount == Decimal(
|
|
"1.35"
|
|
)
|
|
assert line.costs.get(
|
|
external_id="1232610467:additional_payment"
|
|
).amount == Decimal("-50")
|
|
assert not line.costs.filter(amount=99).exists()
|
|
assert SourceRecord.objects.filter(
|
|
source=source,
|
|
entity_type="financial_report_line",
|
|
external_id="1232610467",
|
|
).exists()
|
|
|
|
|
|
def test_wildberries_row_update_removes_disappeared_costs():
|
|
source = SourceSystem.objects.create(code="wildberries", name="Wildberries")
|
|
connection = IntegrationConnection.objects.create(
|
|
source=source,
|
|
provider="wildberries",
|
|
encrypted_credentials="unused",
|
|
)
|
|
row = {
|
|
"rrdId": 22,
|
|
"saleDate": "2026-08-17",
|
|
"docTypeName": "Возврат",
|
|
"retailAmount": "100",
|
|
"penalty": "7",
|
|
}
|
|
normalize_wildberries_finance_row(connection=connection, row=row)
|
|
assert Cost.objects.count() == 1
|
|
|
|
row["penalty"] = "0"
|
|
normalize_wildberries_finance_row(connection=connection, row=row)
|
|
|
|
assert Cost.objects.count() == 0
|
|
assert Sale.objects.get().event_type == SaleEventType.RETURN
|
|
|
|
|
|
def test_wildberries_service_row_does_not_inflate_sales_or_unmatched_products():
|
|
source = SourceSystem.objects.create(code="wildberries", name="Wildberries")
|
|
connection = IntegrationConnection.objects.create(
|
|
source=source,
|
|
provider="wildberries",
|
|
encrypted_credentials="unused",
|
|
)
|
|
row = {
|
|
"rrdId": 23,
|
|
"rrDate": "2026-08-17",
|
|
"docTypeName": "Логистика",
|
|
"deliveryService": "25",
|
|
"deliveryAmount": 3,
|
|
}
|
|
|
|
matched, costs, unmatched, counts_as_sale = normalize_wildberries_finance_row(
|
|
connection=connection,
|
|
row=row,
|
|
)
|
|
|
|
assert matched is False
|
|
assert unmatched is False
|
|
assert counts_as_sale is False
|
|
assert costs == 1
|
|
sale = Sale.objects.get()
|
|
assert sale.counts_as_sale is False
|
|
assert sale.lines.get().quantity == 0
|
|
assert sale.lines.get().costs.get().amount == Decimal("25")
|
|
|
|
|
|
def test_wildberries_sync_uses_cursor_until_empty_204_page():
|
|
source = SourceSystem.objects.create(code="wildberries", name="Wildberries")
|
|
connection = IntegrationConnection.objects.create(
|
|
source=source,
|
|
provider="wildberries",
|
|
encrypted_credentials=encrypt_credentials({"token": "test"}),
|
|
)
|
|
row = {
|
|
"rrdId": 45,
|
|
"saleDt": "2026-08-17",
|
|
"docTypeName": "Продажа",
|
|
"quantity": 1,
|
|
"retailAmount": "100",
|
|
}
|
|
client = Mock()
|
|
client.financial_report.side_effect = [[row], []]
|
|
|
|
with patch("commerce.services.wildberries_sync.WildberriesClient") as client_class:
|
|
client_class.return_value.__enter__.return_value = client
|
|
first = sync_wildberries_connection(connection)
|
|
connection.source.refresh_from_db()
|
|
second = sync_wildberries_connection(connection)
|
|
|
|
assert first.report_complete is False
|
|
assert first.sales == 1
|
|
assert connection.source.metadata["finance_initial_complete"] is True
|
|
assert "finance_rrd_id" not in connection.source.metadata
|
|
assert second.report_complete is True
|
|
assert client.financial_report.call_args_list[0].kwargs["rrd_id"] == 0
|
|
assert client.financial_report.call_args_list[1].kwargs["rrd_id"] == 45
|
|
|
|
|
|
def test_staff_can_create_and_test_wildberries_connection(api_client, user_factory):
|
|
user = user_factory(staff=True)
|
|
api_client.force_authenticate(user=user)
|
|
created = api_client.post(
|
|
reverse("api:v1:commerce:connection-list"),
|
|
{
|
|
"provider": "wildberries",
|
|
"auth_type": "token",
|
|
"token": "secret-wb-token",
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
assert created.status_code == status.HTTP_201_CREATED
|
|
assert created.data["provider"] == "wildberries"
|
|
assert created.data["sync_interval_minutes"] == 60
|
|
assert "token" not in created.data
|
|
|
|
with patch(
|
|
"api.v1.commerce.viewsets.test_wildberries_connection",
|
|
return_value={
|
|
"account_id": "seller-1",
|
|
"account_name": "Магазин WB",
|
|
"tin": "1234567890",
|
|
},
|
|
):
|
|
tested = api_client.post(
|
|
reverse("api:v1:commerce:connection-test", args=[created.data["id"]])
|
|
)
|
|
|
|
assert tested.status_code == status.HTTP_200_OK
|
|
connection = IntegrationConnection.objects.get(provider="wildberries")
|
|
assert connection.status == IntegrationStatus.READY
|
|
assert connection.account_name == "Магазин WB"
|
|
assert connection.source.metadata["tin"] == "1234567890"
|
|
|
|
|
|
def test_background_run_dispatches_wildberries_synchronizer():
|
|
source = SourceSystem.objects.create(code="wildberries", name="Wildberries")
|
|
connection = IntegrationConnection.objects.create(
|
|
source=source,
|
|
provider="wildberries",
|
|
encrypted_credentials=encrypt_credentials({"token": "test"}),
|
|
status=IntegrationStatus.QUEUED,
|
|
task_id="wildberries-task",
|
|
)
|
|
run = connection.sync_runs.create(task_id="wildberries-task")
|
|
stats = Mock()
|
|
stats.to_dict.return_value = {
|
|
"processed": 5,
|
|
"created_products": 0,
|
|
"matched_products": 4,
|
|
"stock_snapshots": 0,
|
|
"images": 0,
|
|
"image_errors": 0,
|
|
"sales": 5,
|
|
"sale_lines": 5,
|
|
"costs": 12,
|
|
}
|
|
|
|
with (
|
|
patch("commerce.tasks.sync_wildberries_connection", return_value=stats) as sync,
|
|
patch("commerce.tasks.send_connection_update"),
|
|
):
|
|
result = sync_moysklad_run.run(str(run.pk))
|
|
|
|
sync.assert_called_once()
|
|
run.refresh_from_db()
|
|
assert result["costs"] == 12
|
|
assert run.sales == 5
|
|
assert run.costs == 12
|