mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
301 lines
7.7 KiB
Go
301 lines
7.7 KiB
Go
package export
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"time"
|
|
|
|
"bookra/apps/backend/internal/bookings"
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
)
|
|
|
|
var ErrTenantMembership = errors.New("tenant membership not found")
|
|
|
|
// utf8BOM lets Excel detect the encoding for Czech diacritics.
|
|
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
|
|
|
const customerPageSize = 500
|
|
|
|
type Service struct {
|
|
repo db.Repository
|
|
bookings *bookings.Service
|
|
}
|
|
|
|
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) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return db.TenantRecord{}, ErrTenantMembership
|
|
}
|
|
return membership.Tenant, nil
|
|
}
|
|
|
|
// WriteBookingsCSV streams every tenant booking in [from, to) as CSV rows.
|
|
// Service labels resolve to service names for appointments and session titles
|
|
// for classes, matching how the rest of the app labels them.
|
|
func (s *Service) WriteBookingsCSV(ctx context.Context, principal domain.Principal, w io.Writer, from, to time.Time) error {
|
|
tenant, err := s.tenantFor(ctx, principal)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bookings, 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
|
|
}
|
|
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, from, 10000)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
staff, err := s.repo.ListStaffByTenant(ctx, tenant.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
locations, err := s.repo.ListLocationsByTenant(ctx, tenant.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
serviceNames := make(map[string]string, len(services))
|
|
for _, service := range services {
|
|
serviceNames[service.ID] = service.Name
|
|
}
|
|
sessionTitles := make(map[string]string, len(sessions))
|
|
for _, session := range sessions {
|
|
sessionTitles[session.ID] = session.Title
|
|
}
|
|
staffNames := make(map[string]string, len(staff))
|
|
for _, member := range staff {
|
|
staffNames[member.ID] = member.DisplayName
|
|
}
|
|
locationNames := make(map[string]string, len(locations))
|
|
for _, location := range locations {
|
|
locationNames[location.ID] = location.Name
|
|
}
|
|
|
|
if _, err := w.Write(utf8BOM); err != nil {
|
|
return err
|
|
}
|
|
out := csv.NewWriter(w)
|
|
defer out.Flush()
|
|
|
|
if err := out.Write([]string{
|
|
"reference", "status", "customer_name", "customer_email", "customer_phone",
|
|
"service", "staff", "location", "starts_at", "ends_at", "created_at", "notes",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, booking := range bookings {
|
|
label := ""
|
|
if booking.ServiceID != nil {
|
|
label = serviceNames[*booking.ServiceID]
|
|
}
|
|
if booking.ClassSessionID != nil {
|
|
label = sessionTitles[*booking.ClassSessionID]
|
|
}
|
|
staffName := ""
|
|
if booking.StaffID != nil {
|
|
staffName = staffNames[*booking.StaffID]
|
|
}
|
|
locationName := ""
|
|
if booking.LocationID != nil {
|
|
locationName = locationNames[*booking.LocationID]
|
|
}
|
|
if err := out.Write([]string{
|
|
booking.Reference,
|
|
booking.Status,
|
|
booking.CustomerName,
|
|
booking.CustomerEmail,
|
|
booking.CustomerPhone,
|
|
label,
|
|
staffName,
|
|
locationName,
|
|
booking.StartsAt.UTC().Format(time.RFC3339),
|
|
booking.EndsAt.UTC().Format(time.RFC3339),
|
|
booking.CreatedAt.UTC().Format(time.RFC3339),
|
|
booking.Notes,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return out.Error()
|
|
}
|
|
|
|
// WriteCustomersCSV streams the tenant's full customer list, paging through
|
|
// the repository in bounded chunks.
|
|
func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Principal, w io.Writer) error {
|
|
tenant, err := s.tenantFor(ctx, principal)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := w.Write(utf8BOM); err != nil {
|
|
return err
|
|
}
|
|
out := csv.NewWriter(w)
|
|
defer out.Flush()
|
|
|
|
if err := out.Write([]string{"name", "email", "phone", "status", "created_at"}); err != nil {
|
|
return err
|
|
}
|
|
for offset := 0; ; offset += customerPageSize {
|
|
page, err := s.repo.ListCustomersByTenant(ctx, tenant.ID, customerPageSize, offset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, customer := range page {
|
|
phone := ""
|
|
if customer.Phone != nil {
|
|
phone = *customer.Phone
|
|
}
|
|
if err := out.Write([]string{
|
|
customer.Name,
|
|
customer.Email,
|
|
phone,
|
|
customer.Status,
|
|
customer.CreatedAt.UTC().Format(time.RFC3339),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(page) < customerPageSize {
|
|
break
|
|
}
|
|
}
|
|
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()
|
|
}
|