| | | 1 | | using System.Security.Claims; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using OzonDomains.Models; |
| | | 4 | | using OzonDomains.Models.AccessControl; |
| | | 5 | | using OzonRepositories.Authorization; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using Services.CacheServices.Cache.UserCacheService; |
| | | 8 | | using Services.DataServices; |
| | | 9 | | using Services.FiltersServices.SortModels; |
| | | 10 | | |
| | | 11 | | namespace OzonOrdersWeb.WebServices.Orders.Grid; |
| | | 12 | | |
| | | 13 | | /// <summary>Счётчики значений справочной колонки при текущих остальных фильтрах.</summary> |
| | | 14 | | public sealed class OrdersGridFacetsRequest |
| | | 15 | | { |
| | | 16 | | /// <summary>colId справочной колонки (ozonClient, appStatus, supplier, …).</summary> |
| | | 17 | | public string Column { get; set; } = string.Empty; |
| | | 18 | | |
| | | 19 | | /// <summary>Текущая модель фильтров грида; фильтр самой колонки игнорируется.</summary> |
| | | 20 | | public Dictionary<string, AgGridFilterModelItem> FilterModel { get; set; } = new(StringComparer.OrdinalIgnoreCase); |
| | | 21 | | |
| | | 22 | | public string? Search { get; set; } |
| | | 23 | | |
| | | 24 | | public bool IncludeArchived { get; set; } |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | public sealed class OrdersGridFacetsResult |
| | | 28 | | { |
| | | 29 | | /// <summary>Значение (как в справочнике фильтра) → количество заказов.</summary> |
| | | 30 | | public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); |
| | | 31 | | |
| | | 32 | | /// <summary>Всего заказов при остальных фильтрах (для пункта «Все»).</summary> |
| | | 33 | | public int Total { get; set; } |
| | | 34 | | |
| | | 35 | | /// <summary>Для колонки счётчики не считаются (нет поля в БД).</summary> |
| | | 36 | | public bool Unsupported { get; set; } |
| | | 37 | | } |
| | | 38 | | |
| | | 39 | | public interface IOrdersGridFacetsService |
| | | 40 | | { |
| | | 41 | | Task<OrdersGridFacetsResult> GetFacetsAsync(OrdersGridFacetsRequest request, ClaimsPrincipal principal, Cancellation |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Один GROUP BY поверх отфильтрованного запроса списка (без пагинации): ключ — то же поле, |
| | | 46 | | /// по которому фильтрует <see cref="OrdersGridMultiValueFilters"/>, поэтому значения совпадают со справочником. |
| | | 47 | | /// </summary> |
| | | 48 | | public sealed class OrdersGridFacetsService : IOrdersGridFacetsService |
| | | 49 | | { |
| | | 50 | | public const string EmptyAppStatus = "Не указан"; |
| | | 51 | | |
| | | 52 | | private readonly ApplicationDbContext _context; |
| | | 53 | | private readonly IOrdersGridQueryService _gridQueryService; |
| | | 54 | | private readonly IOrdersGridScopedQuery _scoped; |
| | | 55 | | private readonly IOrdersGridComputedResolver _computedResolver; |
| | | 56 | | |
| | 0 | 57 | | public OrdersGridFacetsService( |
| | 0 | 58 | | ApplicationDbContext context, |
| | 0 | 59 | | IOrdersGridQueryService gridQueryService, |
| | 0 | 60 | | IOrdersGridScopedQuery scoped, |
| | 0 | 61 | | IOrdersGridComputedResolver computedResolver) |
| | | 62 | | { |
| | 0 | 63 | | _computedResolver = computedResolver; |
| | 0 | 64 | | _context = context; |
| | 0 | 65 | | _gridQueryService = gridQueryService; |
| | 0 | 66 | | _scoped = scoped; |
| | 0 | 67 | | } |
| | | 68 | | |
| | 6 | 69 | | public static bool Supports(string column) => AgGridRequestMapper.IsComputedColumn(column) || column.ToLowerInvarian |
| | 6 | 70 | | "ozonclient" or "deliveryprovider" or "deliveryname" or "clientstatus" or "appstatus" |
| | 6 | 71 | | or "supplierstatus" or "shipmentwarehouse" or "supplier"; |
| | | 72 | | |
| | | 73 | | public async Task<OrdersGridFacetsResult> GetFacetsAsync( |
| | | 74 | | OrdersGridFacetsRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken) |
| | | 75 | | { |
| | 0 | 76 | | if (!Supports(request.Column)) |
| | | 77 | | { |
| | 0 | 78 | | return new OrdersGridFacetsResult { Unsupported = true }; |
| | | 79 | | } |
| | | 80 | | |
| | 0 | 81 | | var user = await _scoped.ResolveUserAsync(principal) ?? throw new UnauthorizedAccessException("Пользователь не н |
| | | 82 | | |
| | 0 | 83 | | var gridRequest = new AgGridDataRequest |
| | 0 | 84 | | { |
| | 0 | 85 | | StartRow = 0, |
| | 0 | 86 | | EndRow = _gridQueryService.BlockSize, |
| | 0 | 87 | | Search = request.Search, |
| | 0 | 88 | | IncludeArchived = request.IncludeArchived, |
| | 0 | 89 | | FilterModel = new Dictionary<string, AgGridFilterModelItem>( |
| | 0 | 90 | | request.FilterModel.Where(kv => !string.Equals(kv.Key, request.Column, StringComparison.OrdinalIgnoreCas |
| | 0 | 91 | | StringComparer.OrdinalIgnoreCase) |
| | 0 | 92 | | }; |
| | 0 | 93 | | var mapped = AgGridRequestMapper.Map(gridRequest, _gridQueryService.BlockSize); |
| | 0 | 94 | | var computedFilters = new List<(OrdersGridFormulaTranslator.Translation, OrdersGridComputedFilters.ComputedFilte |
| | 0 | 95 | | foreach (var cf in mapped.ComputedFilters) |
| | | 96 | | { |
| | 0 | 97 | | var ct = await _computedResolver.ResolveAsync(user.User.Id, cf.ColId, cancellationToken); |
| | 0 | 98 | | if (ct != null) computedFilters.Add((ct, cf)); |
| | 0 | 99 | | } |
| | | 100 | | |
| | | 101 | | // GROUP BY прямо поверх отфильтрованного RLS-запроса: EF выносит его ORDER BY в подзапрос, без повторного Order |
| | 0 | 102 | | var query = await _scoped.BuildFilteredAsync(user, mapped, computedFilters, null, cancellationToken); |
| | 0 | 103 | | var scoped = query; |
| | 0 | 104 | | var ids = scoped.Select(o => o.Id); |
| | | 105 | | |
| | 0 | 106 | | var result = new OrdersGridFacetsResult(); |
| | | 107 | | List<(string? Key, int Count)> groups; |
| | 0 | 108 | | if (AgGridRequestMapper.IsComputedColumn(request.Column)) |
| | | 109 | | { |
| | | 110 | | // Вычисляемый столбец: GROUP BY по переведённой формуле (текст/да-нет); числа и даты — без счётчиков. |
| | 0 | 111 | | var t = await _computedResolver.ResolveAsync(user.User.Id, request.Column, cancellationToken); |
| | 0 | 112 | | if (t is null || (t.Type != "text" && t.Type != "bool")) |
| | | 113 | | { |
| | 0 | 114 | | return new OrdersGridFacetsResult { Unsupported = true }; |
| | | 115 | | } |
| | | 116 | | |
| | 0 | 117 | | groups = await GroupByComputedAsync(scoped, t, cancellationToken); |
| | 0 | 118 | | result.Total = await scoped.CountAsync(cancellationToken); |
| | 0 | 119 | | foreach (var (key, count) in groups) |
| | | 120 | | { |
| | 0 | 121 | | if (string.IsNullOrWhiteSpace(key)) continue; |
| | 0 | 122 | | result.Counts[key] = result.Counts.TryGetValue(key, out var existing) ? existing + count : count; |
| | | 123 | | } |
| | | 124 | | |
| | 0 | 125 | | return result; |
| | | 126 | | } |
| | | 127 | | |
| | 0 | 128 | | switch (request.Column.ToLowerInvariant()) |
| | | 129 | | { |
| | | 130 | | case "ozonclient": |
| | 0 | 131 | | groups = await GroupAsync(scoped, o => o.OzonClient != null ? o.OzonClient.Name : null, cancellationToke |
| | 0 | 132 | | break; |
| | | 133 | | case "deliveryprovider": |
| | 0 | 134 | | groups = await GroupAsync(scoped, o => o.Delivery != null && o.Delivery.Provider != null ? o.Delivery.Pr |
| | 0 | 135 | | break; |
| | | 136 | | case "deliveryname": |
| | 0 | 137 | | groups = await GroupAsync(scoped, o => o.Delivery != null ? o.Delivery.Name : null, cancellationToken); |
| | 0 | 138 | | break; |
| | | 139 | | case "clientstatus": |
| | 0 | 140 | | groups = await GroupAsync(scoped, o => o.ClientStatus != null ? o.ClientStatus.Name : null, cancellation |
| | 0 | 141 | | break; |
| | | 142 | | case "appstatus": |
| | 0 | 143 | | groups = await GroupAsync(scoped, o => o.AppStatus != null ? o.AppStatus.Name : null, cancellationToken) |
| | | 144 | | // Заказы без статуса в отчёте — пункт «Не указан» в списке. |
| | 0 | 145 | | groups = groups.Select(g => ((string?)(g.Key ?? EmptyAppStatus), g.Count)).ToList(); |
| | 0 | 146 | | break; |
| | | 147 | | case "shipmentwarehouse": |
| | 0 | 148 | | groups = await GroupAsync(scoped, o => o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null, ca |
| | 0 | 149 | | break; |
| | | 150 | | case "supplier": |
| | 0 | 151 | | groups = await GroupAsync(scoped, o => o.Supplier != null ? o.Supplier.Name : null, cancellationToken); |
| | 0 | 152 | | break; |
| | | 153 | | case "supplierstatus": |
| | | 154 | | { |
| | | 155 | | // Значение в справочнике — Id статуса поставщика (см. OrdersGridFilterOptionsService). |
| | 0 | 156 | | var states = await _context.OrderSupplierStates.AsNoTracking() |
| | 0 | 157 | | .Where(s => s.ActiveOrderId != null && s.SupplierStatusId.HasValue && ids.Contains(s.OrderId)) |
| | 0 | 158 | | .GroupBy(s => s.SupplierStatusId!.Value) |
| | 0 | 159 | | .Select(g => new { g.Key, Count = g.Count() }) |
| | 0 | 160 | | .ToListAsync(cancellationToken); |
| | 0 | 161 | | groups = states.Select(g => ((string?)g.Key.ToString(), g.Count)).ToList(); |
| | 0 | 162 | | break; |
| | | 163 | | } |
| | | 164 | | default: |
| | 0 | 165 | | return new OrdersGridFacetsResult { Unsupported = true }; |
| | | 166 | | } |
| | | 167 | | |
| | 0 | 168 | | result.Total = await scoped.CountAsync(cancellationToken); |
| | 0 | 169 | | foreach (var (key, count) in groups) |
| | | 170 | | { |
| | 0 | 171 | | if (string.IsNullOrWhiteSpace(key)) continue; |
| | 0 | 172 | | result.Counts[key] = result.Counts.TryGetValue(key, out var existing) ? existing + count : count; |
| | | 173 | | } |
| | | 174 | | |
| | 0 | 175 | | return result; |
| | 0 | 176 | | } |
| | | 177 | | |
| | | 178 | | private static async Task<List<(string? Key, int Count)>> GroupByComputedAsync( |
| | | 179 | | IQueryable<Order> query, OrdersGridFormulaTranslator.Translation t, CancellationToken cancellationToken) |
| | | 180 | | { |
| | 0 | 181 | | if (t.Type == "text") |
| | | 182 | | { |
| | 0 | 183 | | var key = (System.Linq.Expressions.Expression<Func<Order, string?>>)t.Lambda; |
| | 0 | 184 | | return await GroupAsync(query, key, cancellationToken); |
| | | 185 | | } |
| | | 186 | | |
| | 0 | 187 | | var boolKey = (System.Linq.Expressions.Expression<Func<Order, bool?>>)t.Lambda; |
| | 0 | 188 | | var rows = await query.GroupBy(boolKey).Select(g => new { g.Key, Count = g.Count() }).ToListAsync(cancellationTo |
| | 0 | 189 | | return rows.Select(r => ((string?)(r.Key == true ? "true" : "false"), r.Count)).ToList(); |
| | 0 | 190 | | } |
| | | 191 | | |
| | | 192 | | private static async Task<List<(string? Key, int Count)>> GroupAsync( |
| | | 193 | | IQueryable<Order> query, |
| | | 194 | | System.Linq.Expressions.Expression<Func<Order, string?>> keySelector, |
| | | 195 | | CancellationToken cancellationToken) |
| | | 196 | | { |
| | 0 | 197 | | var rows = await query |
| | 0 | 198 | | .GroupBy(keySelector) |
| | 0 | 199 | | .Select(g => new { g.Key, Count = g.Count() }) |
| | 0 | 200 | | .ToListAsync(cancellationToken); |
| | 0 | 201 | | return rows.Select(r => (r.Key, r.Count)).ToList(); |
| | 0 | 202 | | } |
| | | 203 | | } |