< Summary

Line coverage
1%
Covered lines: 22
Uncovered lines: 1150
Coverable lines: 1172
Total lines: 2082
Line coverage: 1.8%
Branch coverage
8%
Covered branches: 81
Total branches: 1004
Branch coverage: 8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .cctor()100%210%
File 1: .ctor(...)100%210%
File 1: DetectIntent(...)0%126561120%
File 1: DetectIntentAsync(...)0%702260%
File 1: IsPresetOrdersTool(...)0%7280%
File 1: LooksLikeCompoundFilterQuery(...)0%342180%
File 1: UpdateContext(...)0%3192560%
File 1: DeduplicateSummaries(...)100%11100%
File 1: DedupeById(...)100%210%
File 1: TryBuildIntentFromContext(...)0%210140%
File 1: IsContextualOrderReference(...)0%4692680%
File 1: IsContextualProductInfoIntent(...)0%210140%
File 1: IsContextualProductReference(...)0%342180%
File 1: LoadBitrixHistoryForAiAsync()100%210%
File 1: IsFullTransactionCommand(...)0%7280%
File 1: IsPendingTransactionClarification(...)0%702260%
File 1: IsOzonLiveProductIntent(...)0%702260%
File 1: IsBitrixStockIntent(...)0%1482380%
File 1: TryResolveBitrixArticle(...)0%342180%
File 1: TryParseOrderId(...)0%156120%
File 1: IsOrderHistoryIntent(...)0%272160%
File 1: IsUserActionsTodayIntent(...)0%342180%
File 1: IsTransactionTypesIntent(...)0%420200%
File 1: IsBarePresetQuery(...)0%702260%
File 1: IsCancelledTodayIntent(...)0%4260%
File 1: IsOrdersTodayIntent(...)0%506220%
File 1: RunQueryAsync()0%197401400%
File 1: BuildEnrichmentIfNeededAsync()0%342180%
File 1: ScopedOrdersAsync()100%210%
File 1: ScopedActiveOrdersAsync(...)100%210%
File 1: WithNavigations(...)100%210%
File 1: FindOrdersForIntentAsync()0%7280%
File 1: MaterializeOrderListAsync()100%210%
File 1: GetOrdersTodayAsync()0%620%
File 1: GetOrderDetailAsync()0%620%
File 1: GetOrderDetailCoreAsync()0%2756520%
File 1: AgentStep()100%210%
File 1: <GetOrderDetailCoreAsync()0%4260%
File 1: <GetOrderDetailCoreAsync()0%4260%
File 1: <GetOrderDetailCoreAsync()0%4260%
File 1: <GetOrderDetailCoreAsync()0%4260%
File 1: <GetOrderDetailCoreAsync()0%4260%
File 1: GetBitrixStockArticleHistoryAsync()0%2040%
File 1: Step()100%210%
File 1: GetOzonLiveProductAsync()0%2040%
File 1: Step()100%210%
File 1: GetOrderProductAsync()0%4260%
File 1: GetOrderHistoryAsync()0%210140%
File 1: GetOrderHistoryRecentAsync()100%210%
File 1: GetUserActionsTodayAsync()0%620%
File 1: BuildScopedHistoryQueryAsync()100%210%
File 1: LoadOrderHistoryRowsAsync()0%620%
File 1: LoadShipmentMapAsync()0%620%
File 1: LoadTransactionsForOrderAsync()0%2040%
File 1: GetOverdueOrdersAsync()0%620%
File 1: GetUnprofitableOrdersAsync()0%620%
File 1: GetCancelledOrdersTodayAsync()0%620%
File 1: GetOrdersSummaryAsync()100%210%
File 1: GetNoSupplierOrdersAsync()0%620%
File 1: MapToSummary(...)0%930300%
File 1: BuildZeroResultsContext(...)0%2040%
File 1: FormatOrderList(...)0%2040%
File 1: FormatQuantityLabel(...)0%620%
File 1: EmitProgressAsync()0%620%
File 1: FilterOrderIdsAsync()0%620%
File 1: GetSummariesByIdsAsync()0%620%
File 1: ResolveOrderIdAsync()0%110100%
File 1: GetToolLabel(...)98.78%8282100%
File 2: ShipmentNumberRegex()100%210%
File 2: SupplierOrderNumberRegex()100%210%
File 2: OrderIdHashRegex()100%210%
File 2: OrderIdWordRegex()100%210%
File 2: BareOrderIdRegex()100%210%

