feat(enakgame): budget controller recommendations and analytics
EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
798a36bd6c
commit
296708244e
@@ -41,6 +41,13 @@ type EnakGameAdminService interface {
|
||||
UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
|
||||
DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||
BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||
BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||
// AcceptBudgetRecommendation applies the recommendation whose multiplier the body
|
||||
// sends back.
|
||||
AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
|
||||
|
||||
GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response
|
||||
EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response
|
||||
|
||||
CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
|
||||
ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response
|
||||
@@ -74,16 +81,21 @@ type EnakGameCustomerService interface {
|
||||
}
|
||||
|
||||
type EnakGameAdminServiceImpl struct {
|
||||
games *processor.EnakGameAdminProcessor
|
||||
budgets *processor.GameBudgetProcessor
|
||||
metrics *processor.GameBudgetMetricsProcessor
|
||||
events *processor.GameEventProcessor
|
||||
vouchers *processor.VoucherAdminProcessor
|
||||
games *processor.EnakGameAdminProcessor
|
||||
budgets *processor.GameBudgetProcessor
|
||||
metrics *processor.GameBudgetMetricsProcessor
|
||||
controller *processor.GameBudgetControllerProcessor
|
||||
events *processor.GameEventProcessor
|
||||
vouchers *processor.VoucherAdminProcessor
|
||||
analytics *processor.EnakGameAnalyticsProcessor
|
||||
}
|
||||
|
||||
func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor,
|
||||
events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl {
|
||||
return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers}
|
||||
controller *processor.GameBudgetControllerProcessor, events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor,
|
||||
analytics *processor.EnakGameAnalyticsProcessor) *EnakGameAdminServiceImpl {
|
||||
return &EnakGameAdminServiceImpl{
|
||||
games: games, budgets: budgets, metrics: metrics, controller: controller, events: events, vouchers: vouchers, analytics: analytics,
|
||||
}
|
||||
}
|
||||
|
||||
type EnakGameCustomerServiceImpl struct {
|
||||
@@ -213,6 +225,30 @@ func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *app
|
||||
return respond(ctx, metrics, err)
|
||||
}
|
||||
|
||||
func (s *EnakGameAdminServiceImpl) BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
|
||||
rec, err := s.controller.Recommendation(ctx, apctx.OrganizationID, id)
|
||||
return respond(ctx, rec, err)
|
||||
}
|
||||
|
||||
func (s *EnakGameAdminServiceImpl) AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
|
||||
var in models.GameBudgetRecommendationAcceptInput
|
||||
if resp := decodeStrict(body, &in); resp != nil {
|
||||
return resp
|
||||
}
|
||||
accepted, err := s.controller.Accept(ctx, apctx.OrganizationID, apctx.UserID, id, in)
|
||||
return respond(ctx, accepted, err)
|
||||
}
|
||||
|
||||
func (s *EnakGameAdminServiceImpl) GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response {
|
||||
stats, err := s.analytics.Games(ctx, apctx.OrganizationID, q)
|
||||
return respond(ctx, stats, err)
|
||||
}
|
||||
|
||||
func (s *EnakGameAdminServiceImpl) EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response {
|
||||
stats, err := s.analytics.Economy(ctx, apctx.OrganizationID, q)
|
||||
return respond(ctx, stats, err)
|
||||
}
|
||||
|
||||
func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
|
||||
var in models.GameEventInput
|
||||
if resp := decodeStrict(body, &in); resp != nil {
|
||||
|
||||
Reference in New Issue
Block a user