239 lines
7.3 KiB
Go
239 lines
7.3 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"apps/backend/internal/module/usage/domain"
|
|
)
|
|
|
|
const analyticsDateLayout = "2006-01-02"
|
|
|
|
func ParseAnalyticsQuery(granularity, fromRaw, toRaw string) (domain.AnalyticsQuery, error) {
|
|
if granularity != "day" && granularity != "month" && granularity != "year" {
|
|
return domain.AnalyticsQuery{}, fmt.Errorf("%w: granularity must be day, month, or year", domain.ErrInvalidRange)
|
|
}
|
|
from, err := time.Parse(analyticsDateLayout, fromRaw)
|
|
if err != nil {
|
|
return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must be YYYY-MM-DD", domain.ErrInvalidRange)
|
|
}
|
|
to, err := time.Parse(analyticsDateLayout, toRaw)
|
|
if err != nil {
|
|
return domain.AnalyticsQuery{}, fmt.Errorf("%w: to must be YYYY-MM-DD", domain.ErrInvalidRange)
|
|
}
|
|
if from.After(to) {
|
|
return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must not be after to", domain.ErrInvalidRange)
|
|
}
|
|
q := domain.AnalyticsQuery{Granularity: granularity, From: from.UTC(), To: to.UTC()}
|
|
if len(analyticsBuckets(q)) > map[string]int{"day": 93, "month": 36, "year": 8}[granularity] {
|
|
return domain.AnalyticsQuery{}, fmt.Errorf("%w: range exceeds %s limit", domain.ErrInvalidRange, granularity)
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
type analyticsBucketRange struct {
|
|
key string
|
|
label string
|
|
from time.Time
|
|
to time.Time
|
|
}
|
|
|
|
func analyticsBuckets(q domain.AnalyticsQuery) []analyticsBucketRange {
|
|
var buckets []analyticsBucketRange
|
|
switch q.Granularity {
|
|
case "day":
|
|
for cur := q.From; !cur.After(q.To); cur = cur.AddDate(0, 0, 1) {
|
|
buckets = append(buckets, analyticsBucketRange{key: cur.Format(analyticsDateLayout), label: cur.Format("01/02"), from: cur, to: cur})
|
|
}
|
|
case "month":
|
|
for cur := time.Date(q.From.Year(), q.From.Month(), 1, 0, 0, 0, 0, time.UTC); !cur.After(q.To); cur = cur.AddDate(0, 1, 0) {
|
|
end := cur.AddDate(0, 1, -1)
|
|
buckets = append(buckets, analyticsBucketRange{key: cur.Format("2006-01"), label: cur.Format("2006/01"), from: maxTime(cur, q.From), to: minTime(end, q.To)})
|
|
}
|
|
case "year":
|
|
for year := q.From.Year(); year <= q.To.Year(); year++ {
|
|
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
|
|
key := fmt.Sprintf("%04d", year)
|
|
buckets = append(buckets, analyticsBucketRange{key: key, label: key, from: maxTime(start, q.From), to: minTime(end, q.To)})
|
|
}
|
|
}
|
|
return buckets
|
|
}
|
|
|
|
func maxTime(a, b time.Time) time.Time {
|
|
if a.After(b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func minTime(a, b time.Time) time.Time {
|
|
if a.Before(b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func proratedCredits(monthly int, from, to time.Time) int {
|
|
credits := 0.0
|
|
for cur := from; !cur.After(to); cur = cur.AddDate(0, 0, 1) {
|
|
days := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
|
credits += float64(monthly) / float64(days)
|
|
}
|
|
return int(math.Round(credits))
|
|
}
|
|
|
|
func isAIMeter(meter string) bool {
|
|
return meter == domain.MeterAICopy || meter == domain.MeterAIResearch || meter == domain.MeterAIImage
|
|
}
|
|
|
|
func emptyMeterCounts() map[string]domain.MeterCount {
|
|
return map[string]domain.MeterCount{
|
|
domain.MeterAICopy: {}, domain.MeterAIResearch: {}, domain.MeterWebSearch: {}, domain.MeterAIImage: {},
|
|
}
|
|
}
|
|
|
|
func (s *Service) TenantAnalytics(ctx context.Context, q domain.AnalyticsQuery, tenantMembers []domain.AnalyticsMember) (*domain.TenantAnalytics, error) {
|
|
events, err := s.Repo.ListEventsRange(ctx, q.From.UnixNano(), q.To.AddDate(0, 0, 1).UnixNano())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type memberAcc struct {
|
|
row domain.AnalyticsMemberRow
|
|
}
|
|
members := make(map[int64]*memberAcc, len(tenantMembers))
|
|
for _, member := range tenantMembers {
|
|
members[member.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: member.UID, Email: member.Email, DisplayName: member.DisplayName}}
|
|
}
|
|
for _, event := range events {
|
|
if members[event.UID] == nil {
|
|
members[event.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: event.UID}}
|
|
}
|
|
}
|
|
|
|
buckets := analyticsBuckets(q)
|
|
bucketByKey := make(map[string]int, len(buckets))
|
|
result := &domain.TenantAnalytics{
|
|
Granularity: q.Granularity, From: q.From.Format(analyticsDateLayout), To: q.To.Format(analyticsDateLayout),
|
|
RangeLabel: fmt.Sprintf("%s - %s", q.From.Format(analyticsDateLayout), q.To.Format(analyticsDateLayout)),
|
|
ByMeter: emptyMeterCounts(), Byok: domain.ByokBlock{ByMeter: emptyMeterCounts()},
|
|
}
|
|
for _, bucket := range buckets {
|
|
row := domain.AnalyticsBucket{Key: bucket.key, Label: bucket.label}
|
|
result.Series = append(result.Series, row)
|
|
bucketByKey[bucket.key] = len(result.Series) - 1
|
|
}
|
|
|
|
for _, acc := range members {
|
|
prefs, prefsErr := s.ensurePrefs(ctx, acc.row.UID)
|
|
if prefsErr != nil {
|
|
return nil, prefsErr
|
|
}
|
|
plan := domain.ResolvePlan(prefs.PlanID)
|
|
acc.row.PlanID, acc.row.Unlimited = plan.ID, prefs.Unlimited
|
|
acc.row.PurchasedCredits = proratedCredits(plan.MonthlyCredits, q.From, q.To)
|
|
result.PurchasedCredits += acc.row.PurchasedCredits
|
|
for i, bucket := range buckets {
|
|
result.Series[i].PurchasedCredits += proratedCredits(plan.MonthlyCredits, bucket.from, bucket.to)
|
|
}
|
|
}
|
|
|
|
for _, event := range events {
|
|
bucketIndex, hasBucket := bucketByKey[eventBucketKey(event.CreatedAt, q.Granularity)]
|
|
acc := members[event.UID]
|
|
if event.KeyMode == domain.KeyModeByok {
|
|
result.Byok.CallCount++
|
|
meter := result.Byok.ByMeter[event.Meter]
|
|
meter.Count++
|
|
result.Byok.ByMeter[event.Meter] = meter
|
|
acc.row.ByokCallCount++
|
|
if hasBucket {
|
|
result.Series[bucketIndex].ByokCallCount++
|
|
}
|
|
continue
|
|
}
|
|
if event.KeyMode != domain.KeyModePlatform {
|
|
continue
|
|
}
|
|
credits := event.Credits
|
|
if credits < 0 {
|
|
credits = 0
|
|
}
|
|
result.ConsumedCredits += credits
|
|
meter := result.ByMeter[event.Meter]
|
|
meter.Count++
|
|
meter.Credits += credits
|
|
result.ByMeter[event.Meter] = meter
|
|
acc.row.TotalCredits += credits
|
|
if isAIMeter(event.Meter) {
|
|
result.AICalls++
|
|
acc.row.AICalls++
|
|
if hasBucket {
|
|
result.Series[bucketIndex].AICalls++
|
|
}
|
|
} else if event.Meter == domain.MeterWebSearch {
|
|
result.SearchCalls++
|
|
acc.row.SearchCalls++
|
|
if hasBucket {
|
|
result.Series[bucketIndex].SearchCalls++
|
|
}
|
|
}
|
|
if hasBucket {
|
|
result.Series[bucketIndex].ConsumedCredits += credits
|
|
}
|
|
}
|
|
|
|
result.RemainingCredits = maxInt(0, result.PurchasedCredits-result.ConsumedCredits)
|
|
result.Pct = percentage(result.ConsumedCredits, result.PurchasedCredits)
|
|
for _, acc := range members {
|
|
acc.row.RemainingCredits = maxInt(0, acc.row.PurchasedCredits-acc.row.TotalCredits)
|
|
acc.row.Pct = percentage(acc.row.TotalCredits, acc.row.PurchasedCredits)
|
|
result.Members = append(result.Members, acc.row)
|
|
}
|
|
sort.Slice(result.Members, func(i, j int) bool {
|
|
if result.Members[i].TotalCredits == result.Members[j].TotalCredits {
|
|
return result.Members[i].UID < result.Members[j].UID
|
|
}
|
|
return result.Members[i].TotalCredits > result.Members[j].TotalCredits
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
func eventBucketKey(nano int64, granularity string) string {
|
|
t := time.Unix(0, nano).UTC()
|
|
switch granularity {
|
|
case "day":
|
|
return t.Format(analyticsDateLayout)
|
|
case "month":
|
|
return t.Format("2006-01")
|
|
default:
|
|
return t.Format("2006")
|
|
}
|
|
}
|
|
|
|
func percentage(used, total int) int {
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
return minInt(999, int(math.Round(float64(used)*100/float64(total))))
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|