< Summary

Information
Class: Services.ApiServices._1CApi.Nomenclature.NomenclatureMatch
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/NomenclatureMatcher.cs
Line coverage
100%
Covered lines: 12
Uncovered lines: 0
Coverable lines: 12
Total lines: 293
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Status()100%11100%
get_RefKey()100%11100%
get_Confidence()100%11100%
get_Candidates()100%11100%
get_Reason()100%11100%
get_Parsed()100%11100%
get_Producer()100%11100%
get_VariantKind()100%11100%
get_Duplicate()100%11100%
get_ProducerlessCard()100%11100%
get_FromLiveLookup()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
 7250public sealed record NomenclatureMatch(
 17151    NomenclatureMatchStatus Status,
 3852    Guid? RefKey,
 853    MatchConfidence Confidence,
 754    IReadOnlyList<IndexedCard> Candidates,
 9355    string Reason,
 8156    ParsedProductRef Parsed,
 5257    ProducerResolution Producer,
 658    ArticleVariantKind? VariantKind = null,
 759    bool Duplicate = false,
 760    bool ProducerlessCard = false,
 8161    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>
 76public sealed class NomenclatureMatcher(
 77    INomenclatureIndexProvider indexProvider,
 78    IProducerDirectoryProvider directoryProvider,
 79    INomenclatureLiveLookup? liveLookup = null,
 80    TimeSpan? negativeCacheTtl = null)
 81{
 82    private readonly TimeSpan _negativeTtl = negativeCacheTtl ?? TimeSpan.FromMinutes(10);
 83    private readonly ConcurrentDictionary<string, DateTime> _liveMisses = new(StringComparer.Ordinal);
 84
 85    /// <summary>Забыть кэш живых промахов: карточка только что создана и должна находиться сразу.</summary>
 86    public void ForgetMisses() => _liveMisses.Clear();
 87
 88    /// <param name="live">Живой поиск для этого вызова; если не задан — тот, что передан в конструктор.</param>
 89    public async Task<NomenclatureMatch> MatchAsync(ProductQuery query, INomenclatureLiveLookup? live = null, Cancellati
 90    {
 91        var parsed = ProductKeyParser.Parse(query.Article, query.ProductKey);
 92        var directory = await directoryProvider.GetAsync(cancellationToken);
 93        var producer = directory.Resolve(parsed, query.EtProducerName);
 94
 95        if (parsed.HasArticleConflict)
 96            return Result(NomenclatureMatchStatus.InputConflict, null, MatchConfidence.Low, [],
 97                $"артикул заказа {parsed.ArticleClean} расходится с ProductKey {parsed.ProductKeyArticleClean}", parsed,
 98
 99        if (parsed.ArticleKey.Length == 0)
 100            return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.Low, [], "артикул пуст", parsed, produ
 101
 102        var index = await indexProvider.GetAsync(cancellationToken);
 103        var cards = index.Find(parsed.ArticleKey);
 104        var fromLive = false;
 105
 106        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Known)
 107        {
 108            var byVariant = FindByVariant(index, parsed, producer, query.EtProducerName);
 109            if (byVariant != null)
 110                return byVariant;
 111        }
 112
 113        if (cards.Count == 0)
 114        {
 115            cards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken);
 116            fromLive = cards.Count > 0;
 117        }
 118
 119        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Unknown)
 120            return Result(NomenclatureMatchStatus.ProducerUnknown, null, MatchConfidence.Low, [],
 121                $"бренд заказа не определён; карточка {parsed.ArticleClean} не создана", parsed, producer);
 122
 123        if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Ambiguous)
 124            return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, [],
 125                $"бренд заказа неоднозначен; карточка {parsed.ArticleClean} не создана", parsed, producer);
 126
 127        if (cards.Count == 0)
 128            return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.High, [],
 129                $"в 1С нет карточки с артикулом {parsed.ArticleClean}", parsed, producer);
 130
 131        var match = Decide(cards, parsed, producer, directory, query);
 132
 133        // Карта может отставать от 1С: наличие в ней карточки другого бренда не доказывает,
 134        // что карточки нужного бренда в 1С нет. Перед созданием по BrandMismatch дочитываем
 135        // живые карточки и объединяем их со снимком карты.
 136        if (match.Status == NomenclatureMatchStatus.BrandMismatch && !fromLive)
 137        {
 138            var liveCards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken, byp
 139            if (liveCards.Count > 0)
 140            {
 141                var combined = liveCards
 142                    .Concat(cards)
 143                    .GroupBy(c => c.RefKey)
 144                    .Select(g => g.First())
 145                    .ToList();
 146                return Decide(combined, parsed, producer, directory, query) with { FromLiveLookup = true };
 147            }
 148        }
 149
 150        return fromLive ? match with { FromLiveLookup = true } : match;
 151    }
 152
 153    private static NomenclatureMatch Decide(
 154        IReadOnlyList<IndexedCard> cards,
 155        ParsedProductRef parsed,
 156        ProducerResolution producer,
 157        ProducerDirectory directory,
 158        ProductQuery query)
 159    {
 160        var etNorm = ArticleKey.Key(query.EtProducerName);
 161
 162        if (producer.Kind == ProducerResolutionKind.Known)
 163        {
 164            var sameBrand = cards.Where(c => SameBrand(c, producer, etNorm)).ToList();
 165            if (sameBrand.Count > 0)
 166                return Pick(sameBrand, MatchConfidence.High, "бренд совпал", parsed, producer, "несколько карточек одног
 167
 168            var producerless = cards.Where(c => !c.HasProducer).ToList();
 169            if (producerless.Count > 0)
 170            {
 171                var picked = Pick(producerless, MatchConfidence.Medium, "в 1С карточка без производителя", parsed, produ
 172                    "несколько карточек без производителя");
 173                return picked with { ProducerlessCard = true };
 174            }
 175
 176            return Result(NomenclatureMatchStatus.BrandMismatch, null, MatchConfidence.High, cards,
 177                $"в 1С есть только {DescribeBrands(cards)} — другой бренд", parsed, producer);
 178        }
 179
 180        // Бренд заказа неизвестен или неоднозначен (общий префикс).
 181        var pool = producer.Kind == ProducerResolutionKind.Ambiguous
 182            ? cards.Where(c => c.Groups.Overlaps(producer.Groups) || !c.HasProducer).ToList()
 183            : cards.ToList();
 184        if (pool.Count == 0)
 185            pool = cards.ToList();
 186
 187        if (pool.Select(c => c.RefKey).Distinct().Count() == 1)
 188            return Result(NomenclatureMatchStatus.Found, pool[0].RefKey, MatchConfidence.Medium, pool,
 189                "бренд заказа неизвестен, в 1С одна карточка", parsed, producer);
 190
 191        var byTitle = pool.Where(c => MentionedInTitle(directory, query.ProductName, c)).ToList();
 192        if (byTitle.Select(c => c.RefKey).Distinct().Count() == 1)
 193            return Result(NomenclatureMatchStatus.Found, byTitle[0].RefKey, MatchConfidence.Medium, pool,
 194                $"бренд выбран по названию товара: {DescribeBrands(byTitle)}", parsed, producer);
 195
 196        return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, pool,
 197            $"неоднозначно: {DescribeBrands(pool)}", parsed, producer);
 198    }
 199
 200    private static bool SameBrand(IndexedCard card, ProducerResolution producer, string etNorm)
 201        => card.Groups.Overlaps(producer.Groups)
 202           || (card.HasProducer && etNorm.Length > 0 && card.ProducerNorm == etNorm);
 203
 204    private static bool MentionedInTitle(ProducerDirectory directory, string? title, IndexedCard card)
 205    {
 206        if (card.Groups.Count > 0)
 207            return directory.NameMentionedIn(title, card.Groups);
 208
 209        var groups = directory.GroupsOfName(card.ProducerName ?? card.ProducerNorm);
 210        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» — как раз копии.
 222        var chosen = candidates
 223            .OrderBy(c => c.Source == "created" ? 1 : 0)
 224            .ThenBy(c => c.RefKey)
 225            .First();
 226        var duplicate = candidates.Select(c => c.RefKey).Distinct().Count() > 1;
 227        return Result(NomenclatureMatchStatus.Found, chosen.RefKey, confidence, candidates,
 228            duplicate ? $"{reason} ({duplicateReason})" : reason, parsed, producer, duplicate);
 229    }
 230
 231    private static NomenclatureMatch? FindByVariant(NomenclatureIndex index, ParsedProductRef parsed, ProducerResolution
 232    {
 233        var etNorm = ArticleKey.Key(etName);
 234        foreach (var variant in ArticleKey.Variants(parsed.ArticleKey))
 235        {
 236            var cards = index.Find(variant.Key).Where(c => SameBrand(c, producer, etNorm)).ToList();
 237            if (cards.Count == 0)
 238                continue;
 239
 240            var picked = Pick(cards, MatchConfidence.Medium, $"найдено по варианту артикула {variant.Key}", parsed, prod
 241                "несколько карточек одного бренда");
 242            return picked with { Status = NomenclatureMatchStatus.FoundByVariant, VariantKind = variant.Kind };
 243        }
 244
 245        return null;
 246    }
 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    {
 256        if (lookup == null)
 257            return [];
 258
 259        var missKey = parsed.ArticleKey + "|" + string.Join(",", producer.Groups.OrderBy(g => g, StringComparer.Ordinal)
 260        if (!bypassNegativeCache && _liveMisses.TryGetValue(missKey, out var until) && DateTime.UtcNow < until)
 261            return [];
 262
 263        var live = await lookup.FindByArticleAsync(parsed.ArticleClean, parsed.ArticleKey, cancellationToken);
 264        if (live.Count == 0)
 265        {
 266            _liveMisses[missKey] = DateTime.UtcNow + _negativeTtl;
 267            return [];
 268        }
 269
 270        return live.Select(c =>
 271        {
 272            var norm = ArticleKey.Key(c.ProducerName);
 273            var groups = norm.Length == 0 ? ProducerResolution.NoGroups : directory.GroupsOfName(c.ProducerName);
 274            return new IndexedCard(c.RefKey, parsed.ArticleKey, c.ProducerName, norm, c.ArticleRaw, "live", groups);
 275        }).ToList();
 276    }
 277
 278    private static string DescribeBrands(IEnumerable<IndexedCard> cards)
 279        => string.Join(" / ", cards
 280            .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? (c.HasProducer ? c.ProducerNorm : "без производител
 281            .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)
 292        => new(status, refKey, confidence, candidates, reason, parsed, producer, Duplicate: duplicate);
 293}