diff --git a/site/core/tests_features.py b/site/core/tests_features.py index 64e5e84..bf8ae66 100644 --- a/site/core/tests_features.py +++ b/site/core/tests_features.py @@ -123,6 +123,7 @@ class InstalledOptionalAppsTests(TestCase): self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) self.assertTrue(reverse("shop:list").startswith("/shop/")) self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/")) + self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/")) self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/")) self.assertTrue(reverse("events:list").startswith("/events/")) self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/")) diff --git a/site/dashboard/templates/dashboard/home.html b/site/dashboard/templates/dashboard/home.html index d968119..cd7340f 100644 --- a/site/dashboard/templates/dashboard/home.html +++ b/site/dashboard/templates/dashboard/home.html @@ -32,6 +32,14 @@
Open shop orders
{{ open_shop_orders|default:0 }}
+
+
Sales (30 days)
+
{{ shop_sales_30d|default:0 }}
+
+
+
Shop revenue (30 days)
+
{{ shop_revenue_30d|default:0 }}
+
Low stock
{{ low_stock_products|default:0 }}
diff --git a/site/print_forge/static/css/portal.css b/site/print_forge/static/css/portal.css index cfba0eb..521bc86 100644 --- a/site/print_forge/static/css/portal.css +++ b/site/print_forge/static/css/portal.css @@ -264,6 +264,63 @@ body.portal { line-height: 1.2; white-space: nowrap; } +.chart-placeholder.chart-daily { + gap: 3px; + padding: 16px 10px 8px; + overflow-x: auto; +} +.chart-placeholder.chart-daily .chart-bar-col { + flex: 1 0 8px; + min-width: 6px; +} +.chart-placeholder.chart-daily .chart-bar-col .bar { + min-height: 0; +} +.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero { + height: 0 !important; + min-height: 0; + opacity: 0.2; +} +.chart-placeholder.chart-daily .chart-bar-label { + font-size: 10px; +} +.chart-placeholder.chart-revenue .chart-bar-col .bar { + background: var(--monica-primary-light); +} +.hbar-chart { + display: flex; + flex-direction: column; + gap: 12px; +} +.hbar-row { + display: grid; + grid-template-columns: minmax(72px, 160px) 1fr auto; + gap: 12px; + align-items: center; +} +.hbar-label { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hbar-track { + height: 16px; + background: #e8f4f5; + overflow: hidden; +} +.hbar-fill { + height: 100%; + background: var(--monica-primary); + opacity: 0.8; + min-width: 0; +} +.hbar-value { + font-size: 13px; + font-weight: 600; + min-width: 2.5ch; + text-align: right; +} .consent-pills { display: flex; gap: 6px; flex-wrap: wrap; } .preview-pane { diff --git a/site/public/context_processors.py b/site/public/context_processors.py index 587799e..8d83608 100644 --- a/site/public/context_processors.py +++ b/site/public/context_processors.py @@ -77,6 +77,13 @@ def tianji_tracking(request): nav_section = "social_accounts" elif namespace == "social": nav_section = "social" + elif namespace == "shop_portal": + if url_name.startswith("product"): + nav_section = "shop_products" + elif url_name.startswith("order"): + nav_section = "shop_orders" + elif url_name == "sales": + nav_section = "shop_sales" elif namespace == "shop": nav_section = "shop" diff --git a/site/shop/hooks.py b/site/shop/hooks.py index 007960a..27d7fe2 100644 --- a/site/shop/hooks.py +++ b/site/shop/hooks.py @@ -9,6 +9,13 @@ from core.registry import ( def register() -> None: register_feature("shop") register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30) + register_portal_nav( + section="shop_sales", + label="Sales", + url_name="shop_portal:sales", + group="Retail", + order=5, + ) register_portal_nav( section="shop_products", label="Products", @@ -30,7 +37,9 @@ def _dashboard(request) -> dict: from django.db.models import Q from shop.models import Order, Product + from shop.stats import sales_summary + sales = sales_summary() return { "open_shop_orders": Order.objects.filter( status__in=[Order.Status.OPEN, Order.Status.PAID] @@ -43,4 +52,6 @@ def _dashboard(request) -> dict: .filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3)) .distinct() .count(), + "shop_sales_30d": sales["order_count"], + "shop_revenue_30d": sales["revenue"], } diff --git a/site/shop/portal_urls.py b/site/shop/portal_urls.py index a9b8434..cbd30b0 100644 --- a/site/shop/portal_urls.py +++ b/site/shop/portal_urls.py @@ -5,6 +5,7 @@ from shop import views app_name = "shop_portal" urlpatterns = [ + path("sales/", views.portal_sales, name="sales"), path("products/", views.portal_product_list, name="product_list"), path("products/new/", views.portal_product_edit, name="product_new"), path("products//", views.portal_product_edit, name="product_edit"), diff --git a/site/shop/stats.py b/site/shop/stats.py new file mode 100644 index 0000000..4466d4a --- /dev/null +++ b/site/shop/stats.py @@ -0,0 +1,166 @@ +"""Sales dashboard aggregates for FEATURE_SHOP.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from decimal import Decimal + +from django.conf import settings +from django.db.models import Count, F, Max, Q, Sum +from django.utils import timezone + +from shop.models import Order, OrderItem + +SOLD_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED) +SALES_WINDOW_DAYS = 30 + + +def _money(value) -> Decimal: + return (value or Decimal("0")).quantize(Decimal("0.01")) + + +def _window_start(days: int): + today = timezone.localdate() + start_date = today - timedelta(days=days - 1) + start_dt = timezone.make_aware( + datetime.combine(start_date, time.min), + timezone.get_current_timezone(), + ) + return today, start_date, start_dt + + +def _sold_orders(start_dt=None): + qs = Order.objects.filter(status__in=SOLD_STATUSES) + if start_dt is None: + return qs + return qs.filter( + Q(paid_at__gte=start_dt) | Q(paid_at__isnull=True, created_at__gte=start_dt) + ) + + +def _sale_date(order) -> date: + when = order.paid_at or order.created_at + return timezone.localtime(when).date() + + +def _bar_pct(value: Decimal | int | float, peak: Decimal | int | float) -> int: + if not peak: + return 0 + if not value: + return 0 + return max(8, int(round((float(value) / float(peak)) * 100))) + + +def sales_summary(*, days: int = SALES_WINDOW_DAYS) -> dict: + """Order count and revenue for the rolling sales window.""" + _, _, start_dt = _window_start(days) + totals = _sold_orders(start_dt).aggregate( + order_count=Count("id"), revenue=Sum("amount") + ) + return { + "order_count": int(totals["order_count"] or 0), + "revenue": _money(totals["revenue"]), + } + + +def sales_dashboard(*, days: int = SALES_WINDOW_DAYS) -> dict: + """Paid/fulfilled order stats, daily series, and top products.""" + _, start_date, start_dt = _window_start(days) + sold = _sold_orders(start_dt) + totals = sold.aggregate(order_count=Count("id"), revenue=Sum("amount")) + order_count = int(totals["order_count"] or 0) + revenue = _money(totals["revenue"]) + units = int( + OrderItem.objects.filter(order__in=sold).aggregate(total=Sum("quantity"))[ + "total" + ] + or 0 + ) + aov = _money(revenue / order_count) if order_count else Decimal("0.00") + currency = (settings.STRIPE_CURRENCY or "usd").lower() + + by_day: dict = { + start_date + timedelta(days=offset): { + "count": 0, + "revenue": Decimal("0.00"), + } + for offset in range(days) + } + for order in sold.only("paid_at", "created_at", "amount"): + day = _sale_date(order) + bucket = by_day.get(day) + if bucket is None: + continue + bucket["count"] += 1 + bucket["revenue"] += order.amount or Decimal("0") + + peak_count = max((row["count"] for row in by_day.values()), default=0) + peak_revenue = max((row["revenue"] for row in by_day.values()), default=Decimal("0")) + daily_sales = [] + daily_revenue = [] + for index, (day, row) in enumerate(by_day.items()): + tick = index == 0 or index == days - 1 or day.weekday() == 0 + label = f"{day.strftime('%b')} {day.day}" + daily_sales.append( + { + "date": day, + "label": label, + "count": row["count"], + "revenue": _money(row["revenue"]), + "pct": _bar_pct(row["count"], peak_count), + "tick": tick, + "tick_label": label if tick else "", + } + ) + daily_revenue.append( + { + "date": day, + "label": label, + "count": row["count"], + "revenue": _money(row["revenue"]), + "pct": _bar_pct(row["revenue"], peak_revenue), + "tick": tick, + "tick_label": label if tick else "", + } + ) + + product_rows = list( + OrderItem.objects.filter(order__in=sold) + .values("sku") + .annotate( + units=Sum("quantity"), + revenue=Sum(F("unit_price") * F("quantity")), + product_name=Max("product__name"), + item_name=Max("name"), + ) + .order_by("-units", "-revenue")[:8] + ) + peak_units = max((int(row["units"] or 0) for row in product_rows), default=0) + top_products = [] + for row in product_rows: + units_sold = int(row["units"] or 0) + top_products.append( + { + "sku": row["sku"], + "name": row["product_name"] or row["item_name"] or row["sku"], + "units": units_sold, + "revenue": _money(row["revenue"]), + "bar_pct": _bar_pct(units_sold, peak_units), + } + ) + + recent_orders = list(sold.order_by("-paid_at", "-created_at")[:8]) + + return { + "days": days, + "currency": currency, + "order_count": order_count, + "revenue": revenue, + "units_sold": units, + "aov": aov, + "daily_sales": daily_sales, + "daily_revenue": daily_revenue, + "top_products": top_products, + "recent_orders": recent_orders, + "has_sales": order_count > 0, + } diff --git a/site/shop/templates/shop/portal/sales.html b/site/shop/templates/shop/portal/sales.html new file mode 100644 index 0000000..2d82d2a --- /dev/null +++ b/site/shop/templates/shop/portal/sales.html @@ -0,0 +1,147 @@ +{% extends "portal_base.html" %} +{% block title %}Sales · Portal{% endblock %} +{% block topbar_title %}Sales{% endblock %} +{% block portal_content %} +
+
+
Orders ({{ days }} days)
+
{{ order_count }}
+
+
+
Revenue ({{ days }} days)
+
{{ revenue }} {{ currency|upper }}
+
+
+
Units sold
+
{{ units_sold }}
+
+
+
Average order
+
{{ aov }} {{ currency|upper }}
+
+
+ +
+
+

Orders per day

+ Last {{ days }} days +
+
+ + {% if not has_sales %} +

No paid orders in this window.

+ {% endif %} +
+
+ +
+
+

Revenue per day

+ Last {{ days }} days +
+
+ +
+
+ +
+
+
+

Top products

+ By units sold +
+
+ {% if top_products %} + + {% else %} +

No product sales yet.

+ {% endif %} +
+
+
+
+

Product mix

+ Products +
+
+ + + + + + {% for row in top_products %} + + + + + + + {% empty %} + + {% endfor %} + +
ProductSKUUnitsRevenue
{{ row.name }}{{ row.sku }}{{ row.units }}{{ row.revenue }} {{ currency|upper }}
No paid line items in the last {{ days }} days.
+
+
+
+ +
+
+

Recent sales

+ All orders +
+
+ + + + + + {% for order in recent_orders %} + + + + + + + {% empty %} + + {% endfor %} + +
NumberCustomerAmountStatus
{{ order.number }}{{ order.customer_name|default:order.email }}{{ order.amount }} {{ order.currency|upper }}{{ order.get_status_display }}
No paid orders yet.
+
+
+{% endblock %} diff --git a/site/shop/tests.py b/site/shop/tests.py index b3a6d26..1759986 100644 --- a/site/shop/tests.py +++ b/site/shop/tests.py @@ -1,4 +1,5 @@ import struct +from datetime import timedelta from decimal import Decimal from io import BytesIO from pathlib import Path @@ -13,10 +14,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploaded from django.db import models from django.test import Client, TestCase, override_settings from django.urls import reverse +from django.utils import timezone from PIL import Image from core.models import StoredFile -from shop.models import Order, Product, ProductColor, ProductImage +from shop.models import Order, OrderItem, Product, ProductColor, ProductImage from shop.services import ( ShopError, add_to_cart, @@ -29,6 +31,7 @@ from shop.services import ( store_product_image, store_product_stl, ) +from shop.stats import sales_dashboard def _tiny_png() -> bytes: image = Image.new("RGBA", (8, 8), (200, 40, 40, 255)) @@ -591,3 +594,108 @@ class ShopPortalTests(TestCase): self.assertFalse(tmp_path.exists()) self.assertEqual(bytes(stored.data), _tiny_stl()) self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL) + + +def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None): + n = Order.objects.count() + 1 + order = Order.objects.create( + number=number or f"ORD-TEST-{n:04d}", + email=f"buyer{n}@example.com", + customer_name="Pat", + status=status or Order.Status.PAID, + amount=product.price * qty, + currency="usd", + paid_at=paid_at if paid_at is not None else timezone.now(), + ) + OrderItem.objects.create( + order=order, + product=product, + name=product.name, + sku=product.sku, + quantity=qty, + unit_price=product.price, + ) + return order + + +class ShopSalesDashboardTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user("merchant", password="test-pass-123") + self.client = Client() + self.client.login(username="merchant", password="test-pass-123") + self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00")) + self.fox = _product(name="Fox", sku="FOX", price=Decimal("12.00")) + + def test_sales_requires_login(self): + anon = Client() + self.assertEqual(anon.get(reverse("shop_portal:sales")).status_code, 302) + + def test_empty_dashboard(self): + data = sales_dashboard() + self.assertEqual(data["order_count"], 0) + self.assertEqual(data["revenue"], Decimal("0.00")) + self.assertEqual(len(data["daily_sales"]), 30) + self.assertFalse(data["has_sales"]) + response = self.client.get(reverse("shop_portal:sales")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No paid orders in this window") + self.assertContains(response, "Sales") + self.assertContains(response, "Orders per day") + self.assertContains(response, "Top products") + + def test_counts_paid_and_fulfilled_in_window(self): + now = timezone.now() + _sold_order(self.dragon, qty=2, paid_at=now) + _sold_order(self.fox, qty=5, paid_at=now - timedelta(days=2)) + _sold_order( + self.dragon, + qty=1, + paid_at=now - timedelta(days=1), + status=Order.Status.FULFILLED, + ) + _sold_order( + self.fox, + qty=9, + paid_at=now - timedelta(days=40), + ) + open_order = _sold_order(self.dragon, qty=3, paid_at=None, status=Order.Status.OPEN) + open_order.paid_at = None + open_order.save(update_fields=["paid_at"]) + cancelled = _sold_order( + self.fox, qty=4, paid_at=now, status=Order.Status.CANCELLED + ) + self.assertEqual(cancelled.status, Order.Status.CANCELLED) + + data = sales_dashboard() + self.assertEqual(data["order_count"], 3) + self.assertEqual(data["units_sold"], 8) + self.assertEqual(data["revenue"], Decimal("114.00")) + self.assertEqual(data["aov"], Decimal("38.00")) + self.assertEqual([row["sku"] for row in data["top_products"]], ["FOX", "DRAGON"]) + self.assertEqual(data["top_products"][0]["units"], 5) + self.assertEqual(data["top_products"][1]["units"], 3) + today_bar = data["daily_sales"][-1] + self.assertEqual(today_bar["count"], 1) + + response = self.client.get(reverse("shop_portal:sales")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Dragon") + self.assertContains(response, "Fox") + self.assertContains(response, "114.00") + self.assertContains(response, "Orders per day") + self.assertContains(response, "Revenue per day") + + def test_portal_nav_and_home_stats(self): + _sold_order(self.dragon, qty=1) + home = self.client.get(reverse("dashboard:home")) + self.assertEqual(home.status_code, 200) + self.assertEqual(home.context["shop_sales_30d"], 1) + self.assertEqual(home.context["shop_revenue_30d"], Decimal("18.00")) + self.assertContains(home, reverse("shop_portal:sales")) + self.assertContains(home, "Sales (30 days)") + products = self.client.get(reverse("shop_portal:product_list")) + self.assertContains(products, "Sales") + sales = self.client.get(reverse("shop_portal:sales")) + self.assertContains(sales, 'class="active"') + diff --git a/site/shop/views.py b/site/shop/views.py index 209f29d..b203b2c 100644 --- a/site/shop/views.py +++ b/site/shop/views.py @@ -34,6 +34,7 @@ from shop.services import ( store_product_stl, sync_product_colors, ) +from shop.stats import sales_dashboard logger = logging.getLogger(__name__) @@ -344,6 +345,11 @@ def portal_stock_adjust(request, pk): return redirect("shop_portal:product_edit", pk=product.pk) +@login_required +def portal_sales(request): + return render(request, "shop/portal/sales.html", sales_dashboard()) + + @login_required def portal_order_list(request): orders = Order.objects.all()[:200]