mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(reports): capacity-based utilization + report CSVs
Utilization now measures booked minutes against each member's availability-rule capacity in the tenant timezone, replacing the confirmed-share proxy. StaffMetric exposes booked/capacity minutes. New endpoints: GET /v1/export/staff-report.csv (reuses StaffMetrics) and GET /v1/export/service-report.csv (per-service bookings/cancelled/ revenue, class bookings rolled up). Export buttons on the staff performance panel and services page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2464aa45fa
commit
7b9080eb1c
13 files changed
+541
-33
No files matched your search
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
@@ -19,11 +21,12 @@ var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
const customerPageSize = 500
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
repo db.Repository
|
||||
bookings *bookings.Service
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
func NewService(repo db.Repository, bookingsService *bookings.Service) *Service {
|
||||
return &Service{repo: repo, bookings: bookingsService}
|
||||
}
|
||||
|
||||
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
|
||||
@@ -172,3 +175,126 @@ func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Princi
|
||||
}
|
||||
return out.Error()
|
||||
}
|
||||
|
||||
// WriteStaffReportCSV exports the per-staff performance metrics over a
|
||||
// trailing window — the same numbers the dashboard performance panel shows.
|
||||
func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
|
||||
metrics, err := s.bookings.StaffMetrics(ctx, principal, days)
|
||||
if err != nil {
|
||||
if errors.Is(err, bookings.ErrTenantMembership) {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write(utf8BOM); err != nil {
|
||||
return err
|
||||
}
|
||||
out := csv.NewWriter(w)
|
||||
defer out.Flush()
|
||||
|
||||
if err := out.Write([]string{
|
||||
"staff", "bookings", "completed", "cancelled",
|
||||
"booked_minutes", "capacity_minutes", "utilization_percent", "revenue_cents",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range metrics.Staff {
|
||||
if err := out.Write([]string{
|
||||
m.DisplayName,
|
||||
strconv.Itoa(m.Bookings),
|
||||
strconv.Itoa(m.Completed),
|
||||
strconv.Itoa(m.Cancelled),
|
||||
strconv.Itoa(m.BookedMinutes),
|
||||
strconv.Itoa(m.CapacityMinutes),
|
||||
strconv.Itoa(m.UtilizationPercent),
|
||||
strconv.FormatInt(m.RevenueCents, 10),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return out.Error()
|
||||
}
|
||||
|
||||
// WriteServiceReportCSV aggregates appointment bookings per catalog service
|
||||
// over a trailing window. Class bookings have no service or price and roll
|
||||
// into a final "class sessions" row when present.
|
||||
func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
|
||||
tenant, err := s.tenantFor(ctx, principal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
to := time.Now().UTC()
|
||||
from := to.AddDate(0, 0, -days)
|
||||
|
||||
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prices := make(map[string]int, len(services))
|
||||
for _, service := range services {
|
||||
prices[service.ID] = service.PriceCents
|
||||
}
|
||||
|
||||
type aggregate struct {
|
||||
bookings int
|
||||
cancelled int
|
||||
revenue int64
|
||||
}
|
||||
byService := make(map[string]*aggregate, len(services))
|
||||
classBookings := 0
|
||||
for _, booking := range bookingsList {
|
||||
if booking.ServiceID == nil {
|
||||
classBookings++
|
||||
continue
|
||||
}
|
||||
agg := byService[*booking.ServiceID]
|
||||
if agg == nil {
|
||||
agg = &aggregate{}
|
||||
byService[*booking.ServiceID] = agg
|
||||
}
|
||||
agg.bookings++
|
||||
if booking.Status == "cancelled" {
|
||||
agg.cancelled++
|
||||
} else {
|
||||
agg.revenue += int64(prices[*booking.ServiceID])
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := w.Write(utf8BOM); err != nil {
|
||||
return err
|
||||
}
|
||||
out := csv.NewWriter(w)
|
||||
defer out.Flush()
|
||||
|
||||
if err := out.Write([]string{"service", "bookings", "cancelled", "revenue_cents"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, service := range services {
|
||||
agg := byService[service.ID]
|
||||
if agg == nil {
|
||||
agg = &aggregate{}
|
||||
}
|
||||
if err := out.Write([]string{
|
||||
service.Name,
|
||||
strconv.Itoa(agg.bookings),
|
||||
strconv.Itoa(agg.cancelled),
|
||||
strconv.FormatInt(agg.revenue, 10),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if classBookings > 0 {
|
||||
if err := out.Write([]string{"class sessions", strconv.Itoa(classBookings), "0", "0"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return out.Error()
|
||||
}
|
||||
@@ -8,12 +8,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
|
||||
|
||||
func newTestService(repo *db.MemoryRepository) *Service {
|
||||
return NewService(repo, bookings.NewService(repo, nil, "http://localhost:3000"))
|
||||
}
|
||||
|
||||
const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
|
||||
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
|
||||
@@ -50,7 +55,7 @@ func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
svc := NewService(repo)
|
||||
svc := newTestService(repo)
|
||||
from := time.Now().Add(-time.Hour)
|
||||
to := time.Now().Add(72 * time.Hour)
|
||||
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
||||
@@ -96,7 +101,7 @@ func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
svc := NewService(repo)
|
||||
svc := newTestService(repo)
|
||||
from := time.Now().Add(-time.Hour)
|
||||
to := time.Now().Add(24 * time.Hour) // far-future booking outside
|
||||
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
||||
@@ -112,7 +117,7 @@ func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) {
|
||||
func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
var buf bytes.Buffer
|
||||
svc := NewService(repo)
|
||||
svc := newTestService(repo)
|
||||
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
|
||||
t.Fatalf("write csv: %v", err)
|
||||
}
|
||||
@@ -136,7 +141,7 @@ func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
|
||||
|
||||
func TestExportRequiresMembership(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(repo)
|
||||
svc := newTestService(repo)
|
||||
var buf bytes.Buffer
|
||||
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
|
||||
if err != ErrTenantMembership {
|
||||
@@ -147,3 +152,96 @@ func TestExportRequiresMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestWriteStaffReportCSV(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
|
||||
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
|
||||
now := time.Now().UTC()
|
||||
seed := func(status string, offsetHours int) {
|
||||
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||
TenantID: demoTenantID,
|
||||
ServiceID: &serviceID,
|
||||
StaffID: &staffID,
|
||||
CustomerName: "Report Customer",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: now.Add(time.Duration(offsetHours) * time.Hour),
|
||||
EndsAt: now.Add(time.Duration(offsetHours+1) * time.Hour),
|
||||
Status: status,
|
||||
Reference: "BK-REP-" + status,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed booking: %v", err)
|
||||
}
|
||||
}
|
||||
seed("confirmed", -5)
|
||||
seed("confirmed", -20)
|
||||
seed("cancelled", -30)
|
||||
|
||||
var buf bytes.Buffer
|
||||
svc := newTestService(repo)
|
||||
if err := svc.WriteStaffReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil {
|
||||
t.Fatalf("staff report csv: %v", err)
|
||||
}
|
||||
records := readCSV(t, &buf)
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("expected header + 1 staff row, got %d rows", len(records))
|
||||
}
|
||||
row := records[1]
|
||||
if row[0] != "Demo Stylist" {
|
||||
t.Fatalf("expected staff name, got %q", row[0])
|
||||
}
|
||||
if row[1] != "3" || row[2] != "2" || row[3] != "1" {
|
||||
t.Fatalf("expected bookings=3 completed=2 cancelled=1, got %v", row[1:4])
|
||||
}
|
||||
if row[4] != "120" {
|
||||
t.Fatalf("expected 120 booked minutes, got %q", row[4])
|
||||
}
|
||||
if row[7] != "240000" {
|
||||
t.Fatalf("expected 240000 revenue cents, got %q", row[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteServiceReportCSV(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
|
||||
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||
now := time.Now().UTC()
|
||||
seed := func(service *string, session *string, status string, ref string) {
|
||||
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||
TenantID: demoTenantID,
|
||||
ServiceID: service,
|
||||
ClassSessionID: session,
|
||||
CustomerName: "Svc Customer",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: now.Add(-5 * time.Hour),
|
||||
EndsAt: now.Add(-4 * time.Hour),
|
||||
Status: status,
|
||||
Reference: ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed booking: %v", err)
|
||||
}
|
||||
}
|
||||
seed(&serviceID, nil, "confirmed", "BK-SVC-1")
|
||||
seed(&serviceID, nil, "cancelled", "BK-SVC-2")
|
||||
seed(nil, &sessionID, "confirmed", "BK-SVC-3")
|
||||
|
||||
var buf bytes.Buffer
|
||||
svc := newTestService(repo)
|
||||
if err := svc.WriteServiceReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil {
|
||||
t.Fatalf("service report csv: %v", err)
|
||||
}
|
||||
records := readCSV(t, &buf)
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("expected header + service row + class row, got %d rows", len(records))
|
||||
}
|
||||
svcRow := records[1]
|
||||
if svcRow[0] != "Signature treatment" || svcRow[1] != "2" || svcRow[2] != "1" || svcRow[3] != "120000" {
|
||||
t.Fatalf("unexpected service row: %v", svcRow)
|
||||
}
|
||||
classRow := records[2]
|
||||
if classRow[0] != "class sessions" || classRow[1] != "1" {
|
||||
t.Fatalf("unexpected class row: %v", classRow)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user