< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
SearchAsync()0%156120%
CloneResult(...)100%210%
BuildFilterAgentSteps(...)0%620%
EmitFilterAgentsAsync()0%620%
BuildCacheKey(...)0%620%
TryBuildRelaxedCriteria(...)0%272160%
BuildSuggestionQuery(...)100%210%
SearchCoreAsync()0%8190900%
MapToSummary(...)0%812280%

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;
 025    private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
 26
 027    public AiOrderSearchAgent(
 028        OrderRepository      orderRepository,
 029        ApplicationDbContext db,
 030        AiOrderScopeApplier  scopeApplier,
 031        IMemoryCache         cache)
 32    {
 033        _orderRepository = orderRepository;
 034        _db              = db;
 035        _scopeApplier    = scopeApplier;
 036        _cache           = cache;
 037    }
 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    {
 050        criteria = AiOrderFilterParser.NormalizeCriteria(criteria);
 51
 052        var cacheKey = BuildCacheKey(userId, criteria, maxResults);
 053        if (_cache.TryGetValue(cacheKey, out AiOrderSearchResult? cached) && cached != null)
 54        {
 055            var cachedHit = CloneResult(cached, fromCache: true);
 056            await EmitFilterAgentsAsync(onProgress, BuildFilterAgentSteps(
 057                search: ("done", "Из кэша (5 мин)", true, cached.TotalCount),
 058                relax: null));
 059            return cachedHit;
 60        }
 61
 062        var agentSteps = BuildFilterAgentSteps(
 063            search: ("active", criteria.DescribeRu(), false, null),
 064            relax: null);
 065        await EmitFilterAgentsAsync(onProgress, agentSteps, "⟳ Поиск заказов по критериям…", "spin");
 66
 067        var result = await SearchCoreAsync(criteria, userId, ct, maxResults);
 68
 069        agentSteps = BuildFilterAgentSteps(
 070            search: ("done", $"{result.TotalCount} заказов", false, result.TotalCount),
 071            relax: null);
 072        await EmitFilterAgentsAsync(onProgress, agentSteps,
 073            $"✓ Поиск: {result.TotalCount} шт.", "check");
 74
 075        if (result.TotalCount == 0 && criteria.HasCriteria)
 76        {
 077            var (relaxed, label) = TryBuildRelaxedCriteria(criteria);
 078            if (relaxed != null)
 79            {
 080                agentSteps = BuildFilterAgentSteps(
 081                    search: ("done", $"{result.TotalCount} заказов", false, result.TotalCount),
 082                    relax: ("active", label, false, null));
 083                await EmitFilterAgentsAsync(onProgress, agentSteps, $"⟳ Ослабление: {label}…", "spin");
 84
 085                var relaxedResult = await SearchCoreAsync(relaxed, userId, ct, maxResults);
 086                if (relaxedResult.TotalCount > 0)
 87                {
 088                    result = new AiOrderSearchResult
 089                    {
 090                        Orders                    = result.Orders,
 091                        TotalCount                = result.TotalCount,
 092                        DisplayedCount            = result.DisplayedCount,
 093                        AppliedFiltersDescription = result.AppliedFiltersDescription,
 094                        HadHistoryJoin            = result.HadHistoryJoin,
 095                        Sql                       = result.Sql,
 096                        RelaxedSuggestion         = relaxed,
 097                        RelaxedCount              = relaxedResult.TotalCount,
 098                        SuggestionQuery           = BuildSuggestionQuery(label, relaxedResult.TotalCount),
 099                    };
 0100                    agentSteps = BuildFilterAgentSteps(
 0101                        search: ("done", "0 заказов", false, 0),
 0102                        relax: ("done", $"Найдено {relaxedResult.TotalCount}", false, relaxedResult.TotalCount));
 0103                    await EmitFilterAgentsAsync(onProgress, agentSteps,
 0104                        $"✓ С ослаблением: {relaxedResult.TotalCount} шт.", "check");
 105                }
 106                else
 107                {
 0108                    agentSteps = BuildFilterAgentSteps(
 0109                        search: ("done", "0 заказов", false, 0),
 0110                        relax: ("done", "Без результатов", false, 0));
 0111                    await EmitFilterAgentsAsync(onProgress, agentSteps, "✓ Ослабление не помогло", "info");
 112                }
 113            }
 0114        }
 115
 0116        _cache.Set(cacheKey, result, CacheTtl);
 0117        return result;
 0118    }
 119
 0120    private static AiOrderSearchResult CloneResult(AiOrderSearchResult source, bool fromCache) => new()
 0121    {
 0122        Orders                    = source.Orders,
 0123        TotalCount                = source.TotalCount,
 0124        DisplayedCount            = source.DisplayedCount,
 0125        AppliedFiltersDescription = source.AppliedFiltersDescription,
 0126        HadHistoryJoin            = source.HadHistoryJoin,
 0127        Sql                       = source.Sql,
 0128        RelaxedSuggestion         = source.RelaxedSuggestion,
 0129        RelaxedCount              = source.RelaxedCount,
 0130        SuggestionQuery           = source.SuggestionQuery,
 0131        FromCache                 = fromCache,
 0132    };
 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    {
 0138        var steps = new List<AiProcessStepDto>
 0139        {
 0140            new()
 0141            {
 0142                Key = "filter_search",
 0143                Label = "Поиск в БД",
 0144                IsAgent = true,
 0145                Status = search.Status,
 0146                Done = search.Status == "done",
 0147                Detail = search.Detail,
 0148                FromCache = search.FromCache,
 0149            }
 0150        };
 0151        if (relax != null)
 152        {
 0153            steps.Add(new AiProcessStepDto
 0154            {
 0155                Key = "filter_relax",
 0156                Label = "Ослабление критериев",
 0157                IsAgent = true,
 0158                Status = relax.Value.Status,
 0159                Done = relax.Value.Status == "done",
 0160                Detail = relax.Value.Detail,
 0161                FromCache = relax.Value.FromCache,
 0162            });
 163        }
 0164        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    {
 0174        if (onProgress == null) return;
 0175        await onProgress(new AiAgentProgressUpdate
 0176        {
 0177            Phase = "agents",
 0178            Steps = steps,
 0179            LogLine = logLine,
 0180            LogIcon = logIcon,
 0181            FromCache = fromCache,
 0182        });
 0183    }
 184
 185    private static string BuildCacheKey(string? userId, AiOrderFilterCriteria criteria, int maxResults)
 186    {
 0187        var json = JsonSerializer.Serialize(criteria);
 0188        var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
 0189        return $"ai_search_{userId ?? "anon"}_{maxResults}_{hash}";
 190    }
 191
 192    private static (AiOrderFilterCriteria? Criteria, string Label) TryBuildRelaxedCriteria(AiOrderFilterCriteria criteri
 193    {
 0194        if (criteria.LastMonths is > 0)
 0195            return (AiOrderFilterCriteriaCopy.WithoutLastMonths(criteria), "без ограничения по месяцам");
 196
 0197        if (criteria.ShippingDateFrom.HasValue || criteria.ShippingDateTo.HasValue
 0198            || criteria.ProcessingDateFrom.HasValue || criteria.ProcessingDateTo.HasValue
 0199            || criteria.ChangedFrom.HasValue || criteria.ChangedTo.HasValue)
 0200            return (AiOrderFilterCriteriaCopy.WithoutDateLimits(criteria), "без ограничения по датам");
 201
 0202        return (null, "");
 203    }
 204
 205    private static string BuildSuggestionQuery(string relaxLabel, int count) =>
 0206        $"Поискать {relaxLabel} (найдено {count})";
 207
 208    private async Task<AiOrderSearchResult> SearchCoreAsync(
 209        AiOrderFilterCriteria criteria,
 210        string?               userId,
 211        CancellationToken     ct,
 212        int                   maxResults)
 213    {
 0214        var now = DateTime.UtcNow;
 215
 216        // ── Базовый scoped query ───────────────────────────────────────────────
 0217        var baseQuery = _orderRepository.GetQueryable(criteria.IncludeArchived);
 0218        baseQuery = await _scopeApplier.ApplyAsync(baseQuery, userId, ct);
 219
 0220        IQueryable<Order> query = baseQuery
 0221            .Include(o => o.AppStatus!).ThenInclude(s => s.StatusColor)
 0222            .Include(o => o.ClientStatus!).ThenInclude(s => s.StatusColor)
 0223            .Include(o => o.Supplier)
 0224            .Include(o => o.ShipmentWarehouse)
 0225            .Include(o => o.OzonClient)
 0226            .Include(o => o.Manufacturer)
 0227            .Include(o => o.ProductInfo)
 0228            .Include(o => o.Delivery).ThenInclude(d => d!.Provider)
 0229            .Include(o => o.Сurrency)
 0230            .Include(o => o.OrderArchive);
 231
 232        // ── История изменений: подзапрос если нужен ───────────────────────────
 0233        bool hadHistoryJoin = false;
 0234        if (!string.IsNullOrWhiteSpace(criteria.ChangedByUser)
 0235            || !string.IsNullOrWhiteSpace(criteria.FieldChangedName)
 0236            || !string.IsNullOrWhiteSpace(criteria.FieldChangedToValue)
 0237            || criteria.ChangedFrom.HasValue
 0238            || criteria.ChangedTo.HasValue)
 239        {
 0240            hadHistoryJoin = true;
 0241            var histQ = _db.OrderHistories.AsQueryable();
 242
 0243            if (!string.IsNullOrWhiteSpace(criteria.ChangedByUser))
 244            {
 0245                var who = criteria.ChangedByUser.Trim();
 0246                histQ = histQ.Where(h => EF.Functions.Like(h.ChangedBy, $"%{who}%"));
 247            }
 0248            if (criteria.ChangedFrom.HasValue)
 0249                histQ = histQ.Where(h => h.ChangedAt >= criteria.ChangedFrom.Value);
 0250            if (criteria.ChangedTo.HasValue)
 0251                histQ = histQ.Where(h => h.ChangedAt <= criteria.ChangedTo.Value.AddDays(1));
 0252            if (!string.IsNullOrWhiteSpace(criteria.FieldChangedName))
 253            {
 0254                var fn = criteria.FieldChangedName.Trim();
 0255                histQ = histQ.Where(h =>
 0256                    EF.Functions.Like(h.ColumnName, $"%{fn}%") ||
 0257                    EF.Functions.Like(h.ColumnDisplayName, $"%{fn}%"));
 258            }
 0259            if (!string.IsNullOrWhiteSpace(criteria.FieldChangedToValue))
 260            {
 0261                var fv = criteria.FieldChangedToValue.Trim();
 0262                histQ = histQ.Where(h => EF.Functions.Like(h.NewValue, $"%{fv}%"));
 263            }
 264
 0265            var orderIdsFromHistory = await histQ
 0266                .Select(h => h.OrderId)
 0267                .Distinct()
 0268                .ToListAsync(ct);
 269
 0270            query = query.Where(o => orderIdsFromHistory.Contains(o.Id));
 0271        }
 272
 273        // ── LastMonths (диапазон ProcessingDate) ─────────────────────────────
 0274        if (criteria.LastMonths is > 0)
 275        {
 0276            var since = now.AddMonths(-criteria.LastMonths.Value);
 0277            query = query.Where(o => o.ProcessingDate >= since);
 278        }
 279
 280        // ── Базовые флаги ─────────────────────────────────────────────────────
 0281        if (criteria.ActionableOnly)
 0282            query = query.Where(o => o.AppStatus != null && !o.AppStatus.IsFinal);
 283
 0284        if (criteria.OverdueOnly)
 0285            query = query.Where(o => o.ShippingDate < now
 0286                                     && o.AppStatus != null && !o.AppStatus.IsFinal);
 287
 0288        if (criteria.EmptyPrice)
 0289            query = query.Where(o => o.Price == null || o.Price == 0);
 290
 0291        if (criteria.OnlyNegativeProfit == true)
 0292            query = query.Where(o => o.MinProfit != null && o.MinProfit < 0);
 293
 0294        if (criteria.OnlyWithReturns == true)
 0295            query = query.Where(o => o.IsReturned);
 296
 0297        if (criteria.OnlyWithTrackingNumber == true)
 0298            query = query.Where(o => o.TrackingNumber != null && o.TrackingNumber != "");
 299
 300        // ── Участники ─────────────────────────────────────────────────────────
 0301        if (!string.IsNullOrWhiteSpace(criteria.SupplierName))
 302        {
 0303            var s = criteria.SupplierName.Trim();
 0304            query = query.Where(o => o.Supplier != null &&
 0305                                     EF.Functions.Like(o.Supplier.Name, $"%{s}%"));
 306        }
 307
 0308        if (!string.IsNullOrWhiteSpace(criteria.OzonClientName))
 309        {
 0310            var c = criteria.OzonClientName.Trim();
 0311            query = query.Where(o => o.OzonClient != null && o.OzonClient.Name != null &&
 0312                                     EF.Functions.Like(o.OzonClient.Name, $"%{c}%"));
 313        }
 314
 0315        if (!string.IsNullOrWhiteSpace(criteria.TransactionConductedBy))
 316        {
 0317            var who = criteria.TransactionConductedBy.Trim();
 0318            query = query.Where(o => _db.Transactions.Any(t =>
 0319                t.Orders.Any(ord => ord.Id == o.Id)
 0320                && t.CreateBy != null
 0321                && EF.Functions.Like(t.CreateBy, $"%{who}%")));
 322        }
 323
 324        // ── Статусы ───────────────────────────────────────────────────────────
 0325        if (!string.IsNullOrWhiteSpace(criteria.AppStatusName))
 326        {
 0327            var st = criteria.AppStatusName.Trim();
 0328            if (AiOrderFilterParser.IsCancelledAppStatus(st))
 329            {
 0330                query = query.Where(o => o.AppStatus != null && o.AppStatus.Name != null &&
 0331                    (EF.Functions.Like(o.AppStatus.Name, "%Отменен%") ||
 0332                     EF.Functions.Like(o.AppStatus.Name, "%Отменён%")));
 333            }
 334            else
 335            {
 0336                query = query.Where(o => o.AppStatus != null && o.AppStatus.Name != null &&
 0337                                         EF.Functions.Like(o.AppStatus.Name, $"%{st}%"));
 338            }
 339        }
 340
 0341        if (!string.IsNullOrWhiteSpace(criteria.ClientStatusName))
 342        {
 0343            var cs = criteria.ClientStatusName.Trim();
 0344            query = query.Where(o => o.ClientStatus != null && o.ClientStatus.Name != null &&
 0345                                     EF.Functions.Like(o.ClientStatus.Name, $"%{cs}%"));
 346        }
 347
 348        // ── Товар ─────────────────────────────────────────────────────────────
 0349        if (!string.IsNullOrWhiteSpace(criteria.Article))
 350        {
 0351            var art = criteria.Article.Trim();
 0352            query = query.Where(o => o.Article != null &&
 0353                                     EF.Functions.Like(o.Article, $"%{art}%"));
 354        }
 355
 0356        if (!string.IsNullOrWhiteSpace(criteria.ProductName))
 357        {
 0358            var pn = criteria.ProductName.Trim();
 0359            query = query.Where(o => o.ProductName != null &&
 0360                                     EF.Functions.Like(o.ProductName, $"%{pn}%"));
 361        }
 362
 0363        if (!string.IsNullOrWhiteSpace(criteria.ManufacturerName))
 364        {
 0365            var mfr = criteria.ManufacturerName.Trim();
 0366            query = query.Where(o => o.Manufacturer != null && o.Manufacturer.Name != null &&
 0367                                     EF.Functions.Like(o.Manufacturer.Name, $"%{mfr}%"));
 368        }
 369
 0370        if (!string.IsNullOrWhiteSpace(criteria.NewCategory))
 371        {
 0372            var cat = criteria.NewCategory.Trim();
 0373            query = query.Where(o => o.NewCategory != null &&
 0374                                     EF.Functions.Like(o.NewCategory, $"%{cat}%"));
 375        }
 376
 0377        if (criteria.MinQuantity is > 0)
 0378            query = query.Where(o => o.Quantity != null && o.Quantity > criteria.MinQuantity);
 379
 0380        if (criteria.MaxQuantity is > 0)
 0381            query = query.Where(o => o.Quantity != null && o.Quantity <= criteria.MaxQuantity);
 382
 383        // ── Финансы ───────────────────────────────────────────────────────────
 0384        if (criteria.PriceMin.HasValue)
 0385            query = query.Where(o => o.Price != null && o.Price >= criteria.PriceMin.Value);
 386
 0387        if (criteria.PriceMax.HasValue)
 0388            query = query.Where(o => o.Price != null && o.Price <= criteria.PriceMax.Value);
 389
 0390        if (criteria.ProfitMin.HasValue)
 0391            query = query.Where(o => o.MinProfit != null && o.MinProfit >= criteria.ProfitMin.Value);
 392
 0393        if (criteria.ProfitMax.HasValue)
 0394            query = query.Where(o => o.MinProfit != null && o.MinProfit <= criteria.ProfitMax.Value);
 395
 0396        if (criteria.PurchasePriceMax.HasValue)
 0397            query = query.Where(o => o.PurchasePrice != null && o.PurchasePrice <= criteria.PurchasePriceMax.Value);
 398
 399        // ── Логистика ─────────────────────────────────────────────────────────
 0400        if (!string.IsNullOrWhiteSpace(criteria.ShipmentWarehouseName))
 401        {
 0402            var wh = criteria.ShipmentWarehouseName.Trim();
 0403            query = query.Where(o => o.ShipmentWarehouse != null && o.ShipmentWarehouse.Name != null &&
 0404                                     EF.Functions.Like(o.ShipmentWarehouse.Name, $"%{wh}%"));
 405        }
 406
 0407        if (!string.IsNullOrWhiteSpace(criteria.DeliveryProviderName))
 408        {
 0409            var prov = criteria.DeliveryProviderName.Trim();
 0410            query = query.Where(o => o.Delivery != null && o.Delivery.Provider != null &&
 0411                                     EF.Functions.Like(o.Delivery.Provider.Name, $"%{prov}%"));
 412        }
 413
 0414        if (!string.IsNullOrWhiteSpace(criteria.DeliveryName))
 415        {
 0416            var dn = criteria.DeliveryName.Trim();
 0417            query = query.Where(o => o.Delivery != null && o.Delivery.Name != null &&
 0418                                     EF.Functions.Like(o.Delivery.Name, $"%{dn}%"));
 419        }
 420
 0421        if (!string.IsNullOrWhiteSpace(criteria.DeliveryCity))
 422        {
 0423            var dc = criteria.DeliveryCity.Trim();
 0424            query = query.Where(o => o.DeliveryCity != null &&
 0425                                     EF.Functions.Like(o.DeliveryCity, $"%{dc}%"));
 426        }
 427
 428        // ── Даты отгрузки/обработки ───────────────────────────────────────────
 0429        if (criteria.ShippingDateFrom.HasValue)
 0430            query = query.Where(o => o.ShippingDate >= criteria.ShippingDateFrom.Value);
 0431        if (criteria.ShippingDateTo.HasValue)
 0432            query = query.Where(o => o.ShippingDate <= criteria.ShippingDateTo.Value.AddDays(1));
 0433        if (criteria.ProcessingDateFrom.HasValue)
 0434            query = query.Where(o => o.ProcessingDate >= criteria.ProcessingDateFrom.Value);
 0435        if (criteria.ProcessingDateTo.HasValue)
 0436            query = query.Where(o => o.ProcessingDate <= criteria.ProcessingDateTo.Value.AddDays(1));
 437
 438        // ── Сортировка и материализация ───────────────────────────────────────
 0439        query = query
 0440            .OrderBy(o => o.ShippingDate ?? DateTime.MaxValue)
 0441            .ThenByDescending(o => o.ProcessingDate);
 442
 0443        var totalQuery  = query.Select(o => o.Id);
 0444        var totalCount  = await totalQuery.CountAsync(ct);
 0445        var limitedQ    = query.Take(maxResults);
 0446        var sql         = limitedQ.ToQueryString();
 0447        var rows        = await limitedQ.ToListAsync(ct);
 448
 0449        var dtos        = rows.Select(MapToSummary).ToList();
 0450        var filtersDesc = criteria.DescribeRu();
 451
 0452        return new AiOrderSearchResult
 0453        {
 0454            Orders                 = dtos,
 0455            TotalCount             = totalCount,
 0456            DisplayedCount         = dtos.Count,
 0457            AppliedFiltersDescription = filtersDesc,
 0458            HadHistoryJoin         = hadHistoryJoin,
 0459            Sql                    = sql,
 0460        };
 0461    }
 462
 0463    private static OrderSummaryDto MapToSummary(Order o) => new()
 0464    {
 0465        Id                   = o.Id,
 0466        ShipmentNumber       = o.ShipmentNumber,
 0467        ProductName          = o.ProductName,
 0468        Article              = o.Article,
 0469        AppStatus            = o.AppStatus?.Name,
 0470        AppStatusColor       = o.AppStatus?.StatusColor?.ColorCode,
 0471        ClientStatus         = o.ClientStatus?.Name,
 0472        ClientStatusColor    = o.ClientStatus?.StatusColor?.ColorCode,
 0473        Supplier             = o.Supplier?.Name,
 0474        ShipmentWarehouseName= o.ShipmentWarehouse?.Name,
 0475        OzonClientName       = o.OzonClient?.Name,
 0476        ShippingDate         = o.ShippingDate,
 0477        Price                = o.Price,
 0478        MinProfit            = o.MinProfit,
 0479        ManufacturerName     = o.Manufacturer?.Name,
 0480        ProductImageUrl      = o.ProductInfo?.ImageUrl,
 0481        Quantity             = o.Quantity,
 0482        TrueQuantity         = o.TrueQuantity,
 0483        IsArchived           = o.OrderArchive != null,
 0484        ArchivedAt           = o.OrderArchive?.ArchivedAt,
 0485        ArchivedBy           = o.OrderArchive?.ArchivedBy,
 0486        ArchiveReason        = o.OrderArchive?.ArchiveReason,
 0487    };
 488}
 489
 490/// <summary>Результат поиска заказов через <see cref="AiOrderSearchAgent"/>.</summary>
 491public sealed class AiOrderSearchResult
 492{
 493    public List<OrderSummaryDto> Orders { get; init; } = [];
 494    public int     TotalCount             { get; init; }
 495    public int     DisplayedCount         { get; init; }
 496    public string? AppliedFiltersDescription { get; init; }
 497    public bool    HadHistoryJoin         { get; init; }
 498    public string? Sql                    { get; init; }
 499    /// <summary>Ослабленные критерии, при которых найдены заказы (если основной поиск дал 0).</summary>
 500    public AiOrderFilterCriteria? RelaxedSuggestion { get; init; }
 501    public int     RelaxedCount           { get; init; }
 502    /// <summary>Готовая фраза для chip «повторить поиск».</summary>
 503    public string? SuggestionQuery        { get; init; }
 504    /// <summary>Результат взят из IMemoryCache.</summary>
 505    public bool    FromCache              { get; init; }
 506}