< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.AnalysisServices.PurchaseRecommendationService
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/PurchaseRecommendationService.cs
Line coverage
81%
Covered lines: 98
Uncovered lines: 22
Coverable lines: 120
Total lines: 261
Line coverage: 81.6%
Branch coverage
38%
Covered branches: 26
Total branches: 68
Branch coverage: 38.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Article()100%11100%
get_ProductName()100%11100%
get_CurrentStock()100%11100%
get_RecommendedQuantity()100%11100%
get_RecommendedOrderDate()100%11100%
get_ServiceLevel()100%11100%
get_Priority()100%11100%
get_Reason()100%11100%
get_ABCClass()100%11100%
get_XYZClass()100%11100%
get_AvgDailyDemand()100%11100%
get_SafetyStock()100%11100%
get_ReorderPoint()100%11100%
GenerateRecommendations(...)59.09%2222100%
CalculateStandardDeviation(...)50%22100%
CalculateSafetyStock(...)25%14855.55%
CalculateEOQ(...)50%7666.66%
ClassifyProduct(...)50%9878.57%
CalculateCoefficientOfVariation(...)50%44100%
CalculatePriority(...)5.55%2291813.33%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/PurchaseRecommendationService.cs

#LineLine coverage
 1using OzonDomains.Models;
 2
 3namespace OzonOrdersWeb.Areas.Studio2.AnalysisServices
 4{
 5    public class PurchaseRecommendationService
 6    {
 7        public class PurchaseRecommendation
 8        {
 39            public string Article { get; set; } = string.Empty;
 210            public string ProductName { get; set; } = string.Empty;
 211            public int CurrentStock { get; set; }
 212            public int RecommendedQuantity { get; set; }
 113            public DateTime RecommendedOrderDate { get; set; }
 114            public double ServiceLevel { get; set; } // Уровень сервиса
 315            public RecommendationPriority Priority { get; set; }
 316            public string Reason { get; set; } = string.Empty;
 117            public char ABCClass { get; set; }
 118            public char XYZClass { get; set; }
 119            public double AvgDailyDemand { get; set; }
 120            public double SafetyStock { get; set; }
 121            public double ReorderPoint { get; set; }
 22        }
 23
 24        public enum RecommendationPriority
 25        {
 26            Critical,  // Критический - требует немедленного заказа
 27            High,      // Высокий - требует скорого заказа
 28            Medium,    // Средний - желательно заказать
 29            Low        // Низкий - можно подождать
 30        }
 31
 32        /// <summary>
 33        /// Генерация рекомендаций по закупкам на основе:
 34        /// 1. Точки перезаказа (Reorder Point)
 35        /// 2. Экономичного объема заказа (EOQ)
 36        /// 3. ABC-XYZ анализа
 37        /// </summary>
 38        public List<PurchaseRecommendation> GenerateRecommendations(
 39            List<Order> orders,
 40            Dictionary<string, int> currentStock,
 41            int leadTimeDays = 7,
 42            double serviceLevel = 0.95) // 95% уровень сервиса
 43        {
 344            var recommendations = new List<PurchaseRecommendation>();
 45
 46            // Группируем продажи по продуктам за последние 90 дней
 347            var cutoffDate = DateTime.Now.AddDays(-90);
 348            var productSales = orders
 8049                .Where(o => o.ProcessingDate >= cutoffDate && !string.IsNullOrEmpty(o.Article))
 8050                .GroupBy(o => o.CleanedArticle ?? o.Article)
 351                .Select(g => new
 352                {
 353                    Article = g.Key,
 354                    ProductName = g.First().ProductName ?? g.Key,
 8055                    DailySales = g.Where(o => o.ProcessingDate.HasValue)
 8056                        .GroupBy(o => o.ProcessingDate!.Value.Date)
 16057                        .Select(dg => dg.Sum(o => o.TrueQuantity ?? o.Quantity ?? 0))
 358                        .ToList(),
 8059                    TotalSold = g.Sum(o => o.TrueQuantity ?? o.Quantity ?? 0),
 8060                    AveragePrice = g.Average(o => o.Price ?? 0)
 361                })
 362                .Where(x => x.DailySales.Count >= 30) // Минимум 30 дней данных
 363                .ToList();
 64
 1065            foreach (var product in productSales)
 66            {
 267                int stock = currentStock.GetValueOrDefault(product.Article ?? string.Empty, 0);
 68
 69                // Рассчитываем статистические показатели
 270                double avgDailyDemand = product.DailySales.Average();
 271                double stdDevDailyDemand = CalculateStandardDeviation(product.DailySales);
 72
 73                // Точка перезаказа (Reorder Point) = Средний спрос за время доставки + Страховой запас
 274                double safetyStock = CalculateSafetyStock(
 275                    stdDevDailyDemand,
 276                    leadTimeDays,
 277                    serviceLevel);
 78
 279                double reorderPoint = (avgDailyDemand * leadTimeDays) + safetyStock;
 80
 81                // Экономичный объем заказа (EOQ)
 82                // EOQ = √((2DS)/H)
 83                // где D - годовой спрос, S - стоимость заказа, H - стоимость хранения
 284                double eoq = CalculateEOQ(
 285                    avgDailyDemand * 365, // Годовой спрос
 286                    costPerOrder: 500, // Стоимость заказа (руб)
 287                    holdingCostRate: 0.2, // 20% от стоимости хранения
 288                    productCost: (double)product.AveragePrice);
 89
 90                // ABC-XYZ классификация
 291                var (abcClass, xyzClass) = ClassifyProduct(
 292                    product.TotalSold,
 293                    product.DailySales);
 94
 95                // Проверяем необходимость заказа
 296                if (stock <= reorderPoint)
 97                {
 198                    var recommendation = new PurchaseRecommendation
 199                    {
 1100                        Article = product.Article ?? string.Empty,
 1101                        ProductName = product.ProductName ?? string.Empty,
 1102                        CurrentStock = stock,
 1103                        RecommendedQuantity = (int)Math.Ceiling(eoq),
 1104                        RecommendedOrderDate = DateTime.Now,
 1105                        ServiceLevel = serviceLevel,
 1106                        Priority = CalculatePriority(abcClass, xyzClass, stock, reorderPoint),
 1107                        Reason = $"Текущий запас ({stock}) ниже точки перезаказа ({reorderPoint:F1})",
 1108                        ABCClass = abcClass,
 1109                        XYZClass = xyzClass,
 1110                        AvgDailyDemand = avgDailyDemand,
 1111                        SafetyStock = safetyStock,
 1112                        ReorderPoint = reorderPoint
 1113                    };
 114
 1115                    recommendations.Add(recommendation);
 116                }
 117            }
 118
 4119            return recommendations.OrderByDescending(r => r.Priority).ToList();
 120        }
 121
 122        /// <summary>
 123        /// Расчет стандартного отклонения
 124        /// Формула: σ = √(Σ(x - μ)² / (n-1))
 125        /// </summary>
 126        private double CalculateStandardDeviation(List<int> values)
 127        {
 4128            if (values.Count < 2) return 0;
 129
 4130            double avg = values.Average();
 124131            double sum = values.Sum(v => Math.Pow(v - avg, 2));
 4132            return Math.Sqrt(sum / (values.Count - 1));
 133        }
 134
 135        /// <summary>
 136        /// Расчет страхового запаса
 137        /// Формула: SS = Z * σ * √L
 138        /// где Z - коэффициент для уровня сервиса, σ - стандартное отклонение, L - время доставки
 139        /// </summary>
 140        private double CalculateSafetyStock(
 141            double stdDevDemand,
 142            int leadTimeDays,
 143            double serviceLevel)
 144        {
 145            // Z-значение для уровня сервиса (из таблицы нормального распределения)
 2146            double z = serviceLevel switch
 2147            {
 0148                >= 0.99 => 2.33,  // 99%
 2149                >= 0.95 => 1.65,  // 95%
 0150                >= 0.90 => 1.28,  // 90%
 0151                >= 0.85 => 1.04,  // 85%
 0152                _ => 1.65         // по умолчанию 95%
 2153            };
 154
 2155            return z * stdDevDemand * Math.Sqrt(leadTimeDays);
 156        }
 157
 158        /// <summary>
 159        /// Расчет экономичного объема заказа (EOQ - Economic Order Quantity)
 160        /// Формула: EOQ = √((2DS)/H)
 161        /// где D - годовой спрос, S - стоимость одного заказа, H - стоимость хранения единицы товара
 162        /// </summary>
 163        private double CalculateEOQ(
 164            double annualDemand,
 165            double costPerOrder,
 166            double holdingCostRate,
 167            double productCost)
 168        {
 2169            if (annualDemand <= 0 || productCost <= 0)
 0170                return 0;
 171
 2172            double holdingCostPerUnit = productCost * holdingCostRate;
 173
 2174            if (holdingCostPerUnit <= 0)
 0175                return 0;
 176
 2177            return Math.Sqrt((2 * annualDemand * costPerOrder) / holdingCostPerUnit);
 178        }
 179
 180        /// <summary>
 181        /// ABC-XYZ классификация продукта
 182        /// ABC - по объему продаж (важность)
 183        /// XYZ - по стабильности спроса (предсказуемость)
 184        /// </summary>
 185        private (char abcClass, char xyzClass) ClassifyProduct(int totalSold, List<int> dailySales)
 186        {
 187            // ABC анализ по объему продаж
 188            // A - 20% продуктов обеспечивают 80% продаж
 189            // B - 30% продуктов обеспечивают 15% продаж
 190            // C - 50% продуктов обеспечивают 5% продаж
 2191            char abcClass = totalSold switch
 2192            {
 1193                > 1000 => 'A',
 0194                > 200 => 'B',
 1195                _ => 'C'
 2196            };
 197
 198            // XYZ анализ по стабильности спроса (коэффициент вариации)
 199            // X - стабильный спрос (CV < 10%)
 200            // Y - умеренно изменчивый (CV 10-25%)
 201            // Z - нестабильный спрос (CV > 25%)
 2202            double cv = CalculateCoefficientOfVariation(dailySales);
 2203            char xyzClass = cv switch
 2204            {
 2205                < 0.1 => 'X',
 0206                < 0.25 => 'Y',
 0207                _ => 'Z'
 2208            };
 209
 2210            return (abcClass, xyzClass);
 211        }
 212
 213        /// <summary>
 214        /// Расчет коэффициента вариации
 215        /// Формула: CV = σ / μ
 216        /// где σ - стандартное отклонение, μ - среднее значение
 217        /// Показывает относительную изменчивость спроса
 218        /// </summary>
 219        private double CalculateCoefficientOfVariation(List<int> values)
 220        {
 2221            if (values.Count == 0) return 0;
 222
 2223            double mean = values.Average();
 2224            if (mean == 0) return 0;
 225
 2226            double stdDev = CalculateStandardDeviation(values);
 227
 2228            return stdDev / mean;
 229        }
 230
 231        /// <summary>
 232        /// Расчет приоритета рекомендации на основе ABC-XYZ классификации
 233        /// </summary>
 234        private RecommendationPriority CalculatePriority(
 235            char abcClass,
 236            char xyzClass,
 237            int currentStock,
 238            double reorderPoint)
 239        {
 240            // Критический приоритет если запас очень низкий
 1241            if (currentStock < reorderPoint * 0.5)
 1242                return RecommendationPriority.Critical;
 243
 244            // Приоритет на основе классификации
 0245            return (abcClass, xyzClass) switch
 0246            {
 0247                ('A', 'X') => RecommendationPriority.Critical,  // Важный и стабильный
 0248                ('A', 'Y') => RecommendationPriority.High,      // Важный, умеренно стабильный
 0249                ('B', 'X') => RecommendationPriority.High,      // Средний и стабильный
 0250                ('A', 'Z') => RecommendationPriority.Medium,    // Важный, но нестабильный
 0251                ('B', 'Y') => RecommendationPriority.Medium,    // Средний и умеренно стабильный
 0252                ('C', 'X') => RecommendationPriority.Medium,    // Низкий, но стабильный
 0253                ('B', 'Z') => RecommendationPriority.Low,       // Средний и нестабильный
 0254                ('C', 'Y') => RecommendationPriority.Low,       // Низкий и умеренно стабильный
 0255                ('C', 'Z') => RecommendationPriority.Low,       // Низкий и нестабильный
 0256                _ => RecommendationPriority.Medium
 0257            };
 258        }
 259    }
 260}
 261