154 lines
5.8 KiB
Go
154 lines
5.8 KiB
Go
package processor
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"math"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
|
||
|
|
"apskel-pos-be/internal/constants"
|
||
|
|
"apskel-pos-be/internal/models"
|
||
|
|
"apskel-pos-be/internal/repository"
|
||
|
|
)
|
||
|
|
|
||
|
|
// enakGameAnalyticsMaxDays bounds a range, so one request cannot scan years of ledger.
|
||
|
|
const enakGameAnalyticsMaxDays = 366
|
||
|
|
|
||
|
|
// EnakGameAnalyticsProcessor answers the EnakGame dashboards (docs/tasks-enakgame.md
|
||
|
|
// EG-903, PRD §36) for an organization and a range of days in Asia/Jakarta.
|
||
|
|
type EnakGameAnalyticsProcessor struct {
|
||
|
|
analytics repository.EnakGameAnalyticsRepository
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewEnakGameAnalyticsProcessor(analytics repository.EnakGameAnalyticsRepository) *EnakGameAnalyticsProcessor {
|
||
|
|
return &EnakGameAnalyticsProcessor{analytics: analytics}
|
||
|
|
}
|
||
|
|
|
||
|
|
// analyticsRange reads from and to, both days included, as [start, end).
|
||
|
|
func analyticsRange(q models.EnakGameAnalyticsQuery) (start, end time.Time, err error) {
|
||
|
|
from, err := time.Parse("2006-01-02", strings.TrimSpace(q.From))
|
||
|
|
if err != nil {
|
||
|
|
return start, end, enakGameRejected("from must be a date like 2026-10-01")
|
||
|
|
}
|
||
|
|
to, err := time.Parse("2006-01-02", strings.TrimSpace(q.To))
|
||
|
|
if err != nil {
|
||
|
|
return start, end, enakGameRejected("to must be a date like 2026-10-31")
|
||
|
|
}
|
||
|
|
if to.Before(from) {
|
||
|
|
return start, end, enakGameRejected("to cannot be before from")
|
||
|
|
}
|
||
|
|
if daysBetween(from, to)+1 > enakGameAnalyticsMaxDays {
|
||
|
|
return start, end, enakGameRejected("the range can span at most %d days", enakGameAnalyticsMaxDays)
|
||
|
|
}
|
||
|
|
return jakartaMidnight(from), jakartaMidnight(to.AddDate(0, 0, 1)), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Games is how the organization's games were played, by the day each session
|
||
|
|
// started.
|
||
|
|
func (p *EnakGameAnalyticsProcessor) Games(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameAnalytics, error) {
|
||
|
|
start, end, err := analyticsRange(q)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var gameID *uuid.UUID
|
||
|
|
if s := strings.TrimSpace(q.GameID); s != "" {
|
||
|
|
id, err := uuid.Parse(s)
|
||
|
|
if err != nil {
|
||
|
|
return nil, enakGameRejected("game_id must be a UUID")
|
||
|
|
}
|
||
|
|
gameID = &id
|
||
|
|
}
|
||
|
|
rows, err := p.analytics.SessionStats(ctx, organizationID, start, end, gameID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := &models.EnakGameAnalytics{From: strings.TrimSpace(q.From), To: strings.TrimSpace(q.To), Games: []models.EnakGameGameStats{}}
|
||
|
|
for _, row := range rows {
|
||
|
|
if row.GameID == nil {
|
||
|
|
out.Totals = gameStats(row)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
g := models.EnakGameGameStats{GameID: *row.GameID, EnakGameStats: gameStats(row)}
|
||
|
|
if row.GameName != nil {
|
||
|
|
g.GameName = *row.GameName
|
||
|
|
}
|
||
|
|
out.Games = append(out.Games, g)
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func gameStats(r repository.GameSessionStats) models.EnakGameStats {
|
||
|
|
s := models.EnakGameStats{
|
||
|
|
Plays: r.Plays, Completed: r.Completed, Refunded: r.Refunded, Expired: r.Expired, Flagged: r.Flagged,
|
||
|
|
Players: r.Players, AverageScore: r.AverageScore, CoinIssued: r.CoinIssued,
|
||
|
|
EntryCostPaid: r.EntryCost, CoinRefunded: r.CoinRefunded,
|
||
|
|
}
|
||
|
|
if r.Completed > 0 {
|
||
|
|
s.AverageReward = ratio(r.CoinIssued, r.Completed)
|
||
|
|
}
|
||
|
|
if r.Plays > 0 {
|
||
|
|
s.RewardPerPlay = ratio(r.CoinIssued, r.Plays)
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
// ratio is part / whole with two decimals.
|
||
|
|
func ratio(part, whole int64) float64 {
|
||
|
|
return math.Round(float64(part)*100/float64(whole)) / 100
|
||
|
|
}
|
||
|
|
|
||
|
|
// Economy is how EnakCoin and EnakPoint moved in the organization, from the ledger.
|
||
|
|
func (p *EnakGameAnalyticsProcessor) Economy(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameEconomyAnalytics, error) {
|
||
|
|
start, end, err := analyticsRange(q)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
flows, err := p.analytics.WalletFlows(ctx, organizationID, start, end)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
balances, err := p.analytics.BalancesAt(ctx, organizationID, end)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := economy(flows, balances)
|
||
|
|
out.From, out.To = strings.TrimSpace(q.From), strings.TrimSpace(q.To)
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// economy turns ledger totals into the headline numbers. Transfers move value between
|
||
|
|
// customers and count in none of them; ByType still lists them.
|
||
|
|
func economy(flows []repository.WalletFlow, balances map[string]int64) *models.EnakGameEconomyAnalytics {
|
||
|
|
type key struct{ currency, txType string }
|
||
|
|
byKey := make(map[key]repository.WalletFlow, len(flows))
|
||
|
|
out := &models.EnakGameEconomyAnalytics{ByType: make([]models.WalletFlowTotals, 0, len(flows))}
|
||
|
|
for _, f := range flows {
|
||
|
|
byKey[key{f.Currency, f.Type}] = f
|
||
|
|
out.ByType = append(out.ByType, models.WalletFlowTotals{
|
||
|
|
Currency: f.Currency, Type: f.Type, Credit: f.Credit, Debit: f.Debit, Transactions: f.Transactions,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
coin := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyCoin, txType}] }
|
||
|
|
point := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyPoint, txType}] }
|
||
|
|
|
||
|
|
c := &out.Coin
|
||
|
|
c.GameRewards = coin(constants.WalletTxTypeGameReward).Credit
|
||
|
|
c.Generated = c.GameRewards + coin(constants.WalletTxTypeEarn).Credit - coin(constants.WalletTxTypeEarnReversal).Debit +
|
||
|
|
coin(constants.WalletTxTypeMigration).Credit + coin(constants.WalletTxTypeAdjustment).Credit
|
||
|
|
c.SpentOnGames = coin(constants.WalletTxTypeGameSpend).Debit - coin(constants.WalletTxTypeGameSpendRefund).Credit
|
||
|
|
c.Exchanged = coin(constants.WalletTxTypeExchangeOut).Debit
|
||
|
|
c.Spent = c.SpentOnGames + c.Exchanged
|
||
|
|
c.Expired = coin(constants.WalletTxTypeExpire).Debit
|
||
|
|
c.Outstanding = balances[constants.WalletCurrencyCoin]
|
||
|
|
|
||
|
|
pt := &out.Point
|
||
|
|
pt.Earned = point(constants.WalletTxTypeEarn).Credit - point(constants.WalletTxTypeEarnReversal).Debit
|
||
|
|
pt.Exchanged = point(constants.WalletTxTypeExchangeIn).Credit
|
||
|
|
pt.Redeemed = point(constants.WalletTxTypeRewardRedeem).Debit - point(constants.WalletTxTypeRewardRedeemRefund).Credit
|
||
|
|
pt.Expired = point(constants.WalletTxTypeExpire).Debit
|
||
|
|
pt.Balance = balances[constants.WalletCurrencyPoint]
|
||
|
|
return out
|
||
|
|
}
|