250 lines
8.1 KiB
Python
250 lines
8.1 KiB
Python
from collections import defaultdict
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from django.db.models import (
|
|
Case,
|
|
CharField,
|
|
Count,
|
|
DecimalField,
|
|
ExpressionWrapper,
|
|
F,
|
|
OuterRef,
|
|
Q,
|
|
Subquery,
|
|
Sum,
|
|
Value,
|
|
When,
|
|
)
|
|
from django.db.models.functions import Coalesce
|
|
|
|
from commerce.models import Cost, Product, SaleEventType, SaleLine, StockSnapshot
|
|
|
|
MONEY_FIELD = DecimalField(max_digits=20, decimal_places=2)
|
|
ZERO_MONEY = Decimal("0.00")
|
|
|
|
|
|
def _date_filter(prefix: str, date_from: datetime | None, date_to: datetime | None):
|
|
query = Q()
|
|
if date_from is not None:
|
|
query &= Q(**{f"{prefix}__gte": date_from})
|
|
if date_to is not None:
|
|
query &= Q(**{f"{prefix}__lte": date_to})
|
|
return query
|
|
|
|
|
|
def _sale_metric_rows(
|
|
product: Product,
|
|
date_from: datetime | None,
|
|
date_to: datetime | None,
|
|
*group_fields: str,
|
|
):
|
|
signed_quantity = ExpressionWrapper(
|
|
F("quantity") * models_case_return_multiplier(),
|
|
output_field=DecimalField(max_digits=20, decimal_places=4),
|
|
)
|
|
signed_gross = ExpressionWrapper(
|
|
F("gross_amount") * models_case_return_multiplier(), output_field=MONEY_FIELD
|
|
)
|
|
signed_discount = ExpressionWrapper(
|
|
F("discount_amount") * models_case_return_multiplier(),
|
|
output_field=MONEY_FIELD,
|
|
)
|
|
signed_cost = ExpressionWrapper(
|
|
F("cost_amount") * models_case_return_multiplier(), output_field=MONEY_FIELD
|
|
)
|
|
|
|
rows = (
|
|
SaleLine.objects.filter(product=product, sale__counts_as_sale=True)
|
|
.filter(_date_filter("sale__occurred_at", date_from, date_to))
|
|
.values(*group_fields)
|
|
.annotate(
|
|
quantity=Coalesce(Sum(signed_quantity), Decimal("0")),
|
|
gross=Coalesce(Sum(signed_gross), ZERO_MONEY),
|
|
discounts=Coalesce(Sum(signed_discount), ZERO_MONEY),
|
|
cost_of_goods=Coalesce(Sum(signed_cost), ZERO_MONEY),
|
|
missing_cost_lines=Count(
|
|
"id",
|
|
filter=Q(
|
|
metadata__cost_source="unavailable",
|
|
),
|
|
),
|
|
)
|
|
.order_by(*group_fields)
|
|
)
|
|
return list(rows)
|
|
|
|
|
|
def _sale_metrics(
|
|
product: Product,
|
|
date_from: datetime | None,
|
|
date_to: datetime | None,
|
|
):
|
|
rows = _sale_metric_rows(product, date_from, date_to, "sale__currency")
|
|
return {row["sale__currency"]: row for row in rows}
|
|
|
|
|
|
def models_case_return_multiplier():
|
|
from django.db.models import Case, IntegerField, Value, When
|
|
|
|
return Case(
|
|
When(sale__event_type=SaleEventType.RETURN, then=Value(-1)),
|
|
default=Value(1),
|
|
output_field=IntegerField(),
|
|
)
|
|
|
|
|
|
def _cost_metrics(
|
|
product: Product,
|
|
date_from: datetime | None,
|
|
date_to: datetime | None,
|
|
):
|
|
rows = (
|
|
Cost.objects.filter(Q(product=product) | Q(sale_line__product=product))
|
|
.filter(_date_filter("occurred_at", date_from, date_to))
|
|
.values("currency", "cost_type")
|
|
.annotate(amount=Coalesce(Sum("amount"), ZERO_MONEY))
|
|
.order_by("currency", "cost_type")
|
|
)
|
|
result: dict[str, dict[str, Decimal]] = defaultdict(dict)
|
|
for row in rows:
|
|
result[row["currency"]][row["cost_type"]] = row["amount"]
|
|
return result
|
|
|
|
|
|
def _channel_metrics(
|
|
product: Product,
|
|
date_from: datetime | None,
|
|
date_to: datetime | None,
|
|
):
|
|
sale_rows = _sale_metric_rows(
|
|
product,
|
|
date_from,
|
|
date_to,
|
|
"sale__channel",
|
|
"sale__currency",
|
|
)
|
|
sales = {(row["sale__channel"], row["sale__currency"]): row for row in sale_rows}
|
|
|
|
cost_rows = (
|
|
Cost.objects.filter(Q(product=product) | Q(sale_line__product=product))
|
|
.filter(_date_filter("occurred_at", date_from, date_to))
|
|
.annotate(
|
|
effective_channel=Case(
|
|
When(
|
|
channel="",
|
|
then=Coalesce("sale_line__sale__channel", Value("")),
|
|
),
|
|
default=F("channel"),
|
|
output_field=CharField(),
|
|
)
|
|
)
|
|
.values("effective_channel", "currency", "cost_type")
|
|
.annotate(amount=Coalesce(Sum("amount"), ZERO_MONEY))
|
|
.order_by("effective_channel", "currency", "cost_type")
|
|
)
|
|
costs: dict[tuple[str, str], dict[str, Decimal]] = defaultdict(dict)
|
|
for row in cost_rows:
|
|
costs[(row["effective_channel"], row["currency"])][row["cost_type"]] = row[
|
|
"amount"
|
|
]
|
|
|
|
result = []
|
|
for channel, currency in sorted(set(sales) | set(costs)):
|
|
sale = sales.get((channel, currency), {})
|
|
gross = sale.get("gross", ZERO_MONEY)
|
|
discounts = sale.get("discounts", ZERO_MONEY)
|
|
cost_of_goods = sale.get("cost_of_goods", ZERO_MONEY)
|
|
expense_breakdown = costs.get((channel, currency), {})
|
|
additional_costs = sum(expense_breakdown.values(), ZERO_MONEY)
|
|
net_revenue = gross - discounts
|
|
result.append(
|
|
{
|
|
"channel": channel or "unattributed",
|
|
"currency": currency,
|
|
"quantity": sale.get("quantity", Decimal("0")),
|
|
"net_revenue": net_revenue,
|
|
"cost_of_goods": cost_of_goods,
|
|
"additional_costs": additional_costs,
|
|
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
|
|
"cost_breakdown": expense_breakdown,
|
|
"missing_cost_lines": sale.get("missing_cost_lines", 0),
|
|
"cost_complete": sale.get("missing_cost_lines", 0) == 0,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _latest_stock(product: Product):
|
|
latest_pk = (
|
|
StockSnapshot.objects.filter(
|
|
product=product,
|
|
source_id=OuterRef("source_id"),
|
|
warehouse_external_id=OuterRef("warehouse_external_id"),
|
|
)
|
|
.order_by("-captured_at", "-created_at")
|
|
.values("pk")[:1]
|
|
)
|
|
snapshots = (
|
|
StockSnapshot.objects.filter(product=product, pk=Subquery(latest_pk))
|
|
.select_related("source")
|
|
.order_by("source__code", "warehouse_external_id")
|
|
)
|
|
|
|
return [
|
|
{
|
|
"source": snapshot.source.code,
|
|
"warehouse_external_id": snapshot.warehouse_external_id,
|
|
"warehouse_name": snapshot.warehouse_name,
|
|
"quantity": snapshot.quantity,
|
|
"reserved": snapshot.reserved,
|
|
"available": snapshot.available,
|
|
"in_transit": snapshot.in_transit,
|
|
"captured_at": snapshot.captured_at,
|
|
}
|
|
for snapshot in snapshots
|
|
]
|
|
|
|
|
|
def get_product_metrics(
|
|
product: Product,
|
|
*,
|
|
date_from: datetime | None = None,
|
|
date_to: datetime | None = None,
|
|
):
|
|
sales = _sale_metrics(product, date_from, date_to)
|
|
costs = _cost_metrics(product, date_from, date_to)
|
|
currencies = sorted(set(sales) | set(costs))
|
|
monetary = []
|
|
for currency in currencies:
|
|
sale = sales.get(currency, {})
|
|
gross = sale.get("gross", ZERO_MONEY)
|
|
discounts = sale.get("discounts", ZERO_MONEY)
|
|
cost_of_goods = sale.get("cost_of_goods", ZERO_MONEY)
|
|
expense_breakdown = costs.get(currency, {})
|
|
additional_costs = sum(expense_breakdown.values(), ZERO_MONEY)
|
|
net_revenue = gross - discounts
|
|
monetary.append(
|
|
{
|
|
"currency": currency,
|
|
"quantity": sale.get("quantity", Decimal("0")),
|
|
"gross_revenue": gross,
|
|
"discounts": discounts,
|
|
"net_revenue": net_revenue,
|
|
"cost_of_goods": cost_of_goods,
|
|
"additional_costs": additional_costs,
|
|
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
|
|
"cost_breakdown": expense_breakdown,
|
|
"missing_cost_lines": sale.get("missing_cost_lines", 0),
|
|
"cost_complete": sale.get("missing_cost_lines", 0) == 0,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"product_id": product.id,
|
|
"period": {"from": date_from, "to": date_to},
|
|
"monetary": monetary,
|
|
"by_channel": _channel_metrics(product, date_from, date_to),
|
|
"stock": _latest_stock(product),
|
|
}
|