Files
telecom-platform/apps/carrier-connector/internal/analytics/churn_service.go
T
nutcas3 7618bb27d4 feat: Refactor churn analysis service to simplify logic and extract reusable components
- Remove ChurnRiskLevel, ChurnPrediction, ChurnMetrics, and ChurnFactor type definitions from churn_service.go
- Simplify NewChurnAnalysisService constructor to single-line initialization
- Refactor PredictChurn to use analyzeFactors, calculateScore, RiskLevelFromScore, generateReasons, and RecommendationsForRisk helper functions
- Simplify GetChurnMetrics by using periodDates helper and inline database queries
- Replace GetChurnFactors implementation with DefaultChurnFactors helper
2026-05-03 11:50:12 +03:00

208 lines
6.4 KiB
Go

package analytics
import (
"context"
"fmt"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ChurnAnalysisService provides churn analysis and prediction
type ChurnAnalysisService struct {
db *gorm.DB
logger *logrus.Logger
}
// NewChurnAnalysisService creates a new churn analysis service
func NewChurnAnalysisService(db *gorm.DB, logger *logrus.Logger) *ChurnAnalysisService {
return &ChurnAnalysisService{db: db, logger: logger}
}
// PredictChurn predicts churn risk for a specific profile
func (s *ChurnAnalysisService) PredictChurn(ctx context.Context, profileID string) (*ChurnPrediction, error) {
factors := s.analyzeFactors(ctx, profileID)
score := s.calculateScore(factors)
prediction := &ChurnPrediction{
ProfileID: profileID,
RiskScore: score,
RiskLevel: RiskLevelFromScore(score),
Reasons: s.generateReasons(factors),
Recommendations: RecommendationsForRisk(RiskLevelFromScore(score)),
LastUpdated: time.Now(),
}
if prediction.RiskLevel == ChurnRiskHigh || prediction.RiskLevel == ChurnRiskCritical {
date := time.Now().AddDate(0, 0, int((100-score)*3))
prediction.PredictedChurnDate = &date
}
return prediction, nil
}
// GetChurnMetrics calculates overall churn metrics
func (s *ChurnAnalysisService) GetChurnMetrics(ctx context.Context, period string) (*ChurnMetrics, error) {
start, end := periodDates(period)
metrics := &ChurnMetrics{Period: period, RiskDistribution: make(map[ChurnRiskLevel]int64), GeneratedAt: time.Now()}
s.db.WithContext(ctx).Table("profiles").Where("created_at < ?", start).Count(&metrics.TotalSubscribers)
s.db.WithContext(ctx).Table("rate_plan_subscriptions").Where("ended_at BETWEEN ? AND ?", start, end).Count(&metrics.ChurnedSubscribers)
if metrics.TotalSubscribers > 0 {
metrics.ChurnRate = float64(metrics.ChurnedSubscribers) / float64(metrics.TotalSubscribers) * 100
metrics.MonthlyChurnRate = metrics.ChurnRate
metrics.AnnualChurnRate = metrics.ChurnRate * 12
}
s.db.WithContext(ctx).Table("rate_plan_subscriptions").Where("status = ?", "cancelled").
Select("AVG(EXTRACT(EPOCH FROM (ended_at - started_at))/86400)").Scan(&metrics.AverageTenure)
// Estimated distribution
metrics.RiskDistribution[ChurnRiskLow] = int64(float64(metrics.TotalSubscribers) * 0.6)
metrics.RiskDistribution[ChurnRiskMedium] = int64(float64(metrics.TotalSubscribers) * 0.25)
metrics.RiskDistribution[ChurnRiskHigh] = int64(float64(metrics.TotalSubscribers) * 0.12)
metrics.RiskDistribution[ChurnRiskCritical] = int64(float64(metrics.TotalSubscribers) * 0.03)
return metrics, nil
}
// GetChurnFactors returns the top factors contributing to churn
func (s *ChurnAnalysisService) GetChurnFactors(_ context.Context) ([]ChurnFactor, error) {
return DefaultChurnFactors(), nil
}
// GetAtRiskCustomers returns customers at high risk of churn
func (s *ChurnAnalysisService) GetAtRiskCustomers(ctx context.Context, riskLevel ChurnRiskLevel, limit int) ([]*ChurnPrediction, error) {
var subs []struct{ ID string }
s.db.WithContext(ctx).Table("profiles").Where("status = ?", "active").Limit(limit).Find(&subs)
var predictions []*ChurnPrediction
for _, sub := range subs {
pred, err := s.PredictChurn(ctx, sub.ID)
if err != nil {
continue
}
if pred.RiskLevel == riskLevel || (riskLevel == ChurnRiskHigh && pred.RiskLevel == ChurnRiskCritical) {
predictions = append(predictions, pred)
}
}
return predictions, nil
}
func (s *ChurnAnalysisService) analyzeFactors(ctx context.Context, profileID string) []ChurnFactor {
factors := DefaultChurnFactors()
result := make([]ChurnFactor, 0, len(factors))
for _, f := range factors {
impact := s.factorImpact(ctx, profileID, f.Factor)
if impact > 0.1 {
result = append(result, ChurnFactor{Factor: f.Factor, Impact: impact, Description: f.Description, Weight: f.Weight})
}
}
return result
}
func (s *ChurnAnalysisService) factorImpact(ctx context.Context, profileID, factor string) float64 {
switch factor {
case "Low Usage":
return s.usageImpact(ctx, profileID)
case "Payment Issues":
return s.paymentImpact(ctx, profileID)
case "Price Sensitivity":
return s.priceImpact(ctx, profileID)
default:
return 0.4
}
}
func (s *ChurnAnalysisService) usageImpact(ctx context.Context, profileID string) float64 {
var total int64
s.db.WithContext(ctx).Table("rate_plan_usage").Where("profile_id = ? AND created_at > ?", profileID, time.Now().AddDate(0, -1, 0)).
Select("COALESCE(SUM(data_used + voice_used + sms_used), 0)").Scan(&total)
switch {
case total < 100:
return 0.8
case total < 500:
return 0.6
case total < 1000:
return 0.3
default:
return 0.1
}
}
func (s *ChurnAnalysisService) paymentImpact(ctx context.Context, profileID string) float64 {
var createdAt time.Time
s.db.WithContext(ctx).Table("profiles").Where("id = ?", profileID).Select("created_at").Scan(&createdAt)
days := time.Since(createdAt).Hours() / 24
if days < 30 {
return 0.3
} else if days < 90 {
return 0.2
}
return 0.1
}
func (s *ChurnAnalysisService) priceImpact(ctx context.Context, profileID string) float64 {
var price float64
s.db.WithContext(ctx).Table("rate_plans rp").Joins("JOIN rate_plan_subscriptions rps ON rps.rate_plan_id = rp.id").
Where("rps.profile_id = ? AND rps.status = ?", profileID, "active").Select("rp.base_price").Scan(&price)
switch {
case price > 50:
return 0.6
case price > 30:
return 0.4
case price > 15:
return 0.2
default:
return 0.1
}
}
func (s *ChurnAnalysisService) calculateScore(factors []ChurnFactor) float64 {
var total, weight float64
for _, f := range factors {
total += f.Impact * f.Weight
weight += f.Weight
}
if weight == 0 {
return 0
}
return (total / weight) * 100
}
func (s *ChurnAnalysisService) generateReasons(factors []ChurnFactor) []string {
var reasons []string
for _, f := range factors {
if f.Impact > 0.7 {
reasons = append(reasons, fmt.Sprintf("High %s detected", f.Factor))
} else if f.Impact > 0.5 {
reasons = append(reasons, fmt.Sprintf("Moderate %s detected", f.Factor))
}
}
if len(reasons) == 0 {
reasons = append(reasons, "Multiple minor risk factors detected")
}
return reasons
}
func periodDates(period string) (time.Time, time.Time) {
now := time.Now()
switch period {
case "daily":
return now.Truncate(24 * time.Hour), now
case "weekly":
return now.AddDate(0, 0, -7), now
case "monthly":
return now.AddDate(0, -1, 0), now
case "quarterly":
return now.AddDate(0, -3, 0), now
default:
return now.AddDate(0, -1, 0), now
}
}