generated from westfarn/web_django_template
Add a shop sales dashboard with 30-day charts (#4)
CI / test (pull_request) Successful in 33s
CI / test (pull_request) Successful in 33s
## Summary - Closes #3 - Portal Retail **Sales** page: 30-day orders/revenue/units/AOV, daily charts, top products - Home dashboard cards for 30-day shop sales and revenue - Stacks on #2. Merge that first, then retarget `master` if needed. ## Test plan - [ ] Sales page requires login; empty state with no paid orders - [ ] Paid/fulfilled in the last 30 days counted; unpaid/old excluded - [ ] Home dashboard shows 30-day sales count - [ ] `manage.py test shop` Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
DJANGO_ALLOWED_HOSTS || true
|
||||||
|
|
||||||
PROJECT="client-ci-${{ gitea.sha }}"
|
PROJECT="print-forge-ci-${{ gitea.sha }}"
|
||||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
steps:
|
||||||
|
# --app must match server-infra app_catalog (print_forge).
|
||||||
- name: Deploy print_forge beta to all webservers
|
- name: Deploy print_forge beta to all webservers
|
||||||
run: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||||
DJANGO_ALLOWED_HOSTS || true
|
DJANGO_ALLOWED_HOSTS || true
|
||||||
|
|
||||||
PROJECT="client-ci-${{ gitea.sha }}"
|
PROJECT="print-forge-ci-${{ gitea.sha }}"
|
||||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ jobs:
|
|||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||||
steps:
|
steps:
|
||||||
|
# --app must match server-infra app_catalog (print_forge).
|
||||||
- name: Deploy print_forge prod to all webservers
|
- name: Deploy print_forge prod to all webservers
|
||||||
run: |
|
run: |
|
||||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ cp .env.prod.example ~/Documents/secrets/print_forge/print_forge_prod.env
|
|||||||
./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env
|
./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
|
Infra: Postgres on 10.0.0.230, server-infra `app_catalog.print_forge`, NPM, CI.
|
||||||
|
Gitea deploy calls `deploy.sh --app print_forge` (must match that catalog key).
|
||||||
|
Secrets: `~/Documents/secrets/print_forge/print_forge_{prod,beta}.env`.
|
||||||
Create a portal user with `createsuperuser` after first deploy.
|
Create a portal user with `createsuperuser` after first deploy.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ done
|
|||||||
if ((${#missing[@]} > 0)); then
|
if ((${#missing[@]} > 0)); then
|
||||||
echo "Missing required environment variables in $ENV_FILE:" >&2
|
echo "Missing required environment variables in $ENV_FILE:" >&2
|
||||||
printf ' - %s\n' "${missing[@]}" >&2
|
printf ' - %s\n' "${missing[@]}" >&2
|
||||||
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>_site/<slug>_site_${DJANGO_ENV}.env and set values." >&2
|
echo "Copy .env.prod.example to ~/Documents/secrets/print_forge/print_forge_${DJANGO_ENV}.env and set values." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ class InstalledOptionalAppsTests(TestCase):
|
|||||||
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
||||||
self.assertTrue(reverse("shop:list").startswith("/shop/"))
|
self.assertTrue(reverse("shop:list").startswith("/shop/"))
|
||||||
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/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("pos_sync:event_list").startswith("/portal/pos/"))
|
||||||
self.assertTrue(reverse("events:list").startswith("/events/"))
|
self.assertTrue(reverse("events:list").startswith("/events/"))
|
||||||
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
<div class="label">Open shop orders</div>
|
<div class="label">Open shop orders</div>
|
||||||
<div class="value">{{ open_shop_orders|default:0 }}</div>
|
<div class="value">{{ open_shop_orders|default:0 }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Sales (30 days)</div>
|
||||||
|
<div class="value">{{ shop_sales_30d|default:0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Shop revenue (30 days)</div>
|
||||||
|
<div class="value" style="font-size:22px">{{ shop_revenue_30d|default:0 }}</div>
|
||||||
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="label">Low stock</div>
|
<div class="label">Low stock</div>
|
||||||
<div class="value">{{ low_stock_products|default:0 }}</div>
|
<div class="value">{{ low_stock_products|default:0 }}</div>
|
||||||
|
|||||||
@@ -264,6 +264,63 @@ body.portal {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
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; }
|
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ def tianji_tracking(request):
|
|||||||
nav_section = "social_accounts"
|
nav_section = "social_accounts"
|
||||||
elif namespace == "social":
|
elif namespace == "social":
|
||||||
nav_section = "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":
|
elif namespace == "shop":
|
||||||
nav_section = "shop"
|
nav_section = "shop"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ from core.registry import (
|
|||||||
def register() -> None:
|
def register() -> None:
|
||||||
register_feature("shop")
|
register_feature("shop")
|
||||||
register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30)
|
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(
|
register_portal_nav(
|
||||||
section="shop_products",
|
section="shop_products",
|
||||||
label="Products",
|
label="Products",
|
||||||
@@ -30,7 +37,9 @@ def _dashboard(request) -> dict:
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from shop.models import Order, Product
|
from shop.models import Order, Product
|
||||||
|
from shop.stats import sales_summary
|
||||||
|
|
||||||
|
sales = sales_summary()
|
||||||
return {
|
return {
|
||||||
"open_shop_orders": Order.objects.filter(
|
"open_shop_orders": Order.objects.filter(
|
||||||
status__in=[Order.Status.OPEN, Order.Status.PAID]
|
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))
|
.filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3))
|
||||||
.distinct()
|
.distinct()
|
||||||
.count(),
|
.count(),
|
||||||
|
"shop_sales_30d": sales["order_count"],
|
||||||
|
"shop_revenue_30d": sales["revenue"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from shop import views
|
|||||||
app_name = "shop_portal"
|
app_name = "shop_portal"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("sales/", views.portal_sales, name="sales"),
|
||||||
path("products/", views.portal_product_list, name="product_list"),
|
path("products/", views.portal_product_list, name="product_list"),
|
||||||
path("products/new/", views.portal_product_edit, name="product_new"),
|
path("products/new/", views.portal_product_edit, name="product_new"),
|
||||||
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
|
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% block title %}Sales · Portal{% endblock %}
|
||||||
|
{% block topbar_title %}Sales{% endblock %}
|
||||||
|
{% block portal_content %}
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Orders ({{ days }} days)</div>
|
||||||
|
<div class="value">{{ order_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Revenue ({{ days }} days)</div>
|
||||||
|
<div class="value">{{ revenue }} {{ currency|upper }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Units sold</div>
|
||||||
|
<div class="value">{{ units_sold }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Average order</div>
|
||||||
|
<div class="value">{{ aov }} {{ currency|upper }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h">
|
||||||
|
<h2>Orders per day</h2>
|
||||||
|
<span class="muted">Last {{ days }} days</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-b">
|
||||||
|
<div class="chart-placeholder chart-daily" role="img"
|
||||||
|
aria-label="Paid orders per day for the last {{ days }} days">
|
||||||
|
{% for bar in daily_sales %}
|
||||||
|
<div class="chart-bar-col">
|
||||||
|
<div class="bar{% if not bar.count %} is-zero{% endif %}"
|
||||||
|
style="height:{{ bar.pct }}%"
|
||||||
|
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
|
||||||
|
<div class="chart-bar-meta">
|
||||||
|
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if not has_sales %}
|
||||||
|
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h">
|
||||||
|
<h2>Revenue per day</h2>
|
||||||
|
<span class="muted">Last {{ days }} days</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-b">
|
||||||
|
<div class="chart-placeholder chart-daily chart-revenue" role="img"
|
||||||
|
aria-label="Revenue per day for the last {{ days }} days">
|
||||||
|
{% for bar in daily_revenue %}
|
||||||
|
<div class="chart-bar-col">
|
||||||
|
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
|
||||||
|
style="height:{{ bar.pct }}%"
|
||||||
|
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
|
||||||
|
<div class="chart-bar-meta">
|
||||||
|
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h">
|
||||||
|
<h2>Top products</h2>
|
||||||
|
<span class="muted">By units sold</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-b">
|
||||||
|
{% if top_products %}
|
||||||
|
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
|
||||||
|
{% for row in top_products %}
|
||||||
|
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
|
||||||
|
<div class="hbar-label">{{ row.name }}</div>
|
||||||
|
<div class="hbar-track">
|
||||||
|
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hbar-value">{{ row.units }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h">
|
||||||
|
<h2>Product mix</h2>
|
||||||
|
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
|
||||||
|
</div>
|
||||||
|
<div class="panel-b" style="padding:0">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
<td>{{ row.sku }}</td>
|
||||||
|
<td>{{ row.units }}</td>
|
||||||
|
<td>{{ row.revenue }} {{ currency|upper }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-h">
|
||||||
|
<h2>Recent sales</h2>
|
||||||
|
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
|
||||||
|
</div>
|
||||||
|
<div class="panel-b" style="padding:0">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for order in recent_orders %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
|
||||||
|
<td>{{ order.customer_name|default:order.email }}</td>
|
||||||
|
<td>{{ order.amount }} {{ order.currency|upper }}</td>
|
||||||
|
<td>{{ order.get_status_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+109
-1
@@ -1,4 +1,5 @@
|
|||||||
import struct
|
import struct
|
||||||
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,10 +14,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploaded
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from core.models import StoredFile
|
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 (
|
from shop.services import (
|
||||||
ShopError,
|
ShopError,
|
||||||
add_to_cart,
|
add_to_cart,
|
||||||
@@ -29,6 +31,7 @@ from shop.services import (
|
|||||||
store_product_image,
|
store_product_image,
|
||||||
store_product_stl,
|
store_product_stl,
|
||||||
)
|
)
|
||||||
|
from shop.stats import sales_dashboard
|
||||||
|
|
||||||
def _tiny_png() -> bytes:
|
def _tiny_png() -> bytes:
|
||||||
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
|
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
|
||||||
@@ -591,3 +594,108 @@ class ShopPortalTests(TestCase):
|
|||||||
self.assertFalse(tmp_path.exists())
|
self.assertFalse(tmp_path.exists())
|
||||||
self.assertEqual(bytes(stored.data), _tiny_stl())
|
self.assertEqual(bytes(stored.data), _tiny_stl())
|
||||||
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_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"')
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from shop.services import (
|
|||||||
store_product_stl,
|
store_product_stl,
|
||||||
sync_product_colors,
|
sync_product_colors,
|
||||||
)
|
)
|
||||||
|
from shop.stats import sales_dashboard
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -344,6 +345,11 @@ def portal_stock_adjust(request, pk):
|
|||||||
return redirect("shop_portal:product_edit", pk=product.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
|
@login_required
|
||||||
def portal_order_list(request):
|
def portal_order_list(request):
|
||||||
orders = Order.objects.all()[:200]
|
orders = Order.objects.all()[:200]
|
||||||
|
|||||||
Reference in New Issue
Block a user