File(s)

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

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.Extensions.Logging;
 4using OzonDomains;
 5using OzonDomains.Models;
 6using OzonRepositories.Context;
 7using OzonRepositories.Data;
 8using Services.ApiServices.OpenRouterApi.Models;
 9using Services.BitrixStockServices;
 10
 11namespace Services.AiAssistant;
 12
 13public partial class AiOrderAnalysisService
 14{
 15    private readonly OrderRepository _orderRepository;
 16    private readonly ApplicationDbContext _db;
 17    private readonly AiOrderScopeApplier _scopeApplier;
 18    private readonly OrderHistoryPageContextLoader _historyPageLoader;
 19    private readonly IBitrixStockSyncArticleHistoryService _bitrixArticleHistory;
 20    private readonly AiProductContextLoader _productContextLoader;
 21    private readonly AiChatEnrichmentBuilder _enrichmentBuilder;
 22    private readonly AiOzonLiveProductService _ozonLiveProduct;
 23    private readonly AiOrderWriteService _writeService;
 24    private readonly AiOrderSearchAgent _searchAgent;
 25    private readonly AiOrderFilterQueryStudio _filterStudio;
 26    private readonly ILogger<AiOrderAnalysisService> _logger;
 27
 028    private static readonly HashSet<string> EnrichmentTools = new(StringComparer.Ordinal)
 029    {
 030        "order_detail",
 031        "order_history",
 032        "order_product",
 033        "bitrix_stock_article"
 034    };
 35
 36    // Ozon: 63894740-0501-1 или 63894740-0501
 37    [GeneratedRegex(@"\b\d{5,}-\d{3,}(?:-\d+)?\b", RegexOptions.Compiled)]
 38    private static partial Regex ShipmentNumberRegex();
 39
 40    // Номер заказа поставщику: "АД 0008158", "ГЦ 0028134", "ВК-0127368"
 41    // \b не работает с кириллицей — используем lookbehind/lookahead по буквам и цифрам
 42    [GeneratedRegex(@"(?<![А-ЯA-Za-zа-яёЁ\d])[А-ЯA-Z]{1,3}[-\s]\d{4,8}(?![А-ЯA-Za-zа-яёЁ\d])", RegexOptions.Compiled)]
 43    private static partial Regex SupplierOrderNumberRegex();
 44
 45    [GeneratedRegex(@"#(\d{4,})\b", RegexOptions.Compiled)]
 46    private static partial Regex OrderIdHashRegex();
 47
 48    [GeneratedRegex(@"\b(?:заказ|order)\s*#?(\d{4,})\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
 49    private static partial Regex OrderIdWordRegex();
 50
 51    // Голый числовой ID: всё сообщение — только цифры 4-7 знаков
 52    [GeneratedRegex(@"^\s*(\d{4,7})\s*$", RegexOptions.Compiled)]
 53    private static partial Regex BareOrderIdRegex();
 54
 055    private static readonly (string[] Keywords, string ToolName)[] IntentMap =
 056    [
 057        (["просроч", "опоздал", "overdue", "deadline"],         "overdue_orders"),
 058        (["невыгод", "убыток", "убыточ", "unprofitabl"],        "unprofitable_orders"),
 059        (["без поставщик", "no supplier", "нет поставщик"],     "no_supplier_orders"),
 060        (["итого", "сводк", "summary"],                         "orders_summary"),
 061        (["сколько", "всего"],                                   "orders_summary"),
 062    ];
 63
 064    public AiOrderAnalysisService(
 065        OrderRepository orderRepository,
 066        ApplicationDbContext db,
 067        AiOrderScopeApplier scopeApplier,
 068        OrderHistoryPageContextLoader historyPageLoader,
 069        IBitrixStockSyncArticleHistoryService bitrixArticleHistory,
 070        AiProductContextLoader productContextLoader,
 071        AiChatEnrichmentBuilder enrichmentBuilder,
 072        AiOzonLiveProductService ozonLiveProduct,
 073        AiOrderWriteService writeService,
 074        AiOrderSearchAgent searchAgent,
 075        AiOrderFilterQueryStudio filterStudio,
 076        ILogger<AiOrderAnalysisService> logger)
 77    {
 078        _orderRepository       = orderRepository;
 079        _db                    = db;
 080        _scopeApplier          = scopeApplier;
 081        _historyPageLoader     = historyPageLoader;
 082        _bitrixArticleHistory  = bitrixArticleHistory;
 083        _productContextLoader  = productContextLoader;
 084        _enrichmentBuilder     = enrichmentBuilder;
 085        _ozonLiveProduct       = ozonLiveProduct;
 086        _writeService          = writeService;
 087        _searchAgent           = searchAgent;
 088        _filterStudio          = filterStudio;
 089        _logger                = logger;
 090    }
 91
 92    public AiQueryIntent? DetectIntent(string userMessage, AiChatConversationContext? context = null)
 93    {
 094        var trimmed = userMessage.Trim();
 095        var lower   = trimmed.ToLowerInvariant();
 96
 97        // ── Write-интенты (проверяем в первую очередь) ────────────────────────
 98
 99        // Подхват pending-транзакции: короткое уточнение полей («цена 150»), не новая команда «проведи…».
 0100        if (context?.PendingTransactionType != null
 0101            && !AiOrderFilterParser.IsReadOnlyOrderSearchIntent(lower)
 0102            && !IsFullTransactionCommand(lower)
 0103            && IsPendingTransactionClarification(lower))
 104        {
 0105            var txShipmentMatch  = ShipmentNumberRegex().Match(trimmed);
 0106            var txSupplierMatch  = SupplierOrderNumberRegex().Match(trimmed);
 0107            var txOrderId        = TryParseOrderId(trimmed);
 108            // Приоритет: Ozon-номер > номер заказа поставщику > контекст
 0109            var resolvedShipment = txShipmentMatch.Success  ? txShipmentMatch.Value
 0110                                 : txSupplierMatch.Success  ? txSupplierMatch.Value
 0111                                 : context.ShipmentNumber;
 0112            return new AiQueryIntent
 0113            {
 0114                ToolName             = "write_transaction",
 0115                WriteKind            = "create_transaction",
 0116                TransactionTypeValue = context.PendingTransactionType,
 0117                ShipmentNumber       = resolvedShipment,
 0118                OrderId              = txOrderId ?? context.OrderId,
 0119            };
 120        }
 121
 0122        if (AiWriteIntentParser.IsTransactionIntent(lower))
 123        {
 0124            var transType = AiWriteIntentParser.TryParseTransactionType(lower);
 0125            var resolvedType = transType.HasValue ? (int)transType.Value : context?.PendingTransactionType;
 126            // Ищем все идентификаторы заказов: Ozon-формат + номера заказов поставщику
 0127            var allShipments = ShipmentNumberRegex().Matches(trimmed)
 0128                .Select(m => m.Value)
 0129                .Union(SupplierOrderNumberRegex().Matches(trimmed).Select(m => m.Value))
 0130                .Distinct().ToList();
 0131            var txOrderId = TryParseOrderId(trimmed);
 0132            return new AiQueryIntent
 0133            {
 0134                ToolName             = "write_transaction",
 0135                WriteKind            = "create_transaction",
 0136                TransactionTypeValue = resolvedType,
 0137                ShipmentNumber       = allShipments.Count > 0 ? allShipments[0] : context?.ShipmentNumber,
 0138                ShipmentNumbers      = allShipments.Count > 1 ? allShipments : null,
 0139                OrderId              = allShipments.Count == 0 ? (txOrderId ?? context?.OrderId) : null,
 0140            };
 141        }
 142
 0143        if (AiWriteIntentParser.IsEditOrderIntent(lower))
 144        {
 0145            var editShipmentMatch = ShipmentNumberRegex().Match(trimmed);
 0146            var editOrderId = TryParseOrderId(trimmed);
 0147            return new AiQueryIntent
 0148            {
 0149                ToolName       = "write_edit",
 0150                WriteKind      = "edit_order",
 0151                ShipmentNumber = editShipmentMatch.Success ? editShipmentMatch.Value : context?.ShipmentNumber,
 0152                OrderId        = editOrderId ?? context?.OrderId,
 0153            };
 154        }
 155
 0156        if (IsTransactionTypesIntent(lower))
 0157            return new AiQueryIntent { ToolName = "transaction_types_info" };
 158
 0159        if (IsUserActionsTodayIntent(lower))
 0160            return new AiQueryIntent { ToolName = "user_actions_today" };
 161
 0162        if (IsCancelledTodayIntent(lower))
 0163            return new AiQueryIntent { ToolName = "cancelled_orders_today" };
 164
 0165        if (IsOrdersTodayIntent(lower))
 0166            return new AiQueryIntent { ToolName = "orders_today" };
 167
 0168        var productKey = AiArticleParser.TryParseProductKey(trimmed);
 0169        if (productKey != null)
 170        {
 171            // Если в сообщении явный запрос live-данных Ozon — используем ozon_live_product
 0172            var toolName = IsOzonLiveProductIntent(lower) ? "ozon_live_product" : "bitrix_stock_article";
 0173            return new AiQueryIntent
 0174            {
 0175                ToolName           = toolName,
 0176                Article            = productKey.Value.Article,
 0177                ManufacturerPrefix = productKey.Value.ManufacturerPrefix,
 0178                OrderId            = context?.OrderId,
 0179                ShipmentNumber     = context?.ShipmentNumber,
 0180            };
 181        }
 182
 0183        if (IsContextualProductInfoIntent(lower) && context?.OrderId is > 0)
 184        {
 0185            return new AiQueryIntent
 0186            {
 0187                ToolName = "order_product",
 0188                OrderId  = context.OrderId.Value
 0189            };
 190        }
 191
 0192        if (IsContextualProductReference(lower) && !string.IsNullOrWhiteSpace(context?.Article))
 193        {
 0194            return new AiQueryIntent
 0195            {
 0196                ToolName           = "bitrix_stock_article",
 0197                Article            = context!.Article,
 0198                ManufacturerPrefix = context.ManufacturerPrefix,
 0199                OrderId            = context.OrderId
 0200            };
 201        }
 202
 0203        if (IsOzonLiveProductIntent(lower) &&
 0204            TryResolveBitrixArticle(trimmed, context, out var ozArticle, out var ozMfr))
 205        {
 0206            return new AiQueryIntent
 0207            {
 0208                ToolName           = "ozon_live_product",
 0209                Article            = ozArticle,
 0210                ManufacturerPrefix = ozMfr,
 0211                OrderId            = context?.OrderId
 0212            };
 213        }
 214
 0215        if (IsBitrixStockIntent(lower) && TryResolveBitrixArticle(trimmed, context, out var bxArticle, out var bxMfr))
 216        {
 0217            return new AiQueryIntent
 0218            {
 0219                ToolName           = "bitrix_stock_article",
 0220                Article            = bxArticle,
 0221                ManufacturerPrefix = bxMfr
 0222            };
 223        }
 224
 0225        var wantsHistory    = IsOrderHistoryIntent(lower);
 0226        var shipmentMatch   = ShipmentNumberRegex().Match(trimmed);
 0227        var orderId         = TryParseOrderId(trimmed);
 0228        var contextualOrder = IsContextualOrderReference(lower);
 229
 0230        if (contextualOrder && TryBuildIntentFromContext(context, lower, wantsHistory, out var ctxIntent))
 0231            return ctxIntent;
 232
 0233        if (wantsHistory)
 234        {
 0235            if (shipmentMatch.Success)
 236            {
 0237                return new AiQueryIntent
 0238                {
 0239                    ToolName       = "order_history",
 0240                    ShipmentNumber = shipmentMatch.Value
 0241                };
 242            }
 243
 0244            if (orderId.HasValue)
 245            {
 0246                return new AiQueryIntent
 0247                {
 0248                    ToolName = "order_history",
 0249                    OrderId  = orderId.Value
 0250                };
 251            }
 252
 0253            if (TryBuildIntentFromContext(context, lower, wantsHistory: true, out var historyFromCtx))
 0254                return historyFromCtx;
 255
 0256            return new AiQueryIntent { ToolName = "order_history_recent" };
 257        }
 258
 0259        if (shipmentMatch.Success)
 260        {
 0261            return new AiQueryIntent
 0262            {
 0263                ToolName       = "order_detail",
 0264                ShipmentNumber = shipmentMatch.Value
 0265            };
 266        }
 267
 0268        if (orderId.HasValue)
 269        {
 0270            return new AiQueryIntent
 0271            {
 0272                ToolName = "order_detail",
 0273                OrderId  = orderId.Value
 0274            };
 275        }
 276
 0277        foreach (var (keywords, toolName) in IntentMap)
 278        {
 0279            if (keywords.Any(k => lower.Contains(k)))
 280            {
 0281                if (toolName == "orders_summary" || lower.Contains("заказ") || lower.Contains("order"))
 0282                    return new AiQueryIntent { ToolName = toolName };
 283            }
 284        }
 285
 0286        return null;
 287    }
 288
 289    /// <summary>
 290    /// Асинхронное распознавание: sync-ветки; orders_filter → студия запроса в RunQueryAsync.
 291    /// </summary>
 292    public Task<AiQueryIntent?> DetectIntentAsync(
 293        string userMessage,
 294        AiChatConversationContext? context,
 295        CancellationToken ct = default)
 296    {
 0297        var lower = userMessage.Trim().ToLowerInvariant();
 0298        var sync  = DetectIntent(userMessage, context);
 299
 300        // Поисковый запрос ошибочно попал в write_transaction / write_edit → orders_filter
 0301        if (sync?.ToolName is "write_transaction" or "write_edit"
 0302            && AiOrderFilterParser.IsReadOnlyOrderSearchIntent(lower))
 303        {
 0304            return Task.FromResult<AiQueryIntent?>(new AiQueryIntent
 0305            {
 0306                ToolName             = "orders_filter",
 0307                RequiresFilterStudio = true,
 0308            });
 309        }
 310
 311        // Составной запрос («убыточные Emex», «просроченные у поставщика X») → orders_filter, не preset-tool
 0312        if (sync != null && IsPresetOrdersTool(sync.ToolName)
 0313            && LooksLikeCompoundFilterQuery(lower)
 0314            && !IsBarePresetQuery(lower, sync.ToolName))
 315        {
 0316            return Task.FromResult<AiQueryIntent?>(new AiQueryIntent
 0317            {
 0318                ToolName             = "orders_filter",
 0319                RequiresFilterStudio = true,
 0320            });
 321        }
 322
 0323        if (sync != null)
 0324            return Task.FromResult<AiQueryIntent?>(sync);
 325
 0326        if (!lower.Contains("заказ") && !lower.Contains("order"))
 0327            return Task.FromResult<AiQueryIntent?>(null);
 328
 0329        if (!AiOrderFilterParser.LooksLikeOrdersFilterQuery(userMessage))
 0330            return Task.FromResult<AiQueryIntent?>(null);
 331
 0332        return Task.FromResult<AiQueryIntent?>(new AiQueryIntent
 0333        {
 0334            ToolName             = "orders_filter",
 0335            RequiresFilterStudio = true,
 0336        });
 337    }
 338
 0339    private static bool IsPresetOrdersTool(string? toolName) => toolName is
 0340        "unprofitable_orders" or "overdue_orders" or "no_supplier_orders" or "orders_summary";
 341
 342    private static bool LooksLikeCompoundFilterQuery(string lower) =>
 0343        lower.Contains("поставщик") || lower.Contains("supplier")
 0344        || lower.Contains("emex") || lower.Contains("эмекс")
 0345        || lower.Contains("найди") || lower.Contains("покажи") || lower.Contains("поиск")
 0346        || lower.Contains("фильтр") || lower.Contains("статус")
 0347        || Regex.IsMatch(lower, @"\b(за|от)\s+\d");
 348
 349    public static AiChatConversationContext UpdateContext(
 350        AiChatConversationContext? previous,
 351        AiQueryIntent? intent,
 352        IReadOnlyList<OrderSummaryDto> orders,
 353        Services.ApiServices.OpenRouterApi.Models.AiActionProposalDto? proposal = null)
 354    {
 0355        var ctx = previous ?? new AiChatConversationContext();
 356
 0357        if (intent != null)
 0358            ctx.LastToolName = intent.ToolName;
 359
 0360        var distinct = DeduplicateSummaries(orders);
 0361        if (distinct.Count == 1)
 362        {
 0363            ctx.OrderId        = distinct[0].Id;
 0364            ctx.ShipmentNumber = distinct[0].ShipmentNumber;
 0365            if (!string.IsNullOrWhiteSpace(distinct[0].Article))
 0366                ctx.Article = distinct[0].Article;
 367        }
 0368        else if (intent?.OrderId is > 0)
 369        {
 0370            ctx.OrderId = intent.OrderId;
 371        }
 0372        else if (!string.IsNullOrWhiteSpace(intent?.ShipmentNumber))
 373        {
 0374            ctx.ShipmentNumber = intent.ShipmentNumber;
 0375            if (distinct.Count > 0)
 0376                ctx.OrderId = distinct[0].Id;
 377        }
 378
 0379        if (!string.IsNullOrWhiteSpace(intent?.Article))
 0380            ctx.Article = intent.Article;
 0381        if (!string.IsNullOrWhiteSpace(intent?.ManufacturerPrefix))
 0382            ctx.ManufacturerPrefix = intent.ManufacturerPrefix;
 383
 384        // Запоминаем тип транзакции если proposal с незаполненными полями — чтобы следующее
 385        // сообщение пользователя ("цена 100") могло подхватить контекст
 0386        if (proposal?.Kind == "create_transaction" && proposal.TransactionDto != null &&
 0387            proposal.MissingFields is { Count: > 0 })
 388        {
 0389            ctx.PendingTransactionType = proposal.TransactionDto.TransactionTypeValue;
 390        }
 0391        else if (proposal?.Kind == "create_transaction" && proposal.MissingFields is null or { Count: 0 })
 392        {
 393            // Proposal показан без missing — сбрасываем pending (транзакция будет подтверждена)
 0394            ctx.PendingTransactionType = null;
 395        }
 0396        else if (proposal == null && intent?.WriteKind == "create_transaction" && intent.TransactionTypeValue.HasValue)
 397        {
 398            // Заказ не найден, но тип транзакции известен — сохраняем для следующего уточнения
 0399            ctx.PendingTransactionType = intent.TransactionTypeValue.Value;
 400        }
 401
 0402        return ctx;
 403    }
 404
 405    public static List<OrderSummaryDto> DeduplicateSummaries(IEnumerable<OrderSummaryDto> orders) =>
 86406        orders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 407
 408    private static List<Order> DedupeById(List<Order> orders) =>
 0409        orders.GroupBy(o => o.Id).Select(g => g.First()).ToList();
 410
 411    private static bool TryBuildIntentFromContext(
 412        AiChatConversationContext? context,
 413        string lower,
 414        bool wantsHistory,
 415        out AiQueryIntent intent)
 416    {
 0417        intent = null!;
 0418        if (context?.OrderId is not > 0 && string.IsNullOrWhiteSpace(context?.ShipmentNumber))
 0419            return false;
 420
 0421        var tool = wantsHistory || lower.Contains("истори") ? "order_history" : "order_detail";
 422
 0423        intent = new AiQueryIntent
 0424        {
 0425            ToolName       = tool,
 0426            OrderId        = context!.OrderId,
 0427            ShipmentNumber = context.ShipmentNumber
 0428        };
 0429        return true;
 430    }
 431
 432    private static bool IsContextualOrderReference(string lower) =>
 0433        lower.Contains("этого заказа") || lower.Contains("этом заказе") ||
 0434        lower.Contains("этот заказ") || lower.Contains("эту заказ") ||
 0435        lower.Contains("этому заказу") || lower.Contains("по нему") ||
 0436        lower.Contains("про него") || lower.Contains("его истор") ||
 0437        (lower.Contains("этого") && (lower.Contains("истори") || lower.Contains("заказ"))) ||
 0438        (lower.Contains("этом") && lower.Contains("заказ")) ||
 0439        lower.Contains("того же заказа") || lower.Contains("тот же заказ") ||
 0440        lower.Contains("что скажешь") || lower.Contains("что там с") ||
 0441        lower.Contains("расскажи про") || lower.Contains("расскажи об") ||
 0442        lower.Contains("покажи заказ") || lower.Contains("открой заказ") ||
 0443        lower.Contains("посмотри заказ") || lower.Contains("давай по заказу") ||
 0444        lower.Contains("инфу по") || lower.Contains("инфо по") ||
 0445        lower.Contains("информац") && lower.Contains(" по ") ||
 0446        lower.Contains("данные по") || lower.Contains("данных по") ||
 0447        lower.Contains("выведи инф") || lower.Contains("выведи дан") ||
 0448        lower.Contains("анализ заказа") || lower.Contains("разбери заказ") ||
 0449        lower.Contains("что с заказом") || lower.Contains("что с этим заказом");
 450
 451    private static bool IsContextualProductInfoIntent(string lower) =>
 0452        lower.Contains("что за продукт") || lower.Contains("какой товар") ||
 0453        lower.Contains("что за товар") || lower.Contains("что в заказе") ||
 0454        lower.Contains("про продукт") || lower.Contains("про товар") ||
 0455        lower.Contains("какой продукт") || lower.Contains("что за продукте");
 456
 457    private static bool IsContextualProductReference(string lower) =>
 0458        lower.Contains("этого товара") || lower.Contains("этом товаре") ||
 0459        lower.Contains("этот товар") || lower.Contains("по товару") ||
 0460        lower.Contains("про товар") || lower.Contains("этого артикул") ||
 0461        (lower.Contains("этого") && (lower.Contains("остат") || lower.Contains("синхронизац") || lower.Contains("цен")))
 462
 463    private const int AiArticleHistoryMaxReports = 20;
 464
 465    private async Task<BitrixStockArticleHistoryPageDto> LoadBitrixHistoryForAiAsync(
 466        string article,
 467        CancellationToken ct) =>
 0468        await _bitrixArticleHistory.GetAllAsync(article, maxReports: AiArticleHistoryMaxReports, ct);
 469
 470    private static bool IsFullTransactionCommand(string lower) =>
 0471        lower.Contains("проведи") || lower.Contains("проведите") || lower.Contains("провести") ||
 0472        lower.Contains("создай транзакц") || lower.Contains("создать транзакц");
 473
 474    private static bool IsPendingTransactionClarification(string lower) =>
 0475        lower.Contains("давай") || lower.Contains("проводи") || lower.Contains("цена") ||
 0476        lower.Contains("ок") || lower.Contains("хорошо")
 0477        || lower.Trim() is "да" or "да." or "да!"
 0478        || (lower.Contains("поставщик") && !lower.Contains("заказан поставщик"))
 0479        || (lower.Contains("склад") && !lower.Contains("на склад") && !lower.Contains("принят на склад"));
 480
 481    private static bool IsOzonLiveProductIntent(string lower) =>
 0482        ((lower.Contains("актуальн") || lower.Contains("сейчас") || lower.Contains("live")) &&
 0483         (lower.Contains("ozon") || lower.Contains("озон") || lower.Contains("остатк") || lower.Contains("цен"))) ||
 0484        lower.Contains("проверить на ozon") || lower.Contains("проверить на озон") ||
 0485        lower.Contains("остатки на ozon") || lower.Contains("данные с ozon") ||
 0486        lower.Contains("ozon seller api") || lower.Contains("запрос к ozon") ||
 0487        lower.Contains("запрос к озон");
 488
 489    private static bool IsBitrixStockIntent(string lower) =>
 0490        lower.Contains("синхронизац") || lower.Contains("bitrix") ||
 0491        lower.Contains("bitrixstock") || lower.Contains("накат") ||
 0492        lower.Contains("остатк") || lower.Contains("наличи") ||
 0493        lower.Contains("product key") || lower.Contains("этап ") ||
 0494        (lower.Contains("почему") && (lower.Contains("цен") || lower.Contains("количеств") || lower.Contains("остат"))) 
 0495        (lower.Contains("товар") && (lower.Contains("цен") || lower.Contains("склад") ||
 0496         lower.Contains("ozon") || lower.Contains("озон") || lower.Contains("остат") ||
 0497         lower.Contains("количеств") || lower.Contains("парт")));
 498
 499    private static bool TryResolveBitrixArticle(
 500        string trimmed,
 501        AiChatConversationContext? context,
 502        out string article,
 503        out string? manufacturerPrefix)
 504    {
 0505        article = context?.Article?.Trim()
 0506                  ?? AiArticleParser.TryParseBareArticle(trimmed)
 0507                  ?? AiArticleParser.TryExtractBareArticleToken(trimmed)
 0508                  ?? "";
 0509        manufacturerPrefix = context?.ManufacturerPrefix;
 0510        if (!string.IsNullOrWhiteSpace(article))
 0511            return true;
 512
 0513        var pk = AiArticleParser.TryParseProductKey(trimmed);
 0514        if (pk == null)
 0515            return false;
 516
 0517        article = pk.Value.Article;
 0518        manufacturerPrefix ??= pk.Value.ManufacturerPrefix;
 0519        return true;
 520    }
 521
 522    private static int? TryParseOrderId(string text)
 523    {
 0524        var hash = OrderIdHashRegex().Match(text);
 0525        if (hash.Success && int.TryParse(hash.Groups[1].Value, out var id1))
 0526            return id1;
 527
 0528        var word = OrderIdWordRegex().Match(text);
 0529        if (word.Success && int.TryParse(word.Groups[1].Value, out var id2))
 0530            return id2;
 531
 532        // Голый числовой ID — если всё сообщение состоит только из числа 4-7 знаков
 0533        var bare = BareOrderIdRegex().Match(text);
 0534        if (bare.Success && int.TryParse(bare.Groups[1].Value, out var id3))
 0535            return id3;
 536
 0537        return null;
 538    }
 539
 540    private static bool IsOrderHistoryIntent(string lower) =>
 0541        lower.Contains("истори") || lower.Contains("изменен") || lower.Contains("изменён") ||
 0542        lower.Contains("кто менял") || lower.Contains("кто измен") ||
 0543        lower.Contains("лог измен") || lower.Contains("журнал") ||
 0544        lower.Contains("timeline") || lower.Contains("audit");
 545
 546    private static bool IsUserActionsTodayIntent(string lower) =>
 0547        (lower.Contains("сегодня") || lower.Contains("today")) &&
 0548        (lower.Contains("действ") || lower.Contains("пользовател") ||
 0549         lower.Contains("кто что") || lower.Contains("кто сделал") ||
 0550         lower.Contains("активност") || lower.Contains("менял") ||
 0551         lower.Contains("изменен") || lower.Contains("изменён"));
 552
 553    private static bool IsTransactionTypesIntent(string lower) =>
 0554        lower.Contains("транзакц") &&
 0555        (lower.Contains("вид") || lower.Contains("тип") || lower.Contains("что делает") ||
 0556         lower.Contains("что они делают") || lower.Contains("какие бывают") ||
 0557         lower.Contains("описан") || lower.Contains("справк") || lower.Contains("объясни") ||
 0558         lower.Contains("значит") || lower.Contains("расскаж"));
 559
 560    /// <summary>«Покажи просроченные» без доп. фильтров — оставляем preset-tool.</summary>
 0561    private static bool IsBarePresetQuery(string lower, string toolName) => toolName switch
 0562    {
 0563        "overdue_orders" => (lower.Contains("просроч") || lower.Contains("overdue"))
 0564                            && !lower.Contains("поставщик") && !lower.Contains("склад")
 0565                            && !lower.Contains(" emex") && !lower.Contains("эмекс"),
 0566        "unprofitable_orders" => (lower.Contains("убыточ") || lower.Contains("невыгод"))
 0567                                 && !lower.Contains("поставщик"),
 0568        "no_supplier_orders" => lower.Contains("без поставщик") || lower.Contains("нет поставщик"),
 0569        "orders_summary" => lower.Contains("сводк") || lower.Contains("итого"),
 0570        _ => false
 0571    };
 572
 573    private static bool IsCancelledTodayIntent(string lower) =>
 0574        (lower.Contains("отмен") || lower.Contains("cancel")) &&
 0575        (lower.Contains("сегодня") || lower.Contains("today"));
 576
 577    private static bool IsOrdersTodayIntent(string lower) =>
 0578        (lower.Contains("сегодня") || lower.Contains("today")) &&
 0579        !lower.Contains("отмен") && !lower.Contains("cancel") &&
 0580        (lower.Contains("заказ") || lower.Contains("order") ||
 0581         lower.Contains("найди") || lower.Contains("покаж") ||
 0582         lower.Contains("выгруз") || lower.Contains("список") ||
 0583         lower.Contains("новые") || lower.Contains("поступ"));
 584
 585    public async Task<AiQueryResult> RunQueryAsync(
 586        AiQueryIntent intent,
 587        string? userId,
 588        CancellationToken ct = default,
 589        string? originalMessage = null,
 590        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 591    {
 0592        _logger.LogInformation("Running AI query tool: {Tool}, shipment={Shipment}",
 0593            intent.ToolName, intent.ShipmentNumber);
 594
 595        // Используем originalMessage для write-операций (для извлечения значений полей)
 0596        var userMessage = originalMessage ?? string.Empty;
 597
 598        string context;
 599        List<OrderSummaryDto> orders;
 600        string? sqlQuery;
 0601        AiOzonLiveUiDto? ozonLiveUi = null;
 602
 603        // ── Write-интенты: строим proposal, не читаем из БД ──────────────────────
 0604        if (intent.WriteKind == "edit_order")
 605        {
 0606            var orderId = await ResolveOrderIdAsync(intent, userId, ct);
 0607            if (orderId == null)
 608            {
 0609                context = "Не удалось найти заказ. Укажите номер отправления или #ID заказа.";
 0610                orders  = [];
 611            }
 612            else
 613            {
 0614                var proposal = await _writeService.BuildEditProposalAsync(orderId.Value, userMessage, userId, ct);
 0615                if (proposal != null)
 616                {
 0617                    orders  = await GetSummariesByIdsAsync([orderId.Value], userId, ct);
 0618                    context = proposal.MissingFields?.Count > 0
 0619                        ? $"Для изменения заказа #{orderId} нужно уточнить: {string.Join(", ", proposal.MissingFields)}.
 0620                        : $"Готово к выполнению: изменение полей заказа #{orderId} ({orders.FirstOrDefault()?.ShipmentNu
 0621                    return new AiQueryResult { Context = context, Orders = orders, ActionProposal = proposal };
 622                }
 0623                context = "Не удалось распознать, какие поля нужно изменить.";
 0624                orders  = [];
 0625            }
 0626            return new AiQueryResult { Context = context, Orders = orders };
 627        }
 628
 0629        if (intent.WriteKind == "create_transaction")
 630        {
 0631            if (intent.TransactionTypeValue == null)
 632            {
 0633                context = "Тип транзакции не распознан. Уточните, какую транзакцию провести. " +
 0634                          "Например: «заказан поставщику», «принят на склад», «к отмене».";
 0635                return new AiQueryResult { Context = context, Orders = [] };
 636            }
 637
 0638            var transType = (OzonDomains.TransactionType)intent.TransactionTypeValue.Value;
 639
 640            // ── Batch-режим: несколько отправлений ─────────────────────────────
 0641            if (intent.ShipmentNumbers is { Count: > 1 })
 642            {
 0643                var proposals     = new List<AiActionProposalDto>();
 0644                var allOrderIds   = new List<int>();
 0645                var ctxParts      = new List<string>();
 646
 0647                foreach (var shipment in intent.ShipmentNumbers)
 648                {
 0649                    var singleIntent = new AiQueryIntent
 0650                    {
 0651                        ToolName       = intent.ToolName,
 0652                        WriteKind      = intent.WriteKind,
 0653                        ShipmentNumber = shipment,
 0654                        TransactionTypeValue = intent.TransactionTypeValue
 0655                    };
 0656                    var oid = await ResolveOrderIdAsync(singleIntent, userId, ct);
 0657                    if (oid == null)
 658                    {
 0659                        ctxParts.Add($"Заказ «{shipment}» не найден.");
 0660                        continue;
 661                    }
 662
 0663                    var p = await _writeService.BuildTransactionProposalAsync(oid.Value, transType, userMessage, userId,
 0664                    if (p != null)
 665                    {
 0666                        proposals.Add(p);
 0667                        allOrderIds.Add(oid.Value);
 0668                        ctxParts.Add(p.MissingFields?.Count > 0
 0669                            ? AiWriteContextFormatter.FormatTransactionProposal(p, null)
 0670                            : $"Готово: «{transType.GetDisplayName()}» для «{shipment}».");
 671                    }
 672                    else
 673                    {
 0674                        ctxParts.Add($"Заказ «{shipment}» недоступен.");
 675                    }
 0676                }
 677
 0678                orders  = allOrderIds.Count > 0 ? await GetSummariesByIdsAsync(allOrderIds, userId, ct) : [];
 0679                context = string.Join(" ", ctxParts);
 0680                return new AiQueryResult
 0681                {
 0682                    Context         = context,
 0683                    Orders          = orders,
 0684                    ActionProposal  = proposals.Count == 1 ? proposals[0] : null,
 0685                    ActionProposals = proposals.Count > 1  ? proposals     : null,
 0686                };
 687            }
 688
 689            // ── Одиночный заказ ─────────────────────────────────────────────────
 0690            var orderId = await ResolveOrderIdAsync(intent, userId, ct);
 0691            if (orderId == null)
 692            {
 0693                context = "Не удалось найти заказ. Укажите номер отправления или #ID заказа.";
 0694                return new AiQueryResult { Context = context, Orders = [] };
 695            }
 696
 0697            var proposal  = await _writeService.BuildTransactionProposalAsync(orderId.Value, transType, userMessage, use
 0698            if (proposal != null)
 699            {
 0700                orders  = await GetSummariesByIdsAsync([orderId.Value], userId, ct);
 0701                var summary = orders.FirstOrDefault();
 0702                context = proposal.MissingFields?.Count > 0
 0703                    ? AiWriteContextFormatter.FormatTransactionProposal(proposal, summary)
 0704                    : $"Готово: транзакция «{transType.GetDisplayName()}» для заказа #{orderId} ({summary?.ShipmentNumbe
 0705                      AiWriteContextFormatter.FormatTransactionProposal(proposal, summary);
 0706                return new AiQueryResult { Context = context, Orders = orders, ActionProposal = proposal };
 707            }
 0708            context = "Не удалось построить транзакцию. Заказ недоступен или не найден.";
 0709            return new AiQueryResult { Context = context, Orders = [] };
 710        }
 711
 0712        if (intent.ToolName == "ozon_live_product")
 713        {
 0714            (context, orders, sqlQuery, ozonLiveUi) =
 0715                await GetOzonLiveProductAsync(intent, userId, ct, onProgress);
 716        }
 0717        else if (intent.ToolName == "order_detail")
 718        {
 0719            var (detailCtx, detailOrders, detailSql, detailOzonLive) =
 0720                await GetOrderDetailAsync(intent, userId, ct, onProgress);
 0721            context    = detailCtx;
 0722            orders     = detailOrders;
 0723            sqlQuery   = detailSql;
 0724            ozonLiveUi = detailOzonLive;
 725        }
 0726        else if (intent.ToolName == "orders_filter")
 727        {
 0728            var criteria = intent.Filter;
 0729            string? filterSpec = null;
 730
 0731            if (criteria == null || intent.RequiresFilterStudio)
 732            {
 0733                var nowMsk = TimeZoneInfo.ConvertTimeFromUtc(
 0734                    DateTime.UtcNow,
 0735                    TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 736
 0737                var studio = await _filterStudio.AnalyzeAndBuildAsync(
 0738                    userMessage ?? string.Empty,
 0739                    nowMsk,
 0740                    onProgress,
 0741                    ct);
 742
 0743                criteria   = studio.Criteria;
 0744                filterSpec = studio.SpecificationSummary;
 745            }
 746
 0747            if (criteria == null || !criteria.HasCriteria)
 748            {
 0749                return new AiQueryResult
 0750                {
 0751                    Context        = "Не удалось разобрать критерии поиска заказов.",
 0752                    IsSearchResult = true,
 0753                };
 754            }
 755
 0756            var searchResult = await _searchAgent.SearchAsync(
 0757                criteria, userId, ct, onProgress: onProgress);
 0758            orders   = DeduplicateSummaries(searchResult.Orders);
 0759            sqlQuery = searchResult.Sql;
 0760            context  = orders.Count == 0
 0761                ? BuildZeroResultsContext(searchResult)
 0762                : $"Поиск заказов: {searchResult.AppliedFiltersDescription}.\n" +
 0763                  (filterSpec != null ? $"Спецификация студии: {filterSpec}.\n" : string.Empty) +
 0764                  $"Всего найдено {searchResult.TotalCount} шт., показано {orders.Count}:\n" +
 0765                  FormatOrderList(orders);
 766
 0767            var enrichment2 = await BuildEnrichmentIfNeededAsync(intent.ToolName, intent, orders, ct);
 0768            return new AiQueryResult
 0769            {
 0770                Context                  = context,
 0771                Orders                   = orders,
 0772                Enrichment               = enrichment2,
 0773                SqlQuery                 = sqlQuery,
 0774                IsSearchResult           = true,
 0775                AppliedFiltersDescription = searchResult.AppliedFiltersDescription,
 0776                TotalFound               = searchResult.TotalCount,
 0777                AppliedCriteria          = criteria,
 0778                FilterSpecification      = filterSpec,
 0779                RelaxedSuggestion        = searchResult.RelaxedSuggestion,
 0780                RelaxedCount             = searchResult.RelaxedCount > 0 ? searchResult.RelaxedCount : null,
 0781                SuggestionQuery          = searchResult.SuggestionQuery,
 0782            };
 783        }
 784        else
 785        {
 0786            (context, orders, sqlQuery) = intent.ToolName switch
 0787            {
 0788                "overdue_orders"         => await GetOverdueOrdersAsync(userId, ct),
 0789                "unprofitable_orders"    => await GetUnprofitableOrdersAsync(userId, ct),
 0790                "cancelled_orders_today" => await GetCancelledOrdersTodayAsync(userId, ct),
 0791                "orders_today"           => await GetOrdersTodayAsync(userId, ct),
 0792                "orders_summary"         => await GetOrdersSummaryAsync(userId, ct),
 0793                "no_supplier_orders"     => await GetNoSupplierOrdersAsync(userId, ct),
 0794                "order_history"          => await GetOrderHistoryAsync(intent, userId, ct),
 0795                "order_history_recent"   => await GetOrderHistoryRecentAsync(userId, ct),
 0796                "user_actions_today"     => await GetUserActionsTodayAsync(userId, ct),
 0797                "transaction_types_info" => (AiTransactionTypeGlossary.FormatForPrompt(), [], null),
 0798                "bitrix_stock_article"   => await GetBitrixStockArticleHistoryAsync(intent, userId, ct, onProgress),
 0799                "order_product"          => await GetOrderProductAsync(intent, userId, ct),
 0800                _                        => ("Запрос не распознан.", [], null)
 0801            };
 802        }
 803
 804        // Дедупликация до отправки в UI и в AI-контекст — чтобы агент
 805        // называл то же число, что видит пользователь в таблице.
 0806        var rawCount = orders.Count;
 0807        orders = DeduplicateSummaries(orders);
 0808        if (orders.Count != rawCount && orders.Count > 0)
 0809            context += $"\n\nПримечание: из {rawCount} строк БД показано {orders.Count} уникальных заказов (дедупликация
 810
 0811        var enrichment = await BuildEnrichmentIfNeededAsync(intent.ToolName, intent, orders, ct);
 0812        if (enrichment != null && ozonLiveUi != null)
 0813            enrichment.OzonLive = ozonLiveUi;
 0814        else if (enrichment == null && ozonLiveUi != null)
 0815            enrichment = new AiChatEnrichmentDto { OzonLive = ozonLiveUi };
 816
 0817        return new AiQueryResult
 0818        {
 0819            Context    = context,
 0820            Orders     = orders,
 0821            Enrichment = enrichment,
 0822            SqlQuery   = sqlQuery,
 0823            OzonLiveUi = ozonLiveUi
 0824        };
 0825    }
 826
 827    private async Task<AiChatEnrichmentDto?> BuildEnrichmentIfNeededAsync(
 828        string? toolName,
 829        AiQueryIntent intent,
 830        List<OrderSummaryDto> orders,
 831        CancellationToken ct)
 832    {
 0833        if (toolName is null || !EnrichmentTools.Contains(toolName))
 0834            return null;
 835
 0836        var article = intent.Article?.Trim();
 0837        var mfr     = intent.ManufacturerPrefix;
 838
 0839        if (string.IsNullOrWhiteSpace(article) && orders.Count > 0)
 840        {
 0841            article = orders[0].Article?.Trim();
 0842            mfr ??= orders[0].ManufacturerName;
 843        }
 844
 0845        if (string.IsNullOrWhiteSpace(article))
 0846            return null;
 847
 0848        string? imageUrl = null;
 0849        string? productName = null;
 0850        if (orders.Count > 0)
 851        {
 0852            imageUrl    = orders[0].ProductImageUrl;
 0853            productName = orders[0].ProductName;
 854        }
 855
 0856        return await _enrichmentBuilder.BuildForArticleAsync(article, mfr, imageUrl, productName, ct);
 0857    }
 858
 859    private async Task<IQueryable<Order>> ScopedOrdersAsync(
 860        string? userId,
 861        bool includeArchived,
 862        CancellationToken ct)
 863    {
 0864        var query = _orderRepository.GetQueryable(includeArchived);
 0865        return await _scopeApplier.ApplyAsync(query, userId, ct);
 0866    }
 867
 868    private Task<IQueryable<Order>> ScopedActiveOrdersAsync(string? userId, CancellationToken ct) =>
 0869        ScopedOrdersAsync(userId, includeArchived: false, ct);
 870
 871    private static IQueryable<Order> WithNavigations(IQueryable<Order> query) =>
 0872        query
 0873            .Include(o => o.AppStatus!).ThenInclude(s => s.StatusColor)
 0874            .Include(o => o.ClientStatus!).ThenInclude(s => s.StatusColor)
 0875            .Include(o => o.Supplier)
 0876            .Include(o => o.ShipmentWarehouse)
 0877            .Include(o => o.OzonClient)
 0878            .Include(o => o.Manufacturer)
 0879            .Include(o => o.EtProducer)
 0880            .Include(o => o.ProductInfo)
 0881            .Include(o => o.Delivery)
 0882            .Include(o => o.Сurrency)
 0883            .Include(o => o.OrderArchive);
 884
 885    private async Task<List<Order>> FindOrdersForIntentAsync(
 886        AiQueryIntent intent,
 887        string? userId,
 888        bool includeArchived,
 889        CancellationToken ct)
 890    {
 0891        if (intent.OrderId.HasValue)
 892        {
 0893            return DedupeById(await WithNavigations(await ScopedOrdersAsync(userId, includeArchived, ct))
 0894                .Where(o => o.Id == intent.OrderId.Value)
 0895                .Take(1)
 0896                .ToListAsync(ct));
 897        }
 898
 0899        if (string.IsNullOrWhiteSpace(intent.ShipmentNumber))
 0900            return [];
 901
 0902        var shipmentNumber = intent.ShipmentNumber.Trim();
 0903        var baseQuery = WithNavigations(await ScopedOrdersAsync(userId, includeArchived, ct));
 904
 0905        var orders = await baseQuery
 0906            .Where(o => o.ShipmentNumber == shipmentNumber)
 0907            .Take(5)
 0908            .ToListAsync(ct);
 909
 0910        orders = DedupeById(orders);
 911
 0912        if (orders.Count == 0)
 913        {
 0914            orders = await baseQuery
 0915                .Where(o => o.ShipmentNumber != null &&
 0916                            o.ShipmentNumber.StartsWith(shipmentNumber + "-"))
 0917                .Take(5)
 0918                .ToListAsync(ct);
 0919            orders = DedupeById(orders);
 920        }
 921
 922        // Fallback: ищем по OrderNumberToSupplier (например "АД 0008158", "ВК-0127368")
 923        // Нормализуем: приводим пробелы и дефисы к единому виду для сравнения
 0924        if (orders.Count == 0)
 925        {
 0926            var normalized  = shipmentNumber.Replace(" ", "-");
 0927            var withSpace   = shipmentNumber.Replace("-", " ");
 0928            orders = await baseQuery
 0929                .Where(o => o.OrderNumberToSupplier != null &&
 0930                            (o.OrderNumberToSupplier == shipmentNumber ||
 0931                             o.OrderNumberToSupplier == normalized     ||
 0932                             o.OrderNumberToSupplier == withSpace))
 0933                .Take(5)
 0934                .ToListAsync(ct);
 0935            orders = DedupeById(orders);
 936        }
 937
 0938        return orders;
 0939    }
 940
 941    private const int OrderListTake = 50;
 942
 943    private static async Task<(List<Order> Rows, string Sql)> MaterializeOrderListAsync(
 944        IQueryable<Order> query,
 945        int take,
 946        CancellationToken ct)
 947    {
 0948        var limited = query.Take(take);
 0949        var sql     = limited.ToQueryString();
 0950        var rows    = await limited.ToListAsync(ct);
 0951        return (rows, sql);
 0952    }
 953
 954    private async Task<(string, List<OrderSummaryDto>, string?)> GetOrdersTodayAsync(string? userId, CancellationToken c
 955    {
 0956        var todayUtc = DateTime.UtcNow.Date;
 0957        var tomorrow = todayUtc.AddDays(1);
 958
 0959        var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 0960            .Where(o => o.ProcessingDate >= todayUtc && o.ProcessingDate < tomorrow)
 0961            .OrderByDescending(o => o.ProcessingDate);
 962
 0963        var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct);
 964
 0965        var mskDate = TimeZoneInfo.ConvertTimeFromUtc(
 0966            DateTime.UtcNow,
 0967            TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 968
 0969        var dtos = orders.Select(MapToSummary).ToList();
 0970        var context = orders.Count == 0
 0971            ? $"Заказов, принятых в обработку сегодня ({mskDate:dd.MM.yyyy}), не найдено."
 0972            : $"Заказы за сегодня ({mskDate:dd.MM.yyyy}, по ProcessingDate, {orders.Count} шт.):\n" +
 0973              FormatOrderList(dtos);
 974
 0975        return (context, dtos, sql);
 0976    }
 977
 978    private async Task<(string, List<OrderSummaryDto>, string?, AiOzonLiveUiDto?)> GetOrderDetailAsync(
 979        AiQueryIntent intent,
 980        string? userId,
 981        CancellationToken ct,
 982        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 983    {
 984        try
 985        {
 0986            return await GetOrderDetailCoreAsync(intent, userId, ct, onProgress);
 987        }
 988        catch (Exception ex)
 989        {
 0990            _logger.LogWarning(ex, "GetOrderDetailAsync failed for shipment={Shipment}", intent.ShipmentNumber);
 0991            var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct);
 0992            if (orders.Count == 0)
 0993                return ($"Ошибка загрузки деталей заказа: {ex.Message}", [], null, null);
 994
 0995            var dtos = DeduplicateSummaries(orders.Select(MapToSummary).ToList());
 0996            var sb   = new System.Text.StringBuilder();
 0997            sb.AppendLine(AiWriteContextFormatter.FormatOrderFactsHeader(orders[0]));
 0998            sb.AppendLine();
 0999            sb.AppendLine("⚠️ Полная загрузка агентов не удалась; показаны базовые данные заказа.");
 01000            return (sb.ToString().TrimEnd(), dtos, null, null);
 1001        }
 01002    }
 1003
 1004    private async Task<(string, List<OrderSummaryDto>, string?, AiOzonLiveUiDto?)> GetOrderDetailCoreAsync(
 1005        AiQueryIntent intent,
 1006        string? userId,
 1007        CancellationToken ct,
 1008        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 1009    {
 01010        if (!intent.OrderId.HasValue && string.IsNullOrWhiteSpace(intent.ShipmentNumber))
 01011            return ("Не указан заказ для анализа.", [], null, null);
 1012
 01013        var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: false, ct);
 01014        var searchedArchive = false;
 1015
 01016        if (orders.Count == 0)
 1017        {
 01018            orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct);
 01019            searchedArchive = orders.Count > 0;
 1020        }
 1021
 01022        if (orders.Count == 0)
 1023        {
 01024            var label = intent.OrderId.HasValue
 01025                ? $"ID {intent.OrderId}"
 01026                : $"«{intent.ShipmentNumber}»";
 01027            return ($"Заказ {label} не найден (включая архив OrderArchive, с учётом прав доступа).", [], null, null);
 1028        }
 1029
 01030        orders = DedupeById(orders);
 1031
 01032        var sb = new System.Text.StringBuilder();
 1033        if (searchedArchive || orders.Any(o => o.OrderArchive != null))
 1034        {
 01035            sb.AppendLine(
 01036                "ℹ️ Заказ находится в архиве (OrderArchive): в главной таблице по умолчанию скрыт. " +
 01037                "Восстановление — /Studio2/OrderArchive.");
 01038            sb.AppendLine();
 1039        }
 1040
 01041        var dtos = orders.Select(MapToSummary).ToList();
 01042        dtos = DeduplicateSummaries(dtos);
 1043
 1044        var orderIds = orders.Select(o => o.Id).ToList();
 01045        var allHistories = await _db.OrderHistories
 01046            .AsNoTracking()
 01047            .Where(h => orderIds.Contains(h.OrderId))
 01048            .OrderBy(h => h.ChangedAt)
 01049            .Take(200)
 01050            .ToListAsync(ct);
 1051
 01052        AiOzonLiveUiDto? ozonLiveUi = null;
 1053
 1054        // Агент-шаги для real-time UI (5 параллельных агентов)
 01055        var agentSteps = new List<AiProcessStepDto>
 01056        {
 01057            new() { Key = "product",      Label = "Товар и WarehouseStock",    IsAgent = true, Status = "pending" },
 01058            new() { Key = "bitrix",       Label = "Синхронизация 1С / Ozon",   IsAgent = true, Status = "pending" },
 01059            new() { Key = "ozon_live",    Label = "Ozon Seller API",            IsAgent = true, Status = "pending" },
 01060            new() { Key = "transactions", Label = "Транзакции",                 IsAgent = true, Status = "pending" },
 01061            new() { Key = "history",      Label = "История изменений",          IsAgent = true, Status = "pending" },
 01062        };
 1063
 1064        // Отправляем агент-шаги клиенту — все pending
 01065        if (onProgress != null)
 01066            await EmitProgressAsync(onProgress, agentSteps, logLine: "⟳ Запуск параллельных агентов…", logIcon: "spin");
 1067
 1068        // ── Параллельный запуск агентов ──────────────────────────────────────────
 01069        foreach (var order in orders)
 1070        {
 01071            var orderHistories = allHistories.Where(h => h.OrderId == order.Id).ToList();
 01072            sb.AppendLine(AiWriteContextFormatter.FormatOrderFactsHeader(order));
 01073            sb.AppendLine();
 01074            sb.AppendLine(AiOrderTimelineFormatter.Format(order, orderHistories));
 01075            sb.AppendLine();
 1076
 01077            var hasArticle = !string.IsNullOrWhiteSpace(order.Article);
 01078            var mfr = order.Manufacturer?.Name ?? order.Manufacturer?.Code ?? order.EtProducer?.Name;
 1079
 1080            // Результаты агентов
 01081            string? productText = null, bitrixText = null, liveText = null;
 01082            string? transactionsText = null, historyText = null;
 01083            AiOzonLiveUiDto? liveUi = null;
 1084
 1085            AiProcessStepDto AgentStep(string key) =>
 01086                agentSteps.First(s => s.Key == key);
 1087
 01088            var productTask = hasArticle
 01089                ? Task.Run(async () =>
 01090                {
 01091                    var sw = System.Diagnostics.Stopwatch.StartNew();
 01092                    var step = AgentStep("product");
 01093                    step.Status = "active";
 01094                    step.Detail = "WarehouseStock…";
 01095                    await EmitProgressAsync(onProgress, agentSteps);
 01096                    try { productText = await _productContextLoader.LoadFormattedAsync(order.Article!, mfr, ct); }
 01097                    catch (Exception ex) { _logger.LogWarning(ex, "Agent product failed"); step.Status = "error"; }
 01098                    sw.Stop(); step.Ms = sw.ElapsedMilliseconds;
 01099                    if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; }
 01100                    await EmitProgressAsync(onProgress, agentSteps,
 01101                        logLine: step.Status == "error" ? "✗ Товар и WarehouseStock" : $"✓ Товар ({step.Ms} мс)",
 01102                        logIcon: step.Status == "error" ? "error" : "check");
 01103                }, ct)
 01104                : Task.CompletedTask;
 1105
 01106            var bitrixTask = hasArticle
 01107                ? Task.Run(async () =>
 01108                {
 01109                    var sw = System.Diagnostics.Stopwatch.StartNew();
 01110                    var step = AgentStep("bitrix");
 01111                    step.Status = "active";
 01112                    step.Detail = "Отчёты синхронизации…";
 01113                    await EmitProgressAsync(onProgress, agentSteps);
 01114                    try
 01115                    {
 01116                        var page = await LoadBitrixHistoryForAiAsync(order.Article!.Trim(), ct);
 01117                        bitrixText = BitrixStockArticleHistoryAiFormatter.FormatForAiAnalysis(page);
 01118                    }
 01119                    catch (Exception ex) { _logger.LogWarning(ex, "Agent bitrix failed"); step.Status = "error"; }
 01120                    sw.Stop(); step.Ms = sw.ElapsedMilliseconds;
 01121                    if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; }
 01122                    await EmitProgressAsync(onProgress, agentSteps,
 01123                        logLine: step.Status == "error" ? "✗ Синхронизация 1С / Ozon" : $"✓ Синхронизация ({step.Ms} мс)
 01124                        logIcon: step.Status == "error" ? "error" : "check");
 01125                }, ct)
 01126                : Task.CompletedTask;
 1127
 01128            var ozonTask = (hasArticle && ozonLiveUi == null)
 01129                ? Task.Run(async () =>
 01130                {
 01131                    var sw = System.Diagnostics.Stopwatch.StartNew();
 01132                    var step = AgentStep("ozon_live");
 01133                    step.Status = "active";
 01134                    step.Detail = "Ozon Seller API…";
 01135                    await EmitProgressAsync(onProgress, agentSteps);
 01136                    try
 01137                    {
 01138                        (liveText, liveUi) = await _ozonLiveProduct.LoadAsync(
 01139                            order.Article!.Trim(), mfr, order.Id, userId, ct);
 01140                    }
 01141                    catch (Exception ex) { _logger.LogWarning(ex, "Agent ozon_live failed"); step.Status = "error"; }
 01142                    sw.Stop(); step.Ms = sw.ElapsedMilliseconds;
 01143                    if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; }
 01144                    await EmitProgressAsync(onProgress, agentSteps,
 01145                        logLine: step.Status == "error" ? "✗ Ozon Seller API" : $"✓ Ozon API ({step.Ms} мс)",
 01146                        logIcon: step.Status == "error" ? "error" : "check");
 01147                }, ct)
 01148                : Task.CompletedTask;
 1149
 01150            var txTask = Task.Run(async () =>
 01151            {
 01152                var sw = System.Diagnostics.Stopwatch.StartNew();
 01153                var step = AgentStep("transactions");
 01154                step.Status = "active";
 01155                step.Detail = "Транзакции заказа…";
 01156                await EmitProgressAsync(onProgress, agentSteps);
 01157                try
 01158                {
 01159                    var txList = await LoadTransactionsForOrderAsync(order, orderHistories, ct);
 01160                    transactionsText = OrderRussianFieldFormatter.FormatOrderDetail(order, txList);
 01161                }
 01162                catch (Exception ex) { _logger.LogWarning(ex, "Agent transactions failed"); step.Status = "error"; }
 01163                sw.Stop(); step.Ms = sw.ElapsedMilliseconds;
 01164                if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; }
 01165                await EmitProgressAsync(onProgress, agentSteps,
 01166                    logLine: step.Status == "error" ? "✗ Транзакции" : $"✓ Транзакции ({step.Ms} мс)",
 01167                    logIcon: step.Status == "error" ? "error" : "check");
 01168            }, ct);
 1169
 01170            var historyTask = Task.Run(async () =>
 01171            {
 01172                var sw = System.Diagnostics.Stopwatch.StartNew();
 01173                var step = AgentStep("history");
 01174                step.Status = "active";
 01175                step.Detail = "История изменений…";
 01176                await EmitProgressAsync(onProgress, agentSteps);
 01177                try { historyText = await _historyPageLoader.LoadFormattedContextAsync(order.Id, userId, ct); }
 01178                catch (Exception ex) { _logger.LogWarning(ex, "Agent history failed"); step.Status = "error"; }
 01179                sw.Stop(); step.Ms = sw.ElapsedMilliseconds;
 01180                if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; }
 01181                await EmitProgressAsync(onProgress, agentSteps,
 01182                    logLine: step.Status == "error" ? "✗ История изменений" : $"✓ История ({step.Ms} мс)",
 01183                    logIcon: step.Status == "error" ? "error" : "check");
 01184            }, ct);
 1185
 01186            await Task.WhenAll(productTask, bitrixTask, ozonTask, txTask, historyTask);
 1187
 1188            // Собираем результаты в порядке для контекста LLM
 01189            if (!string.IsNullOrWhiteSpace(productText))      { sb.AppendLine(productText);      sb.AppendLine(); }
 01190            if (!string.IsNullOrWhiteSpace(bitrixText))       { sb.AppendLine(bitrixText);        sb.AppendLine(); }
 01191            if (!string.IsNullOrWhiteSpace(liveText))         { sb.AppendLine(liveText);          sb.AppendLine(); }
 01192            if (!string.IsNullOrWhiteSpace(transactionsText)) { sb.AppendLine(transactionsText);                   }
 01193            if (!string.IsNullOrWhiteSpace(historyText))      { sb.AppendLine();                  sb.AppendLine(historyT
 1194
 01195            if (liveUi != null) ozonLiveUi = liveUi;
 01196            sb.AppendLine();
 01197        }
 1198
 01199        if (orders.Count > 1 && !string.IsNullOrWhiteSpace(intent.ShipmentNumber))
 1200        {
 01201            sb.Insert(0, $"Найдено {orders.Count} заказов по запросу «{intent.ShipmentNumber}»:\n\n");
 1202        }
 1203
 01204        return (sb.ToString().TrimEnd(), dtos, null, ozonLiveUi);
 01205    }
 1206
 1207    private async Task<(string, List<OrderSummaryDto>, string?)> GetBitrixStockArticleHistoryAsync(
 1208        AiQueryIntent intent,
 1209        string? userId,
 1210        CancellationToken ct,
 1211        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 1212    {
 01213        var article = intent.Article?.Trim();
 01214        if (string.IsNullOrWhiteSpace(article))
 01215            return ("Укажите артикул или product key, например «09146=FEB».", [], null);
 1216
 01217        var agentSteps = new List<AiProcessStepDto>
 01218        {
 01219            new() { Key = "bitrix_history", Label = "История синхронизаций", IsAgent = true, Status = "pending" },
 01220            new() { Key = "warehouse",      Label = "WarehouseStock",         IsAgent = true, Status = "pending" },
 01221            new() { Key = "orders_recent",  Label = "Связанные заказы",       IsAgent = true, Status = "pending" },
 01222        };
 1223
 01224        AiProcessStepDto Step(string key) => agentSteps.First(s => s.Key == key);
 1225
 01226        Step("bitrix_history").Status = "active";
 01227        Step("bitrix_history").Detail = article;
 01228        await EmitProgressAsync(onProgress, agentSteps, "⟳ История синхронизаций Bitrix…", "spin");
 01229        var sw1 = System.Diagnostics.Stopwatch.StartNew();
 01230        var page = await LoadBitrixHistoryForAiAsync(article, ct);
 01231        var text = BitrixStockArticleHistoryAiFormatter.FormatForAiAnalysis(page, intent.ManufacturerPrefix);
 01232        sw1.Stop();
 01233        Step("bitrix_history").Status = "done";
 01234        Step("bitrix_history").Done = true;
 01235        Step("bitrix_history").Ms = sw1.ElapsedMilliseconds;
 01236        Step("bitrix_history").Detail = null;
 01237        await EmitProgressAsync(onProgress, agentSteps, $"✓ История синхронизаций ({sw1.ElapsedMilliseconds} мс)", "chec
 1238
 01239        Step("warehouse").Status = "active";
 01240        Step("warehouse").Detail = "Остатки и движения…";
 01241        await EmitProgressAsync(onProgress, agentSteps, "⟳ WarehouseStock…", "spin");
 01242        var sw2 = System.Diagnostics.Stopwatch.StartNew();
 01243        var productCtx = await _productContextLoader.LoadFormattedAsync(article, intent.ManufacturerPrefix, ct);
 01244        sw2.Stop();
 01245        Step("warehouse").Status = "done";
 01246        Step("warehouse").Done = true;
 01247        Step("warehouse").Ms = sw2.ElapsedMilliseconds;
 01248        Step("warehouse").Detail = null;
 01249        await EmitProgressAsync(onProgress, agentSteps, $"✓ WarehouseStock ({sw2.ElapsedMilliseconds} мс)", "check");
 1250
 01251        var fullText = text + "\n\n" + productCtx;
 1252
 01253        Step("orders_recent").Status = "active";
 01254        Step("orders_recent").Detail = "До 3 заказов…";
 01255        await EmitProgressAsync(onProgress, agentSteps, "⟳ Связанные заказы…", "spin");
 01256        var sw3 = System.Diagnostics.Stopwatch.StartNew();
 01257        var recentQuery = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01258            .Where(o => o.Article == article)
 01259            .OrderByDescending(o => o.ProcessingDate);
 1260
 01261        var (recentOrders, sql) = await MaterializeOrderListAsync(recentQuery, 3, ct);
 01262        sw3.Stop();
 01263        Step("orders_recent").Status = "done";
 01264        Step("orders_recent").Done = true;
 01265        Step("orders_recent").Ms = sw3.ElapsedMilliseconds;
 01266        Step("orders_recent").Detail = $"{recentOrders.Count} шт.";
 01267        await EmitProgressAsync(onProgress, agentSteps, $"✓ Связанные заказы: {recentOrders.Count}", "check");
 1268
 01269        var dtos = DeduplicateSummaries(recentOrders.Select(MapToSummary).ToList());
 01270        return (fullText, dtos, sql);
 01271    }
 1272
 1273    private async Task<(string, List<OrderSummaryDto>, string?, AiOzonLiveUiDto?)> GetOzonLiveProductAsync(
 1274        AiQueryIntent intent,
 1275        string? userId,
 1276        CancellationToken ct,
 1277        Func<AiAgentProgressUpdate, Task>? onProgress = null)
 1278    {
 01279        var article = intent.Article?.Trim();
 01280        if (string.IsNullOrWhiteSpace(article))
 01281            return ("Укажите артикул для запроса к Ozon API.", [], null, null);
 1282
 01283        var agentSteps = new List<AiProcessStepDto>
 01284        {
 01285            new() { Key = "ozon_api",      Label = "Ozon Seller API",  IsAgent = true, Status = "pending" },
 01286            new() { Key = "orders_recent", Label = "Связанные заказы", IsAgent = true, Status = "pending" },
 01287        };
 1288
 01289        AiProcessStepDto Step(string key) => agentSteps.First(s => s.Key == key);
 1290
 01291        Step("ozon_api").Status = "active";
 01292        Step("ozon_api").Detail = article;
 01293        await EmitProgressAsync(onProgress, agentSteps, "⟳ Запрос к Ozon Seller API…", "spin");
 01294        var sw1 = System.Diagnostics.Stopwatch.StartNew();
 01295        var (liveText, liveUi) = await _ozonLiveProduct.LoadAsync(
 01296            article,
 01297            intent.ManufacturerPrefix,
 01298            intent.OrderId,
 01299            userId,
 01300            ct);
 01301        sw1.Stop();
 01302        Step("ozon_api").Status = "done";
 01303        Step("ozon_api").Done = true;
 01304        Step("ozon_api").Ms = sw1.ElapsedMilliseconds;
 01305        Step("ozon_api").Detail = null;
 01306        await EmitProgressAsync(onProgress, agentSteps, $"✓ Ozon API ({sw1.ElapsedMilliseconds} мс)", "check");
 1307
 01308        Step("orders_recent").Status = "active";
 01309        await EmitProgressAsync(onProgress, agentSteps, "⟳ Связанные заказы…", "spin");
 01310        var sw2 = System.Diagnostics.Stopwatch.StartNew();
 01311        var recentQuery = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01312            .Where(o => o.Article == article)
 01313            .OrderByDescending(o => o.ProcessingDate);
 1314
 01315        var (recentOrders, sql) = await MaterializeOrderListAsync(recentQuery, 3, ct);
 01316        sw2.Stop();
 01317        Step("orders_recent").Status = "done";
 01318        Step("orders_recent").Done = true;
 01319        Step("orders_recent").Ms = sw2.ElapsedMilliseconds;
 01320        Step("orders_recent").Detail = $"{recentOrders.Count} шт.";
 01321        await EmitProgressAsync(onProgress, agentSteps, $"✓ Связанные заказы: {recentOrders.Count}", "check");
 1322
 01323        var dtos = DeduplicateSummaries(recentOrders.Select(MapToSummary).ToList());
 01324        return (liveText, dtos, sql, liveUi);
 01325    }
 1326
 1327    private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderProductAsync(
 1328        AiQueryIntent intent,
 1329        string? userId,
 1330        CancellationToken ct)
 1331    {
 01332        if (intent.OrderId is not > 0)
 01333            return ("Укажите заказ (номер отправления или #ID).", [], null);
 1334
 01335        var (context, dtos, sql, _) = await GetOrderDetailAsync(new AiQueryIntent
 01336        {
 01337            ToolName = "order_detail",
 01338            OrderId  = intent.OrderId.Value
 01339        }, userId, ct);
 1340
 01341        if (dtos.Count == 0)
 01342            return (context, dtos, sql);
 1343
 01344        var header = $"=== Товар в заказе #{intent.OrderId} ({dtos[0].ShipmentNumber}) ===\n" +
 01345                     $"Наименование: {dtos[0].ProductName}\n" +
 01346                     $"Артикул: {dtos[0].Article} | Производитель: {dtos[0].ManufacturerName}\n\n";
 1347
 01348        return (header + context, dtos, sql);
 01349    }
 1350
 1351    private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderHistoryAsync(
 1352        AiQueryIntent intent,
 1353        string? userId,
 1354        CancellationToken ct)
 1355    {
 1356        List<int> orderIds;
 01357        string? sql = null;
 1358
 01359        if (intent.OrderId.HasValue)
 1360        {
 01361            var allowed = await FilterOrderIdsAsync([intent.OrderId.Value], userId, ct);
 01362            if (allowed.Length == 0)
 01363                return ($"Заказ #{intent.OrderId} недоступен или не найден.", [], null);
 01364            orderIds = allowed.ToList();
 1365        }
 01366        else if (!string.IsNullOrWhiteSpace(intent.ShipmentNumber))
 1367        {
 01368            var lookup = await GetOrderDetailAsync(new AiQueryIntent
 01369            {
 01370                ToolName       = "order_detail",
 01371                ShipmentNumber = intent.ShipmentNumber
 01372            }, userId, ct);
 1373            orderIds = lookup.Item2.Select(o => o.Id).ToList();
 01374            sql = lookup.Item3;
 01375            if (orderIds.Count == 0)
 01376                return ($"Заказ «{intent.ShipmentNumber}» не найден.", [], null);
 1377        }
 1378        else
 1379        {
 01380            return ("Укажите номер отправления или #ID заказа.", [], null);
 1381        }
 1382
 01383        var sb = new System.Text.StringBuilder();
 01384        foreach (var id in orderIds)
 1385        {
 01386            var pageCtx = await _historyPageLoader.LoadFormattedContextAsync(id, userId, ct);
 01387            if (!string.IsNullOrWhiteSpace(pageCtx))
 1388            {
 01389                sb.AppendLine(pageCtx);
 01390                sb.AppendLine();
 1391            }
 1392        }
 1393
 01394        if (sb.Length == 0)
 1395        {
 01396            return ("История заказа недоступна или не найдена.", [], null);
 1397        }
 1398
 01399        var dtos = await GetSummariesByIdsAsync(orderIds, userId, ct);
 01400        return (sb.ToString().TrimEnd(), dtos, sql);
 01401    }
 1402
 1403    private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderHistoryRecentAsync(
 1404        string? userId,
 1405        CancellationToken ct)
 1406    {
 01407        var historyQuery = await BuildScopedHistoryQueryAsync(userId, ct);
 01408        var limited = historyQuery
 01409            .OrderByDescending(h => h.ChangedAt)
 01410            .Take(60);
 01411        var sql = limited.ToQueryString();
 01412        var rows = await limited.ToListAsync(ct);
 1413
 1414        var orderIds = rows.Select(r => r.OrderId).Distinct().ToList();
 01415        var shipments = await LoadShipmentMapAsync(orderIds, ct);
 01416        var context = OrderHistoryFormatter.FormatHistoryList(rows, shipments, "Последние изменения заказов");
 01417        var dtos = await GetSummariesByIdsAsync(orderIds, userId, ct);
 01418        return (context, dtos, sql);
 01419    }
 1420
 1421    private async Task<(string, List<OrderSummaryDto>, string?)> GetUserActionsTodayAsync(
 1422        string? userId,
 1423        CancellationToken ct)
 1424    {
 01425        var todayUtc = DateTime.UtcNow.Date;
 01426        var tomorrow = todayUtc.AddDays(1);
 1427
 01428        var msk = TimeZoneInfo.ConvertTimeFromUtc(
 01429            DateTime.UtcNow,
 01430            TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 1431
 01432        var historyQuery = await BuildScopedHistoryQueryAsync(userId, ct);
 01433        var limited = historyQuery
 01434            .Where(h => h.ChangedAt >= todayUtc && h.ChangedAt < tomorrow)
 01435            .OrderByDescending(h => h.ChangedAt)
 01436            .Take(400);
 01437        var sql = limited.ToQueryString();
 01438        var rows = await limited.ToListAsync(ct);
 1439
 01440        if (rows.Count == 0)
 1441        {
 01442            return ($"Сегодня ({msk:dd.MM.yyyy}) изменений в orders_history не найдено.", [], sql);
 1443        }
 1444
 1445        var orderIds = rows.Select(r => r.OrderId).Distinct().ToList();
 01446        var shipments = await LoadShipmentMapAsync(orderIds, ct);
 1447
 01448        var groups = rows
 1449            .GroupBy(h => string.IsNullOrWhiteSpace(h.ChangedBy) ? "—" : h.ChangedBy!.Trim())
 01450            .Select(g =>
 01451            {
 01452                var samples = g.Take(5).Select(h =>
 01453                {
 01454                    shipments.TryGetValue(h.OrderId, out var ship);
 01455                    var field = h.ColumnDisplayName ?? h.ColumnName ?? "поле";
 01456                    var shipPart = !string.IsNullOrWhiteSpace(ship) ? $" {ship}" : "";
 01457                    return $"{h.ChangedAt:HH:mm} #{h.OrderId}{shipPart} {field}: «{h.FormattedOldValue}»→«{h.FormattedNe
 01458                }).ToList();
 01459                return (User: g.Key, Count: g.Count(), Samples: (IReadOnlyList<string>)samples);
 01460            })
 01461            .ToList();
 1462
 01463        var summary = OrderHistoryFormatter.FormatUserActivitySummary(
 01464            groups,
 01465            $"Действия пользователей за сегодня ({msk:dd.MM.yyyy})");
 1466
 01467        var detail = OrderHistoryFormatter.FormatHistoryList(
 01468            rows.Take(50).ToList(),
 01469            shipments,
 01470            "Детализация (последние 50 изменений)");
 1471
 01472        var context = summary + "\n\n" + detail;
 01473        var dtos = await GetSummariesByIdsAsync(orderIds.Take(50), userId, ct);
 01474        return (context, dtos, sql);
 01475    }
 1476
 1477    private async Task<IQueryable<OrderHistory>> BuildScopedHistoryQueryAsync(
 1478        string? userId,
 1479        CancellationToken ct)
 1480    {
 01481        var scopedOrders = await ScopedActiveOrdersAsync(userId, ct);
 01482        var scopedOrderIds = scopedOrders.Select(o => o.Id);
 01483        return _db.OrderHistories
 01484            .AsNoTracking()
 01485            .Where(h => scopedOrderIds.Contains(h.OrderId));
 01486    }
 1487
 1488    private async Task<List<OrderHistory>> LoadOrderHistoryRowsAsync(
 1489        int orderId,
 1490        string? userId,
 1491        int take,
 1492        CancellationToken ct)
 1493    {
 01494        var allowed = await FilterOrderIdsAsync([orderId], userId, ct);
 01495        if (allowed.Length == 0)
 01496            return [];
 1497
 01498        return await _db.OrderHistories
 01499            .AsNoTracking()
 01500            .Where(h => h.OrderId == orderId)
 01501            .OrderByDescending(h => h.ChangedAt)
 01502            .Take(take)
 01503            .ToListAsync(ct);
 01504    }
 1505
 1506    private async Task<Dictionary<int, string?>> LoadShipmentMapAsync(
 1507        IReadOnlyList<int> orderIds,
 1508        CancellationToken ct)
 1509    {
 01510        if (orderIds.Count == 0)
 01511            return new Dictionary<int, string?>();
 1512
 01513        return await _db.Orders
 01514            .AsNoTracking()
 01515            .Where(o => orderIds.Contains(o.Id))
 01516            .Select(o => new { o.Id, o.ShipmentNumber })
 1517            .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct);
 01518    }
 1519
 1520    private async Task<List<TransactionSummary>> LoadTransactionsForOrderAsync(
 1521        Order order,
 1522        IReadOnlyList<OrderHistory> histories,
 1523        CancellationToken ct)
 1524    {
 01525        var idSet = AiOrderTransactionIdCollector.Collect(order, histories);
 1526
 01527        var linkedIds = await _db.Transactions
 01528            .AsNoTracking()
 01529            .Where(t => t.Orders.Any(o => o.Id == order.Id))
 01530            .Select(t => t.Id)
 01531            .ToListAsync(ct);
 1532
 01533        foreach (var id in linkedIds)
 01534            idSet.Add(id);
 1535
 01536        if (idSet.Count == 0)
 01537            return [];
 1538
 01539        var rows = await _db.Transactions
 01540            .AsNoTracking()
 01541            .Where(t => idSet.Contains(t.Id))
 01542            .OrderByDescending(t => t.CreatedDateTime)
 01543            .Take(30)
 01544            .Select(t => new
 01545            {
 01546                t.Id,
 01547                t.Type,
 01548                t.CreatedDateTime,
 01549                t.CreateBy,
 01550                t.Comment
 01551            })
 01552            .ToListAsync(ct);
 1553
 1554        return rows.Select(t => new TransactionSummary
 1555        {
 1556            Id        = t.Id,
 1557            TypeName  = t.Type?.GetDisplayName() ?? "—",
 1558            CreatedAt = t.CreatedDateTime,
 1559            CreatedBy = t.CreateBy,
 1560            Comment   = t.Comment
 1561        }).ToList();
 01562    }
 1563
 1564    private async Task<(string, List<OrderSummaryDto>, string?)> GetOverdueOrdersAsync(string? userId, CancellationToken
 1565    {
 01566        var now = DateTime.UtcNow;
 01567        var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01568            .Where(o => o.ShippingDate < now
 01569                     && o.AppStatus != null
 01570                     && !o.AppStatus.IsFinal)
 01571            .OrderBy(o => o.ShippingDate);
 1572
 01573        var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct);
 1574
 01575        var dtos = orders.Select(MapToSummary).ToList();
 01576        var context = orders.Count == 0
 01577            ? "Просроченных заказов не найдено."
 01578            : $"Просроченные заказы ({orders.Count} шт.):\n" + FormatOrderList(dtos);
 1579
 01580        return (context, dtos, sql);
 01581    }
 1582
 1583    private async Task<(string, List<OrderSummaryDto>, string?)> GetUnprofitableOrdersAsync(string? userId, Cancellation
 1584    {
 01585        var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01586            .Where(o => o.AppStatus != null
 01587                     && !o.AppStatus.IsFinal
 01588                     && (o.MinProfit < 0
 01589                         || (o.CostPrice > 0 && o.Price < o.CostPrice * 1.1m)))
 01590            .OrderBy(o => o.MinProfit);
 1591
 01592        var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct);
 1593
 01594        var dtos = orders.Select(MapToSummary).ToList();
 01595        var context = orders.Count == 0
 01596            ? "Невыгодных заказов не найдено."
 01597            : $"Невыгодные заказы ({orders.Count} шт.):\n" + FormatOrderList(dtos);
 1598
 01599        return (context, dtos, sql);
 01600    }
 1601
 1602    private async Task<(string, List<OrderSummaryDto>, string?)> GetCancelledOrdersTodayAsync(string? userId, Cancellati
 1603    {
 01604        var todayUtc = DateTime.UtcNow.Date;
 01605        var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01606            .Where(o => o.ClientStatus != null
 01607                     && (o.ClientStatus.Name == "Отменён" || o.ClientStatus.Name == "Отменен")
 01608                     && o.LastStatusChangeDate >= todayUtc)
 01609            .OrderByDescending(o => o.LastStatusChangeDate);
 1610
 01611        var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct);
 1612
 01613        var dtos = orders.Select(MapToSummary).ToList();
 01614        var context = orders.Count == 0
 01615            ? "Сегодня отменённых заказов нет."
 01616            : $"=== ТОЧНОЕ ЧИСЛО (используй только его): {orders.Count} заказ(ов) отменено сегодня ===\n" +
 01617              $"Отменённые заказы за сегодня ({orders.Count} шт.):\n" + FormatOrderList(dtos);
 1618
 01619        return (context, dtos, sql);
 01620    }
 1621
 1622    private async Task<(string, List<OrderSummaryDto>, string?)> GetOrdersSummaryAsync(string? userId, CancellationToken
 1623    {
 01624        var grouped = (await ScopedActiveOrdersAsync(userId, ct))
 01625            .GroupBy(o => o.AppStatus != null ? o.AppStatus.Name : "Без статуса")
 01626            .Select(g => new { Status = g.Key, Count = g.Count() });
 1627
 01628        var sql = grouped.ToQueryString();
 01629        var groups = await grouped.ToListAsync(ct);
 1630
 01631        var lines = groups
 1632            .OrderByDescending(g => g.Count)
 1633            .Select(g => $"  {g.Status}: {g.Count} заказов");
 1634
 01635        var context = "Сводка заказов по статусу:\n" + string.Join("\n", lines);
 01636        return (context, [], sql);
 01637    }
 1638
 1639    private async Task<(string, List<OrderSummaryDto>, string?)> GetNoSupplierOrdersAsync(string? userId, CancellationTo
 1640    {
 01641        var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct))
 01642            .Where(o => o.SupplierId == null
 01643                     && o.AppStatus != null
 01644                     && !o.AppStatus.IsFinal)
 01645            .OrderByDescending(o => o.ProcessingDate);
 1646
 01647        var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct);
 1648
 01649        var dtos = orders.Select(MapToSummary).ToList();
 01650        var context = orders.Count == 0
 01651            ? "Заказов без поставщика не найдено."
 01652            : $"Заказы без поставщика ({orders.Count} шт.):\n" + FormatOrderList(dtos);
 1653
 01654        return (context, dtos, sql);
 01655    }
 1656
 01657    private static OrderSummaryDto MapToSummary(Order o) => new()
 01658    {
 01659        Id               = o.Id,
 01660        ShipmentNumber   = o.ShipmentNumber,
 01661        ProductName      = o.ProductName,
 01662        Article          = o.Article,
 01663        AppStatus        = o.AppStatus?.Name ?? "Не указан",
 01664        ClientStatus     = o.ClientStatus?.Name,
 01665        AppStatusColor   = o.AppStatus?.GetStatusColor(),
 01666        ClientStatusColor = o.ClientStatus?.GetStatusColor(),
 01667        ManufacturerName = o.Manufacturer?.Name ?? o.EtProducer?.Name,
 01668        ProductImageUrl  = o.ProductInfo?.ImageUrl,
 01669        Quantity         = o.Quantity,
 01670        TrueQuantity     = o.TrueQuantity,
 01671        Supplier         = o.Supplier?.Name,
 01672        ShipmentWarehouseName = o.ShipmentWarehouse?.Name,
 01673        OzonClientName   = o.OzonClient?.Name,
 01674        ShippingDate     = o.ShippingDate,
 01675        Price            = o.Price,
 01676        MinProfit        = o.MinProfit,
 01677        IsArchived       = o.OrderArchive != null,
 01678        ArchivedAt       = o.OrderArchive?.ArchivedAt,
 01679        ArchivedBy       = o.OrderArchive?.ArchivedBy,
 01680        ArchiveReason    = o.OrderArchive?.ArchiveReason
 01681    };
 1682
 1683    private static string BuildZeroResultsContext(AiOrderSearchResult searchResult)
 1684    {
 01685        var sb = new System.Text.StringBuilder();
 01686        sb.Append($"Заказы по критериям ({searchResult.AppliedFiltersDescription}) не найдены.");
 01687        if (searchResult.RelaxedSuggestion != null && searchResult.RelaxedCount > 0)
 1688        {
 01689            sb.AppendLine();
 01690            sb.Append($"Без ужесточения по дате найдено {searchResult.RelaxedCount} заказ(ов). ");
 01691            sb.Append("Предложи пользователю нажать кнопку ослабленного поиска под карточкой.");
 1692        }
 01693        return sb.ToString();
 1694    }
 1695
 1696    private static string FormatOrderList(List<OrderSummaryDto> orders) =>
 01697        string.Join("\n", orders.Select(o =>
 01698        {
 01699            var archive = o.IsArchived
 01700                ? $" | АРХИВ с {o.ArchivedAt:dd.MM.yyyy HH:mm}" +
 01701                  (string.IsNullOrWhiteSpace(o.ArchivedBy) ? "" : $" ({o.ArchivedBy})")
 01702                : "";
 01703            return
 01704                $"  #{o.Id} {o.ShipmentNumber} | {o.ProductName}{archive} | " +
 01705                $"{FormatQuantityLabel(o.Quantity)} | " +
 01706                $"Статус: {o.AppStatus} | Цена: {o.Price:N0} ₽ | " +
 01707                $"Прибыль: {o.MinProfit:N0} ₽ | Дата отгрузки: {o.ShippingDate:dd.MM.yyyy}";
 01708        }));
 1709
 1710    private static string FormatQuantityLabel(int? quantity)
 1711    {
 01712        var qty = quantity ?? 1;
 01713        return qty > 1 ? $"{qty} шт." : "1 шт.";
 1714    }
 1715
 1716    private static async Task EmitProgressAsync(
 1717        Func<AiAgentProgressUpdate, Task>? onProgress,
 1718        List<AiProcessStepDto> steps,
 1719        string? logLine = null,
 1720        string? logIcon = null,
 1721        string phase = "agents",
 1722        bool? fromCache = null)
 1723    {
 01724        if (onProgress == null) return;
 01725        await onProgress(new AiAgentProgressUpdate
 01726        {
 01727            Steps     = steps,
 01728            Phase     = phase,
 01729            LogLine   = logLine,
 01730            LogIcon   = logIcon,
 01731            FromCache = fromCache,
 01732        });
 01733    }
 1734
 1735    public async Task<int[]> FilterOrderIdsAsync(
 1736        IEnumerable<int> ids,
 1737        string? userId,
 1738        CancellationToken ct = default)
 1739    {
 1740        var idArray = ids.Where(id => id > 0).Distinct().ToArray();
 01741        if (idArray.Length == 0)
 01742            return [];
 1743
 01744        var query = _orderRepository.GetQueryable(includeArchived: true).Where(o => idArray.Contains(o.Id));
 01745        query = await _scopeApplier.ApplyAsync(query, userId, ct);
 01746        return await query.Select(o => o.Id).ToArrayAsync(ct);
 01747    }
 1748
 1749    public async Task<List<OrderSummaryDto>> GetSummariesByIdsAsync(
 1750        IEnumerable<int> ids,
 1751        string? userId,
 1752        CancellationToken ct = default)
 1753    {
 01754        var allowedIds = await FilterOrderIdsAsync(ids, userId, ct);
 01755        if (allowedIds.Length == 0)
 01756            return [];
 1757
 01758        var orders = DedupeById(await WithNavigations(_orderRepository.GetQueryable(includeArchived: true))
 01759            .Where(o => allowedIds.Contains(o.Id))
 01760            .OrderBy(o => o.Id)
 01761            .ToListAsync(ct));
 1762
 01763        return DeduplicateSummaries(orders.Select(MapToSummary).ToList());
 01764    }
 1765
 1766    private async Task<int?> ResolveOrderIdAsync(AiQueryIntent intent, string? userId, CancellationToken ct)
 1767    {
 01768        if (intent.OrderId.HasValue)
 1769        {
 01770            var allowed = await FilterOrderIdsAsync([intent.OrderId.Value], userId, ct);
 01771            return allowed.Length > 0 ? intent.OrderId.Value : null;
 1772        }
 1773
 01774        if (!string.IsNullOrWhiteSpace(intent.ShipmentNumber))
 1775        {
 01776            var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: false, ct);
 01777            if (orders.Count == 0)
 01778                orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct);
 01779            return orders.Count > 0 ? orders[0].Id : null;
 1780        }
 1781
 01782        return null;
 01783    }
 1784
 181785    public static string GetToolLabel(string? toolName) => toolName switch
 181786    {
 11787        "orders_today"           => "Заказы за сегодня",
 11788        "order_detail"           => "Анализ заказа",
 11789        "order_history"          => "История заказа",
 11790        "order_history_recent"   => "Последние изменения",
 11791        "user_actions_today"     => "Действия пользователей сегодня",
 11792        "transaction_types_info" => "Справочник транзакций",
 11793        "overdue_orders"         => "Просроченные заказы",
 11794        "unprofitable_orders"    => "Невыгодные заказы",
 11795        "cancelled_orders_today" => "Отменены сегодня",
 11796        "no_supplier_orders"     => "Без поставщика",
 11797        "orders_filter"          => "Поиск заказов",
 11798        "orders_summary"         => "Сводка по статусам",
 11799        "bitrix_stock_article"   => "Синхронизация Ozon и 1С",
 11800        "ozon_live_product"      => "Актуально на Ozon (API)",
 11801        "order_product"          => "Товар в заказе",
 11802        "write_edit"             => "Изменение заказа",
 11803        "write_transaction"      => "Транзакция",
 11804        _                        => toolName ?? "Запрос"
 181805    };
 1806}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/obj/Release/net8.0/System.Text.RegularExpressions.Generator/System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs

File '/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/obj/Release/net8.0/System.Text.RegularExpressions.Generator/System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs' does not exist (any more).

Methods/Properties

.cctor()
.ctor(OzonRepositories.Data.OrderRepository,OzonRepositories.Context.ApplicationDbContext,Services.AiAssistant.AiOrderScopeApplier,Services.AiAssistant.OrderHistoryPageContextLoader,Services.BitrixStockServices.IBitrixStockSyncArticleHistoryService,Services.AiAssistant.AiProductContextLoader,Services.AiAssistant.AiChatEnrichmentBuilder,Services.AiAssistant.AiOzonLiveProductService,Services.AiAssistant.AiOrderWriteService,Services.AiAssistant.AiOrderSearchAgent,Services.AiAssistant.AiOrderFilterQueryStudio,Microsoft.Extensions.Logging.ILogger`1<Services.AiAssistant.AiOrderAnalysisService>)
DetectIntent(System.String,Services.AiAssistant.AiChatConversationContext)
DetectIntentAsync(System.String,Services.AiAssistant.AiChatConversationContext,System.Threading.CancellationToken)
IsPresetOrdersTool(System.String)
LooksLikeCompoundFilterQuery(System.String)
UpdateContext(Services.AiAssistant.AiChatConversationContext,Services.AiAssistant.AiQueryIntent,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OpenRouterApi.Models.OrderSummaryDto>,Services.ApiServices.OpenRouterApi.Models.AiActionProposalDto)
DeduplicateSummaries(System.Collections.Generic.IEnumerable`1<Services.ApiServices.OpenRouterApi.Models.OrderSummaryDto>)
DedupeById(System.Collections.Generic.List`1<OzonDomains.Models.Order>)
TryBuildIntentFromContext(Services.AiAssistant.AiChatConversationContext,System.String,System.Boolean,Services.AiAssistant.AiQueryIntent&)
IsContextualOrderReference(System.String)
IsContextualProductInfoIntent(System.String)
IsContextualProductReference(System.String)
LoadBitrixHistoryForAiAsync()
IsFullTransactionCommand(System.String)
IsPendingTransactionClarification(System.String)
IsOzonLiveProductIntent(System.String)
IsBitrixStockIntent(System.String)
TryResolveBitrixArticle(System.String,Services.AiAssistant.AiChatConversationContext,System.String&,System.String&)
TryParseOrderId(System.String)
IsOrderHistoryIntent(System.String)
IsUserActionsTodayIntent(System.String)
IsTransactionTypesIntent(System.String)
IsBarePresetQuery(System.String,System.String)
IsCancelledTodayIntent(System.String)
IsOrdersTodayIntent(System.String)
RunQueryAsync()
BuildEnrichmentIfNeededAsync()
ScopedOrdersAsync()
ScopedActiveOrdersAsync(System.String,System.Threading.CancellationToken)
WithNavigations(System.Linq.IQueryable`1<OzonDomains.Models.Order>)
FindOrdersForIntentAsync()
MaterializeOrderListAsync()
GetOrdersTodayAsync()
GetOrderDetailAsync()
GetOrderDetailCoreAsync()
AgentStep()
<GetOrderDetailCoreAsync()
<GetOrderDetailCoreAsync()
<GetOrderDetailCoreAsync()
<GetOrderDetailCoreAsync()
<GetOrderDetailCoreAsync()
GetBitrixStockArticleHistoryAsync()
Step()
GetOzonLiveProductAsync()
Step()
GetOrderProductAsync()
GetOrderHistoryAsync()
GetOrderHistoryRecentAsync()
GetUserActionsTodayAsync()
BuildScopedHistoryQueryAsync()
LoadOrderHistoryRowsAsync()
LoadShipmentMapAsync()
LoadTransactionsForOrderAsync()
GetOverdueOrdersAsync()
GetUnprofitableOrdersAsync()
GetCancelledOrdersTodayAsync()
GetOrdersSummaryAsync()
GetNoSupplierOrdersAsync()
MapToSummary(OzonDomains.Models.Order)
BuildZeroResultsContext(Services.AiAssistant.AiOrderSearchResult)
FormatOrderList(System.Collections.Generic.List`1<Services.ApiServices.OpenRouterApi.Models.OrderSummaryDto>)
FormatQuantityLabel(System.Nullable`1<System.Int32>)
EmitProgressAsync()
FilterOrderIdsAsync()
GetSummariesByIdsAsync()
ResolveOrderIdAsync()
GetToolLabel(System.String)
ShipmentNumberRegex()
SupplierOrderNumberRegex()
OrderIdHashRegex()
OrderIdWordRegex()
BareOrderIdRegex()