< Summary

Line coverage
86%
Covered lines: 479
Uncovered lines: 72
Coverable lines: 551
Total lines: 1117
Line coverage: 86.9%
Branch coverage
76%
Covered branches: 262
Total branches: 341
Branch coverage: 76.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .cctor()100%11100%
File 1: .ctor(...)100%44100%
File 1: GetOrCreateAsync()100%44100%
File 1: SignalAsync()100%22100%
File 1: AuditAsync()50%22100%
File 1: ShadowMatchAsync()100%44100%
File 1: LogShadow(...)100%44100%
File 1: Services-ApiServices-_1CApi-Nomenclature-INomenclatureLiveLookup-FindByArticleAsync()37.5%23838.46%
File 1: CreateNewCardAsync()100%9873.68%
File 1: GetOrCreateCoreAsync()91.66%242497.14%
File 1: GetOrCreateManyAsync()100%11100%
File 1: <GetOrCreateManyAsync()100%11100%
File 1: FindAsync()100%11100%
File 1: FindCoreAsync()95.45%2222100%
File 1: FindManyAsync()100%11100%
File 1: <FindManyAsync()100%11100%
File 1: NormalizeArticle(...)100%22100%
File 1: ResolveArticle(...)85.71%1414100%
File 1: NormalizeProducerName(...)100%22100%
File 1: get_RefKey()100%11100%
File 1: ResolveProducerAliasAsync()100%9878.57%
File 1: RememberAsync()100%2255.55%
File 1: FindExistingAsync()70%332068%
File 1: FetchCandidatesAsync()87.5%88100%
File 1: ReadProducersAsync()100%22100%
File 1: ReadProducerAsync()75%121287.5%
File 1: TryFindProducerAsync()100%22100%
File 1: GetOrCreateProducerAsync()91.66%121295.83%
File 1: InferProducerByContainment(...)100%88100%
File 1: SetProducerAsync()62.5%8895.23%
File 1: EnsureRequiredFieldsAsync()52.94%353491.66%
File 1: BuildOriginComment(...)87.5%88100%
File 1: BuildNomenclatureBody(...)100%11100%
File 1: EscapeOData(...)100%11100%
File 2: ResolveManyAsync()83.33%6693.75%
File 2: <ResolveManyAsync()100%11100%
File 2: ResolveOneAsync()94.44%221877.41%
File 2: RejectionNotificationKind(...)83.33%6688.88%
File 2: NotificationScope(...)71.42%7780%
File 2: NotifyAsync()25%11423.07%
File 2: LogResolver(...)100%88100%
File 2: AfterReuseAsync()36.36%2262225%
File 2: CreateResolvedAsync()66.66%121290.9%
File 2: ResolveProducerForCreationAsync()62.5%242491.66%
File 2: CreationArticleFor(...)90%1010100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.cs

