| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Services.DataServices; |
| | | 3 | | |
| | | 4 | | namespace OzonOrdersWeb.Areas.Studio2.AnalysisServices; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Отчёты вкладок «Общая статистика», «Доп. метрики», «Сравнительная» и «Сезонность»: узкая проекция заказов |
| | | 8 | | /// без Include, расчёт в <see cref="ProductAnalysisReportCalculator"/>, кэш результата на 3 минуты. |
| | | 9 | | /// Строки периода кэшируются на минуту — общая статистика и метрики за один период читают БД один раз. |
| | | 10 | | /// </summary> |
| | 0 | 11 | | public sealed class ProductAnalysisReportService(OrdersDataServices orders, ProductAnalysisCache cache, |
| | 0 | 12 | | SalesForecastService forecast, TrendAnalysisService trends, PurchaseRecommendationService purchases) |
| | | 13 | | { |
| | 0 | 14 | | private static readonly TimeSpan ResultTtl = TimeSpan.FromMinutes(3); |
| | 0 | 15 | | private static readonly TimeSpan RowsTtl = TimeSpan.FromMinutes(1); |
| | | 16 | | |
| | 0 | 17 | | private sealed record RowsKey(DateTime? Start, DateTime? End, string? ProductKey, int? AppStatusId, bool OnlyDated, |
| | | 18 | | |
| | | 19 | | private Task<T> CachedAsync<T>(object key, TimeSpan ttl, int weight, Func<Task<T>> factory, CancellationToken ct) => |
| | 0 | 20 | | cache.GetOrAddAsync(key, ttl, weight, factory, ct); |
| | | 21 | | |
| | | 22 | | private Task<IReadOnlyList<AnalyticsOrderRow>> RowsAsync(RowsKey key, CancellationToken ct) => |
| | 0 | 23 | | CachedAsync<IReadOnlyList<AnalyticsOrderRow>>(key, RowsTtl, ProductAnalysisCache.RowsWeight, async () => |
| | 0 | 24 | | { |
| | 0 | 25 | | var query = orders.GetAnalyticsQueryable(); |
| | 0 | 26 | | if (key.OnlyDated) query = query.Where(o => o.ProcessingDate.HasValue); |
| | 0 | 27 | | if (key.OnlyWithProductKey) query = query.Where(o => !string.IsNullOrEmpty(o.ProductKey)); |
| | 0 | 28 | | if (key.Start.HasValue) query = query.Where(o => o.ProcessingDate >= key.Start.Value); |
| | 0 | 29 | | if (key.End.HasValue) query = query.Where(o => o.ProcessingDate <= key.End.Value); |
| | 0 | 30 | | if (key.AppStatusId.HasValue) query = query.Where(o => o.AppStatusId == key.AppStatusId.Value); |
| | 0 | 31 | | if (!string.IsNullOrWhiteSpace(key.ProductKey)) query = query.Where(o => o.ProductKey == key.ProductKey); |
| | 0 | 32 | | return await AnalyticsRowProjection.Project(query).ToListAsync(CancellationToken.None); |
| | 0 | 33 | | }, ct); |
| | | 34 | | |
| | 0 | 35 | | private static readonly CancellationToken Shared = CancellationToken.None; |
| | | 36 | | |
| | | 37 | | // Границы периода приводятся к каноничным (ProductAnalysisCache.Canon): иначе «сейчас» с секундами даёт новый ключ |
| | | 38 | | |
| | | 39 | | public Task<OverallStats> OverallAsync(DateTime? start, DateTime? end, CancellationToken ct) |
| | | 40 | | { |
| | 0 | 41 | | (start, end) = (ProductAnalysisCache.Canon(start), ProductAnalysisCache.Canon(end)); |
| | 0 | 42 | | return CachedAsync(("pa.overall", start, end), ResultTtl, ProductAnalysisCache.ResultWeight, async () => |
| | 0 | 43 | | ProductAnalysisReportCalculator.Overall((await RowsAsync(new RowsKey(start, end, null, null, false), Shared) |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | public Task<AdditionalMetrics> MetricsAsync(DateTime? start, DateTime? end, CancellationToken ct) |
| | | 47 | | { |
| | 0 | 48 | | (start, end) = (ProductAnalysisCache.Canon(start), ProductAnalysisCache.Canon(end)); |
| | 0 | 49 | | return CachedAsync(("pa.metrics", start, end), ResultTtl, ProductAnalysisCache.ResultWeight, async () => |
| | 0 | 50 | | ProductAnalysisReportCalculator.Metrics((await RowsAsync(new RowsKey(start, end, null, null, false), Shared) |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | public Task<ComparativeResult> ComparativeAsync(int monthsBack, string? productKey, CancellationToken ct) |
| | | 54 | | { |
| | 0 | 55 | | monthsBack = Math.Clamp(monthsBack, 1, 24); |
| | 0 | 56 | | var key = string.IsNullOrWhiteSpace(productKey) ? null : productKey.Trim(); |
| | 0 | 57 | | var now = DateTime.Now; |
| | 0 | 58 | | return CachedAsync(("pa.comparative", monthsBack, key, now.Date), ResultTtl, ProductAnalysisCache.ResultWeight, |
| | 0 | 59 | | { |
| | 0 | 60 | | var (_, currentEnd, previousStart, _) = ProductAnalysisReportCalculator.ComparativeRanges(now, monthsBack); |
| | 0 | 61 | | var rows = await RowsAsync(new RowsKey(previousStart, currentEnd, key, null, true), Shared); |
| | 0 | 62 | | return ProductAnalysisReportCalculator.Comparative(rows.ToList(), now, monthsBack, key); |
| | 0 | 63 | | }, ct); |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | public Task<SeasonalityResult> SeasonalityAsync(int? appStatusId, string? productKey, DateTime? start, DateTime? end |
| | | 67 | | { |
| | 0 | 68 | | var key = string.IsNullOrWhiteSpace(productKey) ? null : productKey.Trim(); |
| | 0 | 69 | | var status = appStatusId is > 0 ? appStatusId : null; |
| | | 70 | | // Конец периода включает следующий день — как и прежде. |
| | 0 | 71 | | var endExclusive = end?.AddDays(1); |
| | 0 | 72 | | return CachedAsync(("pa.seasonality", status, key, start, end), ResultTtl, ProductAnalysisCache.ResultWeight, as |
| | 0 | 73 | | ProductAnalysisReportCalculator.Seasonality((await RowsAsync(new RowsKey(start, endExclusive, key, status, t |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | public Task<OrderStatusesResult> OrderStatusesAsync(string? statusType, string? productKey, DateTime? start, DateTim |
| | | 77 | | { |
| | 0 | 78 | | var type = string.Equals(statusType, "client", StringComparison.OrdinalIgnoreCase) ? "client" : "app"; |
| | 0 | 79 | | var key = string.IsNullOrWhiteSpace(productKey) ? null : productKey.Trim(); |
| | 0 | 80 | | var endExclusive = end?.AddDays(1); |
| | 0 | 81 | | return CachedAsync(("pa.statuses", type, key, start, end), ResultTtl, ProductAnalysisCache.ResultWeight, async ( |
| | 0 | 82 | | ProductAnalysisReportCalculator.OrderStatuses((await RowsAsync(new RowsKey(start, endExclusive, key, null, t |
| | | 83 | | } |
| | | 84 | | |
| | | 85 | | public Task<WarehouseRegionResult> WarehouseRegionAsync(DateTime? start, DateTime? end, string? productKey, Cancella |
| | | 86 | | { |
| | 0 | 87 | | (start, end) = (ProductAnalysisCache.Canon(start), ProductAnalysisCache.Canon(end)); |
| | 0 | 88 | | var key = string.IsNullOrWhiteSpace(productKey) ? null : productKey.Trim(); |
| | 0 | 89 | | return CachedAsync(("pa.warehouse-region", start, end, key), ResultTtl, ProductAnalysisCache.ResultWeight, async |
| | 0 | 90 | | ProductAnalysisReportCalculator.WarehouseRegion((await RowsAsync(new RowsKey(start, end, key, null, false, t |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | public Task<DTOs.ForecastTrendAnalysisDto> ForecastAsync(string? productKey, DateTime? start, DateTime? end, int for |
| | | 94 | | { |
| | 0 | 95 | | (start, end) = (ProductAnalysisCache.Canon(start), ProductAnalysisCache.Canon(end)); |
| | 0 | 96 | | var key = string.IsNullOrWhiteSpace(productKey) ? null : productKey.Trim(); |
| | 0 | 97 | | var days = Math.Clamp(forecastDays, 7, 90); |
| | 0 | 98 | | return CachedAsync(("pa.forecast", key, start, end, days), ResultTtl, ProductAnalysisCache.ResultWeight, async ( |
| | 0 | 99 | | { |
| | 0 | 100 | | var rows = await RowsAsync(new RowsKey(start, end, key, null, false), Shared); |
| | 0 | 101 | | return ForecastDtoMapper.Build(AnalyticsRowProjection.ToOrders(rows), days, forecast, trends, purchases); |
| | 0 | 102 | | }, ct); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | public Task<SummaryResult> SummaryAsync(DateTime? start, DateTime? end, CancellationToken ct) |
| | | 106 | | { |
| | 0 | 107 | | (start, end) = (ProductAnalysisCache.Canon(start), ProductAnalysisCache.Canon(end)); |
| | 0 | 108 | | return CachedAsync(("pa.summary", start, end), ResultTtl, ProductAnalysisCache.ResultWeight, async () => |
| | 0 | 109 | | ProductAnalysisReportCalculator.Summary((await RowsAsync(new RowsKey(start, end, null, null, false), Shared) |
| | | 110 | | } |
| | | 111 | | } |