< Summary

Information
Class: Services.AiAssistant.AiOrderSearchResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderSearchAgent.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 10
Coverable lines: 10
Total lines: 506
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Orders()100%210%
get_TotalCount()100%210%
get_DisplayedCount()100%210%
get_AppliedFiltersDescription()100%210%
get_HadHistoryJoin()100%210%
get_Sql()100%210%
get_RelaxedSuggestion()100%210%
get_RelaxedCount()100%210%
get_SuggestionQuery()100%210%
get_FromCache()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderSearchAgent.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using System.Text.Json;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Caching.Memory;
 6using OzonDomains.Models;
 7using OzonRepositories.Context;
 8using OzonRepositories.Data;
 9using Services.ApiServices.OpenRouterApi.Models;
 10
 11namespace Services.AiAssistant;
 12
 13/// <summary>
 14/// Выделенный агент поиска заказов по критериям <see cref="AiOrderFilterCriteria"/>.
 15/// Переиспользуется из любого места — AI-ассистент, API, фоновые задачи.
 16/// </summary>
 17public class AiOrderSearchAgent
 18{
 19    private readonly OrderRepository   _orderRepository;
 20    private readonly ApplicationDbContext _db;
 21    private readonly AiOrderScopeApplier _scopeApplier;
 22    private readonly IMemoryCache      _cache;
 23
 24    private const int DefaultTake = 50;
 25    private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
 26
 27    public AiOrderSearchAgent(
 28        OrderRepository      orderRepository,
 29        ApplicationDbContext db,
 30        AiOrderScopeApplier  scopeApplier,
 31        IMemoryCache         cache)
 32    {
 33        _orderRepository = orderRepository;
 34        _db              = db;
 35        _scopeApplier    = scopeApplier;
 36        _cache           = cache;
 37    }
 38
 39    /// <summary>
 40    /// Выполняет поиск заказов по критериям с учётом Row-Level Security пользователя.
 41    /// Результаты кэшируются на 5 минут (ключ: user + criteria + maxResults).
 42    /// </summary>
 43    public async Task<AiOrderSearchResult> SearchAsync(
 44        AiOrderFilterCriteria criteria,
 45        string?               userId,
 46        CancellationToken     ct = default,
 47        int                   maxResults = DefaultTake,
 48        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 49    {
 50        criteria = AiOrderFilterParser.NormalizeCriteria(criteria);
 51
 52        var cacheKey = BuildCacheKey(userId, criteria, maxResults);
 53        if (_cache.TryGetValue(cacheKey, out AiOrderSearchResult? cached) && cached != null)
 54        {
 55            var cachedHit = CloneResult(cached, fromCache: true);
 56            await EmitFilterAgentsAsync(onProgress, BuildFilterAgentSteps(
 57                search: ("done", "Из кэша (5 мин)", true, cached.TotalCount),
 58                relax: null));
 59            return cachedHit;
 60        }
 61
 62        var agentSteps = BuildFilterAgentSteps(
 63            search: ("active", criteria.DescribeRu(), false, null),
 64            relax: null);
 65        await EmitFilterAgentsAsync(onProgress, agentSteps, "⟳ Поиск заказов по критериям…", "spin");
 66
 67        var result = await SearchCoreAsync(criteria, userId, ct, maxResults);
 68
 69        agentSteps = BuildFilterAgentSteps(
 70            search: ("done", $"{result.TotalCount} заказов", false, result.TotalCount),
 71            relax: null);
 72        await EmitFilterAgentsAsync(onProgress, agentSteps,
 73            $"✓ Поиск: {result.TotalCount} шт.", "check");
 74
 75        if (result.TotalCount == 0 && criteria.HasCriteria)
 76        {
 77            var (relaxed, label) = TryBuildRelaxedCriteria(criteria);
 78            if (relaxed != null)
 79            {
 80                agentSteps = BuildFilterAgentSteps(
 81                    search: ("done", $"{result.TotalCount} заказов", false, result.TotalCount),
 82                    relax: ("active", label, false, null));
 83                await EmitFilterAgentsAsync(onProgress, agentSteps, $"⟳ Ослабление: {label}…", "spin");
 84
 85                var relaxedResult = await SearchCoreAsync(relaxed, userId, ct, maxResults);
 86                if (relaxedResult.TotalCount > 0)
 87                {
 88                    result = new AiOrderSearchResult
 89                    {
 90                        Orders                    = result.Orders,
 91                        TotalCount                = result.TotalCount,
 92                        DisplayedCount            = result.DisplayedCount,
 93                        AppliedFiltersDescription = result.AppliedFiltersDescription,
 94                        HadHistoryJoin            = result.HadHistoryJoin,
 95                        Sql                       = result.Sql,
 96                        RelaxedSuggestion         = relaxed,
 97                        RelaxedCount              = relaxedResult.TotalCount,
 98                        SuggestionQuery           = BuildSuggestionQuery(label, relaxedResult.TotalCount),
 99                    };
 100                    agentSteps = BuildFilterAgentSteps(
 101                        search: ("done", "0 заказов", false, 0),
 102                        relax: ("done", $"Найдено {relaxedResult.TotalCount}", false, relaxedResult.TotalCount));
 103                    await EmitFilterAgentsAsync(onProgress, agentSteps,
 104                        $"✓ С ослаблением: {relaxedResult.TotalCount} шт.", "check");
 105                }
 106                else
 107                {
 108                    agentSteps = BuildFilterAgentSteps(
 109                        search: ("done", "0 заказов", false, 0),
 110                        relax: ("done", "Без результатов", false, 0));
 111                    await EmitFilterAgentsAsync(onProgress, agentSteps, "✓ Ослабление не помогло", "info");
 112                }
 113            }
 114        }
 115
 116        _cache.Set(cacheKey, result, CacheTtl);
 117        return result;
 118    }
 119
 120    private static AiOrderSearchResult CloneResult(AiOrderSearchResult source, bool fromCache) => new()
 121    {
 122        Orders                    = source.Orders,
 123        TotalCount                = source.TotalCount,
 124        DisplayedCount            = source.DisplayedCount,
 125        AppliedFiltersDescription = source.AppliedFiltersDescription,
 126        HadHistoryJoin            = source.HadHistoryJoin,
 127        Sql                       = source.Sql,
 128        RelaxedSuggestion         = source.RelaxedSuggestion,
 129        RelaxedCount              = source.RelaxedCount,
 130        SuggestionQuery           = source.SuggestionQuery,
 131        FromCache                 = fromCache,
 132    };
 133
 134    private static List<AiProcessStepDto> BuildFilterAgentSteps(
 135        (string Status, string? Detail, bool FromCache, int? Count) search,
 136        (string Status, string? Detail, bool FromCache, int? Count)? relax)
 137    {
 138        var steps = new List<AiProcessStepDto>
 139        {
 140            new()
 141            {
 142                Key = "filter_search",
 143                Label = "Поиск в БД",
 144                IsAgent = true,
 145                Status = search.Status,
 146                Done = search.Status == "done",
 147                Detail = search.Detail,
 148                FromCache = search.FromCache,
 149            }
 150        };
 151        if (relax != null)
 152        {
 153            steps.Add(new AiProcessStepDto
 154            {
 155                Key = "filter_relax",
 156                Label = "Ослабление критериев",
 157                IsAgent = true,
 158                Status = relax.Value.Status,
 159                Done = relax.Value.Status == "done",
 160                Detail = relax.Value.Detail,
 161                FromCache = relax.Value.FromCache,
 162            });
 163        }
 164        return steps;
 165    }
 166
 167    private static async Task EmitFilterAgentsAsync(
 168        Func<AiAgentProgressUpdate, Task>? onProgress,
 169        List<AiProcessStepDto> steps,
 170        string? logLine = null,
 171        string? logIcon = null,
 172        bool? fromCache = null)
 173    {
 174        if (onProgress == null) return;
 175        await onProgress(new AiAgentProgressUpdate
 176        {
 177            Phase = "agents",
 178            Steps = steps,
 179            LogLine = logLine,
 180            LogIcon = logIcon,
 181            FromCache = fromCache,
 182        });
 183    }
 184
 185    private static string BuildCacheKey(string? userId, AiOrderFilterCriteria criteria, int maxResults)
 186    {
 187        var json = JsonSerializer.Serialize(criteria);
 188        var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
 189        return $"ai_search_{userId ?? "anon"}_{maxResults}_{hash}";
 190    }
 191
 192    private static (AiOrderFilterCriteria? Criteria, string Label) TryBuildRelaxedCriteria(AiOrderFilterCriteria criteri
 193    {
 194        if (criteria.LastMonths is > 0)
 195            return (AiOrderFilterCriteriaCopy.WithoutLastMonths(criteria), "без ограничения по месяцам");
 196
 197        if (criteria.ShippingDateFrom.HasValue || criteria.ShippingDateTo.HasValue
 198            || criteria.ProcessingDateFrom.HasValue || criteria.ProcessingDateTo.HasValue
 199            || criteria.ChangedFrom.HasValue || criteria.ChangedTo.HasValue)
 200            return (AiOrderFilterCriteriaCopy.WithoutDateLimits(criteria), "без ограничения по датам");
 201
 202        return (null, "");
 203    }
 204
 205    private static string BuildSuggestionQuery(string relaxLabel, int count) =>
 206        $"Поискать {relaxLabel} (найдено {count})";
 207
 208    private async Task<AiOrderSearchResult> SearchCoreAsync(
 209        AiOrderFilterCriteria criteria,
 210        string?               userId,
 211        CancellationToken     ct,
 212        int                   maxResults)
 213    {
 214        var now = DateTime.UtcNow;
 215
 216        // ── Базовый scoped query ───────────────────────────────────────────────
 217        var baseQuery = _orderRepository.GetQueryable(criteria.IncludeArchived);
 218        baseQuery = await _scopeApplier.ApplyAsync(baseQuery, userId, ct);
 219
 220        IQueryable<Order> query = baseQuery
 221            .Include(o => o.AppStatus!).ThenInclude(s => s.StatusColor)
 222            .Include(o => o.ClientStatus!).ThenInclude(s => s.StatusColor)
 223            .Include(o => o.Supplier)
 224            .Include(o => o.ShipmentWarehouse)
 225            .Include(o => o.OzonClient)
 226            .Include(o => o.Manufacturer)
 227            .Include(o => o.ProductInfo)
 228            .Include(o => o.Delivery).ThenInclude(d => d!.Provider)
 229            .Include(o => o.Сurrency)
 230            .Include(o => o.OrderArchive);
 231
 232        // ── История изменений: подзапрос если нужен ───────────────────────────
 233        bool hadHistoryJoin = false;
 234        if (!string.IsNullOrWhiteSpace(criteria.ChangedByUser)
 235            || !string.IsNullOrWhiteSpace(criteria.FieldChangedName)
 236            || !string.IsNullOrWhiteSpace(criteria.FieldChangedToValue)
 237            || criteria.ChangedFrom.HasValue
 238            || criteria.ChangedTo.HasValue)
 239        {
 240            hadHistoryJoin = true;
 241            var histQ = _db.OrderHistories.AsQueryable();
 242
 243            if (!string.IsNullOrWhiteSpace(criteria.ChangedByUser))
 244            {
 245                var who = criteria.ChangedByUser.Trim();
 246                histQ = histQ.Where(h => EF.Functions.Like(h.ChangedBy, $"%{who}%"));
 247            }
 248            if (criteria.ChangedFrom.HasValue)
 249                histQ = histQ.Where(h => h.ChangedAt >= criteria.ChangedFrom.Value);
 250            if (criteria.ChangedTo.HasValue)
 251                histQ = histQ.Where(h => h.ChangedAt <= criteria.ChangedTo.Value.AddDays(1));
 252            if (!string.IsNullOrWhiteSpace(criteria.FieldChangedName))
 253            {
 254                var fn = criteria.FieldChangedName.Trim();
 255                histQ = histQ.Where(h =>
 256                    EF.Functions.Like(h.ColumnName, $"%{fn}%") ||
 257                    EF.Functions.Like(h.ColumnDisplayName, $"%{fn}%"));
 258            }
 259            if (!string.IsNullOrWhiteSpace(criteria.FieldChangedToValue))
 260            {
 261                var fv = criteria.FieldChangedToValue.Trim();
 262                histQ = histQ.Where(h => EF.Functions.Like(h.NewValue, $"%{fv}%"));
 263            }
 264
 265            var orderIdsFromHistory = await histQ
 266                .Select(h => h.OrderId)
 267                .Distinct()
 268                .ToListAsync(ct);
 269
 270            query = query.Where(o => orderIdsFromHistory.Contains(o.Id));
 271        }
 272
 273        // ── LastMonths (диапазон ProcessingDate) ─────────────────────────────
 274        if (criteria.LastMonths is > 0)
 275        {
 276            var since = now.AddMonths(-criteria.LastMonths.Value);
 277            query = query.Where(o => o.ProcessingDate >= since);
 278        }
 279
 280        // ── Базовые флаги ─────────────────────────────────────────────────────
 281        if (criteria.ActionableOnly)
 282            query = query.Where(o => o.AppStatus != null && !o.AppStatus.IsFinal);
 283
 284        if (criteria.OverdueOnly)
 285            query = query.Where(o => o.ShippingDate < now
 286                                     && o.AppStatus != null && !o.AppStatus.IsFinal);
 287
 288        if (criteria.EmptyPrice)
 289            query = query.Where(o => o.Price == null || o.Price == 0);
 290
 291        if (criteria.OnlyNegativeProfit == true)
 292            query = query.Where(o => o.MinProfit != null && o.MinProfit < 0);
 293
 294        if (criteria.OnlyWithReturns == true)
 295            query = query.Where(o => o.IsReturned);
 296
 297        if (criteria.OnlyWithTrackingNumber == true)
 298            query = query.Where(o => o.TrackingNumber != null && o.TrackingNumber != "");
 299
 300        // ── Участники ─────────────────────────────────────────────────────────
 301        if (!string.IsNullOrWhiteSpace(criteria.SupplierName))
 302        {
 303            var s = criteria.SupplierName.Trim();
 304            query = query.Where(o => o.Supplier != null &&
 305                                     EF.Functions.Like(o.Supplier.Name, $"%{s}%"));
 306        }
 307
 308        if (!string.IsNullOrWhiteSpace(criteria.OzonClientName))
 309        {
 310            var c = criteria.OzonClientName.Trim();
 311            query = query.Where(o => o.OzonClient != null && o.OzonClient.Name != null &&
 312                                     EF.Functions.Like(o.OzonClient.Name, $"%{c}%"));
 313        }
 314
 315        if (!string.IsNullOrWhiteSpace(criteria.TransactionConductedBy))
 316        {
 317            var who = criteria.TransactionConductedBy.Trim();
 318            query = query.Where(o => _db.Transactions.Any(t =>
 319                t.Orders.Any(ord => ord.Id == o.Id)
 320                && t.CreateBy != null
 321                && EF.Functions.Like(t.CreateBy, $"%{who}%")));
 322        }
 323
 324        // ── Статусы ───────────────────────────────────────────────────────────
 325        if (!string.IsNullOrWhiteSpace(criteria.AppStatusName))
 326        {
 327            var st = criteria.AppStatusName.Trim();
 328            if (AiOrderFilterParser.IsCancelledAppStatus(st))
 329            {
 330                query = query.Where(o => o.AppStatus != null && o.AppStatus.Name != null &&
 331                    (EF.Functions.Like(o.AppStatus.Name, "%Отменен%") ||
 332                     EF.Functions.Like(o.AppStatus.Name, "%Отменён%")));
 333            }
 334            else
 335            {
 336                query = query.Where(o => o.AppStatus != null && o.AppStatus.Name != null &&
 337                                         EF.Functions.Like(o.AppStatus.Name, $"%{st}%"));
 338            }
 339        }
 340
 341        if (!string.IsNullOrWhiteSpace(criteria.ClientStatusName))
 342        {
 343            var cs = criteria.ClientStatusName.Trim();
 344            query = query.Where(o => o.ClientStatus != null && o.ClientStatus.Name != null &&
 345                                     EF.Functions.Like(o.ClientStatus.Name, $"%{cs}%"));
 346        }
 347
 348        // ── Товар ─────────────────────────────────────────────────────────────
 349        if (!string.IsNullOrWhiteSpace(criteria.Article))
 350        {
 351            var art = criteria.Article.Trim();
 352            query = query.Where(o => o.Article != null &&
 353                                     EF.Functions.Like(o.Article, $"%{art}%"));
 354        }
 355
 356        if (!string.IsNullOrWhiteSpace(criteria.ProductName))
 357        {
 358            var pn = criteria.ProductName.Trim();
 359            query = query.Where(o => o.ProductName != null &&
 360                                     EF.Functions.Like(o.ProductName, $"%{pn}%"));
 361        }
 362
 363        if (!string.IsNullOrWhiteSpace(criteria.ManufacturerName))
 364        {
 365            var mfr = criteria.ManufacturerName.Trim();
 366            query = query.Where(o => o.Manufacturer != null && o.Manufacturer.Name != null &&
 367                                     EF.Functions.Like(o.Manufacturer.Name, $"%{mfr}%"));
 368        }
 369
 370        if (!string.IsNullOrWhiteSpace(criteria.NewCategory))
 371        {
 372            var cat = criteria.NewCategory.Trim();
 373            query = query.Where(o => o.NewCategory != null &&
 374                                     EF.Functions.Like(o.NewCategory, $"%{cat}%"));
 375        }
 376
 377        if (criteria.MinQuantity is > 0)
 378            query = query.Where(o => o.Quantity != null && o.Quantity > criteria.MinQuantity);
 379
 380        if (criteria.MaxQuantity is > 0)
 381            query = query.Where(o => o.Quantity != null && o.Quantity <= criteria.MaxQuantity);
 382
 383        // ── Финансы ───────────────────────────────────────────────────────────
 384        if (criteria.PriceMin.HasValue)
 385            query = query.Where(o => o.Price != null && o.Price >= criteria.PriceMin.Value);
 386
 387        if (criteria.PriceMax.HasValue)
 388            query = query.Where(o => o.Price != null && o.Price <= criteria.PriceMax.Value);
 389
 390        if (criteria.ProfitMin.HasValue)
 391            query = query.Where(o => o.MinProfit != null && o.MinProfit >= criteria.ProfitMin.Value);
 392
 393        if (criteria.ProfitMax.HasValue)
 394            query = query.Where(o => o.MinProfit != null && o.MinProfit <= criteria.ProfitMax.Value);
 395
 396        if (criteria.PurchasePriceMax.HasValue)
 397            query = query.Where(o => o.PurchasePrice != null && o.PurchasePrice <= criteria.PurchasePriceMax.Value);
 398
 399        // ── Логистика ─────────────────────────────────────────────────────────
 400        if (!string.IsNullOrWhiteSpace(criteria.ShipmentWarehouseName))
 401        {
 402            var wh = criteria.ShipmentWarehouseName.Trim();
 403            query = query.Where(o => o.ShipmentWarehouse != null && o.ShipmentWarehouse.Name != null &&
 404                                     EF.Functions.Like(o.ShipmentWarehouse.Name, $"%{wh}%"));
 405        }
 406
 407        if (!string.IsNullOrWhiteSpace(criteria.DeliveryProviderName))
 408        {
 409            var prov = criteria.DeliveryProviderName.Trim();
 410            query = query.Where(o => o.Delivery != null && o.Delivery.Provider != null &&
 411                                     EF.Functions.Like(o.Delivery.Provider.Name, $"%{prov}%"));
 412        }
 413
 414        if (!string.IsNullOrWhiteSpace(criteria.DeliveryName))
 415        {
 416            var dn = criteria.DeliveryName.Trim();
 417            query = query.Where(o => o.Delivery != null && o.Delivery.Name != null &&
 418                                     EF.Functions.Like(o.Delivery.Name, $"%{dn}%"));
 419        }
 420
 421        if (!string.IsNullOrWhiteSpace(criteria.DeliveryCity))
 422        {
 423            var dc = criteria.DeliveryCity.Trim();
 424            query = query.Where(o => o.DeliveryCity != null &&
 425                                     EF.Functions.Like(o.DeliveryCity, $"%{dc}%"));
 426        }
 427
 428        // ── Даты отгрузки/обработки ───────────────────────────────────────────
 429        if (criteria.ShippingDateFrom.HasValue)
 430            query = query.Where(o => o.ShippingDate >= criteria.ShippingDateFrom.Value);
 431        if (criteria.ShippingDateTo.HasValue)
 432            query = query.Where(o => o.ShippingDate <= criteria.ShippingDateTo.Value.AddDays(1));
 433        if (criteria.ProcessingDateFrom.HasValue)
 434            query = query.Where(o => o.ProcessingDate >= criteria.ProcessingDateFrom.Value);
 435        if (criteria.ProcessingDateTo.HasValue)
 436            query = query.Where(o => o.ProcessingDate <= criteria.ProcessingDateTo.Value.AddDays(1));
 437
 438        // ── Сортировка и материализация ───────────────────────────────────────
 439        query = query
 440            .OrderBy(o => o.ShippingDate ?? DateTime.MaxValue)
 441            .ThenByDescending(o => o.ProcessingDate);
 442
 443        var totalQuery  = query.Select(o => o.Id);
 444        var totalCount  = await totalQuery.CountAsync(ct);
 445        var limitedQ    = query.Take(maxResults);
 446        var sql         = limitedQ.ToQueryString();
 447        var rows        = await limitedQ.ToListAsync(ct);
 448
 449        var dtos        = rows.Select(MapToSummary).ToList();
 450        var filtersDesc = criteria.DescribeRu();
 451
 452        return new AiOrderSearchResult
 453        {
 454            Orders                 = dtos,
 455            TotalCount             = totalCount,
 456            DisplayedCount         = dtos.Count,
 457            AppliedFiltersDescription = filtersDesc,
 458            HadHistoryJoin         = hadHistoryJoin,
 459            Sql                    = sql,
 460        };
 461    }
 462
 463    private static OrderSummaryDto MapToSummary(Order o) => new()
 464    {
 465        Id                   = o.Id,
 466        ShipmentNumber       = o.ShipmentNumber,
 467        ProductName          = o.ProductName,
 468        Article              = o.Article,
 469        AppStatus            = o.AppStatus?.Name,
 470        AppStatusColor       = o.AppStatus?.StatusColor?.ColorCode,
 471        ClientStatus         = o.ClientStatus?.Name,
 472        ClientStatusColor    = o.ClientStatus?.StatusColor?.ColorCode,
 473        Supplier             = o.Supplier?.Name,
 474        ShipmentWarehouseName= o.ShipmentWarehouse?.Name,
 475        OzonClientName       = o.OzonClient?.Name,
 476        ShippingDate         = o.ShippingDate,
 477        Price                = o.Price,
 478        MinProfit            = o.MinProfit,
 479        ManufacturerName     = o.Manufacturer?.Name,
 480        ProductImageUrl      = o.ProductInfo?.ImageUrl,
 481        Quantity             = o.Quantity,
 482        TrueQuantity         = o.TrueQuantity,
 483        IsArchived           = o.OrderArchive != null,
 484        ArchivedAt           = o.OrderArchive?.ArchivedAt,
 485        ArchivedBy           = o.OrderArchive?.ArchivedBy,
 486        ArchiveReason        = o.OrderArchive?.ArchiveReason,
 487    };
 488}
 489
 490/// <summary>Результат поиска заказов через <see cref="AiOrderSearchAgent"/>.</summary>
 491public sealed class AiOrderSearchResult
 492{
 0493    public List<OrderSummaryDto> Orders { get; init; } = [];
 0494    public int     TotalCount             { get; init; }
 0495    public int     DisplayedCount         { get; init; }
 0496    public string? AppliedFiltersDescription { get; init; }
 0497    public bool    HadHistoryJoin         { get; init; }
 0498    public string? Sql                    { get; init; }
 499    /// <summary>Ослабленные критерии, при которых найдены заказы (если основной поиск дал 0).</summary>
 0500    public AiOrderFilterCriteria? RelaxedSuggestion { get; init; }
 0501    public int     RelaxedCount           { get; init; }
 502    /// <summary>Готовая фраза для chip «повторить поиск».</summary>
 0503    public string? SuggestionQuery        { get; init; }
 504    /// <summary>Результат взят из IMemoryCache.</summary>
 0505    public bool    FromCache              { get; init; }
 506}