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),
}