mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a trailing-window digest (bookings, revenue, per-service and per-staff breakdown) localized to the tenant locale; zero-activity tenants are skipped. Metrics cores extracted to tenant-scoped variants so the job needs no principal. PATCH /v1/catalog/class-sessions/:id sets a session's seat count with a confirmed-bookings floor — shrinking below it needs allowOverCapacity, matching the waitlist-promotion convention. Session rows gain an inline capacity editor with an overbook confirm state. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
191 lines
6.7 KiB
Go
191 lines
6.7 KiB
Go
// Package reports delivers scheduled business digests — a periodic email to
|
|
// each tenant owner summarizing bookings, revenue, and staff utilization over
|
|
// a trailing window. Dispatch is driven by an external cron hitting the
|
|
// internal job endpoint; cadence and deduplication live with the scheduler,
|
|
// not here.
|
|
package reports
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"bookra/apps/backend/internal/bookings"
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
"bookra/apps/backend/internal/notifications"
|
|
)
|
|
|
|
const digestPageSize = 200
|
|
|
|
// SendEmailFunc matches notifications.Service.SendRawEmail — taken as a
|
|
// parameter so tests can capture messages instead of sending them.
|
|
type SendEmailFunc func(ctx context.Context, msg notifications.EmailMessage) error
|
|
|
|
type Service struct {
|
|
repo db.Repository
|
|
bookingStats *bookings.Service
|
|
frontendURL string
|
|
emailFrom string
|
|
}
|
|
|
|
func NewService(repo db.Repository, bookingStats *bookings.Service, frontendURL, emailFrom string) *Service {
|
|
return &Service{repo: repo, bookingStats: bookingStats, frontendURL: frontendURL, emailFrom: emailFrom}
|
|
}
|
|
|
|
// DispatchDigests emails each tenant owner a summary of the trailing `days`
|
|
// window. A tenantID query scopes the run to one tenant (per-tenant crons,
|
|
// manual retries). Tenants with zero bookings in the window are skipped —
|
|
// an empty digest is noise, not signal.
|
|
func (s *Service) DispatchDigests(ctx context.Context, days int, tenantID string, send SendEmailFunc) (domain.DispatchReportDigestsResponse, error) {
|
|
if days <= 0 || days > 365 {
|
|
days = 7
|
|
}
|
|
|
|
var response domain.DispatchReportDigestsResponse
|
|
if tenantID != "" {
|
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
return s.dispatchToTenant(ctx, tenant, days, send, response)
|
|
}
|
|
|
|
for offset := 0; ; offset += digestPageSize {
|
|
tenants, total, err := s.repo.ListAllTenants(ctx, digestPageSize, offset)
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
for _, tenant := range tenants {
|
|
response, err = s.dispatchToTenant(ctx, tenant, days, send, response)
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
}
|
|
if offset+len(tenants) >= total {
|
|
break
|
|
}
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *Service) dispatchToTenant(ctx context.Context, tenant db.TenantRecord, days int, send SendEmailFunc, response domain.DispatchReportDigestsResponse) (domain.DispatchReportDigestsResponse, error) {
|
|
response.TenantCount++
|
|
|
|
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenant.ID)
|
|
if err != nil || ownerEmail == "" {
|
|
response.SkippedCount++
|
|
return response, nil
|
|
}
|
|
|
|
services, err := s.bookingStats.ServiceMetricsForTenant(ctx, tenant.ID, days)
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
staff, err := s.bookingStats.StaffMetricsForTenant(ctx, tenant.ID, days)
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
|
|
var total, completed, cancelled int
|
|
var revenueCents int64
|
|
for _, svc := range services.Services {
|
|
total += svc.Bookings
|
|
completed += svc.Completed
|
|
cancelled += svc.Cancelled
|
|
revenueCents += svc.RevenueCents
|
|
}
|
|
total += services.ClassBookings
|
|
if total == 0 {
|
|
response.SkippedCount++
|
|
return response, nil
|
|
}
|
|
|
|
msg := renderDigest(s.emailFrom, s.frontendURL, ownerEmail, tenant, services, staff, days, total, completed, cancelled, revenueCents)
|
|
if err := send(ctx, msg); err != nil {
|
|
response.FailedCount++
|
|
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: tenant.ID,
|
|
ActorKind: "system",
|
|
Action: "report.digest_failed",
|
|
EntityType: "tenant",
|
|
EntityID: &tenant.ID,
|
|
Summary: err.Error(),
|
|
})
|
|
return response, nil
|
|
}
|
|
|
|
response.SentCount++
|
|
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: tenant.ID,
|
|
ActorKind: "system",
|
|
Action: "report.digest_sent",
|
|
EntityType: "tenant",
|
|
EntityID: &tenant.ID,
|
|
Summary: fmt.Sprintf("%d bookings / %dd", total, days),
|
|
})
|
|
return response, nil
|
|
}
|
|
|
|
func renderDigest(from, frontendURL, to string, tenant db.TenantRecord, services domain.ServiceMetricsResponse, staff domain.StaffMetricsResponse, days, total, completed, cancelled int, revenueCents int64) notifications.EmailMessage {
|
|
cs := tenant.Locale == "cs"
|
|
dashboardURL := strings.TrimRight(frontendURL, "/") + "/dashboard"
|
|
money := formatCZK(revenueCents, cs)
|
|
|
|
var subject string
|
|
var text strings.Builder
|
|
if cs {
|
|
subject = fmt.Sprintf("%s: přehled za posledních %d dní", tenant.Name, days)
|
|
text.WriteString(fmt.Sprintf("Dobrý den,\n\nzde je souhrn provozu %s za posledních %d dní.\n\n", tenant.Name, days))
|
|
text.WriteString(fmt.Sprintf("Rezervace celkem: %d (dokončené: %d, zrušené: %d)\n", total, completed, cancelled))
|
|
text.WriteString(fmt.Sprintf("Tržby: %s\n", money))
|
|
if services.ClassBookings > 0 {
|
|
text.WriteString(fmt.Sprintf("Z toho rezervace na lekce: %d\n", services.ClassBookings))
|
|
}
|
|
text.WriteString("\nSlužby:\n")
|
|
for _, svc := range services.Services {
|
|
if svc.Bookings == 0 {
|
|
continue
|
|
}
|
|
text.WriteString(fmt.Sprintf("- %s: %d rezervací, %s\n", svc.Name, svc.Bookings, formatCZK(svc.RevenueCents, cs)))
|
|
}
|
|
text.WriteString("\nZaměstnanci:\n")
|
|
for _, member := range staff.Staff {
|
|
text.WriteString(fmt.Sprintf("- %s: %d rezervací, vytížení %d%%\n", member.DisplayName, member.Bookings, member.UtilizationPercent))
|
|
}
|
|
text.WriteString(fmt.Sprintf("\nDetaily najdete na nástěnce: %s\n", dashboardURL))
|
|
} else {
|
|
subject = fmt.Sprintf("%s: last %d days at a glance", tenant.Name, days)
|
|
text.WriteString(fmt.Sprintf("Hello,\n\nhere is the %s summary for the last %d days.\n\n", tenant.Name, days))
|
|
text.WriteString(fmt.Sprintf("Total bookings: %d (completed: %d, cancelled: %d)\n", total, completed, cancelled))
|
|
text.WriteString(fmt.Sprintf("Revenue: %s\n", money))
|
|
if services.ClassBookings > 0 {
|
|
text.WriteString(fmt.Sprintf("Of which class bookings: %d\n", services.ClassBookings))
|
|
}
|
|
text.WriteString("\nServices:\n")
|
|
for _, svc := range services.Services {
|
|
if svc.Bookings == 0 {
|
|
continue
|
|
}
|
|
text.WriteString(fmt.Sprintf("- %s: %d bookings, %s\n", svc.Name, svc.Bookings, formatCZK(svc.RevenueCents, cs)))
|
|
}
|
|
text.WriteString("\nStaff:\n")
|
|
for _, member := range staff.Staff {
|
|
text.WriteString(fmt.Sprintf("- %s: %d bookings, %d%% utilized\n", member.DisplayName, member.Bookings, member.UtilizationPercent))
|
|
}
|
|
text.WriteString(fmt.Sprintf("\nFull details on the dashboard: %s\n", dashboardURL))
|
|
}
|
|
|
|
return notifications.EmailMessage{From: from, To: to, Subject: subject, Text: text.String()}
|
|
}
|
|
|
|
// formatCZK renders cents as a whole-crown figure — digests show trends, not
|
|
// accounting, so haléře precision adds noise.
|
|
func formatCZK(cents int64, cs bool) string {
|
|
crowns := cents / 100
|
|
if cs {
|
|
return fmt.Sprintf("%d Kč", crowns)
|
|
}
|
|
return fmt.Sprintf("CZK %d", crowns)
|
|
}
|