< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCNomenclatureResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 850
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
get_RefKey()100%11100%

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
 11468public 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
 90    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    // раздельно и всё равно создают два разных значения реквизита.
 96    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
 110    public OneCNomenclatureService(
 111        IOneCODataClient client,
 112        ILogger<OneCNomenclatureService> logger,
 113        IOneCNomenclatureCreationLock? creationLock = null,
 114        IOneCNomenclatureMapStore? map = null,
 115        OneCODataConfig? config = null,
 116        ISystemMonitoringSignalWriter? signals = null,
 117        NomenclatureMatcher? matcher = null,
 118        ProducerValueResolver? producerValues = null,
 119        INomenclatureIndexProvider? indexProvider = null,
 120        INomenclatureNotificationSink? notificationSink = null)
 121    {
 122        _producerValues = producerValues;
 123        _indexProvider = indexProvider;
 124        _notificationSink = notificationSink;
 125        _signals = signals;
 126        _matcher = matcher;
 127        _resolverMode = config?.ResolverMode ?? NomenclatureResolverMode.Off;
 128        _client = client;
 129        _logger = logger;
 130        _creationLock = creationLock;
 131        _map = map;
 132        _backfillProducerOnReuse = config?.BackfillProducerOnReuse ?? true;
 133    }
 134
 135    public async Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null)
 136    {
 137        var shadow = await ShadowMatchAsync(new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.E
 138        try
 139        {
 140            var result = await GetOrCreateCoreAsync(order, origin);
 141            LogShadow(shadow, result.RefKey, result.Created, NomenclatureMode.FindOrCreate, order.Article, order.EtProdu
 142            return result;
 143        }
 144        catch (Exception ex)
 145        {
 146            await SignalAsync("create-failed", $"{order.Article}: {ex.Message}");
 147            throw;
 148        }
 149    }
 150
 151    /// <summary>
 152    /// Проблемы с номенклатурой/производителями 1С попадают в «Системные алерты» (Studio2/JobLogs?tab=alerts)
 153    /// по общим правилам пассивных сигналов. Запись сигнала не должна влиять на основную операцию.
 154    /// </summary>
 155    private async Task SignalAsync(string scope, string message)
 156    {
 157        if (_signals == null) return;
 158        await _signals.RecordAsync(NomenclatureSignalSource, "failure", scope, message);
 159    }
 160
 161    private async Task AuditAsync(string scope, string message)
 162    {
 163        if (_signals == null) return;
 164        await _signals.RecordAsync(NomenclatureSignalSource, "success", scope, message);
 165    }
 166
 167    public const string NomenclatureSignalSource = "onec-nomenclature";
 168
 169    /// <summary>Новый поиск в режиме Shadow: только читает, любые сбои гасятся — прежний поиск от него не зависит.</sum
 170    private async Task<NomenclatureMatch?> ShadowMatchAsync(ProductQuery query)
 171    {
 172        if (_resolverMode != NomenclatureResolverMode.Shadow || _matcher == null)
 173            return null;
 174
 175        try
 176        {
 177            return await _matcher.MatchAsync(query, this);
 178        }
 179        catch (Exception ex)
 180        {
 181            _logger.LogWarning(ex, "[NomenclatureShadow] Новый поиск не отработал для {Article}", query.Article);
 182            return null;
 183        }
 184    }
 185
 186    private void LogShadow(NomenclatureMatch? shadow, Guid? oldRefKey, bool oldCreated, NomenclatureMode mode, string? a
 187    {
 188        if (shadow == null)
 189            return;
 190
 191        var outcome = NomenclatureShadowComparer.Compare(oldRefKey, oldCreated, shadow, mode);
 192        if (outcome.Kind == ShadowDiffKind.Agree)
 193        {
 194            _logger.LogDebug("[NomenclatureShadow] совпало {Article}/{Producer}: {Text}", article, producer, outcome.Des
 195            return;
 196        }
 197
 198        _logger.LogInformation("[NomenclatureShadow] {Kind} {Article}/{Producer}: {Text}", outcome.Kind, article, produc
 199    }
 200
 201    /// <summary>Живой поиск для нового матчера: те же OData-запросы, что у прежнего поиска, плюс имя производителя карт
 202    async Task<IReadOnlyList<LiveCard>> INomenclatureLiveLookup.FindByArticleAsync(
 203        string articleClean, string articleKey, CancellationToken cancellationToken)
 204    {
 205        var candidates = await FetchCandidatesAsync(NormalizeArticle(articleClean), articleClean);
 206        var cards = new List<LiveCard>(candidates.Count);
 207        foreach (var candidate in candidates)
 208        {
 209            var producerGuid = await ReadProducerAsync(candidate.RefKey);
 210            string? producerName = null;
 211            if (producerGuid.HasValue)
 212            {
 213                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 214                    PropertyValuesEndpoint, producerGuid.Value, "Ref_Key,Description");
 215                producerName = value?.Description;
 216            }
 217
 218            cards.Add(new LiveCard(candidate.RefKey, candidate.Article, producerName));
 219        }
 220
 221        return cards;
 222    }
 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    {
 237        var name = string.IsNullOrWhiteSpace(order.ProductName) ? creationArticle : order.ProductName.Trim();
 238        var body = BuildNomenclatureBody(creationArticle, name);
 239        ODataNomenclature created;
 240        try
 241        {
 242            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 243        }
 244        catch (InvalidOperationException ex) when (ex is OneCODataException { Error.StatusCode: 500 }
 245                                               || ex.Message.Contains("HTTP 500", StringComparison.Ordinal))
 246        {
 247            _logger.LogWarning("[OData] Создание номенклатуры {Article} с единицей вернуло 500; повторяем без единицы.",
 248            body.Remove("ЕдиницаИзмерения_Key");
 249            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 250        }
 251
 252        if (created.RefKey == Guid.Empty)
 253            throw new InvalidOperationException($"1С создала номенклатуру '{article}' без Ref_Key.");
 254
 255        if (producerGuid.HasValue)
 256            await SetProducerAsync(created.RefKey, producerGuid.Value);
 257
 258        // EnsureRequiredFieldsAsync должен быть последней записью в карточку: SetProducerAsync делает
 259        // full-PUT (PutAsync), который может откатить единицу/категорию, если вызвать проверку раньше него.
 260        // Отметка о происхождении идёт этим же PATCH — в теле POST её слать нельзя: неизвестное поле
 261        // может уронить создание карточки, а PATCH по «Комментарий» проверен на проде.
 262        await EnsureRequiredFieldsAsync(created.RefKey, BuildOriginComment(origin));
 263
 264        await RememberAsync(article, producerNorm, created.RefKey, creationArticle, producerName, "created");
 265
 266        _logger.LogInformation("[OData] Создана номенклатура {Article} (производитель {Producer}): {RefKey}",
 267            creationArticle, producerName ?? "—", created.RefKey);
 268        return new OneCNomenclatureResult(created.RefKey, true, $"{name} [{article}]");
 269    }
 270
 271    private async Task<OneCNomenclatureResult> GetOrCreateCoreAsync(Order order, OneCNomenclatureOrigin? origin)
 272    {
 273        var (article, searchArticle, creationArticle) = ResolveArticle(order);
 274        if (string.IsNullOrWhiteSpace(article))
 275            throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать
 276
 277        var producerName = order.EtProducer?.Name?.Trim();
 278        var producerNorm = NormalizeProducerName(producerName);
 279
 280        // Алиас разрешается ДО ключа карты и локов. Иначе «BLUE» ищется по собственному ключу,
 281        // находит старую дублирующую карточку и до канонического «BLUE PRINT» дело не доходит.
 282        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, producerName);
 283        producerNorm = aliasedNorm;
 284        producerName = aliasedName;
 285
 286        var lockKey = $"{article}|{producerNorm}";
 287        var gate = Locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
 288        await gate.WaitAsync();
 289        try
 290        {
 291            await using var distributedLock = _creationLock == null
 292                ? null
 293                : await _creationLock.AcquireAsync(article, producerNorm);
 294
 295            // Быстрый путь: карта соответствий знает карточку по нормализованному ключу независимо
 296            // от того, как артикул отформатирован в самой 1С. Проверяем уже под локом, чтобы второй
 297            // инстанс увидел запись, сделанную первым.
 298            if (_map != null)
 299            {
 300                var mapped = await _map.FindAsync(article, producerNorm);
 301                if (mapped != null)
 302                    return new OneCNomenclatureResult(mapped.RefKey, false, string.Empty);
 303            }
 304
 305            var producerGuid = aliasProducerGuid
 306                               ?? (string.IsNullOrWhiteSpace(producerName)
 307                                   ? (Guid?)null
 308                                   : await GetOrCreateProducerAsync(producerName));
 309
 310            var existing = await FindExistingAsync(article, searchArticle, producerGuid, producerNorm);
 311            if (existing != null)
 312            {
 313                if (existing.NeedsProducerBackfill && producerGuid.HasValue && _backfillProducerOnReuse)
 314                {
 315                    _logger.LogInformation(
 316                        "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Pr
 317                        article, existing.RefKey, producerName);
 318                    await SetProducerAsync(existing.RefKey, producerGuid.Value);
 319
 320                    // SetProducerAsync — полная перезапись карточки (PUT), а 1С Fresh при ней не
 321                    // сохраняет навигационные реквизиты: единица, счета учёта и категория обнуляются.
 322                    // В ветке создания это лечит EnsureRequiredFieldsAsync ниже, здесь нужен такой же
 323                    // вызов, иначе строка документа уходит в 1С с пустой «Ед.». Комментарий
 324                    // происхождения не пишем: карточку завёл не Studio2.
 325                    await EnsureRequiredFieldsAsync(existing.RefKey);
 326                }
 327
 328                await RememberAsync(article, producerNorm, existing.RefKey, existing.RawArticle, producerName, "probe");
 329                return new OneCNomenclatureResult(existing.RefKey, false, string.Empty);
 330            }
 331
 332            return await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producerNorm, o
 333        }
 334        finally
 335        {
 336            gate.Release();
 337        }
 338    }
 339
 340    public async Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync(
 341        IReadOnlyList<Order> orders,
 342        int maxConcurrency = 3,
 343        OneCNomenclatureOrigin? origin = null)
 344    {
 345        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 346        var tasks = orders.Select(async order =>
 347        {
 348            await throttle.WaitAsync();
 349            try
 350            {
 351                return await GetOrCreateAsync(order, origin);
 352            }
 353            finally
 354            {
 355                throttle.Release();
 356            }
 357        });
 358        return await Task.WhenAll(tasks);
 359    }
 360
 361    public async Task<Guid?> FindAsync(string? article, string? producerName)
 362    {
 363        var shadow = await ShadowMatchAsync(new ProductQuery(article, null, null, producerName));
 364        var found = await FindCoreAsync(article, producerName);
 365        LogShadow(shadow, found, false, NomenclatureMode.FindOnly, article, producerName);
 366        return found;
 367    }
 368
 369    private async Task<Guid?> FindCoreAsync(string? article, string? producerName)
 370    {
 371        var normalizedArticle = NormalizeArticle(article);
 372        if (normalizedArticle.Length == 0)
 373            return null;
 374
 375        var trimmedProducer = producerName?.Trim();
 376        var producerNorm = NormalizeProducerName(trimmedProducer);
 377        var originalProducerNorm = producerNorm;
 378
 379        // Порядок тот же, что в GetOrCreateAsync: алиас → карта соответствий → поиск по 1С.
 380        // Локи не берём — поиск ничего не мутирует, а лок сериализовал бы его с параллельным
 381        // созданием карточек того же артикула.
 382        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, trimmedProduce
 383        producerNorm = aliasedNorm;
 384        trimmedProducer = aliasedName;
 385
 386        if (_map != null)
 387        {
 388            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 нет.
 395            if (mapped == null && !string.Equals(originalProducerNorm, producerNorm, StringComparison.Ordinal))
 396                mapped = await _map.FindAsync(normalizedArticle, originalProducerNorm);
 397
 398            if (mapped != null)
 399                return mapped.RefKey;
 400        }
 401
 402        var producerGuid = aliasProducerGuid;
 403        if (producerGuid == null && !string.IsNullOrWhiteSpace(trimmedProducer))
 404        {
 405            // Значения реквизита нет в 1С — карточки с точным производителем тоже быть не может,
 406            // поэтому ищем дальше как без производителя (та же ветка, куда попал бы GetOrCreateAsync).
 407            var (match, _) = await TryFindProducerAsync(NormalizeProducerName(trimmedProducer));
 408            producerGuid = match?.RefKey;
 409        }
 410
 411        var existing = await FindExistingAsync(normalizedArticle, article?.Trim(), producerGuid, producerNorm);
 412        return existing?.RefKey;
 413    }
 414
 415    public async Task<IReadOnlyList<Guid?>> FindManyAsync(
 416        IReadOnlyList<(string? Article, string? ProducerName)> items,
 417        int maxConcurrency = 3)
 418    {
 419        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 420        var tasks = items.Select(async item =>
 421        {
 422            await throttle.WaitAsync();
 423            try
 424            {
 425                return await FindAsync(item.Article, item.ProducerName);
 426            }
 427            finally
 428            {
 429                throttle.Release();
 430            }
 431        });
 432        return await Task.WhenAll(tasks);
 433    }
 434
 435    internal static string NormalizeArticle(string? value)
 436    {
 437        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 438        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    {
 448        var article = order.Article?.Trim();
 449        var normalized = NormalizeArticle(article);
 450        if (normalized.Length == 0)
 451            return (string.Empty, string.Empty, string.Empty);
 452
 453        var productKeyArticle = order.ProductKey?.Split('=', 2)[0].Trim();
 454        if (!string.IsNullOrWhiteSpace(productKeyArticle)
 455            && NormalizeArticle(productKeyArticle) == normalized
 456            && productKeyArticle.Any(c => !char.IsLetterOrDigit(c)))
 457        {
 458            return (normalized, productKeyArticle, productKeyArticle);
 459        }
 460
 461        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    {
 472        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 473        return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
 474    }
 475
 476    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    {
 484        if (_map == null || producerNorm.Length == 0)
 485            return (null, producerNorm, producerName);
 486
 487        OneCProducerAliasEntry? alias;
 488        try
 489        {
 490            alias = await _map.FindProducerAliasAsync(producerNorm);
 491        }
 492        catch (Exception ex)
 493        {
 494            _logger.LogWarning(ex, "[OData] Не удалось прочитать алиасы производителя для «{Producer}».", producerName);
 495            return (null, producerNorm, producerName);
 496        }
 497
 498        if (alias == null)
 499            return (null, producerNorm, producerName);
 500
 501        var canonicalNorm = NormalizeProducerName(alias.CanonicalName);
 502        return canonicalNorm.Length == 0
 503            ? (alias.RefKey, producerNorm, producerName)
 504            : (alias.RefKey, canonicalNorm, alias.CanonicalName);
 505    }
 506
 507    private async Task RememberAsync(string article, string producerNorm, Guid refKey, string? rawArticle, string? produ
 508    {
 509        if (_map == null)
 510            return;
 511
 512        try
 513        {
 514            await _map.UpsertAsync(new OneCMapEntry(refKey, article, producerNorm, producerName), rawArticle, source);
 515        }
 516        catch (Exception ex)
 517        {
 518            // Карта — ускоритель и защита от дублей, но не источник истины: её недоступность
 519            // не должна ронять проведение документа.
 520            _logger.LogWarning(ex, "[OData] Не удалось записать соответствие {Article}|{Producer} → {RefKey} в карту ном
 521                article, producerNorm, refKey);
 522        }
 523    }
 524
 525    private async Task<ExistingMatch?> FindExistingAsync(string article, string? rawArticle, Guid? producerGuid, string?
 526    {
 527        var candidates = await FetchCandidatesAsync(article, rawArticle);
 528        if (candidates.Count == 0)
 529            return null;
 530
 531        if (!producerGuid.HasValue)
 532        {
 533            // Производитель у входящей позиции не указан — сопоставляем только по артикулу.
 534            // Единственный кандидат берётся без лишнего чтения карточки.
 535            if (candidates.Count == 1)
 536                return new ExistingMatch(candidates[0].RefKey, candidates[0].Article, false);
 537
 538            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();
 541            _logger.LogWarning(
 542                "[OData] Артикул {Article}: {Count} карточек в 1С, производитель во входящей позиции не задан — выбрана 
 543                article, candidates.Count, chosen.RefKey);
 544            await SignalAsync("duplicate", $"Артикул {article}: {candidates.Count} карточек в 1С, производитель не задан
 545            return new ExistingMatch(chosen.RefKey, chosen.Article, false);
 546        }
 547
 548        var resolved = await ReadProducersAsync(candidates);
 549
 550        var exact = resolved.FirstOrDefault(x => x.Producer == producerGuid);
 551        if (exact.Card != null)
 552            return new ExistingMatch(exact.Card.RefKey, exact.Card.Article, false);
 553
 554        // В 1С одно и то же имя бренда бывает записано разными значениями (в т.ч. у другого владельца
 555        // реквизита), и GUID у карточки не совпадает с найденным по Owner_Key. Сверяем по имени.
 556        if (!string.IsNullOrEmpty(producerNorm))
 557        {
 558            foreach (var (card, producer) in resolved.Where(x => x.Producer.HasValue))
 559            {
 560                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 561                    PropertyValuesEndpoint, producer!.Value, "Ref_Key,Description");
 562                if (NormalizeProducerName(value?.Description) == producerNorm)
 563                    return new ExistingMatch(card!.RefKey, card.Article, false);
 564            }
 565        }
 566
 567        // Карточка с тем же артикулом, но вообще без производителя — самый частый источник дублей:
 568        // раньше на неё не попадали и заводили вторую. Переиспользуем, если она единственная.
 569        var producerlessCards = resolved.Where(x => !x.Producer.HasValue).ToList();
 570        if (producerlessCards.Count == 1)
 571            return new ExistingMatch(producerlessCards[0].Card!.RefKey, producerlessCards[0].Card!.Article, true);
 572
 573        // Остальное — тот же артикул у другого бренда (один OEM-номер у разных производителей).
 574        return null;
 575    }
 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    {
 586        var result = new List<ODataNomenclature>();
 587
 588        var exactArticles = new[] { rawArticle?.Trim(), article }
 589            .Where(x => !string.IsNullOrWhiteSpace(x))
 590            .Distinct(StringComparer.OrdinalIgnoreCase);
 591        foreach (var exactArticle in exactArticles)
 592        {
 593            var exact = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 594                new Dictionary<string, string>
 595                {
 596                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 597                    ["$filter"] = $"Артикул eq '{EscapeOData(exactArticle!)}' and IsFolder eq false and DeletionMark eq 
 598                });
 599            result.AddRange(exact.Where(x => result.All(known => known.RefKey != x.RefKey)));
 600        }
 601
 602        if (article.Length >= ProbePrefixLength)
 603        {
 604            var probe = article[..ProbePrefixLength];
 605            var contains = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 606                new Dictionary<string, string>
 607                {
 608                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 609                    ["$filter"] = $"substringof('{EscapeOData(probe)}', Артикул) and IsFolder eq false and DeletionMark 
 610                });
 611            result.AddRange(contains.Where(x => result.All(known => known.RefKey != x.RefKey)));
 612        }
 613
 614        return result.Where(x => NormalizeArticle(x.Article) == article).ToList();
 615    }
 616
 617    private async Task<List<(ODataNomenclature? Card, Guid? Producer)>> ReadProducersAsync(List<ODataNomenclature> candi
 618    {
 619        var result = new List<(ODataNomenclature?, Guid?)>(candidates.Count);
 620        foreach (var candidate in candidates)
 621            result.Add((candidate, await ReadProducerAsync(candidate.RefKey)));
 622        return result;
 623    }
 624
 625    private async Task<Guid?> ReadProducerAsync(Guid refKey)
 626    {
 627        var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, refKey);
 628        if (raw == "{}")
 629            return null;
 630
 631        var properties = JObject.Parse(raw)["ДополнительныеРеквизиты"] as JArray;
 632        var node = properties?.FirstOrDefault(p =>
 633            string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPropertyKey, StringComparison.OrdinalIgnoreCase))
 634        var value = node?["Значение"]?.Value<string>();
 635        return Guid.TryParse(value, out var guid) && guid != Guid.Empty ? guid : null;
 636    }
 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-сравнению строк) и матчим локально по нормализованному имени.
 647        var values = await _client.GetListAsync<ODataPropertyValue>(PropertyValuesEndpoint,
 648            new Dictionary<string, string>
 649            {
 650                ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark",
 651                ["$filter"] = $"Owner_Key eq guid'{ProducerPropertyKey}' and DeletionMark eq false"
 652            });
 653
 654        var exact = values.FirstOrDefault(x => NormalizeProducerName(x.Description) == normalizedTarget);
 655        return exact != null
 656            ? (exact, false)
 657            : (InferProducerByContainment(values, normalizedTarget), true);
 658    }
 659
 660    private async Task<Guid> GetOrCreateProducerAsync(string name)
 661    {
 662        var normalizedTarget = NormalizeProducerName(name);
 663        var gate = ProducerLocks.GetOrAdd(normalizedTarget, _ => new SemaphoreSlim(1, 1));
 664        await gate.WaitAsync();
 665        try
 666        {
 667            // Явные алиасы уже разрешены в ResolveProducerAliasAsync — сюда попадают только имена,
 668            // для которых соответствия не задано.
 669            var (match, byContainment) = await TryFindProducerAsync(normalizedTarget);
 670            if (match != null && !byContainment)
 671                return match.RefKey;
 672
 673            if (match != null)
 674            {
 675                _logger.LogInformation(
 676                    "[OData] Производитель «{Name}» сопоставлен с существующим «{Existing}» по вхождению — новое значени
 677                    name, match.Description);
 678                if (_map != null)
 679                    await _map.UpsertProducerAliasAsync(normalizedTarget, match.RefKey, match.Description, "auto-contain
 680                return match.RefKey;
 681            }
 682
 683            var created = await _client.PostAsync<object, ODataPropertyValue>(PropertyValuesEndpoint, new
 684            {
 685                Description = name,
 686                DeletionMark = false,
 687                Owner_Key = ProducerPropertyKey
 688            });
 689            if (created.RefKey == Guid.Empty)
 690                throw new InvalidOperationException($"1С создала производителя '{name}' без Ref_Key.");
 691
 692            _logger.LogInformation("[OData] Создано значение реквизита «Производитель»: {Name} → {RefKey}", name, create
 693            return created.RefKey;
 694        }
 695        finally
 696        {
 697            gate.Release();
 698        }
 699    }
 700
 701    /// <summary>
 702    /// Сводит усечённые написания бренда к существующему значению: «BLUE» → «BLUE PRINT».
 703    /// Срабатывает только при единственном кандидате — иначе можно склеить разные бренды.
 704    /// </summary>
 705    internal static ODataPropertyValue? InferProducerByContainment(
 706        IReadOnlyCollection<ODataPropertyValue> values,
 707        string normalizedTarget)
 708    {
 709        if (normalizedTarget.Length < MinProducerContainsLength)
 710            return null;
 711
 712        var matches = values
 713            .Where(x =>
 714            {
 715                var candidate = NormalizeProducerName(x.Description);
 716                if (candidate.Length < MinProducerContainsLength)
 717                    return false;
 718                return candidate.StartsWith(normalizedTarget, StringComparison.Ordinal)
 719                       || normalizedTarget.StartsWith(candidate, StringComparison.Ordinal);
 720            })
 721            .ToList();
 722
 723        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        // с заново прочитанной карточкой, чтобы не перезаписать параллельные изменения устаревшим телом.
 730        for (var attempt = 1; attempt <= 2; attempt++)
 731        {
 732            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 733            if (raw == "{}")
 734                throw new InvalidOperationException($"Не удалось прочитать созданную номенклатуру {nomenclatureGuid} для
 735            var item = JObject.Parse(raw);
 736            item.Remove("odata.metadata");
 737            item.Remove("odata.type");
 738            item.Remove("odata.etag");
 739
 740            var properties = item["ДополнительныеРеквизиты"] as JArray ?? new JArray();
 741            var producer = properties.FirstOrDefault(p => string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPro
 742            if (producer == null)
 743            {
 744                producer = new JObject { ["LineNumber"] = properties.Count + 1, ["Свойство_Key"] = ProducerPropertyKey }
 745                properties.Add(producer);
 746            }
 747            producer["Значение"] = producerGuid.ToString();
 748            producer["Значение_Type"] = "StandardODATA.Catalog_ЗначенияСвойствОбъектов";
 749            item["ДополнительныеРеквизиты"] = properties;
 750
 751            try
 752            {
 753                await _client.PutAsync(NomenclatureEndpoint, nomenclatureGuid, item);
 754                return;
 755            }
 756            catch (OneCODataException ex) when (ex.Error?.StatusCode == 412 && attempt == 1)
 757            {
 758                _logger.LogInformation("[OData] Карточка номенклатуры {RefKey} изменилась перед установкой производителя
 759            }
 760        }
 761    }
 762
 763    /// <summary>
 764    /// 1С Fresh может вернуть успешный POST, но не записать навигационные реквизиты карточки.
 765    /// Не продолжаем проведение, пока повторное чтение не подтвердит единицу «шт» и «Без категории».
 766    /// </summary>
 767    private async Task EnsureRequiredFieldsAsync(Guid nomenclatureGuid, string? originComment = null)
 768    {
 769        var patch = new Dictionary<string, object>
 770        {
 771            ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 772            ["КатегорияНоменклатуры_Key"] = CategoryKey,
 773            ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 774            ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 775            ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 776            ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 777            ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 778        };
 779
 780        // «Комментарий» — свободное поле карточки. Наименование (Description) не трогаем:
 781        // оно видно в списках номенклатуры и в печатных формах.
 782        if (!string.IsNullOrWhiteSpace(originComment))
 783            patch["Комментарий"] = originComment;
 784
 785        for (var attempt = 1; attempt <= 2; attempt++)
 786        {
 787            await _client.PatchAsync<object>(NomenclatureEndpoint, nomenclatureGuid, patch);
 788
 789            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 790            if (raw != "{}")
 791            {
 792                var item = JObject.Parse(raw);
 793                var unit = item["ЕдиницаИзмерения_Key"]?.Value<string>();
 794                var category = item["КатегорияНоменклатуры_Key"]?.Value<string>();
 795                var stockAccount = item["СчетУчетаЗапасов_Key"]?.Value<string>();
 796                var expenseAccount = item["СчетУчетаЗатрат_Key"]?.Value<string>();
 797                var activityDirection = item["НаправлениеДеятельности_Key"]?.Value<string>();
 798                var vatSalesAccount = item["СчетУчетаНДСПоРеализации_Key"]?.Value<string>();
 799                var vatPurchaseAccount = item["СчетУчетаНДСПоПриобретеннымЦенностям_Key"]?.Value<string>();
 800                if (string.Equals(unit, OneCODataConstants.UnitShtukaGuid, StringComparison.OrdinalIgnoreCase)
 801                    && string.Equals(category, CategoryKey, StringComparison.OrdinalIgnoreCase)
 802                    && string.Equals(stockAccount, StockAccountKey, StringComparison.OrdinalIgnoreCase)
 803                    && string.Equals(expenseAccount, ExpenseAccountKey, StringComparison.OrdinalIgnoreCase)
 804                    && string.Equals(activityDirection, ActivityDirectionKey, StringComparison.OrdinalIgnoreCase)
 805                    && string.Equals(vatSalesAccount, VatSalesAccountKey, StringComparison.OrdinalIgnoreCase)
 806                    && string.Equals(vatPurchaseAccount, VatPurchaseAccountKey, StringComparison.OrdinalIgnoreCase))
 807                    return;
 808            }
 809
 810            _logger.LogWarning("[OData] Номенклатура {RefKey}: попытка {Attempt}/2 не подтвердила обязательные реквизиты
 811        }
 812
 813        throw new InvalidOperationException(
 814            $"Карточка номенклатуры {nomenclatureGuid} создана, но 1С не сохранила обязательные реквизиты учёта. Докумен
 815    }
 816
 817    /// <summary>
 818    /// Отметка в «Комментарий» карточки: кто завёл позицию, когда и в рамках какой транзакции.
 819    /// Нужна, чтобы в 1С было видно, что карточка не заведена вручную.
 820    /// </summary>
 821    internal static string BuildOriginComment(OneCNomenclatureOrigin? origin, DateTime? nowUtc = null)
 822    {
 823        var moscow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone);
 824        var stamp = moscow.ToString("dd.MM.yyyy HH:mm:ss");
 825
 826        var transaction = origin?.TransactionType?.GetDisplayName();
 827        return string.IsNullOrWhiteSpace(transaction)
 828            ? $"Создано автоматически: Studio2, {stamp} (МСК)."
 829            : $"Создано автоматически: Studio2, {stamp} (МСК). Транзакция: {transaction}.";
 830    }
 831
 832    private static Dictionary<string, object> BuildNomenclatureBody(string article, string name) => new()
 833    {
 834        ["Description"] = name,
 835        ["НаименованиеПолное"] = name,
 836        ["Артикул"] = article,
 837        ["IsFolder"] = false,
 838        ["DeletionMark"] = false,
 839        ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 840        ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 841        ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 842        ["ВидСтавкиНДС"] = "Общая",
 843        ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 844        ["КатегорияНоменклатуры_Key"] = CategoryKey,
 845        ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 846        ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 847    };
 848
 849    private static string EscapeOData(string value) => value.Replace("'", "''");
 850}

Methods/Properties

get_RefKey()