181 lines
5.5 KiB
Python
181 lines
5.5 KiB
Python
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from django.utils import timezone
|
|
|
|
from commerce.models import (
|
|
Cost,
|
|
CostType,
|
|
Product,
|
|
ProductBinding,
|
|
Sale,
|
|
SaleEventType,
|
|
SaleLine,
|
|
SourceSystem,
|
|
StockSnapshot,
|
|
)
|
|
from commerce.services import get_product_metrics, ingest_source_record, match_product
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
def test_ingestion_is_idempotent_and_detects_payload_changes():
|
|
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
|
|
|
first = ingest_source_record(
|
|
source=source,
|
|
entity_type="product",
|
|
external_id="product-1",
|
|
payload={"name": "Фишка", "stock": 10},
|
|
)
|
|
repeated = ingest_source_record(
|
|
source=source,
|
|
entity_type="product",
|
|
external_id="product-1",
|
|
payload={"stock": 10, "name": "Фишка"},
|
|
)
|
|
changed = ingest_source_record(
|
|
source=source,
|
|
entity_type="product",
|
|
external_id="product-1",
|
|
payload={"name": "Фишка", "stock": 8},
|
|
)
|
|
|
|
assert first.created is True
|
|
assert repeated.created is False
|
|
assert repeated.changed is False
|
|
assert changed.changed is True
|
|
assert changed.record.pk == first.record.pk
|
|
assert changed.record.payload["stock"] == 8
|
|
|
|
|
|
def test_product_matching_prefers_binding_and_rejects_ambiguous_sku():
|
|
source = SourceSystem.objects.create(code="retailcrm", name="RetailCRM")
|
|
first = Product.objects.create(sku="FISHKA-RED", name="Фишка красная")
|
|
second = Product.objects.create(sku="FISHKA-BLUE", name="Фишка синяя")
|
|
ProductBinding.objects.create(
|
|
product=first,
|
|
source=source,
|
|
external_id="offer-1",
|
|
external_sku="shared-sku",
|
|
)
|
|
ProductBinding.objects.create(
|
|
product=second,
|
|
source=source,
|
|
external_id="offer-2",
|
|
external_sku="shared-sku",
|
|
)
|
|
|
|
explicit = match_product(source=source, external_id="offer-1")
|
|
ambiguous = match_product(source=source, external_sku="shared-sku")
|
|
canonical = match_product(source=source, external_sku="fishka-blue")
|
|
|
|
assert explicit.product == first
|
|
assert explicit.method == "external_id"
|
|
assert ambiguous.matched is False
|
|
assert canonical.product == second
|
|
assert canonical.method == "canonical_sku"
|
|
|
|
|
|
def test_product_metrics_include_returns_costs_and_latest_stock():
|
|
now = timezone.now()
|
|
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
|
ads = SourceSystem.objects.create(code="ads", name="Реклама")
|
|
product = Product.objects.create(sku="FISHKA", name="Фишка")
|
|
|
|
sale = Sale.objects.create(
|
|
source=source,
|
|
external_id="sale-1",
|
|
event_type=SaleEventType.SALE,
|
|
channel="site",
|
|
occurred_at=now - timedelta(days=2),
|
|
)
|
|
sale_line = SaleLine.objects.create(
|
|
sale=sale,
|
|
product=product,
|
|
external_id="line-1",
|
|
quantity=Decimal("2"),
|
|
gross_amount=Decimal("1000"),
|
|
discount_amount=Decimal("100"),
|
|
cost_amount=Decimal("400"),
|
|
)
|
|
returned = Sale.objects.create(
|
|
source=source,
|
|
external_id="return-1",
|
|
event_type=SaleEventType.RETURN,
|
|
channel="site",
|
|
occurred_at=now - timedelta(days=1),
|
|
)
|
|
SaleLine.objects.create(
|
|
sale=returned,
|
|
product=product,
|
|
external_id="return-line-1",
|
|
quantity=Decimal("1"),
|
|
gross_amount=Decimal("200"),
|
|
cost_amount=Decimal("80"),
|
|
)
|
|
Cost.objects.create(
|
|
source=source,
|
|
product=product,
|
|
external_id="commission-1",
|
|
cost_type=CostType.COMMISSION,
|
|
amount=Decimal("50"),
|
|
occurred_at=now,
|
|
)
|
|
Cost.objects.create(
|
|
source=ads,
|
|
sale_line=sale_line,
|
|
external_id="ad-1",
|
|
cost_type=CostType.ADVERTISING,
|
|
amount=Decimal("20"),
|
|
occurred_at=now,
|
|
)
|
|
|
|
StockSnapshot.objects.create(
|
|
product=product,
|
|
source=source,
|
|
warehouse_external_id="main",
|
|
quantity=Decimal("10"),
|
|
reserved=Decimal("2"),
|
|
captured_at=now - timedelta(hours=2),
|
|
)
|
|
StockSnapshot.objects.create(
|
|
product=product,
|
|
source=source,
|
|
warehouse_external_id="main",
|
|
quantity=Decimal("7"),
|
|
reserved=Decimal("1"),
|
|
captured_at=now,
|
|
)
|
|
StockSnapshot.objects.create(
|
|
product=product,
|
|
source=source,
|
|
warehouse_external_id="reserve",
|
|
quantity=Decimal("3"),
|
|
captured_at=now,
|
|
)
|
|
|
|
result = get_product_metrics(product)
|
|
rub = result["monetary"][0]
|
|
|
|
assert rub["currency"] == "RUB"
|
|
assert rub["quantity"] == Decimal("1")
|
|
assert rub["gross_revenue"] == Decimal("800")
|
|
assert rub["discounts"] == Decimal("100")
|
|
assert rub["net_revenue"] == Decimal("700")
|
|
assert rub["cost_of_goods"] == Decimal("320")
|
|
assert rub["additional_costs"] == Decimal("70")
|
|
assert rub["contribution_margin"] == Decimal("310")
|
|
channels = {row["channel"]: row for row in result["by_channel"]}
|
|
assert channels["site"]["net_revenue"] == Decimal("700")
|
|
assert channels["site"]["additional_costs"] == Decimal("20")
|
|
assert channels["site"]["contribution_margin"] == Decimal("360")
|
|
assert channels["unattributed"]["additional_costs"] == Decimal("50")
|
|
assert len(result["stock"]) == 2
|
|
main = next(
|
|
item for item in result["stock"] if item["warehouse_external_id"] == "main"
|
|
)
|
|
assert main["quantity"] == Decimal("7")
|
|
assert main["available"] == Decimal("6")
|