| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using OzonDomains.Nomenclature; |
| | | 3 | | |
| | | 4 | | namespace Services.ApiServices._1CApi.Nomenclature; |
| | | 5 | | |
| | | 6 | | public 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 | | |
| | | 30 | | public enum MatchConfidence |
| | | 31 | | { |
| | | 32 | | High, |
| | | 33 | | Medium, |
| | | 34 | | Low |
| | | 35 | | } |
| | | 36 | | |
| | | 37 | | /// <summary>Что нужно вызывающему: только найти (реализатор, утеря) или найти либо создать.</summary> |
| | | 38 | | public 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> |
| | | 48 | | public sealed record ProductQuery(string? Article, string? ProductKey, string? ProductName, string? EtProducerName); |
| | | 49 | | |
| | | 50 | | public 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> |
| | | 64 | | public sealed record LiveCard(Guid RefKey, string? ArticleRaw, string? ProducerName); |
| | | 65 | | |
| | | 66 | | /// <summary>Живой поиск в 1С по артикулу. Реализует фасад поверх OData; в матчере нужен только на промах индекса.</summ |
| | | 67 | | public 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> |
| | 54 | 76 | | public sealed class NomenclatureMatcher( |
| | 54 | 77 | | INomenclatureIndexProvider indexProvider, |
| | 54 | 78 | | IProducerDirectoryProvider directoryProvider, |
| | 54 | 79 | | INomenclatureLiveLookup? liveLookup = null, |
| | 54 | 80 | | TimeSpan? negativeCacheTtl = null) |
| | | 81 | | { |
| | 54 | 82 | | private readonly TimeSpan _negativeTtl = negativeCacheTtl ?? TimeSpan.FromMinutes(10); |
| | 54 | 83 | | private readonly ConcurrentDictionary<string, DateTime> _liveMisses = new(StringComparer.Ordinal); |
| | | 84 | | |
| | | 85 | | /// <summary>Забыть кэш живых промахов: карточка только что создана и должна находиться сразу.</summary> |
| | 10 | 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 | | { |
| | 50 | 91 | | var parsed = ProductKeyParser.Parse(query.Article, query.ProductKey); |
| | 50 | 92 | | var directory = await directoryProvider.GetAsync(cancellationToken); |
| | 49 | 93 | | var producer = directory.Resolve(parsed, query.EtProducerName); |
| | | 94 | | |
| | 49 | 95 | | if (parsed.HasArticleConflict) |
| | 2 | 96 | | return Result(NomenclatureMatchStatus.InputConflict, null, MatchConfidence.Low, [], |
| | 2 | 97 | | $"артикул заказа {parsed.ArticleClean} расходится с ProductKey {parsed.ProductKeyArticleClean}", parsed, |
| | | 98 | | |
| | 47 | 99 | | if (parsed.ArticleKey.Length == 0) |
| | 1 | 100 | | return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.Low, [], "артикул пуст", parsed, produ |
| | | 101 | | |
| | 46 | 102 | | var index = await indexProvider.GetAsync(cancellationToken); |
| | 46 | 103 | | var cards = index.Find(parsed.ArticleKey); |
| | 46 | 104 | | var fromLive = false; |
| | | 105 | | |
| | 46 | 106 | | if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Known) |
| | | 107 | | { |
| | 16 | 108 | | var byVariant = FindByVariant(index, parsed, producer, query.EtProducerName); |
| | 16 | 109 | | if (byVariant != null) |
| | 2 | 110 | | return byVariant; |
| | | 111 | | } |
| | | 112 | | |
| | 44 | 113 | | if (cards.Count == 0) |
| | | 114 | | { |
| | 17 | 115 | | cards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken); |
| | 17 | 116 | | fromLive = cards.Count > 0; |
| | | 117 | | } |
| | | 118 | | |
| | 44 | 119 | | if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Unknown) |
| | 3 | 120 | | return Result(NomenclatureMatchStatus.ProducerUnknown, null, MatchConfidence.Low, [], |
| | 3 | 121 | | $"бренд заказа не определён; карточка {parsed.ArticleClean} не создана", parsed, producer); |
| | | 122 | | |
| | 41 | 123 | | if (cards.Count == 0 && producer.Kind == ProducerResolutionKind.Ambiguous) |
| | 0 | 124 | | return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, [], |
| | 0 | 125 | | $"бренд заказа неоднозначен; карточка {parsed.ArticleClean} не создана", parsed, producer); |
| | | 126 | | |
| | 41 | 127 | | if (cards.Count == 0) |
| | 13 | 128 | | return Result(NomenclatureMatchStatus.NotFound, null, MatchConfidence.High, [], |
| | 13 | 129 | | $"в 1С нет карточки с артикулом {parsed.ArticleClean}", parsed, producer); |
| | | 130 | | |
| | 28 | 131 | | var match = Decide(cards, parsed, producer, directory, query); |
| | | 132 | | |
| | | 133 | | // Карта может отставать от 1С: наличие в ней карточки другого бренда не доказывает, |
| | | 134 | | // что карточки нужного бренда в 1С нет. Перед созданием по BrandMismatch дочитываем |
| | | 135 | | // живые карточки и объединяем их со снимком карты. |
| | 28 | 136 | | if (match.Status == NomenclatureMatchStatus.BrandMismatch && !fromLive) |
| | | 137 | | { |
| | 9 | 138 | | var liveCards = await LiveCardsAsync(parsed, directory, producer, live ?? liveLookup, cancellationToken, byp |
| | 9 | 139 | | if (liveCards.Count > 0) |
| | | 140 | | { |
| | 1 | 141 | | var combined = liveCards |
| | 1 | 142 | | .Concat(cards) |
| | | 143 | | .GroupBy(c => c.RefKey) |
| | | 144 | | .Select(g => g.First()) |
| | 1 | 145 | | .ToList(); |
| | 1 | 146 | | return Decide(combined, parsed, producer, directory, query) with { FromLiveLookup = true }; |
| | | 147 | | } |
| | | 148 | | } |
| | | 149 | | |
| | 27 | 150 | | return fromLive ? match with { FromLiveLookup = true } : match; |
| | 49 | 151 | | } |
| | | 152 | | |
| | | 153 | | private static NomenclatureMatch Decide( |
| | | 154 | | IReadOnlyList<IndexedCard> cards, |
| | | 155 | | ParsedProductRef parsed, |
| | | 156 | | ProducerResolution producer, |
| | | 157 | | ProducerDirectory directory, |
| | | 158 | | ProductQuery query) |
| | | 159 | | { |
| | 29 | 160 | | var etNorm = ArticleKey.Key(query.EtProducerName); |
| | | 161 | | |
| | 29 | 162 | | if (producer.Kind == ProducerResolutionKind.Known) |
| | | 163 | | { |
| | 53 | 164 | | var sameBrand = cards.Where(c => SameBrand(c, producer, etNorm)).ToList(); |
| | 25 | 165 | | if (sameBrand.Count > 0) |
| | 15 | 166 | | return Pick(sameBrand, MatchConfidence.High, "бренд совпал", parsed, producer, "несколько карточек одног |
| | | 167 | | |
| | 20 | 168 | | var producerless = cards.Where(c => !c.HasProducer).ToList(); |
| | 10 | 169 | | if (producerless.Count > 0) |
| | | 170 | | { |
| | 1 | 171 | | var picked = Pick(producerless, MatchConfidence.Medium, "в 1С карточка без производителя", parsed, produ |
| | 1 | 172 | | "несколько карточек без производителя"); |
| | 1 | 173 | | return picked with { ProducerlessCard = true }; |
| | | 174 | | } |
| | | 175 | | |
| | 9 | 176 | | return Result(NomenclatureMatchStatus.BrandMismatch, null, MatchConfidence.High, cards, |
| | 9 | 177 | | $"в 1С есть только {DescribeBrands(cards)} — другой бренд", parsed, producer); |
| | | 178 | | } |
| | | 179 | | |
| | | 180 | | // Бренд заказа неизвестен или неоднозначен (общий префикс). |
| | 4 | 181 | | var pool = producer.Kind == ProducerResolutionKind.Ambiguous |
| | 0 | 182 | | ? cards.Where(c => c.Groups.Overlaps(producer.Groups) || !c.HasProducer).ToList() |
| | 4 | 183 | | : cards.ToList(); |
| | 4 | 184 | | if (pool.Count == 0) |
| | 0 | 185 | | pool = cards.ToList(); |
| | | 186 | | |
| | 11 | 187 | | if (pool.Select(c => c.RefKey).Distinct().Count() == 1) |
| | 1 | 188 | | return Result(NomenclatureMatchStatus.Found, pool[0].RefKey, MatchConfidence.Medium, pool, |
| | 1 | 189 | | "бренд заказа неизвестен, в 1С одна карточка", parsed, producer); |
| | | 190 | | |
| | 9 | 191 | | var byTitle = pool.Where(c => MentionedInTitle(directory, query.ProductName, c)).ToList(); |
| | 4 | 192 | | if (byTitle.Select(c => c.RefKey).Distinct().Count() == 1) |
| | 1 | 193 | | return Result(NomenclatureMatchStatus.Found, byTitle[0].RefKey, MatchConfidence.Medium, pool, |
| | 1 | 194 | | $"бренд выбран по названию товара: {DescribeBrands(byTitle)}", parsed, producer); |
| | | 195 | | |
| | 2 | 196 | | return Result(NomenclatureMatchStatus.Ambiguous, null, MatchConfidence.Low, pool, |
| | 2 | 197 | | $"неоднозначно: {DescribeBrands(pool)}", parsed, producer); |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | private static bool SameBrand(IndexedCard card, ProducerResolution producer, string etNorm) |
| | 31 | 201 | | => card.Groups.Overlaps(producer.Groups) |
| | 31 | 202 | | || (card.HasProducer && etNorm.Length > 0 && card.ProducerNorm == etNorm); |
| | | 203 | | |
| | | 204 | | private static bool MentionedInTitle(ProducerDirectory directory, string? title, IndexedCard card) |
| | | 205 | | { |
| | 6 | 206 | | if (card.Groups.Count > 0) |
| | 6 | 207 | | return directory.NameMentionedIn(title, card.Groups); |
| | | 208 | | |
| | 0 | 209 | | var groups = directory.GroupsOfName(card.ProducerName ?? card.ProducerNorm); |
| | 0 | 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» — как раз копии. |
| | 18 | 222 | | var chosen = candidates |
| | 19 | 223 | | .OrderBy(c => c.Source == "created" ? 1 : 0) |
| | 18 | 224 | | .ThenBy(c => c.RefKey) |
| | 18 | 225 | | .First(); |
| | 37 | 226 | | var duplicate = candidates.Select(c => c.RefKey).Distinct().Count() > 1; |
| | 18 | 227 | | return Result(NomenclatureMatchStatus.Found, chosen.RefKey, confidence, candidates, |
| | 18 | 228 | | duplicate ? $"{reason} ({duplicateReason})" : reason, parsed, producer, duplicate); |
| | | 229 | | } |
| | | 230 | | |
| | | 231 | | private static NomenclatureMatch? FindByVariant(NomenclatureIndex index, ParsedProductRef parsed, ProducerResolution |
| | | 232 | | { |
| | 16 | 233 | | var etNorm = ArticleKey.Key(etName); |
| | 90 | 234 | | foreach (var variant in ArticleKey.Variants(parsed.ArticleKey)) |
| | | 235 | | { |
| | 33 | 236 | | var cards = index.Find(variant.Key).Where(c => SameBrand(c, producer, etNorm)).ToList(); |
| | 30 | 237 | | if (cards.Count == 0) |
| | | 238 | | continue; |
| | | 239 | | |
| | 2 | 240 | | var picked = Pick(cards, MatchConfidence.Medium, $"найдено по варианту артикула {variant.Key}", parsed, prod |
| | 2 | 241 | | "несколько карточек одного бренда"); |
| | 2 | 242 | | return picked with { Status = NomenclatureMatchStatus.FoundByVariant, VariantKind = variant.Kind }; |
| | | 243 | | } |
| | | 244 | | |
| | 14 | 245 | | return null; |
| | 2 | 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 | | { |
| | 26 | 256 | | if (lookup == null) |
| | 6 | 257 | | return []; |
| | | 258 | | |
| | | 259 | | var missKey = parsed.ArticleKey + "|" + string.Join(",", producer.Groups.OrderBy(g => g, StringComparer.Ordinal) |
| | 20 | 260 | | if (!bypassNegativeCache && _liveMisses.TryGetValue(missKey, out var until) && DateTime.UtcNow < until) |
| | 2 | 261 | | return []; |
| | | 262 | | |
| | 18 | 263 | | var live = await lookup.FindByArticleAsync(parsed.ArticleClean, parsed.ArticleKey, cancellationToken); |
| | 18 | 264 | | if (live.Count == 0) |
| | | 265 | | { |
| | 16 | 266 | | _liveMisses[missKey] = DateTime.UtcNow + _negativeTtl; |
| | 16 | 267 | | return []; |
| | | 268 | | } |
| | | 269 | | |
| | 2 | 270 | | return live.Select(c => |
| | 2 | 271 | | { |
| | 2 | 272 | | var norm = ArticleKey.Key(c.ProducerName); |
| | 2 | 273 | | var groups = norm.Length == 0 ? ProducerResolution.NoGroups : directory.GroupsOfName(c.ProducerName); |
| | 2 | 274 | | return new IndexedCard(c.RefKey, parsed.ArticleKey, c.ProducerName, norm, c.ArticleRaw, "live", groups); |
| | 2 | 275 | | }).ToList(); |
| | 26 | 276 | | } |
| | | 277 | | |
| | | 278 | | private static string DescribeBrands(IEnumerable<IndexedCard> cards) |
| | 12 | 279 | | => string.Join(" / ", cards |
| | 14 | 280 | | .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? (c.HasProducer ? c.ProducerNorm : "без производител |
| | 12 | 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) |
| | 50 | 292 | | => new(status, refKey, confidence, candidates, reason, parsed, producer, Duplicate: duplicate); |
| | | 293 | | } |