< Summary

Information
Class: Services.ApiServices._1CApi.Nomenclature.NomenclatureMatcher
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/NomenclatureMatcher.cs
Line coverage
94%
Covered lines: 106
Uncovered lines: 6
Coverable lines: 112
Total lines: 293
Line coverage: 94.6%
Branch coverage
82%
Covered branches: 73
Total branches: 88
Branch coverage: 82.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
ForgetMisses()100%11100%
MatchAsync()97.05%343494.59%
Decide(...)75%161692.3%
SameBrand(...)100%66100%
MentionedInTitle(...)16.66%11650%
Pick(...)100%22100%
FindByVariant(...)100%66100%
LiveCardsAsync()100%1010100%
DescribeBrands(...)25%44100%
Result(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/NomenclatureMatcher.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using OzonDomains.Nomenclature;
 3
 4namespace Services.ApiServices._1CApi.Nomenclature;
 5
 6public enum NomenclatureMatchStatus
 7{
 8    /// <summary>Карточка найдена по ключу артикула и бренду.</summary>
 9    Found,
 10
 11    /// <summary>Найдена по «похожему» артикулу (ведущие нули, суффикс-буква) при том же бренде.</summary>
 12    FoundByVariant,
 13
 14    /// <summary>Карточки есть, но выбрать между ними нечем (бренд заказа неизвестен).</summary>
 15    Ambiguous,
 16
 17    /// <summary>Карточки с таким артикулом есть только у других брендов — это другой товар.</summary>
 18    BrandMismatch,
 19
 20    /// <summary>Артикул в заказе расходится с артикульной частью ProductKey; источник нужно исправить вручную.</summary
 21    InputConflict,
 22
 23    /// <summary>Карточки нет, а бренд заказа пустой или не распознан — создавать небезопасно.</summary>
 24    ProducerUnknown,
 25
 26    /// <summary>Карточки нет.</summary>
 27    NotFound
 28}
 29
 30public enum MatchConfidence
 31{
 32    High,
 33    Medium,
 34    Low
 35}
 36
 37/// <summary>Что нужно вызывающему: только найти (реализатор, утеря) или найти либо создать.</summary>
 38public enum NomenclatureMode
 39{
 40    FindOnly,
 41    FindOrCreate
 42}
 43
 44/// <param name="Article">Поле «Артикул» заказа как есть (может содержать <c>=БРЕНД</c>, <c>[N]</c>).</param>
 45/// <param name="ProductKey">Ключ товара <c>АРТИКУЛ=БРЕНД[N]</c>.</param>
 46/// <param name="ProductName">Название товара — подсказка бренда, когда его не знаем.</param>
 47/// <param name="EtProducerName">Имя производителя, записанное в заказе.</param>
 48public sealed record ProductQuery(string? Article, string? ProductKey, string? ProductName, string? EtProducerName);
 49
 50public sealed record NomenclatureMatch(
 51    NomenclatureMatchStatus Status,
 52    Guid? RefKey,
 53    MatchConfidence Confidence,
 54    IReadOnlyList<IndexedCard> Candidates,
 55    string Reason,
 56    ParsedProductRef Parsed,
 57    ProducerResolution Producer,
 58    ArticleVariantKind? VariantKind = null,
 59    bool Duplicate = false,
 60    bool ProducerlessCard = false,
 61    bool FromLiveLookup = false);
 62
 63/// <summary>Карточка, найденная прямым запросом к 1С (когда индекс не знает артикул).</summary>
 64public sealed record LiveCard(Guid RefKey, string? ArticleRaw, string? ProducerName);
 65
 66/// <summary>Живой поиск в 1С по артикулу. Реализует фасад поверх OData; в матчере нужен только на промах индекса.</summ
 67public interface INomenclatureLiveLookup
 68{
 69    Task<IReadOnlyList<LiveCard>> FindByArticleAsync(string articleClean, string articleKey, CancellationToken cancellat
 70}
 71
 72/// <summary>
 73/// Поиск карточки номенклатуры: разбор ключа → бренд (группа) → индекс в памяти → варианты артикула → живой поиск.
 74/// Ничего не создаёт и не пишет; решение «что делать» — в <see cref="NomenclatureResolvePolicy"/>.
 75/// </summary>
 5476public sealed class NomenclatureMatcher(
 5477    INomenclatureIndexProvider indexProvider,
 5478    IProducerDirectoryProvider directoryProvider,
 5479    INomenclatureLiveLookup? liveLookup = null,
 5480    TimeSpan? negativeCacheTtl = null)
 81{
 5482    private readonly TimeSpan _negativeTtl = negativeCacheTtl ?? TimeSpan.FromMinutes(10);
 5483    private readonly ConcurrentDictionary<string, DateTime> _liveMisses = new(StringComparer.Ordinal);
 84
 85    /// <summary>Забыть кэш живых промахов: карточка только что создана и должна находиться сразу.</summary>
 1086    public void ForgetMisses() => _liveMisses.Clear();
 87
 88    /// <param name="live">Живой поиск для этого вызова; если не задан — тот, что передан в конструктор.</param>
 89    public async Task<NomenclatureMatch> MatchAsync(ProductQuery query, INomenclatureLiveLookup? live = null, Cancellati
 90    {
 5091        var parsed = ProductKeyParser.Parse(query.Article, query.ProductKey);
 5092        var directory = await directoryProvider.GetAsync(cancellationToken);
 4993        var producer = directory.Resolve(parsed, query.EtProducerName);
 94
 4995        if (parsed.HasArticleConflict)
 296            return Result(NomenclatureMatchStatus.InputConflict, null, MatchConfidence.Low, [],
 297                $"артикул заказа {parsed.ArticleClean} расходится с ProductKey {parsed.ProductKeyArticleClean}", parsed,
 98
 4799        if (parsed.ArticleKey.Length == 0)
 1100            return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.Low, [], "артикул пуст", parsed, produ
 101
 46102        var index = await indexProvider.GetAsync(cancellationToken);
 46103        var cards = index.Find(parsed.ArticleKey);
 46104        var fromLive = false;
 105
 46106        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Known)
 107        {
 16108            var byVariant = FindByVariant(index, parsed, producer, query.EtProducerName);
 16109            if (byVariant != null)
 2110                return byVariant;
 111        }
 112
 44113        if (cards.Count == 0)
 114        {
 17115            cards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken);
 17116            fromLive = cards.Count > 0;
 117        }
 118
 44119        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Unknown)
 3120            return Result(NomenclatureMatchStatus.ProducerUnknown, null, MatchConfidence.Low, [],
 3121                $"бренд заказа не определён; карточка {parsed.ArticleClean} не создана", parsed, producer);
 122
 41123        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Ambiguous)
 0124            return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, [],
 0125                $"бренд заказа неоднозначен; карточка {parsed.ArticleClean} не создана", parsed, producer);
 126
 41127        if (cards.Count == 0)
 13128            return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.High, [],
 13129                $"в 1С нет карточки с артикулом {parsed.ArticleClean}", parsed, producer);
 130
 28131        var match = Decide(cards, parsed, producer, directory, query);
 132
 133        // Карта может отставать от 1С: наличие в ней карточки другого бренда не доказывает,
 134        // что карточки нужного бренда в 1С нет. Перед созданием по BrandMismatch дочитываем
 135        // живые карточки и объединяем их со снимком карты.
 28136        if (match.Status == NomenclatureMatchStatus.BrandMismatch && !fromLive)
 137        {
 9138            var liveCards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken, byp
 9139            if (liveCards.Count > 0)
 140            {
 1141                var combined = liveCards
 1142                    .Concat(cards)
 143                    .GroupBy(c => c.RefKey)
 144                    .Select(g => g.First())
 1145                    .ToList();
 1146                return Decide(combined, parsed, producer, directory, query) with { FromLiveLookup = true };
 147            }
 148        }
 149
 27150        return fromLive ? match with { FromLiveLookup = true } : match;
 49151    }
 152
 153    private static NomenclatureMatch Decide(
 154        IReadOnlyList<IndexedCard> cards,
 155        ParsedProductRef parsed,
 156        ProducerResolution producer,
 157        ProducerDirectory directory,
 158        ProductQuery query)
 159    {
 29160        var etNorm = ArticleKey.Key(query.EtProducerName);
 161
 29162        if (producer.Kind == ProducerResolutionKind.Known)
 163        {
 53164            var sameBrand = cards.Where(c => SameBrand(c, producer, etNorm)).ToList();
 25165            if (sameBrand.Count > 0)
 15166                return Pick(sameBrand, MatchConfidence.High, "бренд совпал", parsed, producer, "несколько карточек одног
 167
 20168            var producerless = cards.Where(c => !c.HasProducer).ToList();
 10169            if (producerless.Count > 0)
 170            {
 1171                var picked = Pick(producerless, MatchConfidence.Medium, "в 1С карточка без производителя", parsed, produ
 1172                    "несколько карточек без производителя");
 1173                return picked with { ProducerlessCard = true };
 174            }
 175
 9176            return Result(NomenclatureMatchStatus.BrandMismatch, null, MatchConfidence.High, cards,
 9177                $"в 1С есть только {DescribeBrands(cards)} — другой бренд", parsed, producer);
 178        }
 179
 180        // Бренд заказа неизвестен или неоднозначен (общий префикс).
 4181        var pool = producer.Kind == ProducerResolutionKind.Ambiguous
 0182            ? cards.Where(c => c.Groups.Overlaps(producer.Groups) || !c.HasProducer).ToList()
 4183            : cards.ToList();
 4184        if (pool.Count == 0)
 0185            pool = cards.ToList();
 186
 11187        if (pool.Select(c => c.RefKey).Distinct().Count() == 1)
 1188            return Result(NomenclatureMatchStatus.Found, pool[0].RefKey, MatchConfidence.Medium, pool,
 1189                "бренд заказа неизвестен, в 1С одна карточка", parsed, producer);
 190
 9191        var byTitle = pool.Where(c => MentionedInTitle(directory, query.ProductName, c)).ToList();
 4192        if (byTitle.Select(c => c.RefKey).Distinct().Count() == 1)
 1193            return Result(NomenclatureMatchStatus.Found, byTitle[0].RefKey, MatchConfidence.Medium, pool,
 1194                $"бренд выбран по названию товара: {DescribeBrands(byTitle)}", parsed, producer);
 195
 2196        return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, pool,
 2197            $"неоднозначно: {DescribeBrands(pool)}", parsed, producer);
 198    }
 199
 200    private static bool SameBrand(IndexedCard card, ProducerResolution producer, string etNorm)
 31201        => card.Groups.Overlaps(producer.Groups)
 31202           || (card.HasProducer && etNorm.Length > 0 && card.ProducerNorm == etNorm);
 203
 204    private static bool MentionedInTitle(ProducerDirectory directory, string? title, IndexedCard card)
 205    {
 6206        if (card.Groups.Count > 0)
 6207            return directory.NameMentionedIn(title, card.Groups);
 208
 0209        var groups = directory.GroupsOfName(card.ProducerName ?? card.ProducerNorm);
 0210        return groups.Count > 0 && directory.NameMentionedIn(title, groups);
 211    }
 212
 213    private static NomenclatureMatch Pick(
 214        List<IndexedCard> candidates,
 215        MatchConfidence confidence,
 216        string reason,
 217        ParsedProductRef parsed,
 218        ProducerResolution producer,
 219        string duplicateReason)
 220    {
 221        // Среди дублей берём карточку, которую завела сверка с 1С, а не автосоздание: «created» — как раз копии.
 18222        var chosen = candidates
 19223            .OrderBy(c => c.Source == "created" ? 1 : 0)
 18224            .ThenBy(c => c.RefKey)
 18225            .First();
 37226        var duplicate = candidates.Select(c => c.RefKey).Distinct().Count() > 1;
 18227        return Result(NomenclatureMatchStatus.Found, chosen.RefKey, confidence, candidates,
 18228            duplicate ? $"{reason} ({duplicateReason})" : reason, parsed, producer, duplicate);
 229    }
 230
 231    private static NomenclatureMatch? FindByVariant(NomenclatureIndex index, ParsedProductRef parsed, ProducerResolution
 232    {
 16233        var etNorm = ArticleKey.Key(etName);
 90234        foreach (var variant in ArticleKey.Variants(parsed.ArticleKey))
 235        {
 33236            var cards = index.Find(variant.Key).Where(c => SameBrand(c, producer, etNorm)).ToList();
 30237            if (cards.Count == 0)
 238                continue;
 239
 2240            var picked = Pick(cards, MatchConfidence.Medium, $"найдено по варианту артикула {variant.Key}", parsed, prod
 2241                "несколько карточек одного бренда");
 2242            return picked with { Status = NomenclatureMatchStatus.FoundByVariant, VariantKind = variant.Kind };
 243        }
 244
 14245        return null;
 2246    }
 247
 248    private async Task<IReadOnlyList<IndexedCard>> LiveCardsAsync(
 249        ParsedProductRef parsed,
 250        ProducerDirectory directory,
 251        ProducerResolution producer,
 252        INomenclatureLiveLookup? lookup,
 253        CancellationToken cancellationToken,
 254        bool bypassNegativeCache = false)
 255    {
 26256        if (lookup == null)
 6257            return [];
 258
 259        var missKey = parsed.ArticleKey + "|" + string.Join(",", producer.Groups.OrderBy(g => g, StringComparer.Ordinal)
 20260        if (!bypassNegativeCache && _liveMisses.TryGetValue(missKey, out var until) && DateTime.UtcNow < until)
 2261            return [];
 262
 18263        var live = await lookup.FindByArticleAsync(parsed.ArticleClean, parsed.ArticleKey, cancellationToken);
 18264        if (live.Count == 0)
 265        {
 16266            _liveMisses[missKey] = DateTime.UtcNow + _negativeTtl;
 16267            return [];
 268        }
 269
 2270        return live.Select(c =>
 2271        {
 2272            var norm = ArticleKey.Key(c.ProducerName);
 2273            var groups = norm.Length == 0 ? ProducerResolution.NoGroups : directory.GroupsOfName(c.ProducerName);
 2274            return new IndexedCard(c.RefKey, parsed.ArticleKey, c.ProducerName, norm, c.ArticleRaw, "live", groups);
 2275        }).ToList();
 26276    }
 277
 278    private static string DescribeBrands(IEnumerable<IndexedCard> cards)
 12279        => string.Join(" / ", cards
 14280            .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? (c.HasProducer ? c.ProducerNorm : "без производител
 12281            .Distinct(StringComparer.OrdinalIgnoreCase));
 282
 283    private static NomenclatureMatch Result(
 284        NomenclatureMatchStatus status,
 285        Guid? refKey,
 286        MatchConfidence confidence,
 287        IReadOnlyList<IndexedCard> candidates,
 288        string reason,
 289        ParsedProductRef parsed,
 290        ProducerResolution producer,
 291        bool duplicate = false)
 50292        => new(status, refKey, confidence, candidates, reason, parsed, producer, Duplicate: duplicate);
 293}

Methods/Properties

.ctor(Services.ApiServices._1CApi.Nomenclature.INomenclatureIndexProvider,Services.ApiServices._1CApi.Nomenclature.IProducerDirectoryProvider,Services.ApiServices._1CApi.Nomenclature.INomenclatureLiveLookup,System.Nullable`1<System.TimeSpan>)
ForgetMisses()
MatchAsync()
Decide(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Nomenclature.IndexedCard>,OzonDomains.Nomenclature.ParsedProductRef,Services.ApiServices._1CApi.Nomenclature.ProducerResolution,Services.ApiServices._1CApi.Nomenclature.ProducerDirectory,Services.ApiServices._1CApi.Nomenclature.ProductQuery)
SameBrand(Services.ApiServices._1CApi.Nomenclature.IndexedCard,Services.ApiServices._1CApi.Nomenclature.ProducerResolution,System.String)
MentionedInTitle(Services.ApiServices._1CApi.Nomenclature.ProducerDirectory,System.String,Services.ApiServices._1CApi.Nomenclature.IndexedCard)
Pick(System.Collections.Generic.List`1<Services.ApiServices._1CApi.Nomenclature.IndexedCard>,Services.ApiServices._1CApi.Nomenclature.MatchConfidence,System.String,OzonDomains.Nomenclature.ParsedProductRef,Services.ApiServices._1CApi.Nomenclature.ProducerResolution,System.String)
FindByVariant(Services.ApiServices._1CApi.Nomenclature.NomenclatureIndex,OzonDomains.Nomenclature.ParsedProductRef,Services.ApiServices._1CApi.Nomenclature.ProducerResolution,System.String)
LiveCardsAsync()
DescribeBrands(System.Collections.Generic.IEnumerable`1<Services.ApiServices._1CApi.Nomenclature.IndexedCard>)
Result(Services.ApiServices._1CApi.Nomenclature.NomenclatureMatchStatus,System.Nullable`1<System.Guid>,Services.ApiServices._1CApi.Nomenclature.MatchConfidence,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Nomenclature.IndexedCard>,System.String,OzonDomains.Nomenclature.ParsedProductRef,Services.ApiServices._1CApi.Nomenclature.ProducerResolution,System.Boolean)