| | | 1 | | using OzonDomains.Models; |
| | | 2 | | |
| | | 3 | | namespace OzonOrdersWeb.Areas.Studio2.AnalysisServices |
| | | 4 | | { |
| | | 5 | | public class TrendAnalysisService |
| | | 6 | | { |
| | | 7 | | public class TrendResult |
| | | 8 | | { |
| | 5 | 9 | | public string EntityName { get; set; } = string.Empty; |
| | 6 | 10 | | public string EntityType { get; set; } = string.Empty; // product, warehouse, region |
| | 5 | 11 | | public double Slope { get; set; } // коэффициент наклона |
| | 3 | 12 | | public TrendDirection Direction { get; set; } |
| | 3 | 13 | | public double R2 { get; set; } // коэффициент детерминации |
| | 2 | 14 | | public double GrowthPercentage { get; set; } // процент роста |
| | 3 | 15 | | public int DataPointsCount { get; set; } // количество точек данных |
| | | 16 | | } |
| | | 17 | | |
| | | 18 | | public enum TrendDirection |
| | | 19 | | { |
| | | 20 | | StrongGrowth, // Сильный рост |
| | | 21 | | ModerateGrowth, // Умеренный рост |
| | | 22 | | Stable, // Стабильно |
| | | 23 | | ModerateDecline, // Умеренное снижение |
| | | 24 | | StrongDecline // Сильное снижение |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | /// <summary> |
| | | 28 | | /// Анализ трендов по продуктам используя линейную регрессию |
| | | 29 | | /// Метод наименьших квадратов: y = a + b*x |
| | | 30 | | /// где y - продажи, x - время |
| | | 31 | | /// </summary> |
| | | 32 | | public List<TrendResult> AnalyzeProductTrends(List<Order> orders, int periodDays = 90) |
| | | 33 | | { |
| | 2 | 34 | | var cutoffDate = DateTime.Now.AddDays(-periodDays); |
| | 2 | 35 | | var recentOrders = orders |
| | 21 | 36 | | .Where(o => o.ProcessingDate >= cutoffDate) |
| | 2 | 37 | | .ToList(); |
| | | 38 | | |
| | | 39 | | // Группируем по продуктам |
| | 2 | 40 | | var productGroups = recentOrders |
| | 21 | 41 | | .Where(o => !string.IsNullOrEmpty(o.Article)) |
| | 21 | 42 | | .GroupBy(o => o.CleanedArticle ?? o.Article) |
| | 2 | 43 | | .ToList(); |
| | | 44 | | |
| | 2 | 45 | | var trends = new List<TrendResult>(); |
| | | 46 | | |
| | 8 | 47 | | foreach (var productGroup in productGroups) |
| | | 48 | | { |
| | 2 | 49 | | var dailySales = productGroup |
| | 21 | 50 | | .Where(o => o.ProcessingDate.HasValue) |
| | 21 | 51 | | .GroupBy(o => o.ProcessingDate!.Value.Date) |
| | 21 | 52 | | .Select(g => new |
| | 21 | 53 | | { |
| | 21 | 54 | | Date = g.Key, |
| | 21 | 55 | | TotalQuantity = g.Sum(o => o.TrueQuantity ?? o.Quantity ?? 0), |
| | 21 | 56 | | DaysFromStart = (g.Key - cutoffDate).TotalDays |
| | 21 | 57 | | }) |
| | 21 | 58 | | .OrderBy(x => x.Date) |
| | 2 | 59 | | .ToList(); |
| | | 60 | | |
| | 2 | 61 | | if (dailySales.Count < 10) continue; // Минимум 10 точек для анализа |
| | | 62 | | |
| | | 63 | | // Линейная регрессия: y = a + b*x |
| | 1 | 64 | | var (slope, intercept, r2) = CalculateLinearRegression( |
| | 12 | 65 | | dailySales.Select(d => (d.DaysFromStart, (double)d.TotalQuantity)).ToList() |
| | 1 | 66 | | ); |
| | | 67 | | |
| | | 68 | | // Получаем первый и последний день для расчета процента роста |
| | 1 | 69 | | double firstDay = dailySales.First().DaysFromStart; |
| | 1 | 70 | | double lastDay = dailySales.Last().DaysFromStart; |
| | | 71 | | |
| | | 72 | | // Получаем название продукта из первого заказа в группе |
| | 1 | 73 | | string productName = productGroup.FirstOrDefault()?.ProductName ?? productGroup.Key ?? string.Empty; |
| | | 74 | | |
| | 1 | 75 | | var trendResult = new TrendResult |
| | 1 | 76 | | { |
| | 1 | 77 | | EntityName = productName, |
| | 1 | 78 | | EntityType = "product", |
| | 1 | 79 | | Slope = slope, |
| | 1 | 80 | | R2 = r2, |
| | 1 | 81 | | GrowthPercentage = CalculateGrowthPercentageFromRegression(slope, intercept, firstDay, lastDay), |
| | 1 | 82 | | Direction = ClassifyTrend(slope, r2), |
| | 1 | 83 | | DataPointsCount = dailySales.Count |
| | 1 | 84 | | }; |
| | | 85 | | |
| | 1 | 86 | | trends.Add(trendResult); |
| | | 87 | | } |
| | | 88 | | |
| | 3 | 89 | | return trends.OrderByDescending(t => Math.Abs(t.Slope)).ToList(); |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | /// <summary> |
| | | 93 | | /// Анализ трендов по складам |
| | | 94 | | /// </summary> |
| | | 95 | | public List<TrendResult> AnalyzeWarehouseTrends(List<Order> orders, int periodDays = 90) |
| | | 96 | | { |
| | 1 | 97 | | var cutoffDate = DateTime.Now.AddDays(-periodDays); |
| | 1 | 98 | | var recentOrders = orders |
| | 10 | 99 | | .Where(o => o.ProcessingDate >= cutoffDate && o.ShipmentWarehouse != null) |
| | 1 | 100 | | .ToList(); |
| | | 101 | | |
| | 1 | 102 | | var warehouseGroups = recentOrders |
| | 10 | 103 | | .Where(o => !string.IsNullOrEmpty(o.ShipmentWarehouse!.Name)) |
| | 10 | 104 | | .GroupBy(o => o.ShipmentWarehouse!.Name!) |
| | 1 | 105 | | .ToList(); |
| | | 106 | | |
| | 1 | 107 | | return AnalyzeTrends(warehouseGroups, cutoffDate, "warehouse"); |
| | | 108 | | } |
| | | 109 | | |
| | | 110 | | /// <summary> |
| | | 111 | | /// Анализ трендов по регионам |
| | | 112 | | /// </summary> |
| | | 113 | | public List<TrendResult> AnalyzeRegionTrends(List<Order> orders, int periodDays = 90) |
| | | 114 | | { |
| | 1 | 115 | | var cutoffDate = DateTime.Now.AddDays(-periodDays); |
| | 1 | 116 | | var recentOrders = orders |
| | 10 | 117 | | .Where(o => o.ProcessingDate >= cutoffDate && !string.IsNullOrEmpty(o.DeliveryCity)) |
| | 1 | 118 | | .ToList(); |
| | | 119 | | |
| | 1 | 120 | | var regionGroups = recentOrders |
| | 0 | 121 | | .GroupBy(o => o.DeliveryCity!) |
| | 1 | 122 | | .ToList(); |
| | | 123 | | |
| | 1 | 124 | | return AnalyzeTrends(regionGroups, cutoffDate, "region"); |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | private List<TrendResult> AnalyzeTrends( |
| | | 128 | | List<IGrouping<string, Order>> groups, |
| | | 129 | | DateTime cutoffDate, |
| | | 130 | | string entityType) |
| | | 131 | | { |
| | 2 | 132 | | var trends = new List<TrendResult>(); |
| | | 133 | | |
| | 6 | 134 | | foreach (var group in groups) |
| | | 135 | | { |
| | 1 | 136 | | var dailySales = group |
| | 10 | 137 | | .Where(o => o.ProcessingDate.HasValue) |
| | 10 | 138 | | .GroupBy(o => o.ProcessingDate!.Value.Date) |
| | 10 | 139 | | .Select(g => new |
| | 10 | 140 | | { |
| | 10 | 141 | | Date = g.Key, |
| | 10 | 142 | | TotalQuantity = g.Sum(o => o.TrueQuantity ?? o.Quantity ?? 0), |
| | 10 | 143 | | DaysFromStart = (g.Key - cutoffDate).TotalDays |
| | 10 | 144 | | }) |
| | 10 | 145 | | .OrderBy(x => x.Date) |
| | 1 | 146 | | .ToList(); |
| | | 147 | | |
| | 1 | 148 | | if (dailySales.Count < 10) continue; |
| | | 149 | | |
| | 1 | 150 | | var (slope, intercept, r2) = CalculateLinearRegression( |
| | 10 | 151 | | dailySales.Select(d => (d.DaysFromStart, (double)d.TotalQuantity)).ToList() |
| | 1 | 152 | | ); |
| | | 153 | | |
| | | 154 | | // Получаем первый и последний день для расчета процента роста |
| | 1 | 155 | | double firstDay = dailySales.First().DaysFromStart; |
| | 1 | 156 | | double lastDay = dailySales.Last().DaysFromStart; |
| | | 157 | | |
| | 1 | 158 | | var trendResult = new TrendResult |
| | 1 | 159 | | { |
| | 1 | 160 | | EntityName = group.Key, |
| | 1 | 161 | | EntityType = entityType, |
| | 1 | 162 | | Slope = slope, |
| | 1 | 163 | | R2 = r2, |
| | 1 | 164 | | GrowthPercentage = CalculateGrowthPercentageFromRegression(slope, intercept, firstDay, lastDay), |
| | 1 | 165 | | Direction = ClassifyTrend(slope, r2), |
| | 1 | 166 | | DataPointsCount = dailySales.Count |
| | 1 | 167 | | }; |
| | | 168 | | |
| | 1 | 169 | | trends.Add(trendResult); |
| | | 170 | | } |
| | | 171 | | |
| | 3 | 172 | | return trends.OrderByDescending(t => Math.Abs(t.Slope)).ToList(); |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | /// <summary> |
| | | 176 | | /// Расчет линейной регрессии методом наименьших квадратов |
| | | 177 | | /// Формулы: |
| | | 178 | | /// slope (b) = (n*Σ(xy) - Σx*Σy) / (n*Σ(x²) - (Σx)²) |
| | | 179 | | /// intercept (a) = (Σy - b*Σx) / n |
| | | 180 | | /// R² = 1 - (SS_res / SS_tot) |
| | | 181 | | /// где SS_res = Σ(y - ŷ)², SS_tot = Σ(y - ȳ)² |
| | | 182 | | /// </summary> |
| | | 183 | | private (double slope, double intercept, double r2) CalculateLinearRegression( |
| | | 184 | | List<(double x, double y)> points) |
| | | 185 | | { |
| | 2 | 186 | | int n = points.Count; |
| | 2 | 187 | | if (n < 2) |
| | 0 | 188 | | return (0, 0, 0); |
| | | 189 | | |
| | 8 | 190 | | double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; |
| | | 191 | | |
| | 48 | 192 | | foreach (var (x, y) in points) |
| | | 193 | | { |
| | 22 | 194 | | sumX += x; |
| | 22 | 195 | | sumY += y; |
| | 22 | 196 | | sumXY += x * y; |
| | 22 | 197 | | sumX2 += x * x; |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | // Коэффициенты линейной регрессии |
| | 2 | 201 | | double denominator = n * sumX2 - sumX * sumX; |
| | 2 | 202 | | if (Math.Abs(denominator) < 0.0001) |
| | 0 | 203 | | return (0, 0, 0); |
| | | 204 | | |
| | 2 | 205 | | double slope = (n * sumXY - sumX * sumY) / denominator; |
| | 2 | 206 | | double intercept = (sumY - slope * sumX) / n; |
| | | 207 | | |
| | | 208 | | // Коэффициент детерминации R² |
| | 2 | 209 | | double meanY = sumY / n; |
| | 4 | 210 | | double ssTotal = 0, ssResidual = 0; |
| | | 211 | | |
| | 48 | 212 | | foreach (var (x, y) in points) |
| | | 213 | | { |
| | 22 | 214 | | double predicted = intercept + slope * x; |
| | 22 | 215 | | ssTotal += Math.Pow(y - meanY, 2); |
| | 22 | 216 | | ssResidual += Math.Pow(y - predicted, 2); |
| | | 217 | | } |
| | | 218 | | |
| | 2 | 219 | | double r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0; |
| | | 220 | | |
| | 2 | 221 | | return (slope, intercept, r2); |
| | | 222 | | } |
| | | 223 | | |
| | | 224 | | /// <summary> |
| | | 225 | | /// Расчет процента роста на основе линейной регрессии |
| | | 226 | | /// Использует прогнозируемые значения на начало и конец периода вместо фактических |
| | | 227 | | /// Формула: ((прогноз_конец - прогноз_начало) / прогноз_начало) * 100 |
| | | 228 | | /// </summary> |
| | | 229 | | private double CalculateGrowthPercentageFromRegression(double slope, double intercept, double firstDay, double l |
| | | 230 | | { |
| | | 231 | | // Рассчитываем прогнозируемые значения на основе линейной регрессии |
| | | 232 | | // y = intercept + slope * x |
| | 2 | 233 | | double predictedFirst = intercept + slope * firstDay; |
| | 2 | 234 | | double predictedLast = intercept + slope * lastDay; |
| | | 235 | | |
| | | 236 | | // Избегаем деления на ноль и отрицательных значений |
| | 2 | 237 | | if (predictedFirst <= 0) |
| | | 238 | | { |
| | | 239 | | // Если прогноз на начало <= 0, используем абсолютное изменение |
| | 0 | 240 | | return predictedLast > 0 ? 100 : 0; |
| | | 241 | | } |
| | | 242 | | |
| | | 243 | | // Рассчитываем процент роста |
| | 2 | 244 | | double growthPercentage = ((predictedLast - predictedFirst) / predictedFirst) * 100; |
| | | 245 | | |
| | | 246 | | // Ограничиваем экстремальные значения для читаемости |
| | 3 | 247 | | if (growthPercentage > 1000) growthPercentage = 1000; |
| | 2 | 248 | | if (growthPercentage < -100) growthPercentage = -100; |
| | | 249 | | |
| | 2 | 250 | | return growthPercentage; |
| | | 251 | | } |
| | | 252 | | |
| | | 253 | | /// <summary> |
| | | 254 | | /// Классификация тренда на основе коэффициента наклона и R² |
| | | 255 | | /// R² показывает качество модели (0-1, где 1 - идеальная модель) |
| | | 256 | | /// </summary> |
| | | 257 | | private TrendDirection ClassifyTrend(double slope, double r2) |
| | | 258 | | { |
| | 3 | 259 | | if (r2 < 0.3) return TrendDirection.Stable; // Низкая корреляция |
| | | 260 | | |
| | 2 | 261 | | if (slope > 0.5) return TrendDirection.StrongGrowth; |
| | 0 | 262 | | if (slope > 0.1) return TrendDirection.ModerateGrowth; |
| | 0 | 263 | | if (slope < -0.5) return TrendDirection.StrongDecline; |
| | 0 | 264 | | if (slope < -0.1) return TrendDirection.ModerateDecline; |
| | | 265 | | |
| | 0 | 266 | | return TrendDirection.Stable; |
| | | 267 | | } |
| | | 268 | | } |
| | | 269 | | } |
| | | 270 | | |