< Summary

Information
Class: Services.SearchServices.OrderSearch.OrderElasticsearchService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SearchServices/OrderSearch/OrderElasticsearchService.cs
Line coverage
24%
Covered lines: 139
Uncovered lines: 432
Coverable lines: 571
Total lines: 1008
Line coverage: 24.3%
Branch coverage
15%
Covered branches: 37
Total branches: 238
Branch coverage: 15.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/SearchServices/OrderSearch/OrderElasticsearchService.cs

#LineLine coverage
 1using System.Diagnostics;
 2using Microsoft.Extensions.Configuration;
 3using Microsoft.Extensions.Logging;
 4using Elasticsearch.Net;
 5using Nest;
 6using OServices.FiltersServices.FilterModels;
 7using Services.FiltersServices.DataFilterManagers;
 8
 9namespace Services.SearchServices.OrderSearch;
 10
 11public class OrderElasticsearchService : IOrderSearchService
 12{
 13    private readonly IElasticClient _client;
 14    private readonly string _indexName;
 15    private readonly ILogger<OrderElasticsearchService> _logger;
 16    private readonly IOrderSearchDiagnosticsStore _diagnostics;
 17
 018    public OrderElasticsearchService(
 019        IConfiguration configuration,
 020        ILogger<OrderElasticsearchService> logger,
 021        IOrderSearchDiagnosticsStore diagnostics)
 22    {
 023        _logger = logger;
 024        _diagnostics = diagnostics;
 025        _indexName = configuration["OrdersSearch:IndexName"] ?? "orders";
 26
 027        var url = configuration["OrdersSearch:Url"] ?? "http://localhost:9201";
 028        var settings = new ConnectionSettings(new Uri(url))
 029            .DefaultIndex(_indexName)
 030            .RequestTimeout(TimeSpan.FromSeconds(30));
 31
 032        _client = new ElasticClient(settings);
 033    }
 34
 35    public async Task<bool> CreateIndexAsync()
 36    {
 37        try
 38        {
 039            var exists = await _client.Indices.ExistsAsync(_indexName);
 040            if (exists.Exists)
 41            {
 042                return await EnsureScanitMappingAsync();
 43            }
 44
 045            var response = await _client.Indices.CreateAsync(_indexName, c => c
 046                .Map<OrderSearchDocument>(m => m
 047                    .Properties(p => p
 048                        .Number(n => n.Name(d => d.Id).Type(NumberType.Integer))
 049                        .Text(t => t.Name(d => d.ShipmentNumber)
 050                            .Analyzer("standard")
 051                            .Fields(f => f
 052                                .Keyword(k => k.Name("keyword").IgnoreAbove(256))
 053                                .Text(tt => tt.Name("ngram").Analyzer("ngram_analyzer"))))
 054                        .Keyword(k => k.Name(d => d.Scanit))
 055                        .Text(t => t.Name(d => d.ProductName)
 056                            .Analyzer("russian")
 057                            .Fields(f => f
 058                                .Text(tt => tt.Name("ngram").Analyzer("ngram_analyzer"))))
 059                        .Text(t => t.Name(d => d.Article)
 060                            .Analyzer("standard")
 061                            .Fields(f => f
 062                                .Text(tt => tt.Name("ngram").Analyzer("ngram_analyzer"))))
 063                        .Text(t => t.Name(d => d.ProductKey)
 064                            .Analyzer("standard")
 065                            .Fields(f => f
 066                                .Text(tt => tt.Name("ngram").Analyzer("ngram_analyzer"))))
 067                        .Keyword(k => k.Name(d => d.NormalizedArticleKey))
 068                        .Keyword(k => k.Name(d => d.AppStatusName))
 069                        .Keyword(k => k.Name(d => d.SupplierName))
 070                        .Keyword(k => k.Name(d => d.ManufacturerName))
 071                        .Keyword(k => k.Name(d => d.ShipmentWarehouseName))
 072                        .Keyword(k => k.Name(d => d.OzonClientName))
 073                        .Boolean(b => b.Name(d => d.IsArchived))
 074                        .Date(d => d.Name(doc => doc.UpdatedAt))
 075                    )
 076                )
 077                .Settings(s => s
 078                    .Setting("index.max_ngram_diff", 8)
 079                    .Analysis(a => a
 080                        .Analyzers(an => an
 081                            .Custom("russian", ca => ca
 082                                .Tokenizer("standard")
 083                                .Filters("lowercase", "russian_stop", "russian_stemmer"))
 084                            .Custom("ngram_analyzer", ca => ca
 085                                .Tokenizer("keyword")
 086                                .Filters("lowercase", "ngram_filter")))
 087                        .TokenFilters(tf => tf
 088                            .Stop("russian_stop", st => st.StopWords("_russian_"))
 089                            .Stemmer("russian_stemmer", st => st.Language("russian"))
 090                            .NGram("ngram_filter", ng => ng.MinGram(2).MaxGram(10))))
 091                    .NumberOfShards(1)
 092                    .NumberOfReplicas(0)));
 93
 094            if (!response.IsValid)
 95            {
 096                if (response.ServerError?.Error?.Type == "resource_already_exists_exception")
 97                {
 098                    return true;
 99                }
 100
 0101                LogTransientOrError(response, "Ошибка создания индекса {IndexName}: {DebugInfo}", _indexName, response.D
 102            }
 103
 0104            return response.IsValid;
 105        }
 0106        catch (Exception ex)
 107        {
 0108            LogTransientOrError(ex, "Ошибка при создании индекса {IndexName}", _indexName);
 0109            return false;
 110        }
 0111    }
 112
 113    /// <summary>
 114    /// Индекс, созданный до появления Scanit, не знает этого поля — без явного mapping Elasticsearch
 115    /// вывел бы text, а точный поиск идёт по keyword. Добавление поля идемпотентно.
 116    /// </summary>
 117    private static int _scanitMappingEnsured;
 118
 119    /// <summary>
 120    /// Первая индексация после деплоя не должна опередить добавление mapping: иначе Elasticsearch
 121    /// выведет для Scanit text и PutMapping уже упрётся в конфликт типов.
 122    /// </summary>
 123    private async Task EnsureScanitMappingOnceAsync()
 124    {
 0125        if (Volatile.Read(ref _scanitMappingEnsured) == 1)
 126        {
 0127            return;
 128        }
 129
 0130        if (await EnsureScanitMappingAsync())
 131        {
 0132            Volatile.Write(ref _scanitMappingEnsured, 1);
 133        }
 0134    }
 135
 136    private async Task<bool> EnsureScanitMappingAsync()
 137    {
 0138        var response = await _client.Indices.PutMappingAsync<OrderSearchDocument>(m => m
 0139            .Index(_indexName)
 0140            .Properties(p => p.Keyword(k => k.Name(d => d.Scanit))));
 141
 0142        if (!response.IsValid)
 143        {
 0144            LogTransientOrError(response, "Ошибка добавления поля Scanit в индекс {IndexName}: {DebugInfo}",
 0145                _indexName, response.DebugInformation);
 146        }
 147
 0148        return response.IsValid;
 0149    }
 150
 151    public async Task<bool> IndexExistsAsync()
 152    {
 153        try
 154        {
 0155            var response = await _client.Indices.ExistsAsync(_indexName);
 0156            return response.Exists;
 157        }
 0158        catch (Exception ex)
 159        {
 0160            LogTransientOrError(ex, "Ошибка при проверке существования индекса {IndexName}", _indexName);
 0161            return false;
 162        }
 0163    }
 164
 165    public async Task<bool> DeleteIndexAsync()
 166    {
 167        try
 168        {
 0169            var response = await _client.Indices.DeleteAsync(_indexName);
 0170            return response.IsValid;
 171        }
 0172        catch (Exception ex)
 173        {
 0174            LogTransientOrError(ex, "Ошибка при удалении индекса {IndexName}", _indexName);
 0175            return false;
 176        }
 0177    }
 178
 179    public async Task<bool> IndexOrderAsync(OrderSearchDocument document)
 180    {
 0181        var sw = Stopwatch.StartNew();
 182        try
 183        {
 0184            await EnsureScanitMappingOnceAsync();
 0185            document.NormalizedArticleKey = ResolveNormalizedArticleKey(document);
 0186            var response = await _client.IndexAsync(document, i => i.Index(_indexName).Id(document.Id));
 0187            var ok = IsIndexSuccessful(response);
 0188            if (!ok)
 189            {
 0190                LogTransientOrError(response, "Ошибка индексации заказа {OrderId}: {DebugInfo}", document.Id, response.D
 191            }
 192
 0193            _diagnostics.RecordIndex(
 0194                OrderSearchIndexOperation.Single,
 0195                1,
 0196                sw.ElapsedMilliseconds,
 0197                ok,
 0198                ok ? "Индексирован" : "Ошибка индексации",
 0199                document.Id);
 0200            return ok;
 201        }
 0202        catch (Exception ex)
 203        {
 0204            LogTransientOrError(ex, "Ошибка при индексации заказа {OrderId}", document.Id);
 0205            _diagnostics.RecordIndex(
 0206                OrderSearchIndexOperation.Single,
 0207                1,
 0208                sw.ElapsedMilliseconds,
 0209                false,
 0210                ex.Message,
 0211                document.Id);
 0212            return false;
 213        }
 0214    }
 215
 216    public async Task<bool> BulkIndexOrdersAsync(IEnumerable<OrderSearchDocument> documents)
 217    {
 0218        var sw = Stopwatch.StartNew();
 219        try
 220        {
 0221            await EnsureScanitMappingOnceAsync();
 0222            var bulkDescriptor = new BulkDescriptor();
 0223            var count = 0;
 224
 0225            foreach (var document in documents)
 226            {
 0227                count++;
 0228                bulkDescriptor.Index<OrderSearchDocument>(i => i.Document(document).Id(document.Id).Index(_indexName));
 229            }
 230
 0231            if (count == 0)
 232            {
 0233                return true;
 234            }
 235
 0236            var response = await _client.BulkAsync(bulkDescriptor);
 0237            var ok = IsBulkSuccessful(response);
 0238            if (!ok)
 239            {
 0240                LogTransientOrError(response, "Ошибка массовой индексации заказов: {DebugInfo}", response.DebugInformati
 241            }
 242
 0243            _diagnostics.RecordIndex(
 0244                OrderSearchIndexOperation.Bulk,
 0245                count,
 0246                sw.ElapsedMilliseconds,
 0247                ok,
 0248                ok ? $"Bulk {count} док." : "Ошибка bulk-индексации");
 0249            return ok;
 250        }
 0251        catch (Exception ex)
 252        {
 0253            LogTransientOrError(ex, "Ошибка при массовой индексации заказов");
 0254            _diagnostics.RecordIndex(
 0255                OrderSearchIndexOperation.Bulk,
 0256                0,
 0257                sw.ElapsedMilliseconds,
 0258                false,
 0259                ex.Message);
 0260            return false;
 261        }
 0262    }
 263
 264    /// <summary>
 265    /// ES 8 возвращает 201 Created для новых документов — NEST помечает такой bulk как IsValid=false,
 266    /// хотя операции успешны. Смотрим только реальные ошибки (response.Errors / item.Error).
 267    /// </summary>
 268    private static bool IsBulkSuccessful(BulkResponse response)
 269    {
 0270        if (response.Errors)
 271        {
 0272            return false;
 273        }
 274
 0275        if (response.IsValid)
 276        {
 0277            return true;
 278        }
 279
 0280        return response.Items == null || response.Items.All(static item => item.Error == null);
 281    }
 282
 283    private static bool IsIndexSuccessful(IndexResponse response) =>
 0284        response.IsValid || response.Result is Result.Created or Result.Updated;
 285
 286    public async Task<bool> DeleteFromIndexAsync(int orderId)
 287    {
 0288        var sw = Stopwatch.StartNew();
 289        try
 290        {
 0291            var response = await _client.DeleteAsync<OrderSearchDocument>(orderId, d => d.Index(_indexName));
 0292            var ok = response.IsValid || response.Result == Result.NotFound;
 0293            _diagnostics.RecordIndex(
 0294                OrderSearchIndexOperation.Delete,
 0295                1,
 0296                sw.ElapsedMilliseconds,
 0297                ok,
 0298                ok ? "Удалён из индекса" : "Ошибка удаления",
 0299                orderId);
 0300            return ok;
 301        }
 0302        catch (Exception ex)
 303        {
 0304            LogTransientOrError(ex, "Ошибка при удалении заказа {OrderId} из индекса", orderId);
 0305            _diagnostics.RecordIndex(
 0306                OrderSearchIndexOperation.Delete,
 0307                1,
 0308                sw.ElapsedMilliseconds,
 0309                false,
 0310                ex.Message,
 0311                orderId);
 0312            return false;
 313        }
 0314    }
 315
 316    public async Task<OrderSearchDocument?> GetDocumentByIdAsync(int orderId)
 317    {
 318        try
 319        {
 0320            var response = await _client.GetAsync<OrderSearchDocument>(orderId, g => g.Index(_indexName));
 0321            if (!response.Found || response.Source == null)
 322            {
 0323                return null;
 324            }
 325
 0326            return response.Source;
 327        }
 0328        catch (Exception ex)
 329        {
 0330            LogTransientOrError(ex, "Ошибка при чтении заказа {OrderId} из индекса", orderId);
 0331            return null;
 332        }
 0333    }
 334
 335    public async Task<long> GetTotalCountAsync()
 336    {
 337        try
 338        {
 0339            var response = await _client.CountAsync<OrderSearchDocument>(c => c.Index(_indexName));
 0340            return response.Count;
 341        }
 0342        catch (Exception ex)
 343        {
 0344            LogTransientOrError(ex, "Ошибка при подсчёте документов индекса {IndexName}", _indexName);
 0345            return 0;
 346        }
 0347    }
 348
 349    public async Task<IReadOnlySet<int>> GetExistingOrderIdsAsync(
 350        IReadOnlyList<int> orderIds,
 351        CancellationToken cancellationToken = default)
 352    {
 0353        var result = new HashSet<int>();
 0354        if (orderIds.Count == 0)
 355        {
 0356            return result;
 357        }
 358
 359        try
 360        {
 0361            var response = await _client.SearchAsync<OrderSearchDocument>(s => s
 0362                .Index(_indexName)
 0363                .Size(orderIds.Count)
 0364                .Source(false)
 0365                .Query(q => q.Terms(t => t.Field(f => f.Id).Terms(orderIds))),
 0366                cancellationToken);
 367
 0368            if (!response.IsValid)
 369            {
 0370                _logger.LogWarning(
 0371                    "Terms-поиск ID в индексе {IndexName} вернул невалидный ответ: {DebugInfo}",
 0372                    _indexName,
 0373                    response.DebugInformation);
 0374                return result;
 375            }
 376
 0377            foreach (var hit in response.Hits)
 378            {
 0379                if (int.TryParse(hit.Id, out var id))
 380                {
 0381                    result.Add(id);
 382                }
 383            }
 0384        }
 0385        catch (Exception ex)
 386        {
 0387            LogTransientOrError(ex, "Ошибка mget при проверке существующих документов в индексе {IndexName}", _indexName
 0388        }
 389
 0390        return result;
 0391    }
 392
 393    public async Task<IReadOnlyList<int>> GetAllIndexedOrderIdsAsync(CancellationToken cancellationToken = default)
 394    {
 0395        var ids = new List<int>();
 0396        ISearchResponse<OrderSearchDocument>? searchResponse = null;
 397
 398        try
 399        {
 0400            searchResponse = await _client.SearchAsync<OrderSearchDocument>(s => s
 0401                .Index(_indexName)
 0402                .Source(false)
 0403                .Size(2000)
 0404                .Scroll("2m")
 0405                .Query(q => q.MatchAll()), cancellationToken);
 406
 0407            if (!searchResponse.IsValid)
 408            {
 0409                _logger.LogWarning(
 0410                    "Scroll индекса {IndexName} вернул невалидный ответ: {DebugInfo}",
 0411                    _indexName,
 0412                    searchResponse.DebugInformation);
 0413                return ids;
 414            }
 415
 0416            while (searchResponse.Hits.Count > 0)
 417            {
 0418                foreach (var hit in searchResponse.Hits)
 419                {
 0420                    if (int.TryParse(hit.Id, out var id))
 421                    {
 0422                        ids.Add(id);
 423                    }
 424                }
 425
 0426                if (string.IsNullOrEmpty(searchResponse.ScrollId))
 427                {
 428                    break;
 429                }
 430
 0431                searchResponse = await _client.ScrollAsync<OrderSearchDocument>(
 0432                    "2m",
 0433                    searchResponse.ScrollId,
 0434                    ct: cancellationToken);
 435            }
 0436        }
 0437        catch (Exception ex)
 438        {
 0439            LogTransientOrError(ex, "Ошибка scroll при чтении ID из индекса {IndexName}", _indexName);
 0440        }
 441        finally
 442        {
 0443            if (!string.IsNullOrEmpty(searchResponse?.ScrollId))
 444            {
 0445                await _client.ClearScrollAsync(new ClearScrollRequest(searchResponse.ScrollId), cancellationToken);
 446            }
 447        }
 448
 0449        return ids;
 0450    }
 451
 452    public async Task<int> BulkDeleteFromIndexAsync(
 453        IReadOnlyList<int> orderIds,
 454        CancellationToken cancellationToken = default)
 455    {
 0456        if (orderIds.Count == 0)
 457        {
 0458            return 0;
 459        }
 460
 0461        var sw = Stopwatch.StartNew();
 462        try
 463        {
 0464            var bulkDescriptor = new BulkDescriptor();
 0465            foreach (var id in orderIds)
 466            {
 0467                bulkDescriptor.Delete<OrderSearchDocument>(d => d.Index(_indexName).Id(id));
 468            }
 469
 0470            var response = await _client.BulkAsync(bulkDescriptor, cancellationToken);
 0471            var deleted = IsBulkSuccessful(response) ? orderIds.Count : 0;
 0472            _diagnostics.RecordIndex(
 0473                OrderSearchIndexOperation.Delete,
 0474                deleted,
 0475                sw.ElapsedMilliseconds,
 0476                deleted > 0,
 0477                deleted > 0
 0478                    ? $"Bulk-удаление: {deleted} документов"
 0479                    : "Ошибка bulk-удаления");
 480
 0481            if (deleted == 0)
 482            {
 0483                _logger.LogWarning(
 0484                    "Bulk-удаление из индекса {IndexName} завершилось с ошибкой: {DebugInfo}",
 0485                    _indexName,
 0486                    response.DebugInformation);
 487            }
 488
 0489            return deleted;
 490        }
 0491        catch (Exception ex)
 492        {
 0493            LogTransientOrError(ex, "Ошибка bulk-удаления из индекса {IndexName}", _indexName);
 0494            _diagnostics.RecordIndex(
 0495                OrderSearchIndexOperation.Delete,
 0496                0,
 0497                sw.ElapsedMilliseconds,
 0498                false,
 0499                ex.Message);
 0500            return 0;
 501        }
 0502    }
 503
 504    public async Task<OrderSearchIdsResult> SearchOrderIdsAsync(
 505        OrderFilterModel filter,
 506        IReadOnlyList<string>? articleProductKeys = null,
 507        int maxResults = 2000)
 508    {
 509        try
 510        {
 0511            var query = BuildSearchQuery(filter, articleProductKeys);
 0512            var cappedSize = Math.Clamp(maxResults, 1, 10_000);
 513
 0514            var response = await _client.SearchAsync<OrderSearchDocument>(s => s
 0515                .Index(_indexName)
 0516                .Query(_ => query)
 0517                .Source(sf => sf.Includes(f => f.Field(d => d.Id)))
 0518                .Sort(so => so.Descending("_score").Descending(d => d.Id))
 0519                .TrackTotalHits(true)
 0520                .Size(cappedSize));
 521
 0522            if (!response.IsValid)
 523            {
 0524                _logger.LogWarning("Поиск по заказам вернул невалидный ответ: {DebugInfo}", response.DebugInformation);
 0525                throw new InvalidOperationException(
 0526                    $"Elasticsearch search failed: {response.ServerError?.Error?.Reason ?? response.DebugInformation}");
 527            }
 528
 0529            var ids = response.Documents.Select(d => d.Id).ToList();
 0530            var totalHits = response.Total;
 0531            var truncated = totalHits > ids.Count;
 532
 0533            return new OrderSearchIdsResult
 0534            {
 0535                Ids = ids,
 0536                TotalHits = totalHits,
 0537                MaxResults = cappedSize,
 0538                Truncated = truncated
 0539            };
 540        }
 0541        catch (Exception ex)
 542        {
 0543            LogTransientOrError(ex, "Ошибка при поиске заказов в Elasticsearch");
 0544            throw;
 545        }
 0546    }
 547
 548    public async Task<IReadOnlyList<string>> SuggestArticlesAsync(
 549        string term,
 550        int maxSuggestions = 10,
 551        CancellationToken cancellationToken = default)
 552    {
 0553        var normalizedTerm = OrderDataFilterManager.NormalizeArticleForSearch(term);
 0554        if (string.IsNullOrWhiteSpace(normalizedTerm))
 555        {
 0556            return Array.Empty<string>();
 557        }
 558
 559        try
 560        {
 0561            var cappedSize = Math.Clamp(maxSuggestions, 1, 50);
 0562            var query = BuildArticleQuery(term, null);
 563
 0564            var response = await _client.SearchAsync<OrderSearchDocument>(s => s
 0565                .Index(_indexName)
 0566                .Query(_ => query)
 0567                .Source(sf => sf.Includes(f => f
 0568                    .Field(d => d.Article)
 0569                    .Field(d => d.ProductKey)))
 0570                .Sort(so => so.Descending("_score"))
 0571                .Size(cappedSize * 5), cancellationToken);
 572
 0573            if (!response.IsValid)
 574            {
 0575                _logger.LogWarning(
 0576                    "Подсказки по артикулу: невалидный ответ ES: {DebugInfo}", response.DebugInformation);
 0577                return Array.Empty<string>();
 578            }
 579
 0580            var suggestions = new List<string>();
 0581            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 582
 0583            foreach (var doc in response.Documents)
 584            {
 0585                var display = !string.IsNullOrWhiteSpace(doc.ProductKey) ? doc.ProductKey : doc.Article;
 0586                if (string.IsNullOrWhiteSpace(display) || !seen.Add(display))
 587                {
 588                    continue;
 589                }
 590
 0591                suggestions.Add(display);
 0592                if (suggestions.Count >= cappedSize)
 593                {
 594                    break;
 595                }
 596            }
 597
 0598            return suggestions;
 599        }
 0600        catch (Exception ex)
 601        {
 0602            LogTransientOrError(ex, "Ошибка при получении подсказок по артикулу «{Term}»", term);
 0603            return Array.Empty<string>();
 604        }
 0605    }
 606
 607    private static QueryContainer BuildSearchQuery(OrderFilterModel filter, IReadOnlyList<string>? articleProductKeys = 
 608    {
 0609        var must = new List<QueryContainer>();
 610
 0611        if (!string.IsNullOrWhiteSpace(filter.AppStatus) && filter.AppStatus != "Все")
 612        {
 0613            must.Add(new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.AppStatusName), Value = filter.AppS
 614        }
 615
 0616        if (!string.IsNullOrWhiteSpace(filter.Supplier) && filter.Supplier != "Все")
 617        {
 0618            must.Add(new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.SupplierName), Value = filter.Suppl
 619        }
 620
 0621        if (!string.IsNullOrWhiteSpace(filter.Manufacturer))
 622        {
 0623            must.Add(new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ManufacturerName), Value = filter.M
 624        }
 625
 0626        if (!string.IsNullOrWhiteSpace(filter.ShipmentWarehouse) && filter.ShipmentWarehouse != "Все")
 627        {
 0628            must.Add(new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentWarehouseName), Value = fil
 629        }
 630
 0631        if (!string.IsNullOrWhiteSpace(filter.OzonClient) && filter.OzonClient != "Все")
 632        {
 0633            must.Add(new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.OzonClientName), Value = filter.Ozo
 634        }
 635
 0636        if (!string.IsNullOrWhiteSpace(filter.Search))
 637        {
 0638            must.Add(BuildGlobalTextQuery(filter.Search.Trim()));
 639        }
 640
 0641        if (!string.IsNullOrWhiteSpace(filter.ShipmentNumber))
 642        {
 0643            must.Add(BuildShipmentNumberQuery(filter.ShipmentNumber));
 644        }
 645
 0646        if (!string.IsNullOrWhiteSpace(filter.ProductName))
 647        {
 0648            must.Add(new MatchQuery
 0649            {
 0650                Field = Infer.Field<OrderSearchDocument>(f => f.ProductName),
 0651                Query = filter.ProductName.Trim(),
 0652                Fuzziness = Fuzziness.Auto
 0653            });
 654        }
 655
 0656        var article = string.IsNullOrWhiteSpace(filter.Article) ? filter.Article2 : filter.Article;
 0657        if (!string.IsNullOrWhiteSpace(article) || articleProductKeys is { Count: > 0 })
 658        {
 0659            must.Add(BuildArticleQuery(article, articleProductKeys));
 660        }
 661
 0662        if (must.Count == 0)
 663        {
 0664            return new MatchAllQuery();
 665        }
 666
 0667        return new BoolQuery { Must = must };
 668    }
 669
 670    private static QueryContainer BuildGlobalTextQuery(string term)
 671    {
 5672        var trimmed = term.Trim();
 5673        if (string.IsNullOrWhiteSpace(trimmed))
 674        {
 0675            return new MatchNoneQuery();
 676        }
 677
 5678        if (LooksLikeShipmentNumber(trimmed))
 679        {
 3680            var normalized = OrderDataFilterManager.NormalizeShipmentNumberForSearch(trimmed);
 3681            var phrase = string.IsNullOrWhiteSpace(normalized) ? trimmed : normalized;
 3682            return new BoolQuery
 3683            {
 3684                Should = new QueryContainer[]
 3685                {
 3686                    new TermQuery
 3687                    {
 3688                        Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber),
 3689                        Value = trimmed,
 3690                        Boost = 5.0
 3691                    },
 3692                    new MatchPhraseQuery
 3693                    {
 3694                        Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber),
 3695                        Query = phrase,
 3696                        Boost = 10.0
 3697                    },
 3698                    BuildScanitTerm(trimmed)
 3699                },
 3700                MinimumShouldMatch = 1
 3701            };
 702        }
 703
 2704        return trimmed.Length switch
 2705        {
 1706            <= 3 => BuildShortGlobalTextQuery(trimmed),
 1707            <= 7 => BuildMediumGlobalTextQuery(trimmed),
 0708            _ => BuildFullGlobalTextQuery(trimmed)
 2709        };
 710    }
 711
 712    /// <summary>Короткий токен (≤3): только точные поля, без ngram — иначе тысячи ложных совпадений.</summary>
 713    private static QueryContainer BuildShortGlobalTextQuery(string term) =>
 1714        new BoolQuery
 1715        {
 1716            Should = new QueryContainer[]
 1717            {
 1718                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber), Value = term, Boost = 5
 1719                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.Article), Value = term, Boost = 5.0 },
 1720                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ProductKey), Value = term, Boost = 4.0 }
 1721            },
 1722            MinimumShouldMatch = 1
 1723        };
 724
 725    /// <summary>4–7 символов: ngram по артикулу, без ngram по наименованию товара.</summary>
 726    private static QueryContainer BuildMediumGlobalTextQuery(string term) =>
 1727        new BoolQuery
 1728        {
 1729            Should = new QueryContainer[]
 1730            {
 1731                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber), Value = term, Boost = 5
 1732                new MatchPhraseQuery
 1733                {
 1734                    Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber),
 1735                    Query = term,
 1736                    Boost = 4.5
 1737                },
 1738                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.Article), Value = term, Boost = 5.0 },
 1739                new MatchQuery { Field = Infer.Field<OrderSearchDocument>(f => f.Article.Suffix("ngram")), Query = term,
 1740                new MatchQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ProductKey.Suffix("ngram")), Query = te
 1741                new MatchQuery
 1742                {
 1743                    Field = Infer.Field<OrderSearchDocument>(f => f.ProductName),
 1744                    Query = term,
 1745                    Fuzziness = Fuzziness.Auto,
 1746                    Boost = 1.0
 1747                }
 1748            },
 1749            MinimumShouldMatch = 1
 1750        };
 751
 752    /// <summary>Длинный глобальный поиск: полный набор полей.</summary>
 753    private static QueryContainer BuildFullGlobalTextQuery(string term) =>
 0754        new BoolQuery
 0755        {
 0756            Should = new QueryContainer[]
 0757            {
 0758                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber), Value = term, Boost = 5
 0759                new MatchPhraseQuery
 0760                {
 0761                    Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber),
 0762                    Query = term,
 0763                    Boost = 4.5
 0764                },
 0765                new TermQuery { Field = Infer.Field<OrderSearchDocument>(f => f.Article), Value = term, Boost = 5.0 },
 0766                new MatchQuery { Field = Infer.Field<OrderSearchDocument>(f => f.Article.Suffix("ngram")), Query = term,
 0767                new MatchQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ProductKey.Suffix("ngram")), Query = te
 0768                new MatchQuery
 0769                {
 0770                    Field = Infer.Field<OrderSearchDocument>(f => f.ProductName),
 0771                    Query = term,
 0772                    Fuzziness = Fuzziness.Auto,
 0773                    Boost = 1.0
 0774                },
 0775                new MatchQuery { Field = Infer.Field<OrderSearchDocument>(f => f.ProductName.Suffix("ngram")), Query = t
 0776            },
 0777            MinimumShouldMatch = 1
 0778        };
 779
 780    private static bool LooksLikeShipmentNumber(string term)
 781    {
 5782        if (string.IsNullOrWhiteSpace(term))
 783        {
 0784            return false;
 785        }
 786
 5787        var t = term.Trim();
 5788        if (t.Contains('-', StringComparison.Ordinal) && t.Any(char.IsDigit))
 789        {
 2790            return true;
 791        }
 792
 3793        if (t.StartsWith("СП", StringComparison.OrdinalIgnoreCase)
 3794            || t.StartsWith("ВУ", StringComparison.OrdinalIgnoreCase))
 795        {
 0796            return true;
 797        }
 798
 3799        var normalized = OrderDataFilterManager.NormalizeShipmentNumberForSearch(t);
 3800        return normalized.Length >= 6 && normalized.Count(char.IsDigit) >= 4;
 801    }
 802
 803    /// <summary>Точное совпадение со ScanIt без учёта регистра (сканер и ручной ввод дают разный регистр).</summary>
 804    private static QueryContainer BuildScanitTerm(string value) =>
 9805        new TermQuery
 9806        {
 9807            Field = Infer.Field<OrderSearchDocument>(f => f.Scanit),
 9808            Value = value.Trim(),
 9809            CaseInsensitive = true,
 9810            Boost = 10.0
 9811        };
 812
 813    private static QueryContainer BuildShipmentNumberQuery(string shipmentNumber)
 814    {
 5815        var parts = OrderDataFilterManager.GetShipmentNumbersFromFilter(shipmentNumber);
 5816        if (parts.Count == 0)
 817        {
 0818            parts = new List<string> { shipmentNumber };
 819        }
 820
 5821        var should = new List<QueryContainer>();
 22822        foreach (var part in parts)
 823        {
 6824            var normalized = OrderDataFilterManager.NormalizeShipmentNumberForSearch(part);
 6825            if (string.IsNullOrWhiteSpace(normalized))
 826            {
 827                continue;
 828            }
 829
 6830            should.Add(BuildScanitTerm(normalized));
 831
 832            // match_phrase на standard-поле покрывает целый номер. Для фрагмента из пяти и более
 833            // символов добавляем keyword-wildcard, чтобы поведение совпадало с MySQL Contains:
 834            // например, «44568» находит «63044568-0016-2». Более короткие фрагменты намеренно
 835            // не раскрываем wildcard-ом: они дают слишком широкую выдачу.
 6836            should.Add(new MatchPhraseQuery
 6837            {
 6838                Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber),
 6839                Query = normalized,
 6840                Boost = 10.0
 6841            });
 842
 6843            if (normalized.Length >= 5)
 844            {
 6845                should.Add(new WildcardQuery
 6846                {
 6847                    Field = Infer.Field<OrderSearchDocument>(f => f.ShipmentNumber.Suffix("keyword")),
 6848                    Value = $"*{normalized}*",
 6849                    CaseInsensitive = true,
 6850                    Boost = 2.0
 6851                });
 852            }
 853        }
 854
 5855        if (should.Count == 0)
 856        {
 0857            return new MatchNoneQuery();
 858        }
 859
 5860        return new BoolQuery { Should = should, MinimumShouldMatch = 1 };
 861    }
 862
 863    private static QueryContainer BuildArticleQuery(string? article, IReadOnlyList<string>? articleProductKeys)
 864    {
 3865        var terms = CollectArticleSearchTerms(article, articleProductKeys)
 3866            .Select(OrderDataFilterManager.NormalizeArticleForSearch)
 4867            .Where(term => !string.IsNullOrWhiteSpace(term))
 3868            .Distinct(StringComparer.OrdinalIgnoreCase)
 3869            .ToList();
 870
 3871        if (terms.Count == 0)
 872        {
 0873            return new MatchNoneQuery();
 874        }
 875
 3876        var should = new List<QueryContainer>();
 12877        foreach (var term in terms)
 878        {
 3879            var normalizedTerm = term.ToLowerInvariant();
 880
 881            // Wildcard *term* — честное вхождение подпоследовательности в ЛЮБОЙ позиции
 882            // (как SQL LIKE '%term%'), по индексированному терму: без ngram-фаззи и без
 883            // зависимости от заполненности NormalizedArticleKey.
 3884            var wildcardValue = $"*{normalizedTerm}*";
 3885            should.Add(new WildcardQuery
 3886            {
 3887                Field = Infer.Field<OrderSearchDocument>(f => f.NormalizedArticleKey),
 3888                Value = wildcardValue,
 3889                Boost = 11.0
 3890            });
 3891            should.Add(new WildcardQuery
 3892            {
 3893                Field = Infer.Field<OrderSearchDocument>(f => f.Article),
 3894                Value = wildcardValue,
 3895                Boost = 6.0
 3896            });
 3897            should.Add(new WildcardQuery
 3898            {
 3899                Field = Infer.Field<OrderSearchDocument>(f => f.ProductKey),
 3900                Value = wildcardValue,
 3901                Boost = 5.0
 3902            });
 903        }
 904
 3905        return new BoolQuery { Should = should, MinimumShouldMatch = 1 };
 906    }
 907
 908    private static List<string> CollectArticleSearchTerms(string? article, IReadOnlyList<string>? articleProductKeys)
 909    {
 3910        var terms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 911
 3912        if (!string.IsNullOrWhiteSpace(article))
 913        {
 3914            terms.Add(article.Trim());
 915        }
 916
 3917        if (articleProductKeys is { Count: > 0 })
 918        {
 4919            foreach (var key in articleProductKeys)
 920            {
 1921                if (!string.IsNullOrWhiteSpace(key))
 922                {
 1923                    terms.Add(key.Trim());
 924                }
 925            }
 926        }
 927
 3928        return terms.ToList();
 929    }
 930
 931    private static string? ResolveNormalizedArticleKey(OrderSearchDocument document)
 932    {
 0933        var fromProductKey = OrderDataFilterManager.NormalizeArticleForSearch(document.ProductKey);
 0934        if (!string.IsNullOrWhiteSpace(fromProductKey))
 935        {
 0936            return fromProductKey.ToLowerInvariant();
 937        }
 938
 0939        var fromArticle = OrderDataFilterManager.NormalizeArticleForSearch(document.Article);
 0940        return string.IsNullOrWhiteSpace(fromArticle) ? null : fromArticle.ToLowerInvariant();
 941    }
 942
 943    private void LogTransientOrError(Nest.IResponse response, string messageTemplate, params object?[] args)
 944    {
 0945        if (IsTransientElasticsearchResponse(response))
 946        {
 0947            _logger.LogWarning(messageTemplate, args);
 948        }
 949        else
 950        {
 0951            _logger.LogError(messageTemplate, args);
 952        }
 0953    }
 954
 955    private void LogTransientOrError(Exception ex, string messageTemplate, params object?[] args)
 956    {
 0957        if (IsTransientElasticsearchException(ex))
 958        {
 0959            _logger.LogWarning(ex, messageTemplate, args);
 960        }
 961        else
 962        {
 0963            _logger.LogError(ex, messageTemplate, args);
 964        }
 0965    }
 966
 967    private static bool IsTransientElasticsearchResponse(Nest.IResponse response)
 968    {
 0969        if (response.OriginalException != null && IsTransientElasticsearchException(response.OriginalException))
 970        {
 0971            return true;
 972        }
 973
 0974        var reason = response.ServerError?.Error?.Reason ?? string.Empty;
 0975        if (reason.Contains("all shards failed", StringComparison.OrdinalIgnoreCase)
 0976            || reason.Contains("node_not_connected_exception", StringComparison.OrdinalIgnoreCase))
 977        {
 0978            return true;
 979        }
 980
 0981        var debugInfo = response.DebugInformation ?? string.Empty;
 0982        return debugInfo.Contains("all shards failed", StringComparison.OrdinalIgnoreCase)
 0983            || debugInfo.Contains("Connection refused", StringComparison.OrdinalIgnoreCase)
 0984            || debugInfo.Contains("unsuccessful product check", StringComparison.OrdinalIgnoreCase)
 0985            || debugInfo.Contains("node_not_connected_exception", StringComparison.OrdinalIgnoreCase);
 986    }
 987
 988    private static bool IsTransientElasticsearchException(Exception ex)
 989    {
 0990        for (Exception? current = ex; current != null; current = current.InnerException)
 991        {
 0992            if (current is System.Net.Http.HttpRequestException or System.Net.Sockets.SocketException or Elasticsearch.N
 993            {
 0994                return true;
 995            }
 996
 0997            if (current.Message.Contains("all shards failed", StringComparison.OrdinalIgnoreCase)
 0998                || current.Message.Contains("Connection refused", StringComparison.OrdinalIgnoreCase)
 0999                || current.Message.Contains("unsuccessful product check", StringComparison.OrdinalIgnoreCase)
 01000                || current.Message.Contains("node_not_connected_exception", StringComparison.OrdinalIgnoreCase))
 1001            {
 01002                return true;
 1003            }
 1004        }
 1005
 01006        return false;
 1007    }
 1008}