#LineLine coverage
 1using Services.SystemMonitoringServices;
 2using System.Collections.Concurrent;
 3using System.Text;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json.Linq;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using Services.ApiServices._1CApi.Nomenclature;
 9using Services.ApiServices._1CApi.OData.Models;
 10using Services.BackgroundJobsServices;
 11
 12namespace Services.ApiServices._1CApi.OData;
 13
 14/// <summary>Откуда пришло создание карточки — попадает в «Комментарий» номенклатуры в 1С.</summary>
 15public sealed record OneCNomenclatureOrigin(TransactionType? TransactionType);
 16
 17public interface IOneCNomenclatureService
 18{
 19    Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null);
 20
 21    /// <summary>
 22    /// Разрешает номенклатуру для нескольких заказов параллельно (с ограничением конкурентности) —
 23    /// на порядки быстрее, чем последовательный вызов GetOrCreateAsync в цикле, когда несколько
 24    /// позиций документа отсутствуют в 1С. Результат в том же порядке, что и входные orders.
 25    /// </summary>
 26    Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync(
 27        IReadOnlyList<Order> orders,
 28        int maxConcurrency = 3,
 29        OneCNomenclatureOrigin? origin = null);
 30
 31    /// <summary>
 32    /// Ищет карточку номенклатуры теми же правилами, что GetOrCreateAsync (карта соответствий,
 33    /// алиасы производителей, проба по префиксу артикула), но НИКОГДА ничего не создаёт в 1С.
 34    /// null — карточки нет. Нужен для проверок «до записи», где создание карточки недопустимо.
 35    /// </summary>
 36    Task<Guid?> FindAsync(string? article, string? producerName);
 37
 38    /// <summary>Пакетный FindAsync. Результат в том же порядке, что и входные items.</summary>
 39    Task<IReadOnlyList<Guid?>> FindManyAsync(
 40        IReadOnlyList<(string? Article, string? ProducerName)> items,
 41        int maxConcurrency = 3);
 42
 43    /// <summary>
 44    /// Разрешает номенклатуру позиций с возможностью «карточки нет»: <see cref="NomenclatureResolution.RefKey"/> = null
 45    /// и причина в <see cref="NomenclatureResolution.Reason"/>. Менеджеры документов кладут такие позиции в черновик.
 46    /// Реализация по умолчанию — прежнее поведение: «только найти» через <see cref="FindManyAsync"/>,
 47    /// «найти или создать» через <see cref="GetOrCreateManyAsync"/> (там карточка есть всегда или бросается исключение)
 48    /// </summary>
 49    async Task<IReadOnlyList<NomenclatureResolution>> ResolveManyAsync(
 50        IReadOnlyList<Order> orders,
 51        NomenclatureMode mode,
 52        int maxConcurrency = 3,
 53        OneCNomenclatureOrigin? origin = null)
 54    {
 55        if (mode == NomenclatureMode.FindOnly)
 56        {
 57            var found = await FindManyAsync(
 58                orders.Select(o => (Article: (string?)NomenclatureResolution.LegacySearchArticle(o), ProducerName: o.EtP
 59                maxConcurrency);
 60            return found.Select(g => new NomenclatureResolution(g, false, string.Empty, null, null, false)).ToList();
 61        }
 62
 63        var created = await GetOrCreateManyAsync(orders, maxConcurrency, origin);
 64        return created.Select(c => new NomenclatureResolution(c.RefKey, c.Created, c.DisplayName, null, null, false)).To
 65    }
 66}
 67
 68public sealed record OneCNomenclatureResult(Guid RefKey, bool Created, string DisplayName);
 69
 70/// <summary>Гарантирует, что товар из заказа существует и пригоден для проведения документов 1С.</summary>
 71public sealed partial class OneCNomenclatureService : IOneCNomenclatureService, INomenclatureLiveLookup
 72{
 73    private const string NomenclatureEndpoint = "Catalog_Номенклатура";
 74    internal const string PropertyValuesEndpoint = "Catalog_ЗначенияСвойствОбъектов";
 75    internal const string ProducerPropertyKey = "abba39bc-6cc0-11e8-418f-005056890939";
 76    private const string StockAccountKey = "fa5a6448-4871-11e8-4680-fa163e49a0ee";
 77    private const string ExpenseAccountKey = "fa5a70f0-4871-11e8-4680-fa163e49a0ee";
 78    private const string ActivityDirectionKey = "faf5480a-4871-11e8-4680-fa163e49a0ee";
 79    // «Без категории» в Catalog_КатегорииНоменклатуры.
 80    private const string CategoryKey = "fdd99328-4871-11e8-4680-fa163e49a0ee";
 81    private const string VatSalesAccountKey = "3206a2f4-7ed1-49da-92a8-5f2bf0384c0e";
 82    private const string VatPurchaseAccountKey = "9e4ace75-ec94-49b2-842c-3642f4a35a32";
 83
 84    /// <summary>Длина префикса артикула для substringof-пробы. См. FetchCandidatesAsync.</summary>
 85    private const int ProbePrefixLength = 5;
 86
 87    /// <summary>Короче этого имя производителя не участвует в эвристике вхождения — слишком много ложных срабатываний.<
 88    private const int MinProducerContainsLength = 4;
 89
 190    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new(StringComparer.Ordinal);
 91    // Отдельный лок на имя производителя: при параллельном создании (GetOrCreateManyAsync) два разных
 92    // артикула с одним новым производителем не заблокируют друг друга через Locks (ключ там включает
 93    // артикул) и могут создать в 1С два дублирующих Catalog_ЗначенияСвойствОбъектов на один Description.
 94    // Ключ — нормализованное имя (см. NormalizeProducerName), иначе "PARTS-MALL" и "PARTS MALL" лочатся
 95    // раздельно и всё равно создают два разных значения реквизита.
 196    private static readonly ConcurrentDictionary<string, SemaphoreSlim> ProducerLocks = new(StringComparer.Ordinal);
 97
 98    private readonly IOneCODataClient _client;
 99    private readonly ILogger<OneCNomenclatureService> _logger;
 100    private readonly IOneCNomenclatureCreationLock? _creationLock;
 101    private readonly IOneCNomenclatureMapStore? _map;
 102    private readonly bool _backfillProducerOnReuse;
 103    private readonly ISystemMonitoringSignalWriter? _signals;
 104    private readonly NomenclatureMatcher? _matcher;
 105    private readonly NomenclatureResolverMode _resolverMode;
 106    private readonly ProducerValueResolver? _producerValues;
 107    private readonly INomenclatureIndexProvider? _indexProvider;
 108    private readonly INomenclatureNotificationSink? _notificationSink;
 109
 52110    public OneCNomenclatureService(
 52111        IOneCODataClient client,
 52112        ILogger<OneCNomenclatureService> logger,
 52113        IOneCNomenclatureCreationLock? creationLock = null,
 52114        IOneCNomenclatureMapStore? map = null,
 52115        OneCODataConfig? config = null,
 52116        ISystemMonitoringSignalWriter? signals = null,
 52117        NomenclatureMatcher? matcher = null,
 52118        ProducerValueResolver? producerValues = null,
 52119        INomenclatureIndexProvider? indexProvider = null,
 52120        INomenclatureNotificationSink? notificationSink = null)
 121    {
 52122        _producerValues = producerValues;
 52123        _indexProvider = indexProvider;
 52124        _notificationSink = notificationSink;
 52125        _signals = signals;
 52126        _matcher = matcher;
 52127        _resolverMode = config?.ResolverMode ?? NomenclatureResolverMode.Off;
 52128        _client = client;
 52129        _logger = logger;
 52130        _creationLock = creationLock;
 52131        _map = map;
 52132        _backfillProducerOnReuse = config?.BackfillProducerOnReuse ?? true;
 52133    }
 134
 135    public async Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null)
 136    {
 20137        var shadow = await ShadowMatchAsync(new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.E
 138        try
 139        {
 20140            var result = await GetOrCreateCoreAsync(order, origin);
 18141            LogShadow(shadow, result.RefKey, result.Created, NomenclatureMode.FindOrCreate, order.Article, order.EtProdu
 18142            return result;
 143        }
 2144        catch (Exception ex)
 145        {
 2146            await SignalAsync("create-failed", $"{order.Article}: {ex.Message}");
 2147            throw;
 148        }
 18149    }
 150
 151    /// <summary>
 152    /// Проблемы с номенклатурой/производителями 1С попадают в «Системные алерты» (Studio2/JobLogs?tab=alerts)
 153    /// по общим правилам пассивных сигналов. Запись сигнала не должна влиять на основную операцию.
 154    /// </summary>
 155    private async Task SignalAsync(string scope, string message)
 156    {
 9157        if (_signals == null) return;
 5158        await _signals.RecordAsync(NomenclatureSignalSource, "failure", scope, message);
 7159    }
 160
 161    private async Task AuditAsync(string scope, string message)
 162    {
 9163        if (_signals == null) return;
 9164        await _signals.RecordAsync(NomenclatureSignalSource, "success", scope, message);
 9165    }
 166
 167    public const string NomenclatureSignalSource = "onec-nomenclature";
 168
 169    /// <summary>Новый поиск в режиме Shadow: только читает, любые сбои гасятся — прежний поиск от него не зависит.</sum
 170    private async Task<NomenclatureMatch?> ShadowMatchAsync(ProductQuery query)
 171    {
 40172        if (_resolverMode != NomenclatureResolverMode.Shadow || _matcher == null)
 37173            return null;
 174
 175        try
 176        {
 3177            return await _matcher.MatchAsync(query, this);
 178        }
 1179        catch (Exception ex)
 180        {
 1181            _logger.LogWarning(ex, "[NomenclatureShadow] Новый поиск не отработал для {Article}", query.Article);
 1182            return null;
 183        }
 40184    }
 185
 186    private void LogShadow(NomenclatureMatch? shadow, Guid? oldRefKey, bool oldCreated, NomenclatureMode mode, string? a
 187    {
 38188        if (shadow == null)
 36189            return;
 190
 2191        var outcome = NomenclatureShadowComparer.Compare(oldRefKey, oldCreated, shadow, mode);
 2192        if (outcome.Kind == ShadowDiffKind.Agree)
 193        {
 1194            _logger.LogDebug("[NomenclatureShadow] совпало {Article}/{Producer}: {Text}", article, producer, outcome.Des
 1195            return;
 196        }
 197
 1198        _logger.LogInformation("[NomenclatureShadow] {Kind} {Article}/{Producer}: {Text}", outcome.Kind, article, produc
 1199    }
 200
 201    /// <summary>Живой поиск для нового матчера: те же OData-запросы, что у прежнего поиска, плюс имя производителя карт
 202    async Task<IReadOnlyList<LiveCard>> INomenclatureLiveLookup.FindByArticleAsync(
 203        string articleClean, string articleKey, CancellationToken cancellationToken)
 204    {
 15205        var candidates = await FetchCandidatesAsync(NormalizeArticle(articleClean), articleClean);
 15206        var cards = new List<LiveCard>(candidates.Count);
 30207        foreach (var candidate in candidates)
 208        {
 0209            var producerGuid = await ReadProducerAsync(candidate.RefKey);
 0210            string? producerName = null;
 0211            if (producerGuid.HasValue)
 212            {
 0213                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 0214                    PropertyValuesEndpoint, producerGuid.Value, "Ref_Key,Description");
 0215                producerName = value?.Description;
 216            }
 217
 0218            cards.Add(new LiveCard(candidate.RefKey, candidate.Article, producerName));
 0219        }
 220
 15221        return cards;
 15222    }
 223
 224    /// <summary>
 225    /// Создаёт карточку в 1С: тело, единица, производитель, обязательные поля, запись в карту. Вызывать только под локо
 226    /// ключа (артикул + производитель), чтобы параллельные вызовы не завели две карточки.
 227    /// </summary>
 228    private async Task<OneCNomenclatureResult> CreateNewCardAsync(
 229        Order order,
 230        string article,
 231        string creationArticle,
 232        Guid? producerGuid,
 233        string? producerName,
 234        string producerNorm,
 235        OneCNomenclatureOrigin? origin)
 236    {
 19237        var name = string.IsNullOrWhiteSpace(order.ProductName) ? creationArticle : order.ProductName.Trim();
 19238        var body = BuildNomenclatureBody(creationArticle, name);
 239        ODataNomenclature created;
 240        try
 241        {
 19242            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 19243        }
 0244        catch (InvalidOperationException ex) when (ex is OneCODataException { Error.StatusCode: 500 }
 0245                                               || ex.Message.Contains("HTTP 500", StringComparison.Ordinal))
 246        {
 0247            _logger.LogWarning("[OData] Создание номенклатуры {Article} с единицей вернуло 500; повторяем без единицы.",
 0248            body.Remove("ЕдиницаИзмерения_Key");
 0249            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 250        }
 251
 19252        if (created.RefKey == Guid.Empty)
 1253            throw new InvalidOperationException($"1С создала номенклатуру '{article}' без Ref_Key.");
 254
 18255        if (producerGuid.HasValue)
 16256            await SetProducerAsync(created.RefKey, producerGuid.Value);
 257
 258        // EnsureRequiredFieldsAsync должен быть последней записью в карточку: SetProducerAsync делает
 259        // full-PUT (PutAsync), который может откатить единицу/категорию, если вызвать проверку раньше него.
 260        // Отметка о происхождении идёт этим же PATCH — в теле POST её слать нельзя: неизвестное поле
 261        // может уронить создание карточки, а PATCH по «Комментарий» проверен на проде.
 18262        await EnsureRequiredFieldsAsync(created.RefKey, BuildOriginComment(origin));
 263
 18264        await RememberAsync(article, producerNorm, created.RefKey, creationArticle, producerName, "created");
 265
 18266        _logger.LogInformation("[OData] Создана номенклатура {Article} (производитель {Producer}): {RefKey}",
 18267            creationArticle, producerName ?? "—", created.RefKey);
 18268        return new OneCNomenclatureResult(created.RefKey, true, $"{name} [{article}]");
 18269    }
 270
 271    private async Task<OneCNomenclatureResult> GetOrCreateCoreAsync(Order order, OneCNomenclatureOrigin? origin)
 272    {
 20273        var (article, searchArticle, creationArticle) = ResolveArticle(order);
 20274        if (string.IsNullOrWhiteSpace(article))
 1275            throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать
 276
 19277        var producerName = order.EtProducer?.Name?.Trim();
 19278        var producerNorm = NormalizeProducerName(producerName);
 279
 280        // Алиас разрешается ДО ключа карты и локов. Иначе «BLUE» ищется по собственному ключу,
 281        // находит старую дублирующую карточку и до канонического «BLUE PRINT» дело не доходит.
 19282        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, producerName);
 19283        producerNorm = aliasedNorm;
 19284        producerName = aliasedName;
 285
 19286        var lockKey = $"{article}|{producerNorm}";
 287        var gate = Locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
 19288        await gate.WaitAsync();
 289        try
 290        {
 19291            await using var distributedLock = _creationLock == null
 19292                ? null
 19293                : await _creationLock.AcquireAsync(article, producerNorm);
 294
 295            // Быстрый путь: карта соответствий знает карточку по нормализованному ключу независимо
 296            // от того, как артикул отформатирован в самой 1С. Проверяем уже под локом, чтобы второй
 297            // инстанс увидел запись, сделанную первым.
 19298            if (_map != null)
 299            {
 13300                var mapped = await _map.FindAsync(article, producerNorm);
 13301                if (mapped != null)
 2302                    return new OneCNomenclatureResult(mapped.RefKey, false, string.Empty);
 303            }
 304
 17305            var producerGuid = aliasProducerGuid
 17306                               ?? (string.IsNullOrWhiteSpace(producerName)
 17307                                   ? (Guid?)null
 17308                                   : await GetOrCreateProducerAsync(producerName));
 309
 17310            var existing = await FindExistingAsync(article, searchArticle, producerGuid, producerNorm);
 17311            if (existing != null)
 312            {
 7313                if (existing.NeedsProducerBackfill && producerGuid.HasValue && _backfillProducerOnReuse)
 314                {
 2315                    _logger.LogInformation(
 2316                        "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Pr
 2317                        article, existing.RefKey, producerName);
 2318                    await SetProducerAsync(existing.RefKey, producerGuid.Value);
 319
 320                    // SetProducerAsync — полная перезапись карточки (PUT), а 1С Fresh при ней не
 321                    // сохраняет навигационные реквизиты: единица, счета учёта и категория обнуляются.
 322                    // В ветке создания это лечит EnsureRequiredFieldsAsync ниже, здесь нужен такой же
 323                    // вызов, иначе строка документа уходит в 1С с пустой «Ед.». Комментарий
 324                    // происхождения не пишем: карточку завёл не Studio2.
 2325                    await EnsureRequiredFieldsAsync(existing.RefKey);
 326                }
 327
 7328                await RememberAsync(article, producerNorm, existing.RefKey, existing.RawArticle, producerName, "probe");
 7329                return new OneCNomenclatureResult(existing.RefKey, false, string.Empty);
 330            }
 331
 10332            return await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producerNorm, o
 0333        }
 334        finally
 335        {
 19336            gate.Release();
 337        }
 18338    }
 339
 340    public async Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync(
 341        IReadOnlyList<Order> orders,
 342        int maxConcurrency = 3,
 343        OneCNomenclatureOrigin? origin = null)
 344    {
 1345        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 1346        var tasks = orders.Select(async order =>
 1347        {
 2348            await throttle.WaitAsync();
 1349            try
 1350            {
 2351                return await GetOrCreateAsync(order, origin);
 1352            }
 1353            finally
 1354            {
 2355                throttle.Release();
 1356            }
 3357        });
 1358        return await Task.WhenAll(tasks);
 1359    }
 360
 361    public async Task<Guid?> FindAsync(string? article, string? producerName)
 362    {
 20363        var shadow = await ShadowMatchAsync(new ProductQuery(article, null, null, producerName));
 20364        var found = await FindCoreAsync(article, producerName);
 20365        LogShadow(shadow, found, false, NomenclatureMode.FindOnly, article, producerName);
 20366        return found;
 20367    }
 368
 369    private async Task<Guid?> FindCoreAsync(string? article, string? producerName)
 370    {
 20371        var normalizedArticle = NormalizeArticle(article);
 20372        if (normalizedArticle.Length == 0)
 2373            return null;
 374
 18375        var trimmedProducer = producerName?.Trim();
 18376        var producerNorm = NormalizeProducerName(trimmedProducer);
 18377        var originalProducerNorm = producerNorm;
 378
 379        // Порядок тот же, что в GetOrCreateAsync: алиас → карта соответствий → поиск по 1С.
 380        // Локи не берём — поиск ничего не мутирует, а лок сериализовал бы его с параллельным
 381        // созданием карточек того же артикула.
 18382        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, trimmedProduce
 18383        producerNorm = aliasedNorm;
 18384        trimmedProducer = aliasedName;
 385
 18386        if (_map != null)
 387        {
 17388            var mapped = await _map.FindAsync(normalizedArticle, producerNorm);
 389
 390            // GetOrCreateAsync намеренно ищет только по каноническому производителю, чтобы не взять старую
 391            // карточку-дубль. Поиск без создания (сверка остатков, проверки) должен находить любую карточку,
 392            // а карта хранит их под тем написанием, под которым их завели: «FEBI», а не «FEBI BILSTEIN».
 393            // 14.10.2026 алиас FEBI → FEBI BILSTEIN сделал такие карточки невидимыми, защита «1С онлайн»
 394            // перестала их проверять и на Ozon встал товар, которого на M1 нет.
 17395            if (mapped == null && !string.Equals(originalProducerNorm, producerNorm, StringComparison.Ordinal))
 3396                mapped = await _map.FindAsync(normalizedArticle, originalProducerNorm);
 397
 17398            if (mapped != null)
 10399                return mapped.RefKey;
 400        }
 401
 8402        var producerGuid = aliasProducerGuid;
 8403        if (producerGuid == null && !string.IsNullOrWhiteSpace(trimmedProducer))
 404        {
 405            // Значения реквизита нет в 1С — карточки с точным производителем тоже быть не может,
 406            // поэтому ищем дальше как без производителя (та же ветка, куда попал бы GetOrCreateAsync).
 4407            var (match, _) = await TryFindProducerAsync(NormalizeProducerName(trimmedProducer));
 4408            producerGuid = match?.RefKey;
 409        }
 410
 8411        var existing = await FindExistingAsync(normalizedArticle, article?.Trim(), producerGuid, producerNorm);
 8412        return existing?.RefKey;
 20413    }
 414
 415    public async Task<IReadOnlyList<Guid?>> FindManyAsync(
 416        IReadOnlyList<(string? Article, string? ProducerName)> items,
 417        int maxConcurrency = 3)
 418    {
 3419        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 3420        var tasks = items.Select(async item =>
 3421        {
 5422            await throttle.WaitAsync();
 3423            try
 3424            {
 5425                return await FindAsync(item.Article, item.ProducerName);
 3426            }
 3427            finally
 3428            {
 5429                throttle.Release();
 3430            }
 8431        });
 3432        return await Task.WhenAll(tasks);
 3433    }
 434
 435    internal static string NormalizeArticle(string? value)
 436    {
 79437        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 79438        return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
 439    }
 440
 441    /// <summary>
 442    /// Возвращает нормализованный ключ для карты, исходное написание для поиска и артикул для создания в 1С.
 443    /// В заказе <see cref="Order.Article"/> может быть очищен импортом, но ProductKey сохраняет
 444    /// артикул до знака «=» вместе с дефисами, точками и другими разделителями.
 445    /// </summary>
 446    private static (string Normalized, string SearchArticle, string CreationArticle) ResolveArticle(Order order)
 447    {
 20448        var article = order.Article?.Trim();
 20449        var normalized = NormalizeArticle(article);
 20450        if (normalized.Length == 0)
 1451            return (string.Empty, string.Empty, string.Empty);
 452
 19453        var productKeyArticle = order.ProductKey?.Split('=', 2)[0].Trim();
 19454        if (!string.IsNullOrWhiteSpace(productKeyArticle)
 19455            && NormalizeArticle(productKeyArticle) == normalized
 22456            && productKeyArticle.Any(c => !char.IsLetterOrDigit(c)))
 457        {
 1458            return (normalized, productKeyArticle, productKeyArticle);
 459        }
 460
 18461        return (normalized, article ?? normalized, normalized);
 462    }
 463
 464    /// <summary>
 465    /// Убирает дефисы/пробелы/регистр из имени производителя перед поиском и сравнением, иначе
 466    /// "PARTS-MALL" и "PARTS MALL" считаются разными производителями — оба создают своё значение
 467    /// реквизита, и карточка номенклатуры с тем же артикулом не находится по составному ключу
 468    /// (артикул + производитель) в FindExistingAsync, что порождает дубль карточки.
 469    /// </summary>
 470    public static string NormalizeProducerName(string? value)
 471    {
 309472        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 309473        return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
 474    }
 475
 43476    private sealed record ExistingMatch(Guid RefKey, string? RawArticle, bool NeedsProducerBackfill);
 477
 478    /// <summary>
 479    /// Сводит написание производителя к каноническому: «BLUE» → «BLUE PRINT», «Kayaba» → «KYB».
 480    /// Возвращает GUID канонического значения и канонические имя/ключ для карты соответствий.
 481    /// </summary>
 482    private async Task<(Guid? Guid, string Norm, string? Name)> ResolveProducerAliasAsync(string producerNorm, string? p
 483    {
 39484        if (_map == null || producerNorm.Length == 0)
 17485            return (null, producerNorm, producerName);
 486
 487        OneCProducerAliasEntry? alias;
 488        try
 489        {
 22490            alias = await _map.FindProducerAliasAsync(producerNorm);
 22491        }
 0492        catch (Exception ex)
 493        {
 0494            _logger.LogWarning(ex, "[OData] Не удалось прочитать алиасы производителя для «{Producer}».", producerName);
 0495            return (null, producerNorm, producerName);
 496        }
 497
 22498        if (alias == null)
 15499            return (null, producerNorm, producerName);
 500
 7501        var canonicalNorm = NormalizeProducerName(alias.CanonicalName);
 7502        return canonicalNorm.Length == 0
 7503            ? (alias.RefKey, producerNorm, producerName)
 7504            : (alias.RefKey, canonicalNorm, alias.CanonicalName);
 39505    }
 506
 507    private async Task RememberAsync(string article, string producerNorm, Guid refKey, string? rawArticle, string? produ
 508    {
 25509        if (_map == null)
 6510            return;
 511
 512        try
 513        {
 19514            await _map.UpsertAsync(new OneCMapEntry(refKey, article, producerNorm, producerName), rawArticle, source);
 19515        }
 0516        catch (Exception ex)
 517        {
 518            // Карта — ускоритель и защита от дублей, но не источник истины: её недоступность
 519            // не должна ронять проведение документа.
 0520            _logger.LogWarning(ex, "[OData] Не удалось записать соответствие {Article}|{Producer} → {RefKey} в карту ном
 0521                article, producerNorm, refKey);
 0522        }
 25523    }
 524
 525    private async Task<ExistingMatch?> FindExistingAsync(string article, string? rawArticle, Guid? producerGuid, string?
 526    {
 25527        var candidates = await FetchCandidatesAsync(article, rawArticle);
 25528        if (candidates.Count == 0)
 16529            return null;
 530
 9531        if (!producerGuid.HasValue)
 532        {
 533            // Производитель у входящей позиции не указан — сопоставляем только по артикулу.
 534            // Единственный кандидат берётся без лишнего чтения карточки.
 6535            if (candidates.Count == 1)
 6536                return new ExistingMatch(candidates[0].RefKey, candidates[0].Article, false);
 537
 0538            var withProducers = await ReadProducersAsync(candidates);
 539            var producerless = withProducers.FirstOrDefault(x => !x.Producer.HasValue);
 540            var chosen = producerless.Card ?? candidates.OrderBy(x => x.RefKey).First();
 0541            _logger.LogWarning(
 0542                "[OData] Артикул {Article}: {Count} карточек в 1С, производитель во входящей позиции не задан — выбрана 
 0543                article, candidates.Count, chosen.RefKey);
 0544            await SignalAsync("duplicate", $"Артикул {article}: {candidates.Count} карточек в 1С, производитель не задан
 0545            return new ExistingMatch(chosen.RefKey, chosen.Article, false);
 546        }
 547
 3548        var resolved = await ReadProducersAsync(candidates);
 549
 6550        var exact = resolved.FirstOrDefault(x => x.Producer == producerGuid);
 3551        if (exact.Card != null)
 0552            return new ExistingMatch(exact.Card.RefKey, exact.Card.Article, false);
 553
 554        // В 1С одно и то же имя бренда бывает записано разными значениями (в т.ч. у другого владельца
 555        // реквизита), и GUID у карточки не совпадает с найденным по Owner_Key. Сверяем по имени.
 3556        if (!string.IsNullOrEmpty(producerNorm))
 557        {
 558            foreach (var (card, producer) in resolved.Where(x => x.Producer.HasValue))
 559            {
 1560                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 1561                    PropertyValuesEndpoint, producer!.Value, "Ref_Key,Description");
 1562                if (NormalizeProducerName(value?.Description) == producerNorm)
 0563                    return new ExistingMatch(card!.RefKey, card.Article, false);
 1564            }
 565        }
 566
 567        // Карточка с тем же артикулом, но вообще без производителя — самый частый источник дублей:
 568        // раньше на неё не попадали и заводили вторую. Переиспользуем, если она единственная.
 569        var producerlessCards = resolved.Where(x => !x.Producer.HasValue).ToList();
 3570        if (producerlessCards.Count == 1)
 2571            return new ExistingMatch(producerlessCards[0].Card!.RefKey, producerlessCards[0].Card!.Article, true);
 572
 573        // Остальное — тот же артикул у другого бренда (один OEM-номер у разных производителей).
 1574        return null;
 25575    }
 576
 577    /// <summary>
 578    /// Кандидаты по артикулу. В OData 1С нет replace, а <c>Артикул</c> сравнивается точно, поэтому
 579    /// «PR-66622» не находится по фильтру <c>Артикул eq 'PR66622'</c>. Сначала пробуем исходное
 580    /// написание из ProductKey (оно не теряет разделители при импорте), затем нормализованное и
 581    /// только после этого выполняем substringof-пробу. Полное покрытие даёт карта соответствий
 582    /// (IOneCNomenclatureMapStore).
 583    /// </summary>
 584    private async Task<List<ODataNomenclature>> FetchCandidatesAsync(string article, string? rawArticle)
 585    {
 40586        var result = new List<ODataNomenclature>();
 587
 40588        var exactArticles = new[] { rawArticle?.Trim(), article }
 589            .Where(x => !string.IsNullOrWhiteSpace(x))
 40590            .Distinct(StringComparer.OrdinalIgnoreCase);
 186591        foreach (var exactArticle in exactArticles)
 592        {
 53593            var exact = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 53594                new Dictionary<string, string>
 53595                {
 53596                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 53597                    ["$filter"] = $"Артикул eq '{EscapeOData(exactArticle!)}' and IsFolder eq false and DeletionMark eq 
 53598                });
 67599            result.AddRange(exact.Where(x => result.All(known => known.RefKey != x.RefKey)));
 600        }
 601
 40602        if (article.Length >= ProbePrefixLength)
 603        {
 38604            var probe = article[..ProbePrefixLength];
 38605            var contains = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 38606                new Dictionary<string, string>
 38607                {
 38608                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 38609                    ["$filter"] = $"substringof('{EscapeOData(probe)}', Артикул) and IsFolder eq false and DeletionMark 
 38610                });
 43611            result.AddRange(contains.Where(x => result.All(known => known.RefKey != x.RefKey)));
 612        }
 613
 49614        return result.Where(x => NormalizeArticle(x.Article) == article).ToList();
 40615    }
 616
 617    private async Task<List<(ODataNomenclature? Card, Guid? Producer)>> ReadProducersAsync(List<ODataNomenclature> candi
 618    {
 3619        var result = new List<(ODataNomenclature?, Guid?)>(candidates.Count);
 12620        foreach (var candidate in candidates)
 3621            result.Add((candidate, await ReadProducerAsync(candidate.RefKey)));
 3622        return result;
 3623    }
 624
 625    private async Task<Guid?> ReadProducerAsync(Guid refKey)
 626    {
 3627        var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, refKey);
 3628        if (raw == "{}")
 0629            return null;
 630
 3631        var properties = JObject.Parse(raw)["ДополнительныеРеквизиты"] as JArray;
 3632        var node = properties?.FirstOrDefault(p =>
 633            string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPropertyKey, StringComparison.OrdinalIgnoreCase))
 3634        var value = node?["Значение"]?.Value<string>();
 3635        return Guid.TryParse(value, out var guid) && guid != Guid.Empty ? guid : null;
 3636    }
 637
 638    /// <summary>
 639    /// Ищет значение реквизита «Производитель» без создания. ByContainment=true — совпадение найдено
 640    /// эвристикой вхождения, а не точным сравнением имён.
 641    /// </summary>
 642    private async Task<(ODataPropertyValue? Match, bool ByContainment)> TryFindProducerAsync(string normalizedTarget)
 643    {
 644        // Тянем все значения этого реквизита (не фильтруем по Description на сервере — иначе
 645        // разные написания одного бренда, напр. "PARTS-MALL" и "PARTS MALL", не совпадут по
 646        // точному OData-сравнению строк) и матчим локально по нормализованному имени.
 14647        var values = await _client.GetListAsync<ODataPropertyValue>(PropertyValuesEndpoint,
 14648            new Dictionary<string, string>
 14649            {
 14650                ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark",
 14651                ["$filter"] = $"Owner_Key eq guid'{ProducerPropertyKey}' and DeletionMark eq false"
 14652            });
 653
 29654        var exact = values.FirstOrDefault(x => NormalizeProducerName(x.Description) == normalizedTarget);
 14655        return exact != null
 14656            ? (exact, false)
 14657            : (InferProducerByContainment(values, normalizedTarget), true);
 14658    }
 659
 660    private async Task<Guid> GetOrCreateProducerAsync(string name)
 661    {
 10662        var normalizedTarget = NormalizeProducerName(name);
 663        var gate = ProducerLocks.GetOrAdd(normalizedTarget, _ => new SemaphoreSlim(1, 1));
 10664        await gate.WaitAsync();
 665        try
 666        {
 667            // Явные алиасы уже разрешены в ResolveProducerAliasAsync — сюда попадают только имена,
 668            // для которых соответствия не задано.
 10669            var (match, byContainment) = await TryFindProducerAsync(normalizedTarget);
 10670            if (match != null && !byContainment)
 6671                return match.RefKey;
 672
 4673            if (match != null)
 674            {
 1675                _logger.LogInformation(
 1676                    "[OData] Производитель «{Name}» сопоставлен с существующим «{Existing}» по вхождению — новое значени
 1677                    name, match.Description);
 1678                if (_map != null)
 1679                    await _map.UpsertProducerAliasAsync(normalizedTarget, match.RefKey, match.Description, "auto-contain
 1680                return match.RefKey;
 681            }
 682
 3683            var created = await _client.PostAsync<object, ODataPropertyValue>(PropertyValuesEndpoint, new
 3684            {
 3685                Description = name,
 3686                DeletionMark = false,
 3687                Owner_Key = ProducerPropertyKey
 3688            });
 3689            if (created.RefKey == Guid.Empty)
 0690                throw new InvalidOperationException($"1С создала производителя '{name}' без Ref_Key.");
 691
 3692            _logger.LogInformation("[OData] Создано значение реквизита «Производитель»: {Name} → {RefKey}", name, create
 3693            return created.RefKey;
 694        }
 695        finally
 696        {
 10697            gate.Release();
 698        }
 10699    }
 700
 701    /// <summary>
 702    /// Сводит усечённые написания бренда к существующему значению: «BLUE» → «BLUE PRINT».
 703    /// Срабатывает только при единственном кандидате — иначе можно склеить разные бренды.
 704    /// </summary>
 705    internal static ODataPropertyValue? InferProducerByContainment(
 706        IReadOnlyCollection<ODataPropertyValue> values,
 707        string normalizedTarget)
 708    {
 10709        if (normalizedTarget.Length < MinProducerContainsLength)
 2710            return null;
 711
 8712        var matches = values
 8713            .Where(x =>
 8714            {
 8715                var candidate = NormalizeProducerName(x.Description);
 8716                if (candidate.Length < MinProducerContainsLength)
 1717                    return false;
 7718                return candidate.StartsWith(normalizedTarget, StringComparison.Ordinal)
 7719                       || normalizedTarget.StartsWith(candidate, StringComparison.Ordinal);
 8720            })
 8721            .ToList();
 722
 8723        return matches.Count == 1 ? matches[0] : null;
 724    }
 725
 726    private async Task SetProducerAsync(Guid nomenclatureGuid, Guid producerGuid)
 727    {
 728        // 412 означает, что 1С изменила карточку между GET и full-PUT. Повторяем ровно один раз
 729        // с заново прочитанной карточкой, чтобы не перезаписать параллельные изменения устаревшим телом.
 38730        for (var attempt = 1; attempt <= 2; attempt++)
 731        {
 19732            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 19733            if (raw == "{}")
 0734                throw new InvalidOperationException($"Не удалось прочитать созданную номенклатуру {nomenclatureGuid} для
 19735            var item = JObject.Parse(raw);
 19736            item.Remove("odata.metadata");
 19737            item.Remove("odata.type");
 19738            item.Remove("odata.etag");
 739
 19740            var properties = item["ДополнительныеРеквизиты"] as JArray ?? new JArray();
 741            var producer = properties.FirstOrDefault(p => string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPro
 19742            if (producer == null)
 743            {
 18744                producer = new JObject { ["LineNumber"] = properties.Count + 1, ["Свойство_Key"] = ProducerPropertyKey }
 18745                properties.Add(producer);
 746            }
 19747            producer["Значение"] = producerGuid.ToString();
 19748            producer["Значение_Type"] = "StandardODATA.Catalog_ЗначенияСвойствОбъектов";
 19749            item["ДополнительныеРеквизиты"] = properties;
 750
 751            try
 752            {
 19753                await _client.PutAsync(NomenclatureEndpoint, nomenclatureGuid, item);
 18754                return;
 755            }
 1756            catch (OneCODataException ex) when (ex.Error?.StatusCode == 412 && attempt == 1)
 757            {
 1758                _logger.LogInformation("[OData] Карточка номенклатуры {RefKey} изменилась перед установкой производителя
 1759            }
 760        }
 18761    }
 762
 763    /// <summary>
 764    /// 1С Fresh может вернуть успешный POST, но не записать навигационные реквизиты карточки.
 765    /// Не продолжаем проведение, пока повторное чтение не подтвердит единицу «шт» и «Без категории».
 766    /// </summary>
 767    private async Task EnsureRequiredFieldsAsync(Guid nomenclatureGuid, string? originComment = null)
 768    {
 20769        var patch = new Dictionary<string, object>
 20770        {
 20771            ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 20772            ["КатегорияНоменклатуры_Key"] = CategoryKey,
 20773            ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 20774            ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 20775            ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 20776            ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 20777            ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 20778        };
 779
 780        // «Комментарий» — свободное поле карточки. Наименование (Description) не трогаем:
 781        // оно видно в списках номенклатуры и в печатных формах.
 20782        if (!string.IsNullOrWhiteSpace(originComment))
 18783            patch["Комментарий"] = originComment;
 784
 40785        for (var attempt = 1; attempt <= 2; attempt++)
 786        {
 20787            await _client.PatchAsync<object>(NomenclatureEndpoint, nomenclatureGuid, patch);
 788
 20789            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 20790            if (raw != "{}")
 791            {
 20792                var item = JObject.Parse(raw);
 20793                var unit = item["ЕдиницаИзмерения_Key"]?.Value<string>();
 20794                var category = item["КатегорияНоменклатуры_Key"]?.Value<string>();
 20795                var stockAccount = item["СчетУчетаЗапасов_Key"]?.Value<string>();
 20796                var expenseAccount = item["СчетУчетаЗатрат_Key"]?.Value<string>();
 20797                var activityDirection = item["НаправлениеДеятельности_Key"]?.Value<string>();
 20798                var vatSalesAccount = item["СчетУчетаНДСПоРеализации_Key"]?.Value<string>();
 20799                var vatPurchaseAccount = item["СчетУчетаНДСПоПриобретеннымЦенностям_Key"]?.Value<string>();
 20800                if (string.Equals(unit, OneCODataConstants.UnitShtukaGuid, StringComparison.OrdinalIgnoreCase)
 20801                    && string.Equals(category, CategoryKey, StringComparison.OrdinalIgnoreCase)
 20802                    && string.Equals(stockAccount, StockAccountKey, StringComparison.OrdinalIgnoreCase)
 20803                    && string.Equals(expenseAccount, ExpenseAccountKey, StringComparison.OrdinalIgnoreCase)
 20804                    && string.Equals(activityDirection, ActivityDirectionKey, StringComparison.OrdinalIgnoreCase)
 20805                    && string.Equals(vatSalesAccount, VatSalesAccountKey, StringComparison.OrdinalIgnoreCase)
 20806                    && string.Equals(vatPurchaseAccount, VatPurchaseAccountKey, StringComparison.OrdinalIgnoreCase))
 20807                    return;
 808            }
 809
 0810            _logger.LogWarning("[OData] Номенклатура {RefKey}: попытка {Attempt}/2 не подтвердила обязательные реквизиты
 811        }
 812
 0813        throw new InvalidOperationException(
 0814            $"Карточка номенклатуры {nomenclatureGuid} создана, но 1С не сохранила обязательные реквизиты учёта. Докумен
 20815    }
 816
 817    /// <summary>
 818    /// Отметка в «Комментарий» карточки: кто завёл позицию, когда и в рамках какой транзакции.
 819    /// Нужна, чтобы в 1С было видно, что карточка не заведена вручную.
 820    /// </summary>
 821    internal static string BuildOriginComment(OneCNomenclatureOrigin? origin, DateTime? nowUtc = null)
 822    {
 21823        var moscow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone);
 21824        var stamp = moscow.ToString("dd.MM.yyyy HH:mm:ss");
 825
 21826        var transaction = origin?.TransactionType?.GetDisplayName();
 21827        return string.IsNullOrWhiteSpace(transaction)
 21828            ? $"Создано автоматически: Studio2, {stamp} (МСК)."
 21829            : $"Создано автоматически: Studio2, {stamp} (МСК). Транзакция: {transaction}.";
 830    }
 831
 19832    private static Dictionary<string, object> BuildNomenclatureBody(string article, string name) => new()
 19833    {
 19834        ["Description"] = name,
 19835        ["НаименованиеПолное"] = name,
 19836        ["Артикул"] = article,
 19837        ["IsFolder"] = false,
 19838        ["DeletionMark"] = false,
 19839        ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 19840        ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 19841        ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 19842        ["ВидСтавкиНДС"] = "Общая",
 19843        ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 19844        ["КатегорияНоменклатуры_Key"] = CategoryKey,
 19845        ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 19846        ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 19847    };
 848
 91849    private static string EscapeOData(string value) => value.Replace("'", "''");
 850}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.Resolver.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using OzonDomains.Models;
 3using OzonDomains.Nomenclature;
 4using Services.ApiServices._1CApi.Nomenclature;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>
 9/// Разрешение номенклатуры новым поиском (<see cref="NomenclatureResolverMode.On"/>): матчер решает, берём ли карточку,
 10/// создаём или отклоняем позицию. В режимах Off и Shadow метод работает как прежний <c>FindMany</c>/<c>GetOrCreateMany<
 11/// </summary>
 12public sealed partial class OneCNomenclatureService
 13{
 14    public async Task<IReadOnlyList<NomenclatureResolution>> ResolveManyAsync(
 15        IReadOnlyList<Order> orders,
 16        NomenclatureMode mode,
 17        int maxConcurrency = 3,
 18        OneCNomenclatureOrigin? origin = null)
 19    {
 2020        if (_resolverMode != NomenclatureResolverMode.On || _matcher == null)
 21        {
 222            if (mode == NomenclatureMode.FindOnly)
 23            {
 224                var found = await FindManyAsync(
 25                    orders.Select(o => (Article: (string?)NomenclatureResolution.LegacySearchArticle(o), ProducerName: o
 226                    maxConcurrency);
 27                return found.Select(g => new NomenclatureResolution(g, false, string.Empty, null, null, false)).ToList()
 28            }
 29
 030            var created = await GetOrCreateManyAsync(orders, maxConcurrency, origin);
 31            return created.Select(c => new NomenclatureResolution(c.RefKey, c.Created, c.DisplayName, null, null, false)
 32        }
 33
 1834        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 1835        var tasks = orders.Select(async order =>
 1836        {
 1837            await throttle.WaitAsync();
 1838            try
 1839            {
 1840                return await ResolveOneAsync(order, mode, origin);
 1841            }
 1842            finally
 1843            {
 1844                throttle.Release();
 1845            }
 3646        });
 1847        return await Task.WhenAll(tasks);
 2048    }
 49
 50    private async Task<NomenclatureResolution> ResolveOneAsync(Order order, NomenclatureMode mode, OneCNomenclatureOrigi
 51    {
 1852        var query = new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.EtProducer?.Name);
 1853        var match = await _matcher!.MatchAsync(query, this);
 1854        var decision = NomenclatureResolvePolicy.Decide(match, mode);
 1855        LogResolver(order, match, decision);
 56
 1857        switch (decision.Action)
 58        {
 59            case NomenclatureAction.UseCard:
 260                if (match.Duplicate)
 61                {
 062                    await SignalAsync("duplicate", $"Артикул {match.Parsed.ArticleClean}: {match.Reason}");
 063                    await NotifyAsync(order, match, NomenclatureNotificationKind.Duplicate, match.RefKey, match.Reason);
 64                }
 265                await AfterReuseAsync(order, match);
 266                return new NomenclatureResolution(decision.RefKey, false, string.Empty,
 267                    decision.FoundByVariant ? decision.Reason : null, match.Status, decision.FoundByVariant);
 68
 69            case NomenclatureAction.Create:
 70                try
 71                {
 1172                    var resolution = await CreateResolvedAsync(order, match, origin);
 1173                    if (resolution.Created)
 74                    {
 975                        var kind = match.Status == NomenclatureMatchStatus.BrandMismatch
 976                            ? NomenclatureNotificationKind.BrandMismatchCreated
 977                            : NomenclatureNotificationKind.Created;
 978                        await AuditAsync(kind == NomenclatureNotificationKind.BrandMismatchCreated ? "brand-mismatch-cre
 979                            $"Заказ {order.Id}: {match.Parsed.ArticleClean}; {match.Reason}");
 980                        await NotifyAsync(order, match, kind, resolution.RefKey, match.Reason);
 81                    }
 1182                    return resolution;
 83                }
 084                catch (Exception ex)
 85                {
 086                    await SignalAsync("create-failed", $"{order.Article}: {ex.Message}");
 087                    await NotifyAsync(order, match, NomenclatureNotificationKind.CreateFailed, null, ex.Message);
 088                    throw;
 089                }
 90
 91            default:
 592                var notificationKind = RejectionNotificationKind(match.Status);
 593                if (notificationKind.HasValue)
 94                {
 595                    await SignalAsync(NotificationScope(notificationKind.Value), $"Заказ {order.Id}; артикул {match.Pars
 596                    await NotifyAsync(order, match, notificationKind.Value, null, match.Reason);
 97                }
 598                return new NomenclatureResolution(null, false, string.Empty, decision.Reason, match.Status, false);
 99        }
 18100    }
 101
 5102    private static NomenclatureNotificationKind? RejectionNotificationKind(NomenclatureMatchStatus status) => status swi
 5103    {
 1104        NomenclatureMatchStatus.NotFound => NomenclatureNotificationKind.NotFound,
 1105        NomenclatureMatchStatus.InputConflict => NomenclatureNotificationKind.InputConflict,
 1106        NomenclatureMatchStatus.ProducerUnknown => NomenclatureNotificationKind.ProducerUnknown,
 1107        NomenclatureMatchStatus.Ambiguous => NomenclatureNotificationKind.Ambiguous,
 1108        NomenclatureMatchStatus.BrandMismatch => NomenclatureNotificationKind.BrandMismatch,
 0109        _ => null
 5110    };
 111
 5112    private static string NotificationScope(NomenclatureNotificationKind kind) => kind switch
 5113    {
 1114        NomenclatureNotificationKind.NotFound => "missing",
 0115        NomenclatureNotificationKind.Duplicate => "duplicate",
 1116        NomenclatureNotificationKind.InputConflict => "input-conflict",
 1117        NomenclatureNotificationKind.ProducerUnknown => "producer-unknown",
 1118        NomenclatureNotificationKind.Ambiguous => "ambiguous",
 1119        NomenclatureNotificationKind.BrandMismatch => "brand-mismatch",
 0120        _ => "nomenclature"
 5121    };
 122
 123    private async Task NotifyAsync(Order order, NomenclatureMatch match, NomenclatureNotificationKind kind, Guid? refKey
 124    {
 14125        if (_notificationSink == null)
 14126            return;
 127
 0128        var existingBrands = match.Candidates
 129            .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? c.ProducerNorm : c.ProducerName!.Trim())
 130            .Where(x => !string.IsNullOrWhiteSpace(x))
 0131            .Distinct(StringComparer.OrdinalIgnoreCase)
 0132            .ToList();
 0133        var notification = new NomenclatureNotification(kind, order.Id, order.ShipmentNumber, order.ProductName,
 0134            order.Article, order.ProductKey, order.EtProducer?.Name, match.Parsed.ArticleKey, match.Status, reason, refK
 135        try
 136        {
 0137            await _notificationSink.NotifyAsync(notification);
 0138        }
 0139        catch (Exception ex)
 140        {
 0141            _logger.LogWarning(ex, "Не удалось отправить уведомление резолвера номенклатуры для заказа {OrderId}", order
 0142        }
 14143    }
 144
 145    private void LogResolver(Order order, NomenclatureMatch match, NomenclatureDecision decision)
 146    {
 18147        var quiet = match.Status == NomenclatureMatchStatus.Found && match.Confidence == MatchConfidence.High && !match.
 18148        _logger.Log(quiet ? LogLevel.Debug : LogLevel.Information,
 18149            "[NomenclatureResolver] {Status}/{Action} {Article}/{Producer}: {Reason}",
 18150            match.Status, decision.Action, order.Article, order.EtProducer?.Name, decision.Reason);
 18151    }
 152
 153    /// <summary>
 154    /// После выбора существующей карточки: карточка без производителя получает бренд заказа (как раньше), а карточка,
 155    /// найденная живым запросом, попадает в карту, чтобы следующий поиск шёл из индекса.
 156    /// </summary>
 157    private async Task AfterReuseAsync(Order order, NomenclatureMatch match)
 158    {
 2159        if (!match.RefKey.HasValue)
 0160            return;
 161
 2162        if (match.ProducerlessCard && _backfillProducerOnReuse && match.Producer.Kind == ProducerResolutionKind.Known &&
 163        {
 0164            var index = _indexProvider != null ? await _indexProvider.GetAsync() : null;
 0165            var value = await _producerValues.FindForGroupsAsync(match.Producer.Groups, index?.ProducerUsage, order.EtPr
 0166            if (value != null)
 167            {
 0168                _logger.LogInformation(
 0169                    "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Produc
 0170                    match.Parsed.ArticleClean, match.RefKey, value.Name);
 0171                await SetProducerAsync(match.RefKey.Value, value.RefKey);
 0172                await EnsureRequiredFieldsAsync(match.RefKey.Value);
 173            }
 174        }
 175
 2176        if (match.FromLiveLookup)
 177        {
 0178            var card = match.Candidates.FirstOrDefault(c => c.RefKey == match.RefKey);
 0179            if (card != null)
 0180                await RememberAsync(NormalizeArticle(match.Parsed.ArticleClean), NormalizeProducerName(card.ProducerName
 0181                    card.RefKey, card.RawArticle, card.ProducerName, "probe");
 182        }
 2183    }
 184
 185    /// <summary>
 186    /// Создание карточки по решению матчера. Производитель берётся из группы бренда (существующее значение 1С), новое
 187    /// значение заводится только когда у группы его нет. Под локом ключа (артикул + производитель) карта проверяется
 188    /// ещё раз: параллельный вызов или второй инстанс мог создать ту же карточку секундой раньше.
 189    /// </summary>
 190    private async Task<NomenclatureResolution> CreateResolvedAsync(Order order, NomenclatureMatch match, OneCNomenclatur
 191    {
 11192        var parsed = match.Parsed;
 11193        var article = NormalizeArticle(parsed.ArticleClean);
 11194        if (article.Length == 0)
 0195            throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать
 196
 11197        var creationArticle = CreationArticleFor(order, parsed, article);
 11198        var (producerGuid, producerName) = await ResolveProducerForCreationAsync(order, match);
 11199        var producerNorm = NormalizeProducerName(producerName);
 200
 201        var gate = Locks.GetOrAdd($"{article}|{producerNorm}", _ => new SemaphoreSlim(1, 1));
 11202        await gate.WaitAsync();
 203        try
 204        {
 11205            await using var distributedLock = _creationLock == null
 11206                ? null
 11207                : await _creationLock.AcquireAsync(article, producerNorm);
 208
 11209            if (_map != null)
 210            {
 11211                var mapped = await _map.FindAsync(article, producerNorm);
 11212                if (mapped != null)
 2213                    return new NomenclatureResolution(mapped.RefKey, false, string.Empty, null, NomenclatureMatchStatus.
 214            }
 215
 9216            var created = await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producer
 217
 218            // Новая карточка должна находиться сразу, а не через 10 минут: сбрасываем индекс и кэш живых промахов.
 9219            _matcher?.ForgetMisses();
 9220            _indexProvider?.Invalidate();
 9221            return new NomenclatureResolution(created.RefKey, true, created.DisplayName, match.Reason, match.Status, fal
 0222        }
 223        finally
 224        {
 11225            gate.Release();
 226        }
 11227    }
 228
 229    private async Task<(Guid? Guid, string? Name)> ResolveProducerForCreationAsync(Order order, NomenclatureMatch match)
 230    {
 11231        var orderName = order.EtProducer?.Name?.Trim();
 232
 11233        if (match.Producer.Kind == ProducerResolutionKind.Known && _producerValues != null)
 234        {
 11235            var index = _indexProvider != null ? await _indexProvider.GetAsync() : null;
 11236            var value = await _producerValues.FindForGroupsAsync(match.Producer.Groups, index?.ProducerUsage, orderName)
 11237            if (value != null)
 9238                return (value.RefKey, value.Name);
 239        }
 240
 241        // Заглушка («Daihatsu» без префикса) и общий префикс без выбора — бренд неизвестен, карточка без производителя.
 2242        if (match.Producer.Source == "placeholder" || match.Producer.Kind == ProducerResolutionKind.Ambiguous || string.
 0243            return (null, null);
 244
 2245        var (aliasGuid, _, aliasedName) = await ResolveProducerAliasAsync(NormalizeProducerName(orderName), orderName);
 2246        var name = aliasedName ?? orderName;
 2247        return (aliasGuid ?? await GetOrCreateProducerAsync(name), name);
 11248    }
 249
 250    /// <summary>
 251    /// Артикул для новой карточки: написание с разделителями из ключа товара (<c>5048-6</c>), если оно совпадает с очищ
 252    /// артикулом, иначе нормализованный. Хвост <c>=БРЕНД</c>, теги <c>[N]</c> и мусор в карточку не попадают.
 253    /// </summary>
 254    private static string CreationArticleFor(Order order, ParsedProductRef parsed, string normalized)
 255    {
 11256        var keyArticle = order.ProductKey?.Split('=', 2)[0].Trim();
 11257        if (!string.IsNullOrWhiteSpace(keyArticle))
 258        {
 11259            keyArticle = System.Text.RegularExpressions.Regex.Replace(keyArticle, @"\[[^\]]*\]", string.Empty).Trim();
 11260            if (ArticleKey.Key(keyArticle) == parsed.ArticleKey
 87261                && keyArticle.Any(c => !char.IsLetterOrDigit(c) && !char.IsWhiteSpace(c)))
 5262                return keyArticle;
 263        }
 264
 6265        return normalized;
 266    }
 267}

Methods/Properties

.cctor()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OData.OneCNomenclatureService>,Services.ApiServices._1CApi.OData.IOneCNomenclatureCreationLock,Services.ApiServices._1CApi.OData.IOneCNomenclatureMapStore,Services.ApiServices._1CApi.OData.OneCODataConfig,Services.SystemMonitoringServices.ISystemMonitoringSignalWriter,Services.ApiServices._1CApi.Nomenclature.NomenclatureMatcher,Services.ApiServices._1CApi.Nomenclature.ProducerValueResolver,Services.ApiServices._1CApi.Nomenclature.INomenclatureIndexProvider,Services.ApiServices._1CApi.Nomenclature.INomenclatureNotificationSink)
GetOrCreateAsync()
SignalAsync()
AuditAsync()
ShadowMatchAsync()
LogShadow(Services.ApiServices._1CApi.Nomenclature.NomenclatureMatch,System.Nullable`1<System.Guid>,System.Boolean,Services.ApiServices._1CApi.Nomenclature.NomenclatureMode,System.String,System.String)
Services-ApiServices-_1CApi-Nomenclature-INomenclatureLiveLookup-FindByArticleAsync()
CreateNewCardAsync()
GetOrCreateCoreAsync()
GetOrCreateManyAsync()
<GetOrCreateManyAsync()
FindAsync()
FindCoreAsync()
FindManyAsync()
<FindManyAsync()
NormalizeArticle(System.String)
ResolveArticle(OzonDomains.Models.Order)
NormalizeProducerName(System.String)
get_RefKey()
ResolveProducerAliasAsync()
RememberAsync()
FindExistingAsync()
FetchCandidatesAsync()
ReadProducersAsync()
ReadProducerAsync()
TryFindProducerAsync()
GetOrCreateProducerAsync()
InferProducerByContainment(System.Collections.Generic.IReadOnlyCollection`1<Services.ApiServices._1CApi.OData.Models.ODataPropertyValue>,System.String)
SetProducerAsync()
EnsureRequiredFieldsAsync()
BuildOriginComment(Services.ApiServices._1CApi.OData.OneCNomenclatureOrigin,System.Nullable`1<System.DateTime>)
BuildNomenclatureBody(System.String,System.String)
EscapeOData(System.String)
ResolveManyAsync()
<ResolveManyAsync()
ResolveOneAsync()
RejectionNotificationKind(Services.ApiServices._1CApi.Nomenclature.NomenclatureMatchStatus)
NotificationScope(Services.ApiServices._1CApi.Nomenclature.NomenclatureNotificationKind)
NotifyAsync()
LogResolver(OzonDomains.Models.Order,Services.ApiServices._1CApi.Nomenclature.NomenclatureMatch,Services.ApiServices._1CApi.Nomenclature.NomenclatureDecision)
AfterReuseAsync()
CreateResolvedAsync()
ResolveProducerForCreationAsync()
CreationArticleFor(OzonDomains.Models.Order,OzonDomains.Nomenclature.ParsedProductRef,System.String)