| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using OzonOrdersWeb.Areas.Admin.Models; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | |
| | | 7 | | namespace OzonOrdersWeb.Areas.Admin.Services; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Строит человекочитаемую расшифровку Dynamic LINQ выражений DataScope (Order: FK → имена, числовые диапазоны). |
| | | 11 | | /// </summary> |
| | | 12 | | public sealed class DataScopeFilterSummaryService |
| | | 13 | | { |
| | 1 | 14 | | private static readonly TimeSpan RxTimeout = TimeSpan.FromSeconds(1); |
| | | 15 | | |
| | 1 | 16 | | private static readonly Regex RangeFull = new( |
| | 1 | 17 | | @"^\s*(\w+)\s*!=\s*null\s*&&\s*\1\s*>=\s*([-0-9.]+)\s*&&\s*\1\s*<=\s*([-0-9.]+)\s*$", |
| | 1 | 18 | | RegexOptions.CultureInvariant, RxTimeout); |
| | | 19 | | |
| | 1 | 20 | | private static readonly Regex RangeMinOnly = new( |
| | 1 | 21 | | @"^\s*(\w+)\s*!=\s*null\s*&&\s*\1\s*>=\s*([-0-9.]+)\s*$", |
| | 1 | 22 | | RegexOptions.CultureInvariant, RxTimeout); |
| | | 23 | | |
| | 1 | 24 | | private static readonly Regex RangeMaxOnly = new( |
| | 1 | 25 | | @"^\s*(\w+)\s*!=\s*null\s*&&\s*\1\s*<=\s*([-0-9.]+)\s*$", |
| | 1 | 26 | | RegexOptions.CultureInvariant, RxTimeout); |
| | | 27 | | |
| | 1 | 28 | | private static readonly Regex IntEq = new( |
| | 1 | 29 | | @"^\s*(\w+)\s*==\s*(\d+)\s*$", |
| | 1 | 30 | | RegexOptions.CultureInvariant, RxTimeout); |
| | | 31 | | |
| | | 32 | | private readonly ApplicationDbContext _db; |
| | | 33 | | |
| | 120 | 34 | | public DataScopeFilterSummaryService(ApplicationDbContext db) => _db = db; |
| | | 35 | | |
| | | 36 | | public async Task PopulateFilterSummariesAsync(IList<DataScopeListItemVm> items, CancellationToken ct) |
| | | 37 | | { |
| | 61 | 38 | | if (items.Count == 0) return; |
| | | 39 | | |
| | 59 | 40 | | var orderParseByItem = new Dictionary<DataScopeListItemVm, OrderParse?>(); |
| | 59 | 41 | | var idsByField = new Dictionary<string, HashSet<int>>(StringComparer.OrdinalIgnoreCase); |
| | | 42 | | |
| | 272 | 43 | | foreach (var s in items) |
| | | 44 | | { |
| | 77 | 45 | | if (string.IsNullOrWhiteSpace(s.FilterExpression)) |
| | | 46 | | { |
| | 5 | 47 | | s.FilterSummary = "—"; |
| | 5 | 48 | | continue; |
| | | 49 | | } |
| | | 50 | | |
| | 72 | 51 | | if (!string.Equals(s.EntityName, "Order", StringComparison.OrdinalIgnoreCase)) |
| | | 52 | | { |
| | 3 | 53 | | s.FilterSummary = s.FilterExpression.Length > 200 |
| | 3 | 54 | | ? s.FilterExpression[..200] + "…" |
| | 3 | 55 | | : s.FilterExpression; |
| | 3 | 56 | | continue; |
| | | 57 | | } |
| | | 58 | | |
| | 69 | 59 | | var parsed = TryParseOrderExpression(s.FilterExpression); |
| | 69 | 60 | | orderParseByItem[s] = parsed; |
| | | 61 | | |
| | 69 | 62 | | if (parsed is OrderParse.InList inList) |
| | | 63 | | { |
| | 106 | 64 | | foreach (var g in inList.Groups) |
| | | 65 | | { |
| | 27 | 66 | | if (!IsLookupField(g.Field)) continue; |
| | 26 | 67 | | if (!idsByField.TryGetValue(g.Field, out var set)) |
| | | 68 | | { |
| | 25 | 69 | | set = new HashSet<int>(); |
| | 25 | 70 | | idsByField[g.Field] = set; |
| | | 71 | | } |
| | | 72 | | |
| | 145 | 73 | | foreach (var id in g.Ids) set.Add(id); |
| | | 74 | | } |
| | | 75 | | } |
| | | 76 | | } |
| | | 77 | | |
| | 59 | 78 | | var nameByField = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.OrdinalIgnoreCase); |
| | 168 | 79 | | foreach (var (field, ids) in idsByField) |
| | | 80 | | { |
| | 25 | 81 | | if (ids.Count == 0) continue; |
| | 25 | 82 | | nameByField[field] = await LoadNamesAsync(field, ids, ct); |
| | | 83 | | } |
| | | 84 | | |
| | 272 | 85 | | foreach (var s in items) |
| | | 86 | | { |
| | 77 | 87 | | if (s.FilterSummary is not null) continue; |
| | 67 | 88 | | if (!orderParseByItem.TryGetValue(s, out var parsed) || parsed is null) |
| | | 89 | | { |
| | 9 | 90 | | s.FilterSummary = "Составное/произвольное выражение (см. Dynamic LINQ)"; |
| | 9 | 91 | | continue; |
| | | 92 | | } |
| | | 93 | | |
| | 58 | 94 | | if (ReferenceEquals(parsed, OrderParse.LiteralFalse.Value)) |
| | | 95 | | { |
| | 10 | 96 | | s.FilterSummary = "Пусто (всегда false)"; |
| | | 97 | | } |
| | 48 | 98 | | else if (parsed is OrderParse.Range r) |
| | | 99 | | { |
| | 16 | 100 | | s.FilterSummary = FormatRange(r); |
| | | 101 | | } |
| | 32 | 102 | | else if (parsed is OrderParse.InList list) |
| | | 103 | | { |
| | 25 | 104 | | s.FilterSummary = FormatInList(list, nameByField); |
| | | 105 | | } |
| | 7 | 106 | | else if (ReferenceEquals(parsed, OrderParse.Complex.Value)) |
| | | 107 | | { |
| | 7 | 108 | | s.FilterSummary = "Составное/произвольное выражение (см. Dynamic LINQ)"; |
| | | 109 | | } |
| | | 110 | | else |
| | | 111 | | { |
| | 0 | 112 | | s.FilterSummary = "—"; |
| | | 113 | | } |
| | | 114 | | } |
| | 60 | 115 | | } |
| | | 116 | | |
| | | 117 | | private static string FormatRange(OrderParse.Range r) |
| | | 118 | | { |
| | 16 | 119 | | var label = GetValueFieldLabel(r.Field); |
| | 16 | 120 | | if (r.Min.HasValue && r.Max.HasValue) |
| | 3 | 121 | | return $"{label}: от {FormatNum(r.Field, r.Min.Value)} до {FormatNum(r.Field, r.Max.Value)}"; |
| | 20 | 122 | | if (r.Min.HasValue) return $"{label}: от {FormatNum(r.Field, r.Min.Value)}"; |
| | 12 | 123 | | if (r.Max.HasValue) return $"{label}: до {FormatNum(r.Field, r.Max.Value)}"; |
| | 0 | 124 | | return $"{label}: (пусто)"; |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | private static string FormatNum(string field, decimal v) |
| | | 128 | | { |
| | 23 | 129 | | if (IsIntegerValueField(field)) return ((long)Math.Truncate(v)).ToString(CultureInfo.InvariantCulture); |
| | 15 | 130 | | return v.ToString(CultureInfo.InvariantCulture); |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | private static string FormatInList( |
| | | 134 | | OrderParse.InList list, |
| | | 135 | | IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> nameByField) |
| | | 136 | | { |
| | 25 | 137 | | var parts = new List<string>(); |
| | 102 | 138 | | foreach (var g in list.Groups) |
| | | 139 | | { |
| | 26 | 140 | | var field = g.Field; |
| | 26 | 141 | | var label = IsLookupField(field) ? GetLookupFieldLabel(field) : field; |
| | 26 | 142 | | if (nameByField.TryGetValue(field, out var map)) |
| | | 143 | | { |
| | 25 | 144 | | var names = g.Ids |
| | 30 | 145 | | .Select(id => map.TryGetValue(id, out var n) && !string.IsNullOrWhiteSpace(n) ? n! : $"#{id}") |
| | 25 | 146 | | .ToList(); |
| | 25 | 147 | | parts.Add($"{label}: {string.Join(", ", names)}"); |
| | | 148 | | } |
| | | 149 | | else |
| | | 150 | | { |
| | 1 | 151 | | parts.Add( |
| | 3 | 152 | | $"{label}: {string.Join(", ", g.Ids.Select(i => i.ToString(CultureInfo.InvariantCulture)))}"); |
| | | 153 | | } |
| | | 154 | | } |
| | | 155 | | |
| | 25 | 156 | | if (parts.Count == 0) return "—"; |
| | 49 | 157 | | if (parts.Count == 1) return parts[0]; |
| | 1 | 158 | | return string.Join(" или ", parts); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | private async Task<IReadOnlyDictionary<int, string>> LoadNamesAsync(string field, HashSet<int> ids, CancellationToke |
| | | 162 | | { |
| | 25 | 163 | | var idArr = ids.ToArray(); |
| | 25 | 164 | | return field switch |
| | 25 | 165 | | { |
| | 13 | 166 | | "SupplierId" => await _db.Suppliers.AsNoTracking() |
| | 13 | 167 | | .Where(x => idArr.Contains(x.Id)) |
| | 45 | 168 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 3 | 169 | | "ClientStatusId" => await _db.ClientStatuses.AsNoTracking() |
| | 3 | 170 | | .Where(x => idArr.Contains(x.Id)) |
| | 9 | 171 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 1 | 172 | | "AppStatusId" => await _db.AppStatuses.AsNoTracking() |
| | 1 | 173 | | .Where(x => idArr.Contains(x.Id)) |
| | 3 | 174 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 2 | 175 | | "OzonClientId" => await _db.OzonClients.AsNoTracking() |
| | 2 | 176 | | .Where(x => idArr.Contains(x.Id)) |
| | 6 | 177 | | .ToDictionaryAsync(x => x.Id, x => x.Name, ct), |
| | 1 | 178 | | "ManufacturerId" => await _db.Manufacturers.AsNoTracking() |
| | 1 | 179 | | .Where(x => idArr.Contains(x.Id)) |
| | 3 | 180 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 1 | 181 | | "ShipmentWarehouseId" => await _db.Warehouses.AsNoTracking() |
| | 1 | 182 | | .Where(x => idArr.Contains(x.Id)) |
| | 3 | 183 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 1 | 184 | | "DeliveryId" => await _db.Deliveries.AsNoTracking() |
| | 1 | 185 | | .Where(x => idArr.Contains(x.Id)) |
| | 3 | 186 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 1 | 187 | | "EtProducerId" => await _db.EtProducers.AsNoTracking() |
| | 1 | 188 | | .Where(x => idArr.Contains(x.Id)) |
| | 3 | 189 | | .ToDictionaryAsync(x => x.Id, x => x.Name, ct), |
| | 2 | 190 | | "СurrencyId" or "CurrencyId" => await _db.Currencys.AsNoTracking() |
| | 2 | 191 | | .Where(x => idArr.Contains(x.Id)) |
| | 6 | 192 | | .ToDictionaryAsync(x => x.Id, x => x.Name ?? $"#{x.Id}", ct), |
| | 0 | 193 | | _ => idArr.ToDictionary(id => id, id => $"#{id}") |
| | 25 | 194 | | }; |
| | 25 | 195 | | } |
| | | 196 | | |
| | 53 | 197 | | private static bool IsLookupField(string field) => LookupFieldLabels.ContainsKey(field); |
| | | 198 | | |
| | | 199 | | private static bool IsIntegerValueField(string field) => |
| | 19 | 200 | | string.Equals(field, "Quantity", StringComparison.OrdinalIgnoreCase) |
| | 19 | 201 | | || string.Equals(field, "TrueQuantity", StringComparison.OrdinalIgnoreCase); |
| | | 202 | | |
| | 1 | 203 | | private static readonly Dictionary<string, string> LookupFieldLabels = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 204 | | { |
| | 1 | 205 | | ["SupplierId"] = "Поставщик", |
| | 1 | 206 | | ["ClientStatusId"] = "Статус клиента", |
| | 1 | 207 | | ["AppStatusId"] = "Статус в отчёте", |
| | 1 | 208 | | ["OzonClientId"] = "Клиент Ozon", |
| | 1 | 209 | | ["ManufacturerId"] = "Производитель", |
| | 1 | 210 | | ["ShipmentWarehouseId"] = "Склад отгрузки", |
| | 1 | 211 | | ["DeliveryId"] = "Доставка", |
| | 1 | 212 | | ["EtProducerId"] = "Производитель JCEtalon", |
| | 1 | 213 | | ["СurrencyId"] = "Валюта", |
| | 1 | 214 | | ["CurrencyId"] = "Валюта" |
| | 1 | 215 | | }; |
| | | 216 | | |
| | | 217 | | private static string GetLookupFieldLabel(string field) => |
| | 25 | 218 | | LookupFieldLabels.TryGetValue(field, out var l) ? l : field; |
| | | 219 | | |
| | 1 | 220 | | private static readonly Dictionary<string, string> ValueFieldLabels = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 221 | | { |
| | 1 | 222 | | ["Price"] = "Цена", |
| | 1 | 223 | | ["Quantity"] = "Количество", |
| | 1 | 224 | | ["TrueQuantity"] = "TrueQuantity", |
| | 1 | 225 | | ["ShipmentAmount"] = "Сумма отправления", |
| | 1 | 226 | | ["PurchasePrice"] = "Закупочная цена", |
| | 1 | 227 | | ["OriginalPurchasePrice"] = "Исх. закупочная", |
| | 1 | 228 | | ["CostPrice"] = "Себестоимость", |
| | 1 | 229 | | ["MinProfit"] = "MinProfit", |
| | 1 | 230 | | ["MaxProfit"] = "MaxProfit", |
| | 1 | 231 | | ["MinOzonCommission"] = "Min комиссия Ozon", |
| | 1 | 232 | | ["MaxOzonCommission"] = "Max комиссия Ozon", |
| | 1 | 233 | | ["MinDiscount"] = "Min скидка", |
| | 1 | 234 | | ["MaxDiscount"] = "Max скидка" |
| | 1 | 235 | | }; |
| | | 236 | | |
| | | 237 | | private static string GetValueFieldLabel(string field) => |
| | 16 | 238 | | ValueFieldLabels.TryGetValue(field, out var l) ? l : field; |
| | | 239 | | |
| | | 240 | | private static OrderParse? TryParseOrderExpression(string expr) |
| | | 241 | | { |
| | 69 | 242 | | var s = expr.Trim(); |
| | 69 | 243 | | if (s.Equals("false", StringComparison.OrdinalIgnoreCase) || s.Equals("1 == 0", StringComparison.Ordinal)) |
| | 10 | 244 | | return OrderParse.LiteralFalse.Value; |
| | | 245 | | |
| | 59 | 246 | | if (s.Contains("&&", StringComparison.Ordinal)) |
| | | 247 | | { |
| | 41 | 248 | | if (TryParseRange(s) is { } range) return range; |
| | 14 | 249 | | if (s.Contains("||", StringComparison.Ordinal)) return OrderParse.Complex.Value; |
| | | 250 | | return OrderParse.Complex.Value; |
| | | 251 | | } |
| | | 252 | | |
| | 35 | 253 | | s = UnwrapParens(s); |
| | 38 | 254 | | if (string.IsNullOrEmpty(s)) return null; |
| | 40 | 255 | | if (s.Contains("||", StringComparison.Ordinal)) return TryParseInList(s); |
| | 24 | 256 | | if (TryParseEquality(s) is { } t) |
| | 20 | 257 | | return new OrderParse.InList( |
| | 20 | 258 | | new[] { (t.Field, (IReadOnlyList<int>)new List<int> { t.Id }) }); |
| | | 259 | | |
| | 4 | 260 | | return null; |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | private static OrderParse.Range? TryParseRange(string s) |
| | | 264 | | { |
| | 24 | 265 | | var t = s.Trim(); |
| | 25 | 266 | | if (t.Length >= 2 && t[0] == '(' && t[^1] == ')') t = t[1..^1].Trim(); |
| | | 267 | | |
| | 24 | 268 | | var m = RangeFull.Match(t); |
| | 24 | 269 | | if (m.Success) |
| | | 270 | | { |
| | 5 | 271 | | var field = m.Groups[1].Value; |
| | 6 | 272 | | if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var min)) retur |
| | 5 | 273 | | if (!decimal.TryParse(m.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var max)) retur |
| | 3 | 274 | | return new OrderParse.Range(field, min, max); |
| | | 275 | | } |
| | | 276 | | |
| | 19 | 277 | | m = RangeMinOnly.Match(t); |
| | 19 | 278 | | if (m.Success) |
| | | 279 | | { |
| | 8 | 280 | | var field = m.Groups[1].Value; |
| | 8 | 281 | | if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var min)) retur |
| | 8 | 282 | | return new OrderParse.Range(field, min, null); |
| | | 283 | | } |
| | | 284 | | |
| | 11 | 285 | | m = RangeMaxOnly.Match(t); |
| | 11 | 286 | | if (m.Success) |
| | | 287 | | { |
| | 6 | 288 | | var field = m.Groups[1].Value; |
| | 6 | 289 | | if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var max)) retur |
| | 6 | 290 | | return new OrderParse.Range(field, null, max); |
| | | 291 | | } |
| | | 292 | | |
| | 5 | 293 | | return null; |
| | | 294 | | } |
| | | 295 | | |
| | | 296 | | private static OrderParse.InList? TryParseInList(string s) |
| | | 297 | | { |
| | 8 | 298 | | var parts = s.Split("||", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | 8 | 299 | | var list = new List<(string Field, int Id)>(); |
| | 48 | 300 | | foreach (var p in parts) |
| | | 301 | | { |
| | 17 | 302 | | var seg = p.Trim(); |
| | 19 | 303 | | if (TryParseEquality(seg) is not { } eq) return null; |
| | 15 | 304 | | list.Add((eq.Field, eq.Id)); |
| | | 305 | | } |
| | | 306 | | |
| | 6 | 307 | | if (list.Count == 0) return null; |
| | 6 | 308 | | var groups = list |
| | 14 | 309 | | .GroupBy(x => x.Field, StringComparer.OrdinalIgnoreCase) |
| | 34 | 310 | | .Select(g => (g.Key, (IReadOnlyList<int>)g.Select(x => x.Id).Distinct().OrderBy(x => x).ToList())) |
| | 6 | 311 | | .ToList(); |
| | 6 | 312 | | return new OrderParse.InList(groups); |
| | | 313 | | } |
| | | 314 | | |
| | | 315 | | private static (string Field, int Id)? TryParseEquality(string seg) |
| | | 316 | | { |
| | 41 | 317 | | var m = IntEq.Match(seg.Trim()); |
| | 47 | 318 | | if (!m.Success) return null; |
| | 35 | 319 | | var field = m.Groups[1].Value; |
| | 35 | 320 | | if (!int.TryParse(m.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) return nul |
| | 35 | 321 | | return (field, id); |
| | | 322 | | } |
| | | 323 | | |
| | | 324 | | private static string UnwrapParens(string s) |
| | | 325 | | { |
| | 35 | 326 | | s = s.Trim(); |
| | 46 | 327 | | while (s.Length >= 2 && s[0] == '(' && s[^1] == ')') |
| | | 328 | | { |
| | 13 | 329 | | var inner = s.AsSpan(1, s.Length - 2); |
| | 13 | 330 | | var depth = 0; |
| | 13 | 331 | | var ok = true; |
| | 364 | 332 | | for (var i = 0; i < inner.Length; i++) |
| | | 333 | | { |
| | 176 | 334 | | if (inner[i] == '(') depth++; |
| | 164 | 335 | | else if (inner[i] == ')') |
| | | 336 | | { |
| | 6 | 337 | | depth--; |
| | 6 | 338 | | if (depth < 0) |
| | | 339 | | { |
| | 1 | 340 | | ok = false; |
| | 1 | 341 | | break; |
| | | 342 | | } |
| | | 343 | | } |
| | | 344 | | } |
| | | 345 | | |
| | 13 | 346 | | if (!ok || depth != 0) break; |
| | 11 | 347 | | s = inner.ToString().Trim(); |
| | | 348 | | } |
| | | 349 | | |
| | 35 | 350 | | return s; |
| | | 351 | | } |
| | | 352 | | |
| | | 353 | | private abstract class OrderParse |
| | | 354 | | { |
| | | 355 | | public sealed class Range : OrderParse |
| | | 356 | | { |
| | 17 | 357 | | public Range(string field, decimal? min, decimal? max) |
| | | 358 | | { |
| | 17 | 359 | | Field = field; |
| | 17 | 360 | | Min = min; |
| | 17 | 361 | | Max = max; |
| | 17 | 362 | | } |
| | | 363 | | |
| | 35 | 364 | | public string Field { get; } |
| | 39 | 365 | | public decimal? Min { get; } |
| | 25 | 366 | | public decimal? Max { get; } |
| | | 367 | | } |
| | | 368 | | |
| | | 369 | | public sealed class InList : OrderParse |
| | | 370 | | { |
| | 52 | 371 | | public InList(IReadOnlyList<(string Field, IReadOnlyList<int> Ids)> groups) => Groups = groups; |
| | | 372 | | |
| | 51 | 373 | | public IReadOnlyList<(string Field, IReadOnlyList<int> Ids)> Groups { get; } |
| | | 374 | | } |
| | | 375 | | |
| | | 376 | | public sealed class Complex : OrderParse |
| | | 377 | | { |
| | 1 | 378 | | public static readonly Complex Value = new(); |
| | 2 | 379 | | private Complex() { } |
| | | 380 | | } |
| | | 381 | | |
| | | 382 | | public sealed class LiteralFalse : OrderParse |
| | | 383 | | { |
| | 1 | 384 | | public static readonly LiteralFalse Value = new(); |
| | 2 | 385 | | private LiteralFalse() { } |
| | | 386 | | } |
| | | 387 | | } |
| | | 388 | | } |