Methods/Properties

.ctor(Microsoft.Extensions.Configuration.IConfiguration,Microsoft.Extensions.Logging.ILogger`1<Services.SearchServices.OrderSearch.OrderElasticsearchService>,Services.SearchServices.OrderSearch.IOrderSearchDiagnosticsStore)
CreateIndexAsync()
EnsureScanitMappingOnceAsync()
EnsureScanitMappingAsync()
IndexExistsAsync()
DeleteIndexAsync()
IndexOrderAsync()
BulkIndexOrdersAsync()
IsBulkSuccessful(Nest.BulkResponse)
IsIndexSuccessful(Nest.IndexResponse)
DeleteFromIndexAsync()
GetDocumentByIdAsync()
GetTotalCountAsync()
GetExistingOrderIdsAsync()
GetAllIndexedOrderIdsAsync()
BulkDeleteFromIndexAsync()
SearchOrderIdsAsync()
SuggestArticlesAsync()
BuildSearchQuery(OServices.FiltersServices.FilterModels.OrderFilterModel,System.Collections.Generic.IReadOnlyList`1<System.String>)
BuildGlobalTextQuery(System.String)
BuildShortGlobalTextQuery(System.String)
BuildMediumGlobalTextQuery(System.String)
BuildFullGlobalTextQuery(System.String)
LooksLikeShipmentNumber(System.String)
BuildScanitTerm(System.String)
BuildShipmentNumberQuery(System.String)
BuildArticleQuery(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
CollectArticleSearchTerms(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
ResolveNormalizedArticleKey(Services.SearchServices.OrderSearch.OrderSearchDocument)
LogTransientOrError(Nest.IResponse,System.String,System.Object[])
LogTransientOrError(System.Exception,System.String,System.Object[])
IsTransientElasticsearchResponse(Nest.IResponse)
IsTransientElasticsearchException(System.Exception)