< Summary

Information
Class: Services.BitrixStockServices.BitrixOzonStockLinkage
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixOzonStockLinkage.cs
Line coverage
65%
Covered lines: 232
Uncovered lines: 122
Coverable lines: 354
Total lines: 774
Line coverage: 65.5%
Branch coverage
56%
Covered branches: 148
Total branches: 262
Branch coverage: 56.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixOzonStockLinkage.cs

#LineLine coverage
 1using Newtonsoft.Json.Linq;
 2using OzonDomains.Models;
 3using OzonDomains.Models.BitrixModels;
 4using Services.ApiServices.OzonApi.Filters;
 5using Services.FiltersServices.DataFilterManagers;
 6
 7namespace Services.BitrixStockServices;
 8
 9/// <summary>
 10/// Связка остатков Bitrix → Ozon по WarehouseMapping при загрузке BitrixStock.
 11/// Учитываются только связи с заполненным OzonWarehouseId и допустимым именованием склада Ozon.
 12/// </summary>
 13public static class BitrixOzonStockLinkage
 14{
 15    /// <summary>Ищет блок остатков Ozon по offer_id (точное совпадение, артикул без суффикса, без учёта регистра).</sum
 16    public static JObject? FindStocksForArticle(JObject? allStocks, string articleWithKey, string? article = null)
 17    {
 318        if (allStocks?["items"] is not JArray items)
 19        {
 120            return null;
 21        }
 22
 223        var candidates = BuildOfferIdCandidates(articleWithKey, article);
 724        foreach (var stockItem in items)
 25        {
 226            var offerId = stockItem["offer_id"]?.ToString();
 227            if (string.IsNullOrWhiteSpace(offerId))
 28            {
 29                continue;
 30            }
 31
 232            if (candidates.Contains(offerId.Trim()))
 33            {
 134                return new JObject { ["items"] = new JArray { stockItem } };
 35            }
 36        }
 37
 138        return null;
 139    }
 40
 41    public static JObject? FindStocksForArticle(JObject? allStocks, RemainingStockBitrix item) =>
 042        FindStocksForArticle(allStocks, item.ArticleWithKey, item.Article);
 43
 44    public static HashSet<string> BuildOfferIdCandidates(string? articleWithKey, string? article = null)
 45    {
 746        var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 747        if (!string.IsNullOrWhiteSpace(articleWithKey))
 448            set.Add(articleWithKey.Trim());
 749        if (!string.IsNullOrWhiteSpace(article))
 250            set.Add(article.Trim());
 751        return set;
 52    }
 53
 54    public static JArray GetWarehousesArray(JToken? warehouseInfo)
 55    {
 1056        if (warehouseInfo == null)
 57        {
 358            return new JArray();
 59        }
 60
 761        if (warehouseInfo["result"] is JArray resultArray)
 62        {
 563            return resultArray;
 64        }
 65
 266        if (warehouseInfo["products"] is JArray productsArray)
 67        {
 168            return productsArray;
 69        }
 70
 171        return new JArray();
 72    }
 73
 74    private static bool HasRequiredLinkageFields(WarehouseMapping mapping) =>
 3675        !string.IsNullOrWhiteSpace(mapping.OzonWarehouseId) &&
 3676        !string.IsNullOrWhiteSpace(mapping.BitrixWarehouseName) &&
 3677        !string.IsNullOrWhiteSpace(mapping.OzonWarehouseName);
 78
 79    /// <summary>
 80    /// Именование 1С↔Ozon: M2→M2, M2→M2S, B1→B12. Не покрывает B2→B12 (B12 не начинается с B2).
 81    /// </summary>
 82    public static bool MatchesStockLinkageNamingRule(WarehouseMapping mapping)
 83    {
 2184        if (!HasRequiredLinkageFields(mapping))
 85        {
 286            return false;
 87        }
 88
 1989        var bitrix = mapping.BitrixWarehouseName!.Trim();
 1990        var ozon = mapping.OzonWarehouseName!.Trim();
 1991        if (string.Equals(bitrix, ozon, StringComparison.OrdinalIgnoreCase))
 92        {
 993            return true;
 94        }
 95
 1096        return ozon.StartsWith(bitrix, StringComparison.OrdinalIgnoreCase);
 97    }
 98
 99    /// <summary>
 100    /// Связь применима по именованию (M2→M2, M2→M2S, B1→B12; не M2→M1).
 101    /// Для полного набора складов на один Ozon Warehouse ID см. <see cref="GetStockLinkageMappings"/>.
 102    /// </summary>
 103    public static bool IsStockLinkageMapping(WarehouseMapping mapping) =>
 6104        MatchesStockLinkageNamingRule(mapping);
 105
 106    /// <summary>
 107    /// Связи для наката/отчёта: именование + все 1С-склады на тот же Ozon Warehouse ID
 108    /// (коллизия B1+B2→B12, M2+OZON Interparts→M2).
 109    /// </summary>
 110    public static List<WarehouseMapping> GetStockLinkageMappings(IEnumerable<WarehouseMapping> clientMappings)
 111    {
 11112        var eligible = clientMappings.Where(HasRequiredLinkageFields).ToList();
 11113        if (eligible.Count == 0)
 114        {
 1115            return eligible;
 116        }
 117
 10118        var primaryWarehouseIds = eligible
 10119            .Where(MatchesStockLinkageNamingRule)
 11120            .Select(m => m.OzonWarehouseId!.Trim())
 10121            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 122
 10123        return eligible
 15124            .Where(m => primaryWarehouseIds.Contains(m.OzonWarehouseId!.Trim()))
 10125            .ToList();
 126    }
 127
 128    /// <summary>
 129    /// Все 1С-склады, привязанные к одному Ozon Warehouse ID (для суммы B1+B2→B12).
 130    /// </summary>
 131    public static IReadOnlyList<string> CollectLinkedBitrixStoreNames(
 132        long ozonWarehouseId,
 133        IEnumerable<WarehouseMapping> linkageMappings,
 134        IReadOnlyList<WarehouseOzon> warehouseList,
 135        IEnumerable<string> allowedBitrixStores)
 136    {
 1137        if (ozonWarehouseId <= 0)
 138        {
 0139            return Array.Empty<string>();
 140        }
 141
 1142        var allowed = allowedBitrixStores
 4143            .Where(s => !string.IsNullOrWhiteSpace(s))
 4144            .Select(s => s.Trim())
 1145            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 146
 1147        return linkageMappings
 1148            .Where(m =>
 1149            {
 2150                var resolved = ResolveOzonWarehouse(m, warehouseList);
 2151                return resolved?.WarehouseId == ozonWarehouseId;
 1152            })
 2153            .Select(m => m.BitrixWarehouseName?.Trim())
 2154            .Where(name => !string.IsNullOrWhiteSpace(name) && allowed.Contains(name!))
 2155            .Select(name => name!)
 1156            .Distinct(StringComparer.OrdinalIgnoreCase)
 2157            .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
 1158            .ToList();
 159    }
 160
 161    public static bool IsInactiveProduct(RemainingStockBitrix item) =>
 9162        string.Equals(item.Active, "N", StringComparison.OrdinalIgnoreCase);
 163
 164    public static bool HasValidOzonProductId(StockByStore store) =>
 19165        long.TryParse(store.ProductId?.Trim(), out var id) && id > 0;
 166
 167    public static bool IsMissingOzonProductError(string? message) =>
 8168        !string.IsNullOrWhiteSpace(message) &&
 8169        message.Contains("product_id", StringComparison.OrdinalIgnoreCase);
 170
 171    /// <summary>Убирает склады Ozon без товара в каталоге клиента (нет product_id).</summary>
 172    public static void PruneUnlistedOzonProductStores(RemainingStockBitrix item, string? clientName = null)
 173    {
 2174        lock (item.OzonStores)
 175        {
 2176            item.OzonStores.RemoveAll(s =>
 5177                (string.IsNullOrWhiteSpace(clientName) ||
 5178                 string.Equals(s.ClientName, clientName, StringComparison.OrdinalIgnoreCase)) &&
 5179                (!HasValidOzonProductId(s) || IsMissingOzonProductError(s.LastOzonSyncError)));
 2180        }
 2181    }
 182
 183    public static void PruneUnlistedOzonProductStores(
 184        IEnumerable<RemainingStockBitrix> items,
 185        string? clientName = null)
 186    {
 0187        foreach (var item in items)
 188        {
 0189            PruneUnlistedOzonProductStores(item, clientName);
 190        }
 0191    }
 192
 193    /// <summary>Суммирует остатки из itemStocks по схемам (fbs, rfbs, fbo).</summary>
 194    public static (int Fbs, int Rfbs, int Fbo) ExtractStocksByType(JObject? itemStocks)
 195    {
 24196        int fbs = 0, rfbs = 0, fbo = 0;
 8197        if (itemStocks?["items"] is JArray items)
 28198            foreach (var stockItem in items)
 7199                if (stockItem["stocks"] is JArray stocks)
 34200                    foreach (var s in stocks)
 201                    {
 10202                        var present = s["present"]?.Value<int>() ?? 0;
 10203                        switch (s["type"]?.ToString())
 204                        {
 14205                            case "fbs": fbs += present; break;
 4206                            case "rfbs": rfbs += present; break;
 1207                            case "fbo": fbo += present; break;
 208                        }
 209                    }
 8210        return (fbs, rfbs, fbo);
 211    }
 212
 213    public static long ExtractProductIdFromStocks(JObject? itemStocks)
 214    {
 3215        if (itemStocks?["items"] is not JArray items || items.Count == 0)
 216        {
 2217            return 0;
 218        }
 219
 1220        return items[0]?["product_id"]?.Value<long>() ?? 0;
 221    }
 222
 223    /// <returns>true — использован FBS-fallback (склад взят из WarehouseMapping, т.к. /v2/product/info/stocks-by-wareho
 224    public static bool ApplyMappedStocks(
 225        RemainingStockBitrix item,
 226        OzonClient client,
 227        JToken? warehouseInfo,
 228        IReadOnlyList<WarehouseMapping> clientMappings,
 229        IReadOnlyList<WarehouseOzon> warehouseList,
 230        HashSet<string> allowedBitrixStores,
 231        string offerArticle,
 232        long stocksProductId = 0,
 233        OzonApiFoundInfo? ozonInfo = null)
 234    {
 2235        var linkageMappings = GetStockLinkageMappings(clientMappings);
 2236        if (linkageMappings.Count == 0)
 237        {
 0238            return false;
 239        }
 240
 2241        var warehousesArray = GetWarehousesArray(warehouseInfo);
 2242        var defaultProductId = stocksProductId > 0
 2243            ? stocksProductId
 2244            : ExtractDefaultProductId(warehousesArray);
 2245        var mappedWarehouseIds = new HashSet<long>();
 2246        var inactive = IsInactiveProduct(item);
 247
 248
 2249        if (inactive)
 250        {
 0251            RegisterLinkageWarehouseIdsForDisplay(
 0252                linkageMappings,
 0253                warehouseList,
 0254                allowedBitrixStores,
 0255                mappedWarehouseIds);
 256        }
 257        else
 258        {
 10259            foreach (var mapping in linkageMappings)
 260            {
 3261                ApplySingleMapping(
 3262                    item,
 3263                    client,
 3264                    mapping,
 3265                    warehouseList,
 3266                    allowedBitrixStores,
 3267                    offerArticle,
 3268                    defaultProductId,
 3269                    mappedWarehouseIds);
 270            }
 271        }
 272
 2273        EnrichMappedWarehousesFromApi(
 2274            item,
 2275            client,
 2276            warehousesArray,
 2277            linkageMappings,
 2278            warehouseList,
 2279            allowedBitrixStores,
 2280            mappedWarehouseIds,
 2281            offerArticle,
 2282            defaultProductId);
 283
 284        // FBS-fallback: /v2/product/info/stocks-by-warehouse/fbs не вернул данных по SKU товара
 285        // (характерно для SDS-схемы). Используем суммарный FBS-остаток из /v4/product/info/stocks
 286        // и привязываем к складу из WarehouseMapping. Срабатывает даже при FBS=0 — создаём запись
 287        // с ozonQty=0, чтобы сравнение с 1С qty дало Push.
 2288        var fbsFallback = false;
 2289        if (warehousesArray.Count == 0 && ozonInfo is { Found: true })
 290        {
 2291            fbsFallback = ApplyFbsFallbackStocks(
 2292                item, client, ozonInfo, linkageMappings, warehouseList,
 2293                allowedBitrixStores, offerArticle, defaultProductId, mappedWarehouseIds);
 294        }
 295
 296        // Для страницы BitrixStock показываем все фактические склады Ozon.
 297        // Удаление unmapped складов отключено, иначе реальные склады (в т.ч. с 0) пропадают из таблицы.
 298
 2299        PruneUnlistedOzonProductStores(item, client.Name);
 2300        return fbsFallback;
 301    }
 302
 303    /// <summary>
 304    /// FBS-fallback: создаёт OzonStore-записи из WarehouseMapping когда Ozon FBS warehouse API
 305    /// не вернул данных по SKU (SDS/схема без привязки к конкретному складу).
 306    /// </summary>
 307    private static bool ApplyFbsFallbackStocks(
 308        RemainingStockBitrix item,
 309        OzonClient client,
 310        OzonApiFoundInfo ozonInfo,
 311        List<WarehouseMapping> linkageMappings,
 312        IReadOnlyList<WarehouseOzon> warehouseList,
 313        HashSet<string> allowedBitrixStores,
 314        string offerArticle,
 315        long defaultProductId,
 316        HashSet<long> mappedWarehouseIds)
 317    {
 2318        var productIdStr = defaultProductId > 0
 2319            ? defaultProductId.ToString()
 2320            : ozonInfo.OzonProductId > 0 ? ozonInfo.OzonProductId.ToString() : string.Empty;
 2321        var resolvedOfferId = ozonInfo.FoundOfferId ?? offerArticle;
 2322        var fbsTotal = ozonInfo.FbsFamilyTotal;
 2323        var fallbackUsed = false;
 2324        var storesByWarehouseId = new Dictionary<long, (WarehouseOzon Warehouse, List<string> LinkStores)>();
 325
 10326        foreach (var mapping in linkageMappings)
 327        {
 3328            var bitrixName = mapping.BitrixWarehouseName?.Trim();
 3329            if (string.IsNullOrWhiteSpace(bitrixName) || !allowedBitrixStores.Contains(bitrixName!))
 330            {
 331                continue;
 332            }
 333
 3334            var ozonWarehouse = ResolveOzonWarehouse(mapping, warehouseList);
 3335            if (ozonWarehouse == null)
 336            {
 337                continue;
 338            }
 339
 3340            mappedWarehouseIds.Add(ozonWarehouse.WarehouseId);
 341
 3342            if (!storesByWarehouseId.TryGetValue(ozonWarehouse.WarehouseId, out var entry))
 343            {
 3344                entry = (ozonWarehouse, new List<string>());
 3345                storesByWarehouseId[ozonWarehouse.WarehouseId] = entry;
 346            }
 347
 3348            if (!entry.LinkStores.Contains(bitrixName!, StringComparer.OrdinalIgnoreCase))
 349            {
 3350                entry.LinkStores.Add(bitrixName!);
 351            }
 352        }
 353
 354        // Суммарный FBS+rFBS остаток относится ко всем складам сразу. Если связанных складов
 355        // несколько, приписать эту сумму каждому нельзя: 1 шт на M2 + 1 шт на M2S превращались
 356        // в «2 на каждом складе» и давали вечные ложные Decrease/Clear. В таком случае остаток
 357        // считается неизвестным — строка идёт как пропуск, накат по ней не выполняется.
 2358        var stockUnknown = storesByWarehouseId.Count > 1;
 359
 10360        foreach (var (warehouseId, entry) in storesByWarehouseId)
 361        {
 3362            var linkStores = entry.LinkStores
 3363                .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
 3364                .ToList();
 365
 3366            UpsertOzonStore(item, client,
 3367                warehouseId,
 3368                entry.Warehouse.Name ?? "",
 3369                stockUnknown ? 0 : fbsTotal,
 3370                productIdStr,
 3371                resolvedOfferId,
 3372                linkStores,
 3373                ozonPresentBefore: stockUnknown ? null : fbsTotal,
 3374                isFbsFallback: true,
 3375                isFbsStockUnknown: stockUnknown);
 376
 3377            fallbackUsed = true;
 378        }
 379
 2380        return fallbackUsed;
 381    }
 382
 383    /// <summary>Склады по связкам для отображения (без наката Bitrix → Ozon).</summary>
 384    private static void RegisterLinkageWarehouseIdsForDisplay(
 385        IReadOnlyList<WarehouseMapping> linkageMappings,
 386        IReadOnlyList<WarehouseOzon> warehouseList,
 387        HashSet<string> allowedBitrixStores,
 388        HashSet<long> mappedWarehouseIds)
 389    {
 0390        foreach (var mapping in linkageMappings)
 391        {
 0392            var bitrixName = mapping.BitrixWarehouseName!.Trim();
 0393            if (!allowedBitrixStores.Contains(bitrixName))
 394            {
 395                continue;
 396            }
 397
 0398            var ozonWarehouse = ResolveOzonWarehouse(mapping, warehouseList);
 0399            if (ozonWarehouse != null)
 400            {
 0401                mappedWarehouseIds.Add(ozonWarehouse.WarehouseId);
 402            }
 403        }
 0404    }
 405
 406    private static void ApplySingleMapping(
 407        RemainingStockBitrix item,
 408        OzonClient client,
 409        WarehouseMapping mapping,
 410        IReadOnlyList<WarehouseOzon> warehouseList,
 411        HashSet<string> allowedBitrixStores,
 412        string offerArticle,
 413        long defaultProductId,
 414        HashSet<long> mappedWarehouseIds)
 415    {
 3416        var bitrixName = mapping.BitrixWarehouseName!.Trim();
 3417        if (!allowedBitrixStores.Contains(bitrixName))
 418        {
 0419            return;
 420        }
 421
 3422        var ozonWarehouse = ResolveOzonWarehouse(mapping, warehouseList);
 3423        if (ozonWarehouse == null)
 424        {
 0425            return;
 426        }
 427
 3428        mappedWarehouseIds.Add(ozonWarehouse.WarehouseId);
 3429    }
 430
 431    /// <summary>Подтягивает product_id и остатки по всем складам из ответа Ozon, помечая связанные склады.</summary>
 432    private static void EnrichMappedWarehousesFromApi(
 433        RemainingStockBitrix item,
 434        OzonClient client,
 435        JArray warehousesArray,
 436        List<WarehouseMapping> linkageMappings,
 437        IReadOnlyList<WarehouseOzon> warehouseList,
 438        HashSet<string> allowedBitrixStores,
 439        HashSet<long> mappedWarehouseIds,
 440        string offerArticle,
 441        long defaultProductId)
 442    {
 2443        if (warehousesArray.Count == 0)
 444        {
 2445            return;
 446        }
 447
 0448        foreach (var w in warehousesArray)
 449        {
 0450            var warehouseId = w["warehouse_id"]?.Value<long>() ?? 0;
 0451            if (warehouseId <= 0)
 452            {
 453                continue;
 454            }
 455
 0456            var linkedBitrixStores = CollectLinkedBitrixStoreNames(
 0457                warehouseId,
 0458                linkageMappings,
 0459                warehouseList,
 0460                allowedBitrixStores).ToList();
 461
 0462            var warehouseName = w["warehouse_name"]?.ToString()?.Trim()
 0463                                ?? linkageMappings
 0464                                    .Where(m =>
 0465                                    {
 0466                                        var resolved = ResolveOzonWarehouse(m, warehouseList);
 0467                                        return resolved?.WarehouseId == warehouseId;
 0468                                    })
 0469                                    .Select(m => m.OzonWarehouseName?.Trim())
 0470                                    .FirstOrDefault(name => !string.IsNullOrWhiteSpace(name))
 0471                                ?? $"Warehouse {warehouseId}";
 0472            var ozonPresent = w["present"]?.Value<int>() ?? 0;
 0473            var productId = w["product_id"]?.ToString();
 0474            if (string.IsNullOrWhiteSpace(productId) && defaultProductId > 0)
 475            {
 0476                productId = defaultProductId.ToString();
 477            }
 478
 479            // Для отображения в UI используем фактический остаток Ozon (present),
 480            // а не расчетный/связанный остаток Bitrix.
 0481            var displayAmount = ozonPresent;
 482
 0483            UpsertOzonStore(
 0484                item,
 0485                client,
 0486                warehouseId,
 0487                warehouseName,
 0488                displayAmount,
 0489                productId ?? string.Empty,
 0490                offerArticle,
 0491                linkedBitrixStores,
 0492                ozonPresentBefore: ozonPresent);
 493        }
 0494    }
 495
 496    /// <summary>Убирает накат на склады клиента, не входящие в активные связи с остатком Bitrix.</summary>
 497    private static void ZeroUnmappedClientStores(
 498        RemainingStockBitrix item,
 499        string clientName,
 500        HashSet<long> mappedWarehouseIds)
 501    {
 0502        lock (item.OzonStores)
 503        {
 0504            item.OzonStores.RemoveAll(s =>
 0505                string.Equals(s.ClientName, clientName, StringComparison.OrdinalIgnoreCase) &&
 0506                s.StoreId > 0 &&
 0507                !mappedWarehouseIds.Contains(s.StoreId));
 0508        }
 0509    }
 510
 511    public static WarehouseOzon? ResolveOzonWarehouse(WarehouseMapping mapping, IReadOnlyList<WarehouseOzon> warehouseLi
 512    {
 19513        if (!string.IsNullOrWhiteSpace(mapping.OzonWarehouseId) &&
 19514            long.TryParse(mapping.OzonWarehouseId.Trim(), out var warehouseId))
 515        {
 35516            var byId = warehouseList.FirstOrDefault(w => w.WarehouseId == warehouseId);
 16517            if (byId != null)
 518            {
 14519                return byId;
 520            }
 521        }
 522
 5523        if (!string.IsNullOrWhiteSpace(mapping.OzonWarehouseName))
 524        {
 5525            return warehouseList.FirstOrDefault(w =>
 10526                string.Equals(w.Name?.Trim(), mapping.OzonWarehouseName.Trim(), StringComparison.OrdinalIgnoreCase));
 527        }
 528
 0529        return null;
 530    }
 531
 532    /// <summary>Суммирует остатки 1С по всем складам из <paramref name="linkStores"/>.</summary>
 533    public static int GetLinkedOneCQuantity(RemainingStockBitrix item, IEnumerable<string>? linkStores)
 534    {
 5535        if (linkStores == null)
 536        {
 0537            return 0;
 538        }
 539
 5540        var total = 0;
 26541        foreach (var linkStore in linkStores)
 542        {
 8543            if (string.IsNullOrWhiteSpace(linkStore))
 544            {
 545                continue;
 546            }
 547
 8548            total += GetBitrixAmount(item, linkStore.Trim());
 549        }
 550
 5551        return total;
 552    }
 553
 554    /// <summary>Подпись связанных складов 1С (через запятую).</summary>
 555    public static string GetLinkedOneCStoreLabel(IEnumerable<string>? linkStores)
 556    {
 2557        if (linkStores == null)
 558        {
 0559            return "—";
 560        }
 561
 2562        var names = linkStores
 5563            .Where(s => !string.IsNullOrWhiteSpace(s))
 5564            .Select(s => s.Trim())
 2565            .Distinct(StringComparer.OrdinalIgnoreCase)
 4566            .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
 2567            .ToList();
 568
 2569        return names.Count == 0 ? "—" : string.Join(", ", names);
 570    }
 571
 572    /// <summary>Есть ли хотя бы один связанный склад 1С в <paramref name="item"/>.</summary>
 573    public static bool HasLinkedOneCStore(RemainingStockBitrix item, IEnumerable<string>? linkStores)
 574    {
 2575        if (linkStores == null || item.Stores == null)
 576        {
 0577            return false;
 578        }
 579
 2580        return linkStores.Any(ls =>
 5581            !string.IsNullOrWhiteSpace(ls) &&
 8582            item.Stores.Any(s => string.Equals(s.Title?.Trim(), ls.Trim(), StringComparison.OrdinalIgnoreCase)));
 583    }
 584
 585    /// <summary>Суммирует резерв заказов по всем связанным складам 1С.</summary>
 586    public static int GetLinkedReservedQuantity(
 587        string? article,
 588        IEnumerable<string>? linkStores,
 589        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap)
 590    {
 21591        var normalizedArticle = OrderDataFilterManager.NormalizeArticleForSearch(article);
 21592        if (string.IsNullOrEmpty(normalizedArticle) || linkStores == null ||
 21593            !reserveMap.TryGetValue(normalizedArticle, out var storeReserves))
 594        {
 13595            return 0;
 596        }
 597
 8598        var total = 0;
 34599        foreach (var linkStore in linkStores)
 600        {
 9601            if (string.IsNullOrWhiteSpace(linkStore))
 602            {
 603                continue;
 604            }
 605
 9606            if (storeReserves.TryGetValue(linkStore.Trim(), out var reserved))
 607            {
 8608                total += reserved;
 609            }
 610        }
 611
 8612        return total;
 613    }
 614
 615    private static int GetBitrixAmount(RemainingStockBitrix item, string bitrixStoreName)
 616    {
 8617        var store = item.Stores.FirstOrDefault(s =>
 19618            string.Equals(s.Title?.Trim(), bitrixStoreName, StringComparison.OrdinalIgnoreCase));
 8619        if (store == null)
 620        {
 1621            return 0;
 622        }
 623
 7624        return (int)Math.Max(0, Math.Round(store.Amount));
 625    }
 626
 627    private static void UpsertOzonStore(
 628        RemainingStockBitrix item,
 629        OzonClient client,
 630        long warehouseId,
 631        string warehouseName,
 632        int displayAmount,
 633        string? productId,
 634        string offerArticle,
 635        List<string> linkedBitrixStores,
 636        int? ozonPresentBefore = null,
 637        bool isFbsFallback = false,
 638        bool isFbsStockUnknown = false)
 639    {
 3640        if (IsInactiveProduct(item))
 641        {
 0642            if (ozonPresentBefore.HasValue)
 643            {
 0644                displayAmount = ozonPresentBefore.Value;
 645            }
 646
 0647            lock (item.OzonStores)
 648            {
 0649                var existing = item.OzonStores.FirstOrDefault(s =>
 0650                    string.Equals(s.ClientName, client.Name, StringComparison.OrdinalIgnoreCase) &&
 0651                    (warehouseId > 0
 0652                        ? s.StoreId == warehouseId
 0653                        : string.Equals(s.Title, warehouseName, StringComparison.OrdinalIgnoreCase)));
 654
 0655                if (existing != null)
 656                {
 0657                    if (ozonPresentBefore.HasValue)
 658                    {
 0659                        existing.OzonPresentOnOzon = ozonPresentBefore.Value;
 0660                        existing.Amount = ozonPresentBefore.Value;
 661                    }
 662
 0663                    existing.Title = warehouseName;
 0664                    existing.LinkStores = MergeLinkStores(existing.LinkStores, linkedBitrixStores);
 0665                    if (!string.IsNullOrWhiteSpace(productId))
 666                    {
 0667                        existing.ProductId = productId;
 668                    }
 669
 0670                    existing.PendingOzonApiSync = false;
 0671                    return;
 672                }
 673
 0674                if (!ozonPresentBefore.HasValue && warehouseId <= 0)
 675                {
 0676                    return;
 677                }
 678
 0679                item.OzonStores.Add(new StockByStore
 0680                {
 0681                    StoreId = warehouseId,
 0682                    Title = warehouseName,
 0683                    Amount = displayAmount,
 0684                    ClientName = client.Name,
 0685                    ClientId = client.DecryptClientId,
 0686                    ProductId = productId ?? string.Empty,
 0687                    OfferId = offerArticle,
 0688                    LinkStores = linkedBitrixStores.ToList(),
 0689                    OzonPresentOnOzon = ozonPresentBefore,
 0690                    PendingOzonApiSync = false,
 0691                    IsFbsFallback = isFbsFallback,
 0692                    IsFbsStockUnknown = isFbsStockUnknown
 0693                });
 0694            }
 695
 0696            return;
 697        }
 698
 3699        lock (item.OzonStores)
 700        {
 3701            var existing = item.OzonStores.FirstOrDefault(s =>
 4702                string.Equals(s.ClientName, client.Name, StringComparison.OrdinalIgnoreCase) &&
 4703                (warehouseId > 0
 4704                    ? s.StoreId == warehouseId
 4705                    : string.Equals(s.Title, warehouseName, StringComparison.OrdinalIgnoreCase)));
 706
 3707            if (existing != null)
 708            {
 0709                if (ozonPresentBefore.HasValue)
 710                {
 0711                    existing.OzonPresentOnOzon = ozonPresentBefore.Value;
 712                }
 713
 0714                existing.Amount = displayAmount;
 0715                existing.Title = warehouseName;
 0716                existing.LinkStores = MergeLinkStores(existing.LinkStores, linkedBitrixStores);
 0717                if (!string.IsNullOrWhiteSpace(productId))
 718                {
 0719                    existing.ProductId = productId;
 720                }
 721
 722                // Авто-отправка в Ozon только вручную (кнопки на странице / фоновые задания).
 0723                existing.PendingOzonApiSync = false;
 0724                return;
 725            }
 726
 3727            var ozonPresent = ozonPresentBefore;
 3728            item.OzonStores.Add(new StockByStore
 3729            {
 3730                StoreId = warehouseId,
 3731                Title = warehouseName,
 3732                Amount = displayAmount,
 3733                ClientName = client.Name,
 3734                ClientId = client.DecryptClientId,
 3735                ProductId = productId ?? string.Empty,
 3736                OfferId = offerArticle,
 3737                LinkStores = linkedBitrixStores.ToList(),
 3738                OzonPresentOnOzon = ozonPresent,
 3739                PendingOzonApiSync = false,
 3740                IsFbsFallback = isFbsFallback,
 3741                IsFbsStockUnknown = isFbsStockUnknown
 3742            });
 3743        }
 3744    }
 745
 746    private static long ExtractDefaultProductId(JArray warehousesArray)
 747    {
 0748        foreach (var w in warehousesArray)
 749        {
 0750            var productId = w["product_id"]?.Value<long>() ?? 0;
 0751            if (productId > 0)
 752            {
 0753                return productId;
 754            }
 755        }
 756
 0757        return 0;
 0758    }
 759
 760    private static List<string>? MergeLinkStores(List<string>? current, IEnumerable<string> additional)
 761    {
 0762        var set = new HashSet<string>(current ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
 0763        foreach (var a in additional)
 764        {
 0765            if (!string.IsNullOrWhiteSpace(a))
 766            {
 0767                set.Add(a.Trim());
 768            }
 769        }
 770
 0771        return set.ToList();
 772    }
 773
 774}

Methods/Properties

FindStocksForArticle(Newtonsoft.Json.Linq.JObject,System.String,System.String)
FindStocksForArticle(Newtonsoft.Json.Linq.JObject,OzonDomains.Models.BitrixModels.RemainingStockBitrix)
BuildOfferIdCandidates(System.String,System.String)
GetWarehousesArray(Newtonsoft.Json.Linq.JToken)
HasRequiredLinkageFields(OzonDomains.Models.WarehouseMapping)
MatchesStockLinkageNamingRule(OzonDomains.Models.WarehouseMapping)
IsStockLinkageMapping(OzonDomains.Models.WarehouseMapping)
GetStockLinkageMappings(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.WarehouseMapping>)
CollectLinkedBitrixStoreNames(System.Int64,System.Collections.Generic.IEnumerable`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.IEnumerable`1<System.String>)
IsInactiveProduct(OzonDomains.Models.BitrixModels.RemainingStockBitrix)
HasValidOzonProductId(OzonDomains.Models.BitrixModels.StockByStore)
IsMissingOzonProductError(System.String)
PruneUnlistedOzonProductStores(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.String)
PruneUnlistedOzonProductStores(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>,System.String)
ExtractStocksByType(Newtonsoft.Json.Linq.JObject)
ExtractProductIdFromStocks(Newtonsoft.Json.Linq.JObject)
ApplyMappedStocks(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,Newtonsoft.Json.Linq.JToken,System.Collections.Generic.IReadOnlyList`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.HashSet`1<System.String>,System.String,System.Int64,Services.BitrixStockServices.OzonApiFoundInfo)
ApplyFbsFallbackStocks(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,Services.BitrixStockServices.OzonApiFoundInfo,System.Collections.Generic.List`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.HashSet`1<System.String>,System.String,System.Int64,System.Collections.Generic.HashSet`1<System.Int64>)
RegisterLinkageWarehouseIdsForDisplay(System.Collections.Generic.IReadOnlyList`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.HashSet`1<System.String>,System.Collections.Generic.HashSet`1<System.Int64>)
ApplySingleMapping(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,OzonDomains.Models.WarehouseMapping,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.HashSet`1<System.String>,System.String,System.Int64,System.Collections.Generic.HashSet`1<System.Int64>)
EnrichMappedWarehousesFromApi(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,Newtonsoft.Json.Linq.JArray,System.Collections.Generic.List`1<OzonDomains.Models.WarehouseMapping>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>,System.Collections.Generic.HashSet`1<System.String>,System.Collections.Generic.HashSet`1<System.Int64>,System.String,System.Int64)
ZeroUnmappedClientStores(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.String,System.Collections.Generic.HashSet`1<System.Int64>)
ResolveOzonWarehouse(OzonDomains.Models.WarehouseMapping,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.OzonApi.Filters.WarehouseOzon>)
GetLinkedOneCQuantity(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.Collections.Generic.IEnumerable`1<System.String>)
GetLinkedOneCStoreLabel(System.Collections.Generic.IEnumerable`1<System.String>)
HasLinkedOneCStore(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.Collections.Generic.IEnumerable`1<System.String>)
GetLinkedReservedQuantity(System.String,System.Collections.Generic.IEnumerable`1<System.String>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Collections.Generic.Dictionary`2<System.String,System.Int32>>)
GetBitrixAmount(OzonDomains.Models.BitrixModels.RemainingStockBitrix,System.String)
UpsertOzonStore(OzonDomains.Models.BitrixModels.RemainingStockBitrix,OzonDomains.Models.OzonClient,System.Int64,System.String,System.Int32,System.String,System.String,System.Collections.Generic.List`1<System.String>,System.Nullable`1<System.Int32>,System.Boolean,System.Boolean)
ExtractDefaultProductId(Newtonsoft.Json.Linq.JArray)
MergeLinkStores(System.Collections.Generic.List`1<System.String>,System.Collections.Generic.IEnumerable`1<System.String>)