| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using OzonDomains; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using OzonRepositories.Data; |
| | | 8 | | using Services.ApiServices.OpenRouterApi.Models; |
| | | 9 | | using Services.BitrixStockServices; |
| | | 10 | | |
| | | 11 | | namespace Services.AiAssistant; |
| | | 12 | | |
| | | 13 | | public 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 | | |
| | 0 | 28 | | private static readonly HashSet<string> EnrichmentTools = new(StringComparer.Ordinal) |
| | 0 | 29 | | { |
| | 0 | 30 | | "order_detail", |
| | 0 | 31 | | "order_history", |
| | 0 | 32 | | "order_product", |
| | 0 | 33 | | "bitrix_stock_article" |
| | 0 | 34 | | }; |
| | | 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 | | |
| | 0 | 55 | | private static readonly (string[] Keywords, string ToolName)[] IntentMap = |
| | 0 | 56 | | [ |
| | 0 | 57 | | (["просроч", "опоздал", "overdue", "deadline"], "overdue_orders"), |
| | 0 | 58 | | (["невыгод", "убыток", "убыточ", "unprofitabl"], "unprofitable_orders"), |
| | 0 | 59 | | (["без поставщик", "no supplier", "нет поставщик"], "no_supplier_orders"), |
| | 0 | 60 | | (["итого", "сводк", "summary"], "orders_summary"), |
| | 0 | 61 | | (["сколько", "всего"], "orders_summary"), |
| | 0 | 62 | | ]; |
| | | 63 | | |
| | 0 | 64 | | public AiOrderAnalysisService( |
| | 0 | 65 | | OrderRepository orderRepository, |
| | 0 | 66 | | ApplicationDbContext db, |
| | 0 | 67 | | AiOrderScopeApplier scopeApplier, |
| | 0 | 68 | | OrderHistoryPageContextLoader historyPageLoader, |
| | 0 | 69 | | IBitrixStockSyncArticleHistoryService bitrixArticleHistory, |
| | 0 | 70 | | AiProductContextLoader productContextLoader, |
| | 0 | 71 | | AiChatEnrichmentBuilder enrichmentBuilder, |
| | 0 | 72 | | AiOzonLiveProductService ozonLiveProduct, |
| | 0 | 73 | | AiOrderWriteService writeService, |
| | 0 | 74 | | AiOrderSearchAgent searchAgent, |
| | 0 | 75 | | AiOrderFilterQueryStudio filterStudio, |
| | 0 | 76 | | ILogger<AiOrderAnalysisService> logger) |
| | | 77 | | { |
| | 0 | 78 | | _orderRepository = orderRepository; |
| | 0 | 79 | | _db = db; |
| | 0 | 80 | | _scopeApplier = scopeApplier; |
| | 0 | 81 | | _historyPageLoader = historyPageLoader; |
| | 0 | 82 | | _bitrixArticleHistory = bitrixArticleHistory; |
| | 0 | 83 | | _productContextLoader = productContextLoader; |
| | 0 | 84 | | _enrichmentBuilder = enrichmentBuilder; |
| | 0 | 85 | | _ozonLiveProduct = ozonLiveProduct; |
| | 0 | 86 | | _writeService = writeService; |
| | 0 | 87 | | _searchAgent = searchAgent; |
| | 0 | 88 | | _filterStudio = filterStudio; |
| | 0 | 89 | | _logger = logger; |
| | 0 | 90 | | } |
| | | 91 | | |
| | | 92 | | public AiQueryIntent? DetectIntent(string userMessage, AiChatConversationContext? context = null) |
| | | 93 | | { |
| | 0 | 94 | | var trimmed = userMessage.Trim(); |
| | 0 | 95 | | var lower = trimmed.ToLowerInvariant(); |
| | | 96 | | |
| | | 97 | | // ── Write-интенты (проверяем в первую очередь) ──────────────────────── |
| | | 98 | | |
| | | 99 | | // Подхват pending-транзакции: короткое уточнение полей («цена 150»), не новая команда «проведи…». |
| | 0 | 100 | | if (context?.PendingTransactionType != null |
| | 0 | 101 | | && !AiOrderFilterParser.IsReadOnlyOrderSearchIntent(lower) |
| | 0 | 102 | | && !IsFullTransactionCommand(lower) |
| | 0 | 103 | | && IsPendingTransactionClarification(lower)) |
| | | 104 | | { |
| | 0 | 105 | | var txShipmentMatch = ShipmentNumberRegex().Match(trimmed); |
| | 0 | 106 | | var txSupplierMatch = SupplierOrderNumberRegex().Match(trimmed); |
| | 0 | 107 | | var txOrderId = TryParseOrderId(trimmed); |
| | | 108 | | // Приоритет: Ozon-номер > номер заказа поставщику > контекст |
| | 0 | 109 | | var resolvedShipment = txShipmentMatch.Success ? txShipmentMatch.Value |
| | 0 | 110 | | : txSupplierMatch.Success ? txSupplierMatch.Value |
| | 0 | 111 | | : context.ShipmentNumber; |
| | 0 | 112 | | return new AiQueryIntent |
| | 0 | 113 | | { |
| | 0 | 114 | | ToolName = "write_transaction", |
| | 0 | 115 | | WriteKind = "create_transaction", |
| | 0 | 116 | | TransactionTypeValue = context.PendingTransactionType, |
| | 0 | 117 | | ShipmentNumber = resolvedShipment, |
| | 0 | 118 | | OrderId = txOrderId ?? context.OrderId, |
| | 0 | 119 | | }; |
| | | 120 | | } |
| | | 121 | | |
| | 0 | 122 | | if (AiWriteIntentParser.IsTransactionIntent(lower)) |
| | | 123 | | { |
| | 0 | 124 | | var transType = AiWriteIntentParser.TryParseTransactionType(lower); |
| | 0 | 125 | | var resolvedType = transType.HasValue ? (int)transType.Value : context?.PendingTransactionType; |
| | | 126 | | // Ищем все идентификаторы заказов: Ozon-формат + номера заказов поставщику |
| | 0 | 127 | | var allShipments = ShipmentNumberRegex().Matches(trimmed) |
| | 0 | 128 | | .Select(m => m.Value) |
| | 0 | 129 | | .Union(SupplierOrderNumberRegex().Matches(trimmed).Select(m => m.Value)) |
| | 0 | 130 | | .Distinct().ToList(); |
| | 0 | 131 | | var txOrderId = TryParseOrderId(trimmed); |
| | 0 | 132 | | return new AiQueryIntent |
| | 0 | 133 | | { |
| | 0 | 134 | | ToolName = "write_transaction", |
| | 0 | 135 | | WriteKind = "create_transaction", |
| | 0 | 136 | | TransactionTypeValue = resolvedType, |
| | 0 | 137 | | ShipmentNumber = allShipments.Count > 0 ? allShipments[0] : context?.ShipmentNumber, |
| | 0 | 138 | | ShipmentNumbers = allShipments.Count > 1 ? allShipments : null, |
| | 0 | 139 | | OrderId = allShipments.Count == 0 ? (txOrderId ?? context?.OrderId) : null, |
| | 0 | 140 | | }; |
| | | 141 | | } |
| | | 142 | | |
| | 0 | 143 | | if (AiWriteIntentParser.IsEditOrderIntent(lower)) |
| | | 144 | | { |
| | 0 | 145 | | var editShipmentMatch = ShipmentNumberRegex().Match(trimmed); |
| | 0 | 146 | | var editOrderId = TryParseOrderId(trimmed); |
| | 0 | 147 | | return new AiQueryIntent |
| | 0 | 148 | | { |
| | 0 | 149 | | ToolName = "write_edit", |
| | 0 | 150 | | WriteKind = "edit_order", |
| | 0 | 151 | | ShipmentNumber = editShipmentMatch.Success ? editShipmentMatch.Value : context?.ShipmentNumber, |
| | 0 | 152 | | OrderId = editOrderId ?? context?.OrderId, |
| | 0 | 153 | | }; |
| | | 154 | | } |
| | | 155 | | |
| | 0 | 156 | | if (IsTransactionTypesIntent(lower)) |
| | 0 | 157 | | return new AiQueryIntent { ToolName = "transaction_types_info" }; |
| | | 158 | | |
| | 0 | 159 | | if (IsUserActionsTodayIntent(lower)) |
| | 0 | 160 | | return new AiQueryIntent { ToolName = "user_actions_today" }; |
| | | 161 | | |
| | 0 | 162 | | if (IsCancelledTodayIntent(lower)) |
| | 0 | 163 | | return new AiQueryIntent { ToolName = "cancelled_orders_today" }; |
| | | 164 | | |
| | 0 | 165 | | if (IsOrdersTodayIntent(lower)) |
| | 0 | 166 | | return new AiQueryIntent { ToolName = "orders_today" }; |
| | | 167 | | |
| | 0 | 168 | | var productKey = AiArticleParser.TryParseProductKey(trimmed); |
| | 0 | 169 | | if (productKey != null) |
| | | 170 | | { |
| | | 171 | | // Если в сообщении явный запрос live-данных Ozon — используем ozon_live_product |
| | 0 | 172 | | var toolName = IsOzonLiveProductIntent(lower) ? "ozon_live_product" : "bitrix_stock_article"; |
| | 0 | 173 | | return new AiQueryIntent |
| | 0 | 174 | | { |
| | 0 | 175 | | ToolName = toolName, |
| | 0 | 176 | | Article = productKey.Value.Article, |
| | 0 | 177 | | ManufacturerPrefix = productKey.Value.ManufacturerPrefix, |
| | 0 | 178 | | OrderId = context?.OrderId, |
| | 0 | 179 | | ShipmentNumber = context?.ShipmentNumber, |
| | 0 | 180 | | }; |
| | | 181 | | } |
| | | 182 | | |
| | 0 | 183 | | if (IsContextualProductInfoIntent(lower) && context?.OrderId is > 0) |
| | | 184 | | { |
| | 0 | 185 | | return new AiQueryIntent |
| | 0 | 186 | | { |
| | 0 | 187 | | ToolName = "order_product", |
| | 0 | 188 | | OrderId = context.OrderId.Value |
| | 0 | 189 | | }; |
| | | 190 | | } |
| | | 191 | | |
| | 0 | 192 | | if (IsContextualProductReference(lower) && !string.IsNullOrWhiteSpace(context?.Article)) |
| | | 193 | | { |
| | 0 | 194 | | return new AiQueryIntent |
| | 0 | 195 | | { |
| | 0 | 196 | | ToolName = "bitrix_stock_article", |
| | 0 | 197 | | Article = context!.Article, |
| | 0 | 198 | | ManufacturerPrefix = context.ManufacturerPrefix, |
| | 0 | 199 | | OrderId = context.OrderId |
| | 0 | 200 | | }; |
| | | 201 | | } |
| | | 202 | | |
| | 0 | 203 | | if (IsOzonLiveProductIntent(lower) && |
| | 0 | 204 | | TryResolveBitrixArticle(trimmed, context, out var ozArticle, out var ozMfr)) |
| | | 205 | | { |
| | 0 | 206 | | return new AiQueryIntent |
| | 0 | 207 | | { |
| | 0 | 208 | | ToolName = "ozon_live_product", |
| | 0 | 209 | | Article = ozArticle, |
| | 0 | 210 | | ManufacturerPrefix = ozMfr, |
| | 0 | 211 | | OrderId = context?.OrderId |
| | 0 | 212 | | }; |
| | | 213 | | } |
| | | 214 | | |
| | 0 | 215 | | if (IsBitrixStockIntent(lower) && TryResolveBitrixArticle(trimmed, context, out var bxArticle, out var bxMfr)) |
| | | 216 | | { |
| | 0 | 217 | | return new AiQueryIntent |
| | 0 | 218 | | { |
| | 0 | 219 | | ToolName = "bitrix_stock_article", |
| | 0 | 220 | | Article = bxArticle, |
| | 0 | 221 | | ManufacturerPrefix = bxMfr |
| | 0 | 222 | | }; |
| | | 223 | | } |
| | | 224 | | |
| | 0 | 225 | | var wantsHistory = IsOrderHistoryIntent(lower); |
| | 0 | 226 | | var shipmentMatch = ShipmentNumberRegex().Match(trimmed); |
| | 0 | 227 | | var orderId = TryParseOrderId(trimmed); |
| | 0 | 228 | | var contextualOrder = IsContextualOrderReference(lower); |
| | | 229 | | |
| | 0 | 230 | | if (contextualOrder && TryBuildIntentFromContext(context, lower, wantsHistory, out var ctxIntent)) |
| | 0 | 231 | | return ctxIntent; |
| | | 232 | | |
| | 0 | 233 | | if (wantsHistory) |
| | | 234 | | { |
| | 0 | 235 | | if (shipmentMatch.Success) |
| | | 236 | | { |
| | 0 | 237 | | return new AiQueryIntent |
| | 0 | 238 | | { |
| | 0 | 239 | | ToolName = "order_history", |
| | 0 | 240 | | ShipmentNumber = shipmentMatch.Value |
| | 0 | 241 | | }; |
| | | 242 | | } |
| | | 243 | | |
| | 0 | 244 | | if (orderId.HasValue) |
| | | 245 | | { |
| | 0 | 246 | | return new AiQueryIntent |
| | 0 | 247 | | { |
| | 0 | 248 | | ToolName = "order_history", |
| | 0 | 249 | | OrderId = orderId.Value |
| | 0 | 250 | | }; |
| | | 251 | | } |
| | | 252 | | |
| | 0 | 253 | | if (TryBuildIntentFromContext(context, lower, wantsHistory: true, out var historyFromCtx)) |
| | 0 | 254 | | return historyFromCtx; |
| | | 255 | | |
| | 0 | 256 | | return new AiQueryIntent { ToolName = "order_history_recent" }; |
| | | 257 | | } |
| | | 258 | | |
| | 0 | 259 | | if (shipmentMatch.Success) |
| | | 260 | | { |
| | 0 | 261 | | return new AiQueryIntent |
| | 0 | 262 | | { |
| | 0 | 263 | | ToolName = "order_detail", |
| | 0 | 264 | | ShipmentNumber = shipmentMatch.Value |
| | 0 | 265 | | }; |
| | | 266 | | } |
| | | 267 | | |
| | 0 | 268 | | if (orderId.HasValue) |
| | | 269 | | { |
| | 0 | 270 | | return new AiQueryIntent |
| | 0 | 271 | | { |
| | 0 | 272 | | ToolName = "order_detail", |
| | 0 | 273 | | OrderId = orderId.Value |
| | 0 | 274 | | }; |
| | | 275 | | } |
| | | 276 | | |
| | 0 | 277 | | foreach (var (keywords, toolName) in IntentMap) |
| | | 278 | | { |
| | 0 | 279 | | if (keywords.Any(k => lower.Contains(k))) |
| | | 280 | | { |
| | 0 | 281 | | if (toolName == "orders_summary" || lower.Contains("заказ") || lower.Contains("order")) |
| | 0 | 282 | | return new AiQueryIntent { ToolName = toolName }; |
| | | 283 | | } |
| | | 284 | | } |
| | | 285 | | |
| | 0 | 286 | | 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 | | { |
| | 0 | 297 | | var lower = userMessage.Trim().ToLowerInvariant(); |
| | 0 | 298 | | var sync = DetectIntent(userMessage, context); |
| | | 299 | | |
| | | 300 | | // Поисковый запрос ошибочно попал в write_transaction / write_edit → orders_filter |
| | 0 | 301 | | if (sync?.ToolName is "write_transaction" or "write_edit" |
| | 0 | 302 | | && AiOrderFilterParser.IsReadOnlyOrderSearchIntent(lower)) |
| | | 303 | | { |
| | 0 | 304 | | return Task.FromResult<AiQueryIntent?>(new AiQueryIntent |
| | 0 | 305 | | { |
| | 0 | 306 | | ToolName = "orders_filter", |
| | 0 | 307 | | RequiresFilterStudio = true, |
| | 0 | 308 | | }); |
| | | 309 | | } |
| | | 310 | | |
| | | 311 | | // Составной запрос («убыточные Emex», «просроченные у поставщика X») → orders_filter, не preset-tool |
| | 0 | 312 | | if (sync != null && IsPresetOrdersTool(sync.ToolName) |
| | 0 | 313 | | && LooksLikeCompoundFilterQuery(lower) |
| | 0 | 314 | | && !IsBarePresetQuery(lower, sync.ToolName)) |
| | | 315 | | { |
| | 0 | 316 | | return Task.FromResult<AiQueryIntent?>(new AiQueryIntent |
| | 0 | 317 | | { |
| | 0 | 318 | | ToolName = "orders_filter", |
| | 0 | 319 | | RequiresFilterStudio = true, |
| | 0 | 320 | | }); |
| | | 321 | | } |
| | | 322 | | |
| | 0 | 323 | | if (sync != null) |
| | 0 | 324 | | return Task.FromResult<AiQueryIntent?>(sync); |
| | | 325 | | |
| | 0 | 326 | | if (!lower.Contains("заказ") && !lower.Contains("order")) |
| | 0 | 327 | | return Task.FromResult<AiQueryIntent?>(null); |
| | | 328 | | |
| | 0 | 329 | | if (!AiOrderFilterParser.LooksLikeOrdersFilterQuery(userMessage)) |
| | 0 | 330 | | return Task.FromResult<AiQueryIntent?>(null); |
| | | 331 | | |
| | 0 | 332 | | return Task.FromResult<AiQueryIntent?>(new AiQueryIntent |
| | 0 | 333 | | { |
| | 0 | 334 | | ToolName = "orders_filter", |
| | 0 | 335 | | RequiresFilterStudio = true, |
| | 0 | 336 | | }); |
| | | 337 | | } |
| | | 338 | | |
| | 0 | 339 | | private static bool IsPresetOrdersTool(string? toolName) => toolName is |
| | 0 | 340 | | "unprofitable_orders" or "overdue_orders" or "no_supplier_orders" or "orders_summary"; |
| | | 341 | | |
| | | 342 | | private static bool LooksLikeCompoundFilterQuery(string lower) => |
| | 0 | 343 | | lower.Contains("поставщик") || lower.Contains("supplier") |
| | 0 | 344 | | || lower.Contains("emex") || lower.Contains("эмекс") |
| | 0 | 345 | | || lower.Contains("найди") || lower.Contains("покажи") || lower.Contains("поиск") |
| | 0 | 346 | | || lower.Contains("фильтр") || lower.Contains("статус") |
| | 0 | 347 | | || 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 | | { |
| | 0 | 355 | | var ctx = previous ?? new AiChatConversationContext(); |
| | | 356 | | |
| | 0 | 357 | | if (intent != null) |
| | 0 | 358 | | ctx.LastToolName = intent.ToolName; |
| | | 359 | | |
| | 0 | 360 | | var distinct = DeduplicateSummaries(orders); |
| | 0 | 361 | | if (distinct.Count == 1) |
| | | 362 | | { |
| | 0 | 363 | | ctx.OrderId = distinct[0].Id; |
| | 0 | 364 | | ctx.ShipmentNumber = distinct[0].ShipmentNumber; |
| | 0 | 365 | | if (!string.IsNullOrWhiteSpace(distinct[0].Article)) |
| | 0 | 366 | | ctx.Article = distinct[0].Article; |
| | | 367 | | } |
| | 0 | 368 | | else if (intent?.OrderId is > 0) |
| | | 369 | | { |
| | 0 | 370 | | ctx.OrderId = intent.OrderId; |
| | | 371 | | } |
| | 0 | 372 | | else if (!string.IsNullOrWhiteSpace(intent?.ShipmentNumber)) |
| | | 373 | | { |
| | 0 | 374 | | ctx.ShipmentNumber = intent.ShipmentNumber; |
| | 0 | 375 | | if (distinct.Count > 0) |
| | 0 | 376 | | ctx.OrderId = distinct[0].Id; |
| | | 377 | | } |
| | | 378 | | |
| | 0 | 379 | | if (!string.IsNullOrWhiteSpace(intent?.Article)) |
| | 0 | 380 | | ctx.Article = intent.Article; |
| | 0 | 381 | | if (!string.IsNullOrWhiteSpace(intent?.ManufacturerPrefix)) |
| | 0 | 382 | | ctx.ManufacturerPrefix = intent.ManufacturerPrefix; |
| | | 383 | | |
| | | 384 | | // Запоминаем тип транзакции если proposal с незаполненными полями — чтобы следующее |
| | | 385 | | // сообщение пользователя ("цена 100") могло подхватить контекст |
| | 0 | 386 | | if (proposal?.Kind == "create_transaction" && proposal.TransactionDto != null && |
| | 0 | 387 | | proposal.MissingFields is { Count: > 0 }) |
| | | 388 | | { |
| | 0 | 389 | | ctx.PendingTransactionType = proposal.TransactionDto.TransactionTypeValue; |
| | | 390 | | } |
| | 0 | 391 | | else if (proposal?.Kind == "create_transaction" && proposal.MissingFields is null or { Count: 0 }) |
| | | 392 | | { |
| | | 393 | | // Proposal показан без missing — сбрасываем pending (транзакция будет подтверждена) |
| | 0 | 394 | | ctx.PendingTransactionType = null; |
| | | 395 | | } |
| | 0 | 396 | | else if (proposal == null && intent?.WriteKind == "create_transaction" && intent.TransactionTypeValue.HasValue) |
| | | 397 | | { |
| | | 398 | | // Заказ не найден, но тип транзакции известен — сохраняем для следующего уточнения |
| | 0 | 399 | | ctx.PendingTransactionType = intent.TransactionTypeValue.Value; |
| | | 400 | | } |
| | | 401 | | |
| | 0 | 402 | | return ctx; |
| | | 403 | | } |
| | | 404 | | |
| | | 405 | | public static List<OrderSummaryDto> DeduplicateSummaries(IEnumerable<OrderSummaryDto> orders) => |
| | 86 | 406 | | orders.GroupBy(o => o.Id).Select(g => g.First()).ToList(); |
| | | 407 | | |
| | | 408 | | private static List<Order> DedupeById(List<Order> orders) => |
| | 0 | 409 | | 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 | | { |
| | 0 | 417 | | intent = null!; |
| | 0 | 418 | | if (context?.OrderId is not > 0 && string.IsNullOrWhiteSpace(context?.ShipmentNumber)) |
| | 0 | 419 | | return false; |
| | | 420 | | |
| | 0 | 421 | | var tool = wantsHistory || lower.Contains("истори") ? "order_history" : "order_detail"; |
| | | 422 | | |
| | 0 | 423 | | intent = new AiQueryIntent |
| | 0 | 424 | | { |
| | 0 | 425 | | ToolName = tool, |
| | 0 | 426 | | OrderId = context!.OrderId, |
| | 0 | 427 | | ShipmentNumber = context.ShipmentNumber |
| | 0 | 428 | | }; |
| | 0 | 429 | | return true; |
| | | 430 | | } |
| | | 431 | | |
| | | 432 | | private static bool IsContextualOrderReference(string lower) => |
| | 0 | 433 | | lower.Contains("этого заказа") || lower.Contains("этом заказе") || |
| | 0 | 434 | | lower.Contains("этот заказ") || lower.Contains("эту заказ") || |
| | 0 | 435 | | lower.Contains("этому заказу") || lower.Contains("по нему") || |
| | 0 | 436 | | lower.Contains("про него") || lower.Contains("его истор") || |
| | 0 | 437 | | (lower.Contains("этого") && (lower.Contains("истори") || lower.Contains("заказ"))) || |
| | 0 | 438 | | (lower.Contains("этом") && lower.Contains("заказ")) || |
| | 0 | 439 | | lower.Contains("того же заказа") || lower.Contains("тот же заказ") || |
| | 0 | 440 | | lower.Contains("что скажешь") || lower.Contains("что там с") || |
| | 0 | 441 | | lower.Contains("расскажи про") || lower.Contains("расскажи об") || |
| | 0 | 442 | | lower.Contains("покажи заказ") || lower.Contains("открой заказ") || |
| | 0 | 443 | | lower.Contains("посмотри заказ") || lower.Contains("давай по заказу") || |
| | 0 | 444 | | lower.Contains("инфу по") || lower.Contains("инфо по") || |
| | 0 | 445 | | lower.Contains("информац") && lower.Contains(" по ") || |
| | 0 | 446 | | lower.Contains("данные по") || lower.Contains("данных по") || |
| | 0 | 447 | | lower.Contains("выведи инф") || lower.Contains("выведи дан") || |
| | 0 | 448 | | lower.Contains("анализ заказа") || lower.Contains("разбери заказ") || |
| | 0 | 449 | | lower.Contains("что с заказом") || lower.Contains("что с этим заказом"); |
| | | 450 | | |
| | | 451 | | private static bool IsContextualProductInfoIntent(string lower) => |
| | 0 | 452 | | lower.Contains("что за продукт") || lower.Contains("какой товар") || |
| | 0 | 453 | | lower.Contains("что за товар") || lower.Contains("что в заказе") || |
| | 0 | 454 | | lower.Contains("про продукт") || lower.Contains("про товар") || |
| | 0 | 455 | | lower.Contains("какой продукт") || lower.Contains("что за продукте"); |
| | | 456 | | |
| | | 457 | | private static bool IsContextualProductReference(string lower) => |
| | 0 | 458 | | lower.Contains("этого товара") || lower.Contains("этом товаре") || |
| | 0 | 459 | | lower.Contains("этот товар") || lower.Contains("по товару") || |
| | 0 | 460 | | lower.Contains("про товар") || lower.Contains("этого артикул") || |
| | 0 | 461 | | (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) => |
| | 0 | 468 | | await _bitrixArticleHistory.GetAllAsync(article, maxReports: AiArticleHistoryMaxReports, ct); |
| | | 469 | | |
| | | 470 | | private static bool IsFullTransactionCommand(string lower) => |
| | 0 | 471 | | lower.Contains("проведи") || lower.Contains("проведите") || lower.Contains("провести") || |
| | 0 | 472 | | lower.Contains("создай транзакц") || lower.Contains("создать транзакц"); |
| | | 473 | | |
| | | 474 | | private static bool IsPendingTransactionClarification(string lower) => |
| | 0 | 475 | | lower.Contains("давай") || lower.Contains("проводи") || lower.Contains("цена") || |
| | 0 | 476 | | lower.Contains("ок") || lower.Contains("хорошо") |
| | 0 | 477 | | || lower.Trim() is "да" or "да." or "да!" |
| | 0 | 478 | | || (lower.Contains("поставщик") && !lower.Contains("заказан поставщик")) |
| | 0 | 479 | | || (lower.Contains("склад") && !lower.Contains("на склад") && !lower.Contains("принят на склад")); |
| | | 480 | | |
| | | 481 | | private static bool IsOzonLiveProductIntent(string lower) => |
| | 0 | 482 | | ((lower.Contains("актуальн") || lower.Contains("сейчас") || lower.Contains("live")) && |
| | 0 | 483 | | (lower.Contains("ozon") || lower.Contains("озон") || lower.Contains("остатк") || lower.Contains("цен"))) || |
| | 0 | 484 | | lower.Contains("проверить на ozon") || lower.Contains("проверить на озон") || |
| | 0 | 485 | | lower.Contains("остатки на ozon") || lower.Contains("данные с ozon") || |
| | 0 | 486 | | lower.Contains("ozon seller api") || lower.Contains("запрос к ozon") || |
| | 0 | 487 | | lower.Contains("запрос к озон"); |
| | | 488 | | |
| | | 489 | | private static bool IsBitrixStockIntent(string lower) => |
| | 0 | 490 | | lower.Contains("синхронизац") || lower.Contains("bitrix") || |
| | 0 | 491 | | lower.Contains("bitrixstock") || lower.Contains("накат") || |
| | 0 | 492 | | lower.Contains("остатк") || lower.Contains("наличи") || |
| | 0 | 493 | | lower.Contains("product key") || lower.Contains("этап ") || |
| | 0 | 494 | | (lower.Contains("почему") && (lower.Contains("цен") || lower.Contains("количеств") || lower.Contains("остат"))) |
| | 0 | 495 | | (lower.Contains("товар") && (lower.Contains("цен") || lower.Contains("склад") || |
| | 0 | 496 | | lower.Contains("ozon") || lower.Contains("озон") || lower.Contains("остат") || |
| | 0 | 497 | | 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 | | { |
| | 0 | 505 | | article = context?.Article?.Trim() |
| | 0 | 506 | | ?? AiArticleParser.TryParseBareArticle(trimmed) |
| | 0 | 507 | | ?? AiArticleParser.TryExtractBareArticleToken(trimmed) |
| | 0 | 508 | | ?? ""; |
| | 0 | 509 | | manufacturerPrefix = context?.ManufacturerPrefix; |
| | 0 | 510 | | if (!string.IsNullOrWhiteSpace(article)) |
| | 0 | 511 | | return true; |
| | | 512 | | |
| | 0 | 513 | | var pk = AiArticleParser.TryParseProductKey(trimmed); |
| | 0 | 514 | | if (pk == null) |
| | 0 | 515 | | return false; |
| | | 516 | | |
| | 0 | 517 | | article = pk.Value.Article; |
| | 0 | 518 | | manufacturerPrefix ??= pk.Value.ManufacturerPrefix; |
| | 0 | 519 | | return true; |
| | | 520 | | } |
| | | 521 | | |
| | | 522 | | private static int? TryParseOrderId(string text) |
| | | 523 | | { |
| | 0 | 524 | | var hash = OrderIdHashRegex().Match(text); |
| | 0 | 525 | | if (hash.Success && int.TryParse(hash.Groups[1].Value, out var id1)) |
| | 0 | 526 | | return id1; |
| | | 527 | | |
| | 0 | 528 | | var word = OrderIdWordRegex().Match(text); |
| | 0 | 529 | | if (word.Success && int.TryParse(word.Groups[1].Value, out var id2)) |
| | 0 | 530 | | return id2; |
| | | 531 | | |
| | | 532 | | // Голый числовой ID — если всё сообщение состоит только из числа 4-7 знаков |
| | 0 | 533 | | var bare = BareOrderIdRegex().Match(text); |
| | 0 | 534 | | if (bare.Success && int.TryParse(bare.Groups[1].Value, out var id3)) |
| | 0 | 535 | | return id3; |
| | | 536 | | |
| | 0 | 537 | | return null; |
| | | 538 | | } |
| | | 539 | | |
| | | 540 | | private static bool IsOrderHistoryIntent(string lower) => |
| | 0 | 541 | | lower.Contains("истори") || lower.Contains("изменен") || lower.Contains("изменён") || |
| | 0 | 542 | | lower.Contains("кто менял") || lower.Contains("кто измен") || |
| | 0 | 543 | | lower.Contains("лог измен") || lower.Contains("журнал") || |
| | 0 | 544 | | lower.Contains("timeline") || lower.Contains("audit"); |
| | | 545 | | |
| | | 546 | | private static bool IsUserActionsTodayIntent(string lower) => |
| | 0 | 547 | | (lower.Contains("сегодня") || lower.Contains("today")) && |
| | 0 | 548 | | (lower.Contains("действ") || lower.Contains("пользовател") || |
| | 0 | 549 | | lower.Contains("кто что") || lower.Contains("кто сделал") || |
| | 0 | 550 | | lower.Contains("активност") || lower.Contains("менял") || |
| | 0 | 551 | | lower.Contains("изменен") || lower.Contains("изменён")); |
| | | 552 | | |
| | | 553 | | private static bool IsTransactionTypesIntent(string lower) => |
| | 0 | 554 | | lower.Contains("транзакц") && |
| | 0 | 555 | | (lower.Contains("вид") || lower.Contains("тип") || lower.Contains("что делает") || |
| | 0 | 556 | | lower.Contains("что они делают") || lower.Contains("какие бывают") || |
| | 0 | 557 | | lower.Contains("описан") || lower.Contains("справк") || lower.Contains("объясни") || |
| | 0 | 558 | | lower.Contains("значит") || lower.Contains("расскаж")); |
| | | 559 | | |
| | | 560 | | /// <summary>«Покажи просроченные» без доп. фильтров — оставляем preset-tool.</summary> |
| | 0 | 561 | | private static bool IsBarePresetQuery(string lower, string toolName) => toolName switch |
| | 0 | 562 | | { |
| | 0 | 563 | | "overdue_orders" => (lower.Contains("просроч") || lower.Contains("overdue")) |
| | 0 | 564 | | && !lower.Contains("поставщик") && !lower.Contains("склад") |
| | 0 | 565 | | && !lower.Contains(" emex") && !lower.Contains("эмекс"), |
| | 0 | 566 | | "unprofitable_orders" => (lower.Contains("убыточ") || lower.Contains("невыгод")) |
| | 0 | 567 | | && !lower.Contains("поставщик"), |
| | 0 | 568 | | "no_supplier_orders" => lower.Contains("без поставщик") || lower.Contains("нет поставщик"), |
| | 0 | 569 | | "orders_summary" => lower.Contains("сводк") || lower.Contains("итого"), |
| | 0 | 570 | | _ => false |
| | 0 | 571 | | }; |
| | | 572 | | |
| | | 573 | | private static bool IsCancelledTodayIntent(string lower) => |
| | 0 | 574 | | (lower.Contains("отмен") || lower.Contains("cancel")) && |
| | 0 | 575 | | (lower.Contains("сегодня") || lower.Contains("today")); |
| | | 576 | | |
| | | 577 | | private static bool IsOrdersTodayIntent(string lower) => |
| | 0 | 578 | | (lower.Contains("сегодня") || lower.Contains("today")) && |
| | 0 | 579 | | !lower.Contains("отмен") && !lower.Contains("cancel") && |
| | 0 | 580 | | (lower.Contains("заказ") || lower.Contains("order") || |
| | 0 | 581 | | lower.Contains("найди") || lower.Contains("покаж") || |
| | 0 | 582 | | lower.Contains("выгруз") || lower.Contains("список") || |
| | 0 | 583 | | 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 | | { |
| | 0 | 592 | | _logger.LogInformation("Running AI query tool: {Tool}, shipment={Shipment}", |
| | 0 | 593 | | intent.ToolName, intent.ShipmentNumber); |
| | | 594 | | |
| | | 595 | | // Используем originalMessage для write-операций (для извлечения значений полей) |
| | 0 | 596 | | var userMessage = originalMessage ?? string.Empty; |
| | | 597 | | |
| | | 598 | | string context; |
| | | 599 | | List<OrderSummaryDto> orders; |
| | | 600 | | string? sqlQuery; |
| | 0 | 601 | | AiOzonLiveUiDto? ozonLiveUi = null; |
| | | 602 | | |
| | | 603 | | // ── Write-интенты: строим proposal, не читаем из БД ────────────────────── |
| | 0 | 604 | | if (intent.WriteKind == "edit_order") |
| | | 605 | | { |
| | 0 | 606 | | var orderId = await ResolveOrderIdAsync(intent, userId, ct); |
| | 0 | 607 | | if (orderId == null) |
| | | 608 | | { |
| | 0 | 609 | | context = "Не удалось найти заказ. Укажите номер отправления или #ID заказа."; |
| | 0 | 610 | | orders = []; |
| | | 611 | | } |
| | | 612 | | else |
| | | 613 | | { |
| | 0 | 614 | | var proposal = await _writeService.BuildEditProposalAsync(orderId.Value, userMessage, userId, ct); |
| | 0 | 615 | | if (proposal != null) |
| | | 616 | | { |
| | 0 | 617 | | orders = await GetSummariesByIdsAsync([orderId.Value], userId, ct); |
| | 0 | 618 | | context = proposal.MissingFields?.Count > 0 |
| | 0 | 619 | | ? $"Для изменения заказа #{orderId} нужно уточнить: {string.Join(", ", proposal.MissingFields)}. |
| | 0 | 620 | | : $"Готово к выполнению: изменение полей заказа #{orderId} ({orders.FirstOrDefault()?.ShipmentNu |
| | 0 | 621 | | return new AiQueryResult { Context = context, Orders = orders, ActionProposal = proposal }; |
| | | 622 | | } |
| | 0 | 623 | | context = "Не удалось распознать, какие поля нужно изменить."; |
| | 0 | 624 | | orders = []; |
| | 0 | 625 | | } |
| | 0 | 626 | | return new AiQueryResult { Context = context, Orders = orders }; |
| | | 627 | | } |
| | | 628 | | |
| | 0 | 629 | | if (intent.WriteKind == "create_transaction") |
| | | 630 | | { |
| | 0 | 631 | | if (intent.TransactionTypeValue == null) |
| | | 632 | | { |
| | 0 | 633 | | context = "Тип транзакции не распознан. Уточните, какую транзакцию провести. " + |
| | 0 | 634 | | "Например: «заказан поставщику», «принят на склад», «к отмене»."; |
| | 0 | 635 | | return new AiQueryResult { Context = context, Orders = [] }; |
| | | 636 | | } |
| | | 637 | | |
| | 0 | 638 | | var transType = (OzonDomains.TransactionType)intent.TransactionTypeValue.Value; |
| | | 639 | | |
| | | 640 | | // ── Batch-режим: несколько отправлений ───────────────────────────── |
| | 0 | 641 | | if (intent.ShipmentNumbers is { Count: > 1 }) |
| | | 642 | | { |
| | 0 | 643 | | var proposals = new List<AiActionProposalDto>(); |
| | 0 | 644 | | var allOrderIds = new List<int>(); |
| | 0 | 645 | | var ctxParts = new List<string>(); |
| | | 646 | | |
| | 0 | 647 | | foreach (var shipment in intent.ShipmentNumbers) |
| | | 648 | | { |
| | 0 | 649 | | var singleIntent = new AiQueryIntent |
| | 0 | 650 | | { |
| | 0 | 651 | | ToolName = intent.ToolName, |
| | 0 | 652 | | WriteKind = intent.WriteKind, |
| | 0 | 653 | | ShipmentNumber = shipment, |
| | 0 | 654 | | TransactionTypeValue = intent.TransactionTypeValue |
| | 0 | 655 | | }; |
| | 0 | 656 | | var oid = await ResolveOrderIdAsync(singleIntent, userId, ct); |
| | 0 | 657 | | if (oid == null) |
| | | 658 | | { |
| | 0 | 659 | | ctxParts.Add($"Заказ «{shipment}» не найден."); |
| | 0 | 660 | | continue; |
| | | 661 | | } |
| | | 662 | | |
| | 0 | 663 | | var p = await _writeService.BuildTransactionProposalAsync(oid.Value, transType, userMessage, userId, |
| | 0 | 664 | | if (p != null) |
| | | 665 | | { |
| | 0 | 666 | | proposals.Add(p); |
| | 0 | 667 | | allOrderIds.Add(oid.Value); |
| | 0 | 668 | | ctxParts.Add(p.MissingFields?.Count > 0 |
| | 0 | 669 | | ? AiWriteContextFormatter.FormatTransactionProposal(p, null) |
| | 0 | 670 | | : $"Готово: «{transType.GetDisplayName()}» для «{shipment}»."); |
| | | 671 | | } |
| | | 672 | | else |
| | | 673 | | { |
| | 0 | 674 | | ctxParts.Add($"Заказ «{shipment}» недоступен."); |
| | | 675 | | } |
| | 0 | 676 | | } |
| | | 677 | | |
| | 0 | 678 | | orders = allOrderIds.Count > 0 ? await GetSummariesByIdsAsync(allOrderIds, userId, ct) : []; |
| | 0 | 679 | | context = string.Join(" ", ctxParts); |
| | 0 | 680 | | return new AiQueryResult |
| | 0 | 681 | | { |
| | 0 | 682 | | Context = context, |
| | 0 | 683 | | Orders = orders, |
| | 0 | 684 | | ActionProposal = proposals.Count == 1 ? proposals[0] : null, |
| | 0 | 685 | | ActionProposals = proposals.Count > 1 ? proposals : null, |
| | 0 | 686 | | }; |
| | | 687 | | } |
| | | 688 | | |
| | | 689 | | // ── Одиночный заказ ───────────────────────────────────────────────── |
| | 0 | 690 | | var orderId = await ResolveOrderIdAsync(intent, userId, ct); |
| | 0 | 691 | | if (orderId == null) |
| | | 692 | | { |
| | 0 | 693 | | context = "Не удалось найти заказ. Укажите номер отправления или #ID заказа."; |
| | 0 | 694 | | return new AiQueryResult { Context = context, Orders = [] }; |
| | | 695 | | } |
| | | 696 | | |
| | 0 | 697 | | var proposal = await _writeService.BuildTransactionProposalAsync(orderId.Value, transType, userMessage, use |
| | 0 | 698 | | if (proposal != null) |
| | | 699 | | { |
| | 0 | 700 | | orders = await GetSummariesByIdsAsync([orderId.Value], userId, ct); |
| | 0 | 701 | | var summary = orders.FirstOrDefault(); |
| | 0 | 702 | | context = proposal.MissingFields?.Count > 0 |
| | 0 | 703 | | ? AiWriteContextFormatter.FormatTransactionProposal(proposal, summary) |
| | 0 | 704 | | : $"Готово: транзакция «{transType.GetDisplayName()}» для заказа #{orderId} ({summary?.ShipmentNumbe |
| | 0 | 705 | | AiWriteContextFormatter.FormatTransactionProposal(proposal, summary); |
| | 0 | 706 | | return new AiQueryResult { Context = context, Orders = orders, ActionProposal = proposal }; |
| | | 707 | | } |
| | 0 | 708 | | context = "Не удалось построить транзакцию. Заказ недоступен или не найден."; |
| | 0 | 709 | | return new AiQueryResult { Context = context, Orders = [] }; |
| | | 710 | | } |
| | | 711 | | |
| | 0 | 712 | | if (intent.ToolName == "ozon_live_product") |
| | | 713 | | { |
| | 0 | 714 | | (context, orders, sqlQuery, ozonLiveUi) = |
| | 0 | 715 | | await GetOzonLiveProductAsync(intent, userId, ct, onProgress); |
| | | 716 | | } |
| | 0 | 717 | | else if (intent.ToolName == "order_detail") |
| | | 718 | | { |
| | 0 | 719 | | var (detailCtx, detailOrders, detailSql, detailOzonLive) = |
| | 0 | 720 | | await GetOrderDetailAsync(intent, userId, ct, onProgress); |
| | 0 | 721 | | context = detailCtx; |
| | 0 | 722 | | orders = detailOrders; |
| | 0 | 723 | | sqlQuery = detailSql; |
| | 0 | 724 | | ozonLiveUi = detailOzonLive; |
| | | 725 | | } |
| | 0 | 726 | | else if (intent.ToolName == "orders_filter") |
| | | 727 | | { |
| | 0 | 728 | | var criteria = intent.Filter; |
| | 0 | 729 | | string? filterSpec = null; |
| | | 730 | | |
| | 0 | 731 | | if (criteria == null || intent.RequiresFilterStudio) |
| | | 732 | | { |
| | 0 | 733 | | var nowMsk = TimeZoneInfo.ConvertTimeFromUtc( |
| | 0 | 734 | | DateTime.UtcNow, |
| | 0 | 735 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 736 | | |
| | 0 | 737 | | var studio = await _filterStudio.AnalyzeAndBuildAsync( |
| | 0 | 738 | | userMessage ?? string.Empty, |
| | 0 | 739 | | nowMsk, |
| | 0 | 740 | | onProgress, |
| | 0 | 741 | | ct); |
| | | 742 | | |
| | 0 | 743 | | criteria = studio.Criteria; |
| | 0 | 744 | | filterSpec = studio.SpecificationSummary; |
| | | 745 | | } |
| | | 746 | | |
| | 0 | 747 | | if (criteria == null || !criteria.HasCriteria) |
| | | 748 | | { |
| | 0 | 749 | | return new AiQueryResult |
| | 0 | 750 | | { |
| | 0 | 751 | | Context = "Не удалось разобрать критерии поиска заказов.", |
| | 0 | 752 | | IsSearchResult = true, |
| | 0 | 753 | | }; |
| | | 754 | | } |
| | | 755 | | |
| | 0 | 756 | | var searchResult = await _searchAgent.SearchAsync( |
| | 0 | 757 | | criteria, userId, ct, onProgress: onProgress); |
| | 0 | 758 | | orders = DeduplicateSummaries(searchResult.Orders); |
| | 0 | 759 | | sqlQuery = searchResult.Sql; |
| | 0 | 760 | | context = orders.Count == 0 |
| | 0 | 761 | | ? BuildZeroResultsContext(searchResult) |
| | 0 | 762 | | : $"Поиск заказов: {searchResult.AppliedFiltersDescription}.\n" + |
| | 0 | 763 | | (filterSpec != null ? $"Спецификация студии: {filterSpec}.\n" : string.Empty) + |
| | 0 | 764 | | $"Всего найдено {searchResult.TotalCount} шт., показано {orders.Count}:\n" + |
| | 0 | 765 | | FormatOrderList(orders); |
| | | 766 | | |
| | 0 | 767 | | var enrichment2 = await BuildEnrichmentIfNeededAsync(intent.ToolName, intent, orders, ct); |
| | 0 | 768 | | return new AiQueryResult |
| | 0 | 769 | | { |
| | 0 | 770 | | Context = context, |
| | 0 | 771 | | Orders = orders, |
| | 0 | 772 | | Enrichment = enrichment2, |
| | 0 | 773 | | SqlQuery = sqlQuery, |
| | 0 | 774 | | IsSearchResult = true, |
| | 0 | 775 | | AppliedFiltersDescription = searchResult.AppliedFiltersDescription, |
| | 0 | 776 | | TotalFound = searchResult.TotalCount, |
| | 0 | 777 | | AppliedCriteria = criteria, |
| | 0 | 778 | | FilterSpecification = filterSpec, |
| | 0 | 779 | | RelaxedSuggestion = searchResult.RelaxedSuggestion, |
| | 0 | 780 | | RelaxedCount = searchResult.RelaxedCount > 0 ? searchResult.RelaxedCount : null, |
| | 0 | 781 | | SuggestionQuery = searchResult.SuggestionQuery, |
| | 0 | 782 | | }; |
| | | 783 | | } |
| | | 784 | | else |
| | | 785 | | { |
| | 0 | 786 | | (context, orders, sqlQuery) = intent.ToolName switch |
| | 0 | 787 | | { |
| | 0 | 788 | | "overdue_orders" => await GetOverdueOrdersAsync(userId, ct), |
| | 0 | 789 | | "unprofitable_orders" => await GetUnprofitableOrdersAsync(userId, ct), |
| | 0 | 790 | | "cancelled_orders_today" => await GetCancelledOrdersTodayAsync(userId, ct), |
| | 0 | 791 | | "orders_today" => await GetOrdersTodayAsync(userId, ct), |
| | 0 | 792 | | "orders_summary" => await GetOrdersSummaryAsync(userId, ct), |
| | 0 | 793 | | "no_supplier_orders" => await GetNoSupplierOrdersAsync(userId, ct), |
| | 0 | 794 | | "order_history" => await GetOrderHistoryAsync(intent, userId, ct), |
| | 0 | 795 | | "order_history_recent" => await GetOrderHistoryRecentAsync(userId, ct), |
| | 0 | 796 | | "user_actions_today" => await GetUserActionsTodayAsync(userId, ct), |
| | 0 | 797 | | "transaction_types_info" => (AiTransactionTypeGlossary.FormatForPrompt(), [], null), |
| | 0 | 798 | | "bitrix_stock_article" => await GetBitrixStockArticleHistoryAsync(intent, userId, ct, onProgress), |
| | 0 | 799 | | "order_product" => await GetOrderProductAsync(intent, userId, ct), |
| | 0 | 800 | | _ => ("Запрос не распознан.", [], null) |
| | 0 | 801 | | }; |
| | | 802 | | } |
| | | 803 | | |
| | | 804 | | // Дедупликация до отправки в UI и в AI-контекст — чтобы агент |
| | | 805 | | // называл то же число, что видит пользователь в таблице. |
| | 0 | 806 | | var rawCount = orders.Count; |
| | 0 | 807 | | orders = DeduplicateSummaries(orders); |
| | 0 | 808 | | if (orders.Count != rawCount && orders.Count > 0) |
| | 0 | 809 | | context += $"\n\nПримечание: из {rawCount} строк БД показано {orders.Count} уникальных заказов (дедупликация |
| | | 810 | | |
| | 0 | 811 | | var enrichment = await BuildEnrichmentIfNeededAsync(intent.ToolName, intent, orders, ct); |
| | 0 | 812 | | if (enrichment != null && ozonLiveUi != null) |
| | 0 | 813 | | enrichment.OzonLive = ozonLiveUi; |
| | 0 | 814 | | else if (enrichment == null && ozonLiveUi != null) |
| | 0 | 815 | | enrichment = new AiChatEnrichmentDto { OzonLive = ozonLiveUi }; |
| | | 816 | | |
| | 0 | 817 | | return new AiQueryResult |
| | 0 | 818 | | { |
| | 0 | 819 | | Context = context, |
| | 0 | 820 | | Orders = orders, |
| | 0 | 821 | | Enrichment = enrichment, |
| | 0 | 822 | | SqlQuery = sqlQuery, |
| | 0 | 823 | | OzonLiveUi = ozonLiveUi |
| | 0 | 824 | | }; |
| | 0 | 825 | | } |
| | | 826 | | |
| | | 827 | | private async Task<AiChatEnrichmentDto?> BuildEnrichmentIfNeededAsync( |
| | | 828 | | string? toolName, |
| | | 829 | | AiQueryIntent intent, |
| | | 830 | | List<OrderSummaryDto> orders, |
| | | 831 | | CancellationToken ct) |
| | | 832 | | { |
| | 0 | 833 | | if (toolName is null || !EnrichmentTools.Contains(toolName)) |
| | 0 | 834 | | return null; |
| | | 835 | | |
| | 0 | 836 | | var article = intent.Article?.Trim(); |
| | 0 | 837 | | var mfr = intent.ManufacturerPrefix; |
| | | 838 | | |
| | 0 | 839 | | if (string.IsNullOrWhiteSpace(article) && orders.Count > 0) |
| | | 840 | | { |
| | 0 | 841 | | article = orders[0].Article?.Trim(); |
| | 0 | 842 | | mfr ??= orders[0].ManufacturerName; |
| | | 843 | | } |
| | | 844 | | |
| | 0 | 845 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 846 | | return null; |
| | | 847 | | |
| | 0 | 848 | | string? imageUrl = null; |
| | 0 | 849 | | string? productName = null; |
| | 0 | 850 | | if (orders.Count > 0) |
| | | 851 | | { |
| | 0 | 852 | | imageUrl = orders[0].ProductImageUrl; |
| | 0 | 853 | | productName = orders[0].ProductName; |
| | | 854 | | } |
| | | 855 | | |
| | 0 | 856 | | return await _enrichmentBuilder.BuildForArticleAsync(article, mfr, imageUrl, productName, ct); |
| | 0 | 857 | | } |
| | | 858 | | |
| | | 859 | | private async Task<IQueryable<Order>> ScopedOrdersAsync( |
| | | 860 | | string? userId, |
| | | 861 | | bool includeArchived, |
| | | 862 | | CancellationToken ct) |
| | | 863 | | { |
| | 0 | 864 | | var query = _orderRepository.GetQueryable(includeArchived); |
| | 0 | 865 | | return await _scopeApplier.ApplyAsync(query, userId, ct); |
| | 0 | 866 | | } |
| | | 867 | | |
| | | 868 | | private Task<IQueryable<Order>> ScopedActiveOrdersAsync(string? userId, CancellationToken ct) => |
| | 0 | 869 | | ScopedOrdersAsync(userId, includeArchived: false, ct); |
| | | 870 | | |
| | | 871 | | private static IQueryable<Order> WithNavigations(IQueryable<Order> query) => |
| | 0 | 872 | | query |
| | 0 | 873 | | .Include(o => o.AppStatus!).ThenInclude(s => s.StatusColor) |
| | 0 | 874 | | .Include(o => o.ClientStatus!).ThenInclude(s => s.StatusColor) |
| | 0 | 875 | | .Include(o => o.Supplier) |
| | 0 | 876 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 877 | | .Include(o => o.OzonClient) |
| | 0 | 878 | | .Include(o => o.Manufacturer) |
| | 0 | 879 | | .Include(o => o.EtProducer) |
| | 0 | 880 | | .Include(o => o.ProductInfo) |
| | 0 | 881 | | .Include(o => o.Delivery) |
| | 0 | 882 | | .Include(o => o.Сurrency) |
| | 0 | 883 | | .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 | | { |
| | 0 | 891 | | if (intent.OrderId.HasValue) |
| | | 892 | | { |
| | 0 | 893 | | return DedupeById(await WithNavigations(await ScopedOrdersAsync(userId, includeArchived, ct)) |
| | 0 | 894 | | .Where(o => o.Id == intent.OrderId.Value) |
| | 0 | 895 | | .Take(1) |
| | 0 | 896 | | .ToListAsync(ct)); |
| | | 897 | | } |
| | | 898 | | |
| | 0 | 899 | | if (string.IsNullOrWhiteSpace(intent.ShipmentNumber)) |
| | 0 | 900 | | return []; |
| | | 901 | | |
| | 0 | 902 | | var shipmentNumber = intent.ShipmentNumber.Trim(); |
| | 0 | 903 | | var baseQuery = WithNavigations(await ScopedOrdersAsync(userId, includeArchived, ct)); |
| | | 904 | | |
| | 0 | 905 | | var orders = await baseQuery |
| | 0 | 906 | | .Where(o => o.ShipmentNumber == shipmentNumber) |
| | 0 | 907 | | .Take(5) |
| | 0 | 908 | | .ToListAsync(ct); |
| | | 909 | | |
| | 0 | 910 | | orders = DedupeById(orders); |
| | | 911 | | |
| | 0 | 912 | | if (orders.Count == 0) |
| | | 913 | | { |
| | 0 | 914 | | orders = await baseQuery |
| | 0 | 915 | | .Where(o => o.ShipmentNumber != null && |
| | 0 | 916 | | o.ShipmentNumber.StartsWith(shipmentNumber + "-")) |
| | 0 | 917 | | .Take(5) |
| | 0 | 918 | | .ToListAsync(ct); |
| | 0 | 919 | | orders = DedupeById(orders); |
| | | 920 | | } |
| | | 921 | | |
| | | 922 | | // Fallback: ищем по OrderNumberToSupplier (например "АД 0008158", "ВК-0127368") |
| | | 923 | | // Нормализуем: приводим пробелы и дефисы к единому виду для сравнения |
| | 0 | 924 | | if (orders.Count == 0) |
| | | 925 | | { |
| | 0 | 926 | | var normalized = shipmentNumber.Replace(" ", "-"); |
| | 0 | 927 | | var withSpace = shipmentNumber.Replace("-", " "); |
| | 0 | 928 | | orders = await baseQuery |
| | 0 | 929 | | .Where(o => o.OrderNumberToSupplier != null && |
| | 0 | 930 | | (o.OrderNumberToSupplier == shipmentNumber || |
| | 0 | 931 | | o.OrderNumberToSupplier == normalized || |
| | 0 | 932 | | o.OrderNumberToSupplier == withSpace)) |
| | 0 | 933 | | .Take(5) |
| | 0 | 934 | | .ToListAsync(ct); |
| | 0 | 935 | | orders = DedupeById(orders); |
| | | 936 | | } |
| | | 937 | | |
| | 0 | 938 | | return orders; |
| | 0 | 939 | | } |
| | | 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 | | { |
| | 0 | 948 | | var limited = query.Take(take); |
| | 0 | 949 | | var sql = limited.ToQueryString(); |
| | 0 | 950 | | var rows = await limited.ToListAsync(ct); |
| | 0 | 951 | | return (rows, sql); |
| | 0 | 952 | | } |
| | | 953 | | |
| | | 954 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOrdersTodayAsync(string? userId, CancellationToken c |
| | | 955 | | { |
| | 0 | 956 | | var todayUtc = DateTime.UtcNow.Date; |
| | 0 | 957 | | var tomorrow = todayUtc.AddDays(1); |
| | | 958 | | |
| | 0 | 959 | | var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 960 | | .Where(o => o.ProcessingDate >= todayUtc && o.ProcessingDate < tomorrow) |
| | 0 | 961 | | .OrderByDescending(o => o.ProcessingDate); |
| | | 962 | | |
| | 0 | 963 | | var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct); |
| | | 964 | | |
| | 0 | 965 | | var mskDate = TimeZoneInfo.ConvertTimeFromUtc( |
| | 0 | 966 | | DateTime.UtcNow, |
| | 0 | 967 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 968 | | |
| | 0 | 969 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 970 | | var context = orders.Count == 0 |
| | 0 | 971 | | ? $"Заказов, принятых в обработку сегодня ({mskDate:dd.MM.yyyy}), не найдено." |
| | 0 | 972 | | : $"Заказы за сегодня ({mskDate:dd.MM.yyyy}, по ProcessingDate, {orders.Count} шт.):\n" + |
| | 0 | 973 | | FormatOrderList(dtos); |
| | | 974 | | |
| | 0 | 975 | | return (context, dtos, sql); |
| | 0 | 976 | | } |
| | | 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 | | { |
| | 0 | 986 | | return await GetOrderDetailCoreAsync(intent, userId, ct, onProgress); |
| | | 987 | | } |
| | | 988 | | catch (Exception ex) |
| | | 989 | | { |
| | 0 | 990 | | _logger.LogWarning(ex, "GetOrderDetailAsync failed for shipment={Shipment}", intent.ShipmentNumber); |
| | 0 | 991 | | var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct); |
| | 0 | 992 | | if (orders.Count == 0) |
| | 0 | 993 | | return ($"Ошибка загрузки деталей заказа: {ex.Message}", [], null, null); |
| | | 994 | | |
| | 0 | 995 | | var dtos = DeduplicateSummaries(orders.Select(MapToSummary).ToList()); |
| | 0 | 996 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 997 | | sb.AppendLine(AiWriteContextFormatter.FormatOrderFactsHeader(orders[0])); |
| | 0 | 998 | | sb.AppendLine(); |
| | 0 | 999 | | sb.AppendLine("⚠️ Полная загрузка агентов не удалась; показаны базовые данные заказа."); |
| | 0 | 1000 | | return (sb.ToString().TrimEnd(), dtos, null, null); |
| | | 1001 | | } |
| | 0 | 1002 | | } |
| | | 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 | | { |
| | 0 | 1010 | | if (!intent.OrderId.HasValue && string.IsNullOrWhiteSpace(intent.ShipmentNumber)) |
| | 0 | 1011 | | return ("Не указан заказ для анализа.", [], null, null); |
| | | 1012 | | |
| | 0 | 1013 | | var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: false, ct); |
| | 0 | 1014 | | var searchedArchive = false; |
| | | 1015 | | |
| | 0 | 1016 | | if (orders.Count == 0) |
| | | 1017 | | { |
| | 0 | 1018 | | orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct); |
| | 0 | 1019 | | searchedArchive = orders.Count > 0; |
| | | 1020 | | } |
| | | 1021 | | |
| | 0 | 1022 | | if (orders.Count == 0) |
| | | 1023 | | { |
| | 0 | 1024 | | var label = intent.OrderId.HasValue |
| | 0 | 1025 | | ? $"ID {intent.OrderId}" |
| | 0 | 1026 | | : $"«{intent.ShipmentNumber}»"; |
| | 0 | 1027 | | return ($"Заказ {label} не найден (включая архив OrderArchive, с учётом прав доступа).", [], null, null); |
| | | 1028 | | } |
| | | 1029 | | |
| | 0 | 1030 | | orders = DedupeById(orders); |
| | | 1031 | | |
| | 0 | 1032 | | var sb = new System.Text.StringBuilder(); |
| | | 1033 | | if (searchedArchive || orders.Any(o => o.OrderArchive != null)) |
| | | 1034 | | { |
| | 0 | 1035 | | sb.AppendLine( |
| | 0 | 1036 | | "ℹ️ Заказ находится в архиве (OrderArchive): в главной таблице по умолчанию скрыт. " + |
| | 0 | 1037 | | "Восстановление — /Studio2/OrderArchive."); |
| | 0 | 1038 | | sb.AppendLine(); |
| | | 1039 | | } |
| | | 1040 | | |
| | 0 | 1041 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 1042 | | dtos = DeduplicateSummaries(dtos); |
| | | 1043 | | |
| | | 1044 | | var orderIds = orders.Select(o => o.Id).ToList(); |
| | 0 | 1045 | | var allHistories = await _db.OrderHistories |
| | 0 | 1046 | | .AsNoTracking() |
| | 0 | 1047 | | .Where(h => orderIds.Contains(h.OrderId)) |
| | 0 | 1048 | | .OrderBy(h => h.ChangedAt) |
| | 0 | 1049 | | .Take(200) |
| | 0 | 1050 | | .ToListAsync(ct); |
| | | 1051 | | |
| | 0 | 1052 | | AiOzonLiveUiDto? ozonLiveUi = null; |
| | | 1053 | | |
| | | 1054 | | // Агент-шаги для real-time UI (5 параллельных агентов) |
| | 0 | 1055 | | var agentSteps = new List<AiProcessStepDto> |
| | 0 | 1056 | | { |
| | 0 | 1057 | | new() { Key = "product", Label = "Товар и WarehouseStock", IsAgent = true, Status = "pending" }, |
| | 0 | 1058 | | new() { Key = "bitrix", Label = "Синхронизация 1С / Ozon", IsAgent = true, Status = "pending" }, |
| | 0 | 1059 | | new() { Key = "ozon_live", Label = "Ozon Seller API", IsAgent = true, Status = "pending" }, |
| | 0 | 1060 | | new() { Key = "transactions", Label = "Транзакции", IsAgent = true, Status = "pending" }, |
| | 0 | 1061 | | new() { Key = "history", Label = "История изменений", IsAgent = true, Status = "pending" }, |
| | 0 | 1062 | | }; |
| | | 1063 | | |
| | | 1064 | | // Отправляем агент-шаги клиенту — все pending |
| | 0 | 1065 | | if (onProgress != null) |
| | 0 | 1066 | | await EmitProgressAsync(onProgress, agentSteps, logLine: "⟳ Запуск параллельных агентов…", logIcon: "spin"); |
| | | 1067 | | |
| | | 1068 | | // ── Параллельный запуск агентов ────────────────────────────────────────── |
| | 0 | 1069 | | foreach (var order in orders) |
| | | 1070 | | { |
| | 0 | 1071 | | var orderHistories = allHistories.Where(h => h.OrderId == order.Id).ToList(); |
| | 0 | 1072 | | sb.AppendLine(AiWriteContextFormatter.FormatOrderFactsHeader(order)); |
| | 0 | 1073 | | sb.AppendLine(); |
| | 0 | 1074 | | sb.AppendLine(AiOrderTimelineFormatter.Format(order, orderHistories)); |
| | 0 | 1075 | | sb.AppendLine(); |
| | | 1076 | | |
| | 0 | 1077 | | var hasArticle = !string.IsNullOrWhiteSpace(order.Article); |
| | 0 | 1078 | | var mfr = order.Manufacturer?.Name ?? order.Manufacturer?.Code ?? order.EtProducer?.Name; |
| | | 1079 | | |
| | | 1080 | | // Результаты агентов |
| | 0 | 1081 | | string? productText = null, bitrixText = null, liveText = null; |
| | 0 | 1082 | | string? transactionsText = null, historyText = null; |
| | 0 | 1083 | | AiOzonLiveUiDto? liveUi = null; |
| | | 1084 | | |
| | | 1085 | | AiProcessStepDto AgentStep(string key) => |
| | 0 | 1086 | | agentSteps.First(s => s.Key == key); |
| | | 1087 | | |
| | 0 | 1088 | | var productTask = hasArticle |
| | 0 | 1089 | | ? Task.Run(async () => |
| | 0 | 1090 | | { |
| | 0 | 1091 | | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1092 | | var step = AgentStep("product"); |
| | 0 | 1093 | | step.Status = "active"; |
| | 0 | 1094 | | step.Detail = "WarehouseStock…"; |
| | 0 | 1095 | | await EmitProgressAsync(onProgress, agentSteps); |
| | 0 | 1096 | | try { productText = await _productContextLoader.LoadFormattedAsync(order.Article!, mfr, ct); } |
| | 0 | 1097 | | catch (Exception ex) { _logger.LogWarning(ex, "Agent product failed"); step.Status = "error"; } |
| | 0 | 1098 | | sw.Stop(); step.Ms = sw.ElapsedMilliseconds; |
| | 0 | 1099 | | if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; } |
| | 0 | 1100 | | await EmitProgressAsync(onProgress, agentSteps, |
| | 0 | 1101 | | logLine: step.Status == "error" ? "✗ Товар и WarehouseStock" : $"✓ Товар ({step.Ms} мс)", |
| | 0 | 1102 | | logIcon: step.Status == "error" ? "error" : "check"); |
| | 0 | 1103 | | }, ct) |
| | 0 | 1104 | | : Task.CompletedTask; |
| | | 1105 | | |
| | 0 | 1106 | | var bitrixTask = hasArticle |
| | 0 | 1107 | | ? Task.Run(async () => |
| | 0 | 1108 | | { |
| | 0 | 1109 | | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1110 | | var step = AgentStep("bitrix"); |
| | 0 | 1111 | | step.Status = "active"; |
| | 0 | 1112 | | step.Detail = "Отчёты синхронизации…"; |
| | 0 | 1113 | | await EmitProgressAsync(onProgress, agentSteps); |
| | 0 | 1114 | | try |
| | 0 | 1115 | | { |
| | 0 | 1116 | | var page = await LoadBitrixHistoryForAiAsync(order.Article!.Trim(), ct); |
| | 0 | 1117 | | bitrixText = BitrixStockArticleHistoryAiFormatter.FormatForAiAnalysis(page); |
| | 0 | 1118 | | } |
| | 0 | 1119 | | catch (Exception ex) { _logger.LogWarning(ex, "Agent bitrix failed"); step.Status = "error"; } |
| | 0 | 1120 | | sw.Stop(); step.Ms = sw.ElapsedMilliseconds; |
| | 0 | 1121 | | if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; } |
| | 0 | 1122 | | await EmitProgressAsync(onProgress, agentSteps, |
| | 0 | 1123 | | logLine: step.Status == "error" ? "✗ Синхронизация 1С / Ozon" : $"✓ Синхронизация ({step.Ms} мс) |
| | 0 | 1124 | | logIcon: step.Status == "error" ? "error" : "check"); |
| | 0 | 1125 | | }, ct) |
| | 0 | 1126 | | : Task.CompletedTask; |
| | | 1127 | | |
| | 0 | 1128 | | var ozonTask = (hasArticle && ozonLiveUi == null) |
| | 0 | 1129 | | ? Task.Run(async () => |
| | 0 | 1130 | | { |
| | 0 | 1131 | | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1132 | | var step = AgentStep("ozon_live"); |
| | 0 | 1133 | | step.Status = "active"; |
| | 0 | 1134 | | step.Detail = "Ozon Seller API…"; |
| | 0 | 1135 | | await EmitProgressAsync(onProgress, agentSteps); |
| | 0 | 1136 | | try |
| | 0 | 1137 | | { |
| | 0 | 1138 | | (liveText, liveUi) = await _ozonLiveProduct.LoadAsync( |
| | 0 | 1139 | | order.Article!.Trim(), mfr, order.Id, userId, ct); |
| | 0 | 1140 | | } |
| | 0 | 1141 | | catch (Exception ex) { _logger.LogWarning(ex, "Agent ozon_live failed"); step.Status = "error"; } |
| | 0 | 1142 | | sw.Stop(); step.Ms = sw.ElapsedMilliseconds; |
| | 0 | 1143 | | if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; } |
| | 0 | 1144 | | await EmitProgressAsync(onProgress, agentSteps, |
| | 0 | 1145 | | logLine: step.Status == "error" ? "✗ Ozon Seller API" : $"✓ Ozon API ({step.Ms} мс)", |
| | 0 | 1146 | | logIcon: step.Status == "error" ? "error" : "check"); |
| | 0 | 1147 | | }, ct) |
| | 0 | 1148 | | : Task.CompletedTask; |
| | | 1149 | | |
| | 0 | 1150 | | var txTask = Task.Run(async () => |
| | 0 | 1151 | | { |
| | 0 | 1152 | | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1153 | | var step = AgentStep("transactions"); |
| | 0 | 1154 | | step.Status = "active"; |
| | 0 | 1155 | | step.Detail = "Транзакции заказа…"; |
| | 0 | 1156 | | await EmitProgressAsync(onProgress, agentSteps); |
| | 0 | 1157 | | try |
| | 0 | 1158 | | { |
| | 0 | 1159 | | var txList = await LoadTransactionsForOrderAsync(order, orderHistories, ct); |
| | 0 | 1160 | | transactionsText = OrderRussianFieldFormatter.FormatOrderDetail(order, txList); |
| | 0 | 1161 | | } |
| | 0 | 1162 | | catch (Exception ex) { _logger.LogWarning(ex, "Agent transactions failed"); step.Status = "error"; } |
| | 0 | 1163 | | sw.Stop(); step.Ms = sw.ElapsedMilliseconds; |
| | 0 | 1164 | | if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; } |
| | 0 | 1165 | | await EmitProgressAsync(onProgress, agentSteps, |
| | 0 | 1166 | | logLine: step.Status == "error" ? "✗ Транзакции" : $"✓ Транзакции ({step.Ms} мс)", |
| | 0 | 1167 | | logIcon: step.Status == "error" ? "error" : "check"); |
| | 0 | 1168 | | }, ct); |
| | | 1169 | | |
| | 0 | 1170 | | var historyTask = Task.Run(async () => |
| | 0 | 1171 | | { |
| | 0 | 1172 | | var sw = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1173 | | var step = AgentStep("history"); |
| | 0 | 1174 | | step.Status = "active"; |
| | 0 | 1175 | | step.Detail = "История изменений…"; |
| | 0 | 1176 | | await EmitProgressAsync(onProgress, agentSteps); |
| | 0 | 1177 | | try { historyText = await _historyPageLoader.LoadFormattedContextAsync(order.Id, userId, ct); } |
| | 0 | 1178 | | catch (Exception ex) { _logger.LogWarning(ex, "Agent history failed"); step.Status = "error"; } |
| | 0 | 1179 | | sw.Stop(); step.Ms = sw.ElapsedMilliseconds; |
| | 0 | 1180 | | if (step.Status != "error") { step.Status = "done"; step.Done = true; step.Detail = null; } |
| | 0 | 1181 | | await EmitProgressAsync(onProgress, agentSteps, |
| | 0 | 1182 | | logLine: step.Status == "error" ? "✗ История изменений" : $"✓ История ({step.Ms} мс)", |
| | 0 | 1183 | | logIcon: step.Status == "error" ? "error" : "check"); |
| | 0 | 1184 | | }, ct); |
| | | 1185 | | |
| | 0 | 1186 | | await Task.WhenAll(productTask, bitrixTask, ozonTask, txTask, historyTask); |
| | | 1187 | | |
| | | 1188 | | // Собираем результаты в порядке для контекста LLM |
| | 0 | 1189 | | if (!string.IsNullOrWhiteSpace(productText)) { sb.AppendLine(productText); sb.AppendLine(); } |
| | 0 | 1190 | | if (!string.IsNullOrWhiteSpace(bitrixText)) { sb.AppendLine(bitrixText); sb.AppendLine(); } |
| | 0 | 1191 | | if (!string.IsNullOrWhiteSpace(liveText)) { sb.AppendLine(liveText); sb.AppendLine(); } |
| | 0 | 1192 | | if (!string.IsNullOrWhiteSpace(transactionsText)) { sb.AppendLine(transactionsText); } |
| | 0 | 1193 | | if (!string.IsNullOrWhiteSpace(historyText)) { sb.AppendLine(); sb.AppendLine(historyT |
| | | 1194 | | |
| | 0 | 1195 | | if (liveUi != null) ozonLiveUi = liveUi; |
| | 0 | 1196 | | sb.AppendLine(); |
| | 0 | 1197 | | } |
| | | 1198 | | |
| | 0 | 1199 | | if (orders.Count > 1 && !string.IsNullOrWhiteSpace(intent.ShipmentNumber)) |
| | | 1200 | | { |
| | 0 | 1201 | | sb.Insert(0, $"Найдено {orders.Count} заказов по запросу «{intent.ShipmentNumber}»:\n\n"); |
| | | 1202 | | } |
| | | 1203 | | |
| | 0 | 1204 | | return (sb.ToString().TrimEnd(), dtos, null, ozonLiveUi); |
| | 0 | 1205 | | } |
| | | 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 | | { |
| | 0 | 1213 | | var article = intent.Article?.Trim(); |
| | 0 | 1214 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 1215 | | return ("Укажите артикул или product key, например «09146=FEB».", [], null); |
| | | 1216 | | |
| | 0 | 1217 | | var agentSteps = new List<AiProcessStepDto> |
| | 0 | 1218 | | { |
| | 0 | 1219 | | new() { Key = "bitrix_history", Label = "История синхронизаций", IsAgent = true, Status = "pending" }, |
| | 0 | 1220 | | new() { Key = "warehouse", Label = "WarehouseStock", IsAgent = true, Status = "pending" }, |
| | 0 | 1221 | | new() { Key = "orders_recent", Label = "Связанные заказы", IsAgent = true, Status = "pending" }, |
| | 0 | 1222 | | }; |
| | | 1223 | | |
| | 0 | 1224 | | AiProcessStepDto Step(string key) => agentSteps.First(s => s.Key == key); |
| | | 1225 | | |
| | 0 | 1226 | | Step("bitrix_history").Status = "active"; |
| | 0 | 1227 | | Step("bitrix_history").Detail = article; |
| | 0 | 1228 | | await EmitProgressAsync(onProgress, agentSteps, "⟳ История синхронизаций Bitrix…", "spin"); |
| | 0 | 1229 | | var sw1 = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1230 | | var page = await LoadBitrixHistoryForAiAsync(article, ct); |
| | 0 | 1231 | | var text = BitrixStockArticleHistoryAiFormatter.FormatForAiAnalysis(page, intent.ManufacturerPrefix); |
| | 0 | 1232 | | sw1.Stop(); |
| | 0 | 1233 | | Step("bitrix_history").Status = "done"; |
| | 0 | 1234 | | Step("bitrix_history").Done = true; |
| | 0 | 1235 | | Step("bitrix_history").Ms = sw1.ElapsedMilliseconds; |
| | 0 | 1236 | | Step("bitrix_history").Detail = null; |
| | 0 | 1237 | | await EmitProgressAsync(onProgress, agentSteps, $"✓ История синхронизаций ({sw1.ElapsedMilliseconds} мс)", "chec |
| | | 1238 | | |
| | 0 | 1239 | | Step("warehouse").Status = "active"; |
| | 0 | 1240 | | Step("warehouse").Detail = "Остатки и движения…"; |
| | 0 | 1241 | | await EmitProgressAsync(onProgress, agentSteps, "⟳ WarehouseStock…", "spin"); |
| | 0 | 1242 | | var sw2 = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1243 | | var productCtx = await _productContextLoader.LoadFormattedAsync(article, intent.ManufacturerPrefix, ct); |
| | 0 | 1244 | | sw2.Stop(); |
| | 0 | 1245 | | Step("warehouse").Status = "done"; |
| | 0 | 1246 | | Step("warehouse").Done = true; |
| | 0 | 1247 | | Step("warehouse").Ms = sw2.ElapsedMilliseconds; |
| | 0 | 1248 | | Step("warehouse").Detail = null; |
| | 0 | 1249 | | await EmitProgressAsync(onProgress, agentSteps, $"✓ WarehouseStock ({sw2.ElapsedMilliseconds} мс)", "check"); |
| | | 1250 | | |
| | 0 | 1251 | | var fullText = text + "\n\n" + productCtx; |
| | | 1252 | | |
| | 0 | 1253 | | Step("orders_recent").Status = "active"; |
| | 0 | 1254 | | Step("orders_recent").Detail = "До 3 заказов…"; |
| | 0 | 1255 | | await EmitProgressAsync(onProgress, agentSteps, "⟳ Связанные заказы…", "spin"); |
| | 0 | 1256 | | var sw3 = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1257 | | var recentQuery = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1258 | | .Where(o => o.Article == article) |
| | 0 | 1259 | | .OrderByDescending(o => o.ProcessingDate); |
| | | 1260 | | |
| | 0 | 1261 | | var (recentOrders, sql) = await MaterializeOrderListAsync(recentQuery, 3, ct); |
| | 0 | 1262 | | sw3.Stop(); |
| | 0 | 1263 | | Step("orders_recent").Status = "done"; |
| | 0 | 1264 | | Step("orders_recent").Done = true; |
| | 0 | 1265 | | Step("orders_recent").Ms = sw3.ElapsedMilliseconds; |
| | 0 | 1266 | | Step("orders_recent").Detail = $"{recentOrders.Count} шт."; |
| | 0 | 1267 | | await EmitProgressAsync(onProgress, agentSteps, $"✓ Связанные заказы: {recentOrders.Count}", "check"); |
| | | 1268 | | |
| | 0 | 1269 | | var dtos = DeduplicateSummaries(recentOrders.Select(MapToSummary).ToList()); |
| | 0 | 1270 | | return (fullText, dtos, sql); |
| | 0 | 1271 | | } |
| | | 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 | | { |
| | 0 | 1279 | | var article = intent.Article?.Trim(); |
| | 0 | 1280 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 1281 | | return ("Укажите артикул для запроса к Ozon API.", [], null, null); |
| | | 1282 | | |
| | 0 | 1283 | | var agentSteps = new List<AiProcessStepDto> |
| | 0 | 1284 | | { |
| | 0 | 1285 | | new() { Key = "ozon_api", Label = "Ozon Seller API", IsAgent = true, Status = "pending" }, |
| | 0 | 1286 | | new() { Key = "orders_recent", Label = "Связанные заказы", IsAgent = true, Status = "pending" }, |
| | 0 | 1287 | | }; |
| | | 1288 | | |
| | 0 | 1289 | | AiProcessStepDto Step(string key) => agentSteps.First(s => s.Key == key); |
| | | 1290 | | |
| | 0 | 1291 | | Step("ozon_api").Status = "active"; |
| | 0 | 1292 | | Step("ozon_api").Detail = article; |
| | 0 | 1293 | | await EmitProgressAsync(onProgress, agentSteps, "⟳ Запрос к Ozon Seller API…", "spin"); |
| | 0 | 1294 | | var sw1 = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1295 | | var (liveText, liveUi) = await _ozonLiveProduct.LoadAsync( |
| | 0 | 1296 | | article, |
| | 0 | 1297 | | intent.ManufacturerPrefix, |
| | 0 | 1298 | | intent.OrderId, |
| | 0 | 1299 | | userId, |
| | 0 | 1300 | | ct); |
| | 0 | 1301 | | sw1.Stop(); |
| | 0 | 1302 | | Step("ozon_api").Status = "done"; |
| | 0 | 1303 | | Step("ozon_api").Done = true; |
| | 0 | 1304 | | Step("ozon_api").Ms = sw1.ElapsedMilliseconds; |
| | 0 | 1305 | | Step("ozon_api").Detail = null; |
| | 0 | 1306 | | await EmitProgressAsync(onProgress, agentSteps, $"✓ Ozon API ({sw1.ElapsedMilliseconds} мс)", "check"); |
| | | 1307 | | |
| | 0 | 1308 | | Step("orders_recent").Status = "active"; |
| | 0 | 1309 | | await EmitProgressAsync(onProgress, agentSteps, "⟳ Связанные заказы…", "spin"); |
| | 0 | 1310 | | var sw2 = System.Diagnostics.Stopwatch.StartNew(); |
| | 0 | 1311 | | var recentQuery = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1312 | | .Where(o => o.Article == article) |
| | 0 | 1313 | | .OrderByDescending(o => o.ProcessingDate); |
| | | 1314 | | |
| | 0 | 1315 | | var (recentOrders, sql) = await MaterializeOrderListAsync(recentQuery, 3, ct); |
| | 0 | 1316 | | sw2.Stop(); |
| | 0 | 1317 | | Step("orders_recent").Status = "done"; |
| | 0 | 1318 | | Step("orders_recent").Done = true; |
| | 0 | 1319 | | Step("orders_recent").Ms = sw2.ElapsedMilliseconds; |
| | 0 | 1320 | | Step("orders_recent").Detail = $"{recentOrders.Count} шт."; |
| | 0 | 1321 | | await EmitProgressAsync(onProgress, agentSteps, $"✓ Связанные заказы: {recentOrders.Count}", "check"); |
| | | 1322 | | |
| | 0 | 1323 | | var dtos = DeduplicateSummaries(recentOrders.Select(MapToSummary).ToList()); |
| | 0 | 1324 | | return (liveText, dtos, sql, liveUi); |
| | 0 | 1325 | | } |
| | | 1326 | | |
| | | 1327 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderProductAsync( |
| | | 1328 | | AiQueryIntent intent, |
| | | 1329 | | string? userId, |
| | | 1330 | | CancellationToken ct) |
| | | 1331 | | { |
| | 0 | 1332 | | if (intent.OrderId is not > 0) |
| | 0 | 1333 | | return ("Укажите заказ (номер отправления или #ID).", [], null); |
| | | 1334 | | |
| | 0 | 1335 | | var (context, dtos, sql, _) = await GetOrderDetailAsync(new AiQueryIntent |
| | 0 | 1336 | | { |
| | 0 | 1337 | | ToolName = "order_detail", |
| | 0 | 1338 | | OrderId = intent.OrderId.Value |
| | 0 | 1339 | | }, userId, ct); |
| | | 1340 | | |
| | 0 | 1341 | | if (dtos.Count == 0) |
| | 0 | 1342 | | return (context, dtos, sql); |
| | | 1343 | | |
| | 0 | 1344 | | var header = $"=== Товар в заказе #{intent.OrderId} ({dtos[0].ShipmentNumber}) ===\n" + |
| | 0 | 1345 | | $"Наименование: {dtos[0].ProductName}\n" + |
| | 0 | 1346 | | $"Артикул: {dtos[0].Article} | Производитель: {dtos[0].ManufacturerName}\n\n"; |
| | | 1347 | | |
| | 0 | 1348 | | return (header + context, dtos, sql); |
| | 0 | 1349 | | } |
| | | 1350 | | |
| | | 1351 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderHistoryAsync( |
| | | 1352 | | AiQueryIntent intent, |
| | | 1353 | | string? userId, |
| | | 1354 | | CancellationToken ct) |
| | | 1355 | | { |
| | | 1356 | | List<int> orderIds; |
| | 0 | 1357 | | string? sql = null; |
| | | 1358 | | |
| | 0 | 1359 | | if (intent.OrderId.HasValue) |
| | | 1360 | | { |
| | 0 | 1361 | | var allowed = await FilterOrderIdsAsync([intent.OrderId.Value], userId, ct); |
| | 0 | 1362 | | if (allowed.Length == 0) |
| | 0 | 1363 | | return ($"Заказ #{intent.OrderId} недоступен или не найден.", [], null); |
| | 0 | 1364 | | orderIds = allowed.ToList(); |
| | | 1365 | | } |
| | 0 | 1366 | | else if (!string.IsNullOrWhiteSpace(intent.ShipmentNumber)) |
| | | 1367 | | { |
| | 0 | 1368 | | var lookup = await GetOrderDetailAsync(new AiQueryIntent |
| | 0 | 1369 | | { |
| | 0 | 1370 | | ToolName = "order_detail", |
| | 0 | 1371 | | ShipmentNumber = intent.ShipmentNumber |
| | 0 | 1372 | | }, userId, ct); |
| | | 1373 | | orderIds = lookup.Item2.Select(o => o.Id).ToList(); |
| | 0 | 1374 | | sql = lookup.Item3; |
| | 0 | 1375 | | if (orderIds.Count == 0) |
| | 0 | 1376 | | return ($"Заказ «{intent.ShipmentNumber}» не найден.", [], null); |
| | | 1377 | | } |
| | | 1378 | | else |
| | | 1379 | | { |
| | 0 | 1380 | | return ("Укажите номер отправления или #ID заказа.", [], null); |
| | | 1381 | | } |
| | | 1382 | | |
| | 0 | 1383 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 1384 | | foreach (var id in orderIds) |
| | | 1385 | | { |
| | 0 | 1386 | | var pageCtx = await _historyPageLoader.LoadFormattedContextAsync(id, userId, ct); |
| | 0 | 1387 | | if (!string.IsNullOrWhiteSpace(pageCtx)) |
| | | 1388 | | { |
| | 0 | 1389 | | sb.AppendLine(pageCtx); |
| | 0 | 1390 | | sb.AppendLine(); |
| | | 1391 | | } |
| | | 1392 | | } |
| | | 1393 | | |
| | 0 | 1394 | | if (sb.Length == 0) |
| | | 1395 | | { |
| | 0 | 1396 | | return ("История заказа недоступна или не найдена.", [], null); |
| | | 1397 | | } |
| | | 1398 | | |
| | 0 | 1399 | | var dtos = await GetSummariesByIdsAsync(orderIds, userId, ct); |
| | 0 | 1400 | | return (sb.ToString().TrimEnd(), dtos, sql); |
| | 0 | 1401 | | } |
| | | 1402 | | |
| | | 1403 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOrderHistoryRecentAsync( |
| | | 1404 | | string? userId, |
| | | 1405 | | CancellationToken ct) |
| | | 1406 | | { |
| | 0 | 1407 | | var historyQuery = await BuildScopedHistoryQueryAsync(userId, ct); |
| | 0 | 1408 | | var limited = historyQuery |
| | 0 | 1409 | | .OrderByDescending(h => h.ChangedAt) |
| | 0 | 1410 | | .Take(60); |
| | 0 | 1411 | | var sql = limited.ToQueryString(); |
| | 0 | 1412 | | var rows = await limited.ToListAsync(ct); |
| | | 1413 | | |
| | | 1414 | | var orderIds = rows.Select(r => r.OrderId).Distinct().ToList(); |
| | 0 | 1415 | | var shipments = await LoadShipmentMapAsync(orderIds, ct); |
| | 0 | 1416 | | var context = OrderHistoryFormatter.FormatHistoryList(rows, shipments, "Последние изменения заказов"); |
| | 0 | 1417 | | var dtos = await GetSummariesByIdsAsync(orderIds, userId, ct); |
| | 0 | 1418 | | return (context, dtos, sql); |
| | 0 | 1419 | | } |
| | | 1420 | | |
| | | 1421 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetUserActionsTodayAsync( |
| | | 1422 | | string? userId, |
| | | 1423 | | CancellationToken ct) |
| | | 1424 | | { |
| | 0 | 1425 | | var todayUtc = DateTime.UtcNow.Date; |
| | 0 | 1426 | | var tomorrow = todayUtc.AddDays(1); |
| | | 1427 | | |
| | 0 | 1428 | | var msk = TimeZoneInfo.ConvertTimeFromUtc( |
| | 0 | 1429 | | DateTime.UtcNow, |
| | 0 | 1430 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 1431 | | |
| | 0 | 1432 | | var historyQuery = await BuildScopedHistoryQueryAsync(userId, ct); |
| | 0 | 1433 | | var limited = historyQuery |
| | 0 | 1434 | | .Where(h => h.ChangedAt >= todayUtc && h.ChangedAt < tomorrow) |
| | 0 | 1435 | | .OrderByDescending(h => h.ChangedAt) |
| | 0 | 1436 | | .Take(400); |
| | 0 | 1437 | | var sql = limited.ToQueryString(); |
| | 0 | 1438 | | var rows = await limited.ToListAsync(ct); |
| | | 1439 | | |
| | 0 | 1440 | | if (rows.Count == 0) |
| | | 1441 | | { |
| | 0 | 1442 | | return ($"Сегодня ({msk:dd.MM.yyyy}) изменений в orders_history не найдено.", [], sql); |
| | | 1443 | | } |
| | | 1444 | | |
| | | 1445 | | var orderIds = rows.Select(r => r.OrderId).Distinct().ToList(); |
| | 0 | 1446 | | var shipments = await LoadShipmentMapAsync(orderIds, ct); |
| | | 1447 | | |
| | 0 | 1448 | | var groups = rows |
| | | 1449 | | .GroupBy(h => string.IsNullOrWhiteSpace(h.ChangedBy) ? "—" : h.ChangedBy!.Trim()) |
| | 0 | 1450 | | .Select(g => |
| | 0 | 1451 | | { |
| | 0 | 1452 | | var samples = g.Take(5).Select(h => |
| | 0 | 1453 | | { |
| | 0 | 1454 | | shipments.TryGetValue(h.OrderId, out var ship); |
| | 0 | 1455 | | var field = h.ColumnDisplayName ?? h.ColumnName ?? "поле"; |
| | 0 | 1456 | | var shipPart = !string.IsNullOrWhiteSpace(ship) ? $" {ship}" : ""; |
| | 0 | 1457 | | return $"{h.ChangedAt:HH:mm} #{h.OrderId}{shipPart} {field}: «{h.FormattedOldValue}»→«{h.FormattedNe |
| | 0 | 1458 | | }).ToList(); |
| | 0 | 1459 | | return (User: g.Key, Count: g.Count(), Samples: (IReadOnlyList<string>)samples); |
| | 0 | 1460 | | }) |
| | 0 | 1461 | | .ToList(); |
| | | 1462 | | |
| | 0 | 1463 | | var summary = OrderHistoryFormatter.FormatUserActivitySummary( |
| | 0 | 1464 | | groups, |
| | 0 | 1465 | | $"Действия пользователей за сегодня ({msk:dd.MM.yyyy})"); |
| | | 1466 | | |
| | 0 | 1467 | | var detail = OrderHistoryFormatter.FormatHistoryList( |
| | 0 | 1468 | | rows.Take(50).ToList(), |
| | 0 | 1469 | | shipments, |
| | 0 | 1470 | | "Детализация (последние 50 изменений)"); |
| | | 1471 | | |
| | 0 | 1472 | | var context = summary + "\n\n" + detail; |
| | 0 | 1473 | | var dtos = await GetSummariesByIdsAsync(orderIds.Take(50), userId, ct); |
| | 0 | 1474 | | return (context, dtos, sql); |
| | 0 | 1475 | | } |
| | | 1476 | | |
| | | 1477 | | private async Task<IQueryable<OrderHistory>> BuildScopedHistoryQueryAsync( |
| | | 1478 | | string? userId, |
| | | 1479 | | CancellationToken ct) |
| | | 1480 | | { |
| | 0 | 1481 | | var scopedOrders = await ScopedActiveOrdersAsync(userId, ct); |
| | 0 | 1482 | | var scopedOrderIds = scopedOrders.Select(o => o.Id); |
| | 0 | 1483 | | return _db.OrderHistories |
| | 0 | 1484 | | .AsNoTracking() |
| | 0 | 1485 | | .Where(h => scopedOrderIds.Contains(h.OrderId)); |
| | 0 | 1486 | | } |
| | | 1487 | | |
| | | 1488 | | private async Task<List<OrderHistory>> LoadOrderHistoryRowsAsync( |
| | | 1489 | | int orderId, |
| | | 1490 | | string? userId, |
| | | 1491 | | int take, |
| | | 1492 | | CancellationToken ct) |
| | | 1493 | | { |
| | 0 | 1494 | | var allowed = await FilterOrderIdsAsync([orderId], userId, ct); |
| | 0 | 1495 | | if (allowed.Length == 0) |
| | 0 | 1496 | | return []; |
| | | 1497 | | |
| | 0 | 1498 | | return await _db.OrderHistories |
| | 0 | 1499 | | .AsNoTracking() |
| | 0 | 1500 | | .Where(h => h.OrderId == orderId) |
| | 0 | 1501 | | .OrderByDescending(h => h.ChangedAt) |
| | 0 | 1502 | | .Take(take) |
| | 0 | 1503 | | .ToListAsync(ct); |
| | 0 | 1504 | | } |
| | | 1505 | | |
| | | 1506 | | private async Task<Dictionary<int, string?>> LoadShipmentMapAsync( |
| | | 1507 | | IReadOnlyList<int> orderIds, |
| | | 1508 | | CancellationToken ct) |
| | | 1509 | | { |
| | 0 | 1510 | | if (orderIds.Count == 0) |
| | 0 | 1511 | | return new Dictionary<int, string?>(); |
| | | 1512 | | |
| | 0 | 1513 | | return await _db.Orders |
| | 0 | 1514 | | .AsNoTracking() |
| | 0 | 1515 | | .Where(o => orderIds.Contains(o.Id)) |
| | 0 | 1516 | | .Select(o => new { o.Id, o.ShipmentNumber }) |
| | | 1517 | | .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct); |
| | 0 | 1518 | | } |
| | | 1519 | | |
| | | 1520 | | private async Task<List<TransactionSummary>> LoadTransactionsForOrderAsync( |
| | | 1521 | | Order order, |
| | | 1522 | | IReadOnlyList<OrderHistory> histories, |
| | | 1523 | | CancellationToken ct) |
| | | 1524 | | { |
| | 0 | 1525 | | var idSet = AiOrderTransactionIdCollector.Collect(order, histories); |
| | | 1526 | | |
| | 0 | 1527 | | var linkedIds = await _db.Transactions |
| | 0 | 1528 | | .AsNoTracking() |
| | 0 | 1529 | | .Where(t => t.Orders.Any(o => o.Id == order.Id)) |
| | 0 | 1530 | | .Select(t => t.Id) |
| | 0 | 1531 | | .ToListAsync(ct); |
| | | 1532 | | |
| | 0 | 1533 | | foreach (var id in linkedIds) |
| | 0 | 1534 | | idSet.Add(id); |
| | | 1535 | | |
| | 0 | 1536 | | if (idSet.Count == 0) |
| | 0 | 1537 | | return []; |
| | | 1538 | | |
| | 0 | 1539 | | var rows = await _db.Transactions |
| | 0 | 1540 | | .AsNoTracking() |
| | 0 | 1541 | | .Where(t => idSet.Contains(t.Id)) |
| | 0 | 1542 | | .OrderByDescending(t => t.CreatedDateTime) |
| | 0 | 1543 | | .Take(30) |
| | 0 | 1544 | | .Select(t => new |
| | 0 | 1545 | | { |
| | 0 | 1546 | | t.Id, |
| | 0 | 1547 | | t.Type, |
| | 0 | 1548 | | t.CreatedDateTime, |
| | 0 | 1549 | | t.CreateBy, |
| | 0 | 1550 | | t.Comment |
| | 0 | 1551 | | }) |
| | 0 | 1552 | | .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(); |
| | 0 | 1562 | | } |
| | | 1563 | | |
| | | 1564 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOverdueOrdersAsync(string? userId, CancellationToken |
| | | 1565 | | { |
| | 0 | 1566 | | var now = DateTime.UtcNow; |
| | 0 | 1567 | | var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1568 | | .Where(o => o.ShippingDate < now |
| | 0 | 1569 | | && o.AppStatus != null |
| | 0 | 1570 | | && !o.AppStatus.IsFinal) |
| | 0 | 1571 | | .OrderBy(o => o.ShippingDate); |
| | | 1572 | | |
| | 0 | 1573 | | var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct); |
| | | 1574 | | |
| | 0 | 1575 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 1576 | | var context = orders.Count == 0 |
| | 0 | 1577 | | ? "Просроченных заказов не найдено." |
| | 0 | 1578 | | : $"Просроченные заказы ({orders.Count} шт.):\n" + FormatOrderList(dtos); |
| | | 1579 | | |
| | 0 | 1580 | | return (context, dtos, sql); |
| | 0 | 1581 | | } |
| | | 1582 | | |
| | | 1583 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetUnprofitableOrdersAsync(string? userId, Cancellation |
| | | 1584 | | { |
| | 0 | 1585 | | var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1586 | | .Where(o => o.AppStatus != null |
| | 0 | 1587 | | && !o.AppStatus.IsFinal |
| | 0 | 1588 | | && (o.MinProfit < 0 |
| | 0 | 1589 | | || (o.CostPrice > 0 && o.Price < o.CostPrice * 1.1m))) |
| | 0 | 1590 | | .OrderBy(o => o.MinProfit); |
| | | 1591 | | |
| | 0 | 1592 | | var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct); |
| | | 1593 | | |
| | 0 | 1594 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 1595 | | var context = orders.Count == 0 |
| | 0 | 1596 | | ? "Невыгодных заказов не найдено." |
| | 0 | 1597 | | : $"Невыгодные заказы ({orders.Count} шт.):\n" + FormatOrderList(dtos); |
| | | 1598 | | |
| | 0 | 1599 | | return (context, dtos, sql); |
| | 0 | 1600 | | } |
| | | 1601 | | |
| | | 1602 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetCancelledOrdersTodayAsync(string? userId, Cancellati |
| | | 1603 | | { |
| | 0 | 1604 | | var todayUtc = DateTime.UtcNow.Date; |
| | 0 | 1605 | | var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1606 | | .Where(o => o.ClientStatus != null |
| | 0 | 1607 | | && (o.ClientStatus.Name == "Отменён" || o.ClientStatus.Name == "Отменен") |
| | 0 | 1608 | | && o.LastStatusChangeDate >= todayUtc) |
| | 0 | 1609 | | .OrderByDescending(o => o.LastStatusChangeDate); |
| | | 1610 | | |
| | 0 | 1611 | | var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct); |
| | | 1612 | | |
| | 0 | 1613 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 1614 | | var context = orders.Count == 0 |
| | 0 | 1615 | | ? "Сегодня отменённых заказов нет." |
| | 0 | 1616 | | : $"=== ТОЧНОЕ ЧИСЛО (используй только его): {orders.Count} заказ(ов) отменено сегодня ===\n" + |
| | 0 | 1617 | | $"Отменённые заказы за сегодня ({orders.Count} шт.):\n" + FormatOrderList(dtos); |
| | | 1618 | | |
| | 0 | 1619 | | return (context, dtos, sql); |
| | 0 | 1620 | | } |
| | | 1621 | | |
| | | 1622 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetOrdersSummaryAsync(string? userId, CancellationToken |
| | | 1623 | | { |
| | 0 | 1624 | | var grouped = (await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1625 | | .GroupBy(o => o.AppStatus != null ? o.AppStatus.Name : "Без статуса") |
| | 0 | 1626 | | .Select(g => new { Status = g.Key, Count = g.Count() }); |
| | | 1627 | | |
| | 0 | 1628 | | var sql = grouped.ToQueryString(); |
| | 0 | 1629 | | var groups = await grouped.ToListAsync(ct); |
| | | 1630 | | |
| | 0 | 1631 | | var lines = groups |
| | | 1632 | | .OrderByDescending(g => g.Count) |
| | | 1633 | | .Select(g => $" {g.Status}: {g.Count} заказов"); |
| | | 1634 | | |
| | 0 | 1635 | | var context = "Сводка заказов по статусу:\n" + string.Join("\n", lines); |
| | 0 | 1636 | | return (context, [], sql); |
| | 0 | 1637 | | } |
| | | 1638 | | |
| | | 1639 | | private async Task<(string, List<OrderSummaryDto>, string?)> GetNoSupplierOrdersAsync(string? userId, CancellationTo |
| | | 1640 | | { |
| | 0 | 1641 | | var query = WithNavigations(await ScopedActiveOrdersAsync(userId, ct)) |
| | 0 | 1642 | | .Where(o => o.SupplierId == null |
| | 0 | 1643 | | && o.AppStatus != null |
| | 0 | 1644 | | && !o.AppStatus.IsFinal) |
| | 0 | 1645 | | .OrderByDescending(o => o.ProcessingDate); |
| | | 1646 | | |
| | 0 | 1647 | | var (orders, sql) = await MaterializeOrderListAsync(query, OrderListTake, ct); |
| | | 1648 | | |
| | 0 | 1649 | | var dtos = orders.Select(MapToSummary).ToList(); |
| | 0 | 1650 | | var context = orders.Count == 0 |
| | 0 | 1651 | | ? "Заказов без поставщика не найдено." |
| | 0 | 1652 | | : $"Заказы без поставщика ({orders.Count} шт.):\n" + FormatOrderList(dtos); |
| | | 1653 | | |
| | 0 | 1654 | | return (context, dtos, sql); |
| | 0 | 1655 | | } |
| | | 1656 | | |
| | 0 | 1657 | | private static OrderSummaryDto MapToSummary(Order o) => new() |
| | 0 | 1658 | | { |
| | 0 | 1659 | | Id = o.Id, |
| | 0 | 1660 | | ShipmentNumber = o.ShipmentNumber, |
| | 0 | 1661 | | ProductName = o.ProductName, |
| | 0 | 1662 | | Article = o.Article, |
| | 0 | 1663 | | AppStatus = o.AppStatus?.Name ?? "Не указан", |
| | 0 | 1664 | | ClientStatus = o.ClientStatus?.Name, |
| | 0 | 1665 | | AppStatusColor = o.AppStatus?.GetStatusColor(), |
| | 0 | 1666 | | ClientStatusColor = o.ClientStatus?.GetStatusColor(), |
| | 0 | 1667 | | ManufacturerName = o.Manufacturer?.Name ?? o.EtProducer?.Name, |
| | 0 | 1668 | | ProductImageUrl = o.ProductInfo?.ImageUrl, |
| | 0 | 1669 | | Quantity = o.Quantity, |
| | 0 | 1670 | | TrueQuantity = o.TrueQuantity, |
| | 0 | 1671 | | Supplier = o.Supplier?.Name, |
| | 0 | 1672 | | ShipmentWarehouseName = o.ShipmentWarehouse?.Name, |
| | 0 | 1673 | | OzonClientName = o.OzonClient?.Name, |
| | 0 | 1674 | | ShippingDate = o.ShippingDate, |
| | 0 | 1675 | | Price = o.Price, |
| | 0 | 1676 | | MinProfit = o.MinProfit, |
| | 0 | 1677 | | IsArchived = o.OrderArchive != null, |
| | 0 | 1678 | | ArchivedAt = o.OrderArchive?.ArchivedAt, |
| | 0 | 1679 | | ArchivedBy = o.OrderArchive?.ArchivedBy, |
| | 0 | 1680 | | ArchiveReason = o.OrderArchive?.ArchiveReason |
| | 0 | 1681 | | }; |
| | | 1682 | | |
| | | 1683 | | private static string BuildZeroResultsContext(AiOrderSearchResult searchResult) |
| | | 1684 | | { |
| | 0 | 1685 | | var sb = new System.Text.StringBuilder(); |
| | 0 | 1686 | | sb.Append($"Заказы по критериям ({searchResult.AppliedFiltersDescription}) не найдены."); |
| | 0 | 1687 | | if (searchResult.RelaxedSuggestion != null && searchResult.RelaxedCount > 0) |
| | | 1688 | | { |
| | 0 | 1689 | | sb.AppendLine(); |
| | 0 | 1690 | | sb.Append($"Без ужесточения по дате найдено {searchResult.RelaxedCount} заказ(ов). "); |
| | 0 | 1691 | | sb.Append("Предложи пользователю нажать кнопку ослабленного поиска под карточкой."); |
| | | 1692 | | } |
| | 0 | 1693 | | return sb.ToString(); |
| | | 1694 | | } |
| | | 1695 | | |
| | | 1696 | | private static string FormatOrderList(List<OrderSummaryDto> orders) => |
| | 0 | 1697 | | string.Join("\n", orders.Select(o => |
| | 0 | 1698 | | { |
| | 0 | 1699 | | var archive = o.IsArchived |
| | 0 | 1700 | | ? $" | АРХИВ с {o.ArchivedAt:dd.MM.yyyy HH:mm}" + |
| | 0 | 1701 | | (string.IsNullOrWhiteSpace(o.ArchivedBy) ? "" : $" ({o.ArchivedBy})") |
| | 0 | 1702 | | : ""; |
| | 0 | 1703 | | return |
| | 0 | 1704 | | $" #{o.Id} {o.ShipmentNumber} | {o.ProductName}{archive} | " + |
| | 0 | 1705 | | $"{FormatQuantityLabel(o.Quantity)} | " + |
| | 0 | 1706 | | $"Статус: {o.AppStatus} | Цена: {o.Price:N0} ₽ | " + |
| | 0 | 1707 | | $"Прибыль: {o.MinProfit:N0} ₽ | Дата отгрузки: {o.ShippingDate:dd.MM.yyyy}"; |
| | 0 | 1708 | | })); |
| | | 1709 | | |
| | | 1710 | | private static string FormatQuantityLabel(int? quantity) |
| | | 1711 | | { |
| | 0 | 1712 | | var qty = quantity ?? 1; |
| | 0 | 1713 | | 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 | | { |
| | 0 | 1724 | | if (onProgress == null) return; |
| | 0 | 1725 | | await onProgress(new AiAgentProgressUpdate |
| | 0 | 1726 | | { |
| | 0 | 1727 | | Steps = steps, |
| | 0 | 1728 | | Phase = phase, |
| | 0 | 1729 | | LogLine = logLine, |
| | 0 | 1730 | | LogIcon = logIcon, |
| | 0 | 1731 | | FromCache = fromCache, |
| | 0 | 1732 | | }); |
| | 0 | 1733 | | } |
| | | 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(); |
| | 0 | 1741 | | if (idArray.Length == 0) |
| | 0 | 1742 | | return []; |
| | | 1743 | | |
| | 0 | 1744 | | var query = _orderRepository.GetQueryable(includeArchived: true).Where(o => idArray.Contains(o.Id)); |
| | 0 | 1745 | | query = await _scopeApplier.ApplyAsync(query, userId, ct); |
| | 0 | 1746 | | return await query.Select(o => o.Id).ToArrayAsync(ct); |
| | 0 | 1747 | | } |
| | | 1748 | | |
| | | 1749 | | public async Task<List<OrderSummaryDto>> GetSummariesByIdsAsync( |
| | | 1750 | | IEnumerable<int> ids, |
| | | 1751 | | string? userId, |
| | | 1752 | | CancellationToken ct = default) |
| | | 1753 | | { |
| | 0 | 1754 | | var allowedIds = await FilterOrderIdsAsync(ids, userId, ct); |
| | 0 | 1755 | | if (allowedIds.Length == 0) |
| | 0 | 1756 | | return []; |
| | | 1757 | | |
| | 0 | 1758 | | var orders = DedupeById(await WithNavigations(_orderRepository.GetQueryable(includeArchived: true)) |
| | 0 | 1759 | | .Where(o => allowedIds.Contains(o.Id)) |
| | 0 | 1760 | | .OrderBy(o => o.Id) |
| | 0 | 1761 | | .ToListAsync(ct)); |
| | | 1762 | | |
| | 0 | 1763 | | return DeduplicateSummaries(orders.Select(MapToSummary).ToList()); |
| | 0 | 1764 | | } |
| | | 1765 | | |
| | | 1766 | | private async Task<int?> ResolveOrderIdAsync(AiQueryIntent intent, string? userId, CancellationToken ct) |
| | | 1767 | | { |
| | 0 | 1768 | | if (intent.OrderId.HasValue) |
| | | 1769 | | { |
| | 0 | 1770 | | var allowed = await FilterOrderIdsAsync([intent.OrderId.Value], userId, ct); |
| | 0 | 1771 | | return allowed.Length > 0 ? intent.OrderId.Value : null; |
| | | 1772 | | } |
| | | 1773 | | |
| | 0 | 1774 | | if (!string.IsNullOrWhiteSpace(intent.ShipmentNumber)) |
| | | 1775 | | { |
| | 0 | 1776 | | var orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: false, ct); |
| | 0 | 1777 | | if (orders.Count == 0) |
| | 0 | 1778 | | orders = await FindOrdersForIntentAsync(intent, userId, includeArchived: true, ct); |
| | 0 | 1779 | | return orders.Count > 0 ? orders[0].Id : null; |
| | | 1780 | | } |
| | | 1781 | | |
| | 0 | 1782 | | return null; |
| | 0 | 1783 | | } |
| | | 1784 | | |
| | 18 | 1785 | | public static string GetToolLabel(string? toolName) => toolName switch |
| | 18 | 1786 | | { |
| | 1 | 1787 | | "orders_today" => "Заказы за сегодня", |
| | 1 | 1788 | | "order_detail" => "Анализ заказа", |
| | 1 | 1789 | | "order_history" => "История заказа", |
| | 1 | 1790 | | "order_history_recent" => "Последние изменения", |
| | 1 | 1791 | | "user_actions_today" => "Действия пользователей сегодня", |
| | 1 | 1792 | | "transaction_types_info" => "Справочник транзакций", |
| | 1 | 1793 | | "overdue_orders" => "Просроченные заказы", |
| | 1 | 1794 | | "unprofitable_orders" => "Невыгодные заказы", |
| | 1 | 1795 | | "cancelled_orders_today" => "Отменены сегодня", |
| | 1 | 1796 | | "no_supplier_orders" => "Без поставщика", |
| | 1 | 1797 | | "orders_filter" => "Поиск заказов", |
| | 1 | 1798 | | "orders_summary" => "Сводка по статусам", |
| | 1 | 1799 | | "bitrix_stock_article" => "Синхронизация Ozon и 1С", |
| | 1 | 1800 | | "ozon_live_product" => "Актуально на Ozon (API)", |
| | 1 | 1801 | | "order_product" => "Товар в заказе", |
| | 1 | 1802 | | "write_edit" => "Изменение заказа", |
| | 1 | 1803 | | "write_transaction" => "Транзакция", |
| | 1 | 1804 | | _ => toolName ?? "Запрос" |
| | 18 | 1805 | | }; |
| | | 1806 | | } |