< Summary

Information
Class: Services.BitrixStockServices.BitrixStockAutoCollectResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockInventoryService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 8
Coverable lines: 8
Total lines: 501
Line coverage: 0%
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_ClearItems()100%210%
get_PushItems()100%210%
get_ProductsScanned()100%210%
get_Warning()100%210%
get_LogLines()100%210%
get_MappingsCreated()100%210%
get_MappingsUpdated()100%210%
get_OzonLinkageSync()100%210%

File(s)

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

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Caching.Memory;
 5using Microsoft.Extensions.Http;
 6using Newtonsoft.Json.Linq;
 7using OzonDomains;
 8using OzonDomains.Models;
 9using OzonDomains.Models.BitrixModels;
 10using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix;
 11using OzonRepositories.Context;
 12using OzonRepositories.Data;
 13using OzonRepositories.Data.Bitrix;
 14using Services.ApiServices.OzonApi;
 15using Services.ApiServices.OzonApi.Filters;
 16using Services.DataServices;
 17using SlqStudio.Application.Services.EmailService;
 18
 19namespace Services.BitrixStockServices;
 20
 21public class BitrixStockAutoCollectResult
 22{
 023    public List<BitrixStockClearItemDto> ClearItems { get; set; } = new();
 024    public List<BitrixStockPushItemDto> PushItems { get; set; } = new();
 025    public int ProductsScanned { get; set; }
 026    public string? Warning { get; set; }
 027    public List<string> LogLines { get; } = new();
 028    public int MappingsCreated { get; set; }
 029    public int MappingsUpdated { get; set; }
 030    public BitrixOzonStockSyncSummary? OzonLinkageSync { get; set; }
 31}
 32
 33public interface IBitrixStockInventoryService
 34{
 35    RemainingStockFilter ApplySchedulePreset(string actionType, RemainingStockFilter filter);
 36    Task<BitrixStockAutoCollectResult> CollectForScheduleAsync(BitrixStockAutoSchedule schedule,
 37        CancellationToken cancellationToken = default);
 38}
 39
 40public class BitrixStockInventoryService : IBitrixStockInventoryService
 41{
 42    private readonly BitrixStockRepository _repository;
 43    private readonly OzonClientServices _ozonClientServices;
 44    private readonly IHttpClientFactory _httpClientFactory;
 45    private readonly IEmailService? _emailService;
 46    private readonly WarehouseMappingDataServices _warehouseMappingDataServices;
 47    private readonly IMemoryCache _memoryCache;
 48    private readonly EtProducerRepository _etProducerRepository;
 49    private readonly IBitrixOzonLinkedStockApiSyncService _ozonLinkedStockApiSyncService;
 50    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 51    private readonly IBitrixStockSyncPresetService _presetService;
 52
 53    public BitrixStockInventoryService(
 54        BitrixStockRepository repository,
 55        OzonClientServices ozonClientServices,
 56        IHttpClientFactory httpClientFactory,
 57        IEmailService? emailService,
 58        WarehouseMappingDataServices warehouseMappingDataServices,
 59        IMemoryCache memoryCache,
 60        EtProducerRepository etProducerRepository,
 61        IBitrixOzonLinkedStockApiSyncService ozonLinkedStockApiSyncService,
 62        IDbContextFactory<ApplicationDbContext> dbFactory,
 63        IBitrixStockSyncPresetService presetService)
 64    {
 65        _repository = repository;
 66        _ozonClientServices = ozonClientServices;
 67        _httpClientFactory = httpClientFactory;
 68        _emailService = emailService;
 69        _warehouseMappingDataServices = warehouseMappingDataServices;
 70        _memoryCache = memoryCache;
 71        _etProducerRepository = etProducerRepository;
 72        _ozonLinkedStockApiSyncService = ozonLinkedStockApiSyncService;
 73        _dbFactory = dbFactory;
 74        _presetService = presetService;
 75    }
 76
 77    public RemainingStockFilter ApplySchedulePreset(string actionType, RemainingStockFilter filter) => filter;
 78
 79    public async Task<BitrixStockAutoCollectResult> CollectForScheduleAsync(
 80        BitrixStockAutoSchedule schedule,
 81        CancellationToken cancellationToken = default)
 82    {
 83        var result = new BitrixStockAutoCollectResult();
 84        var activeStores = await _repository.GetActiveStoresAsync();
 85        var filter = BitrixStockAutoFilterFactory.ResolveForSchedule(schedule, activeStores);
 86
 87        var maxItems = schedule.MaxItemsPerRun > 0 ? schedule.MaxItemsPerRun : 500;
 88
 89        var paged = await _repository.GetRemainingStockAsync(filter);
 90        var items = paged.Items.ToList();
 91        result.ProductsScanned = items.Count;
 92
 93        await ApplySupplierPrefixesAsync(items, cancellationToken);
 94
 95        var client = (await _ozonClientServices.GetOzonClients())
 96            .FirstOrDefault(c => c.Id == schedule.OzonClientId && c.ClientType == ClientType.OZON);
 97
 98        if (client == null ||
 99            string.IsNullOrEmpty(client.DecryptClientId) ||
 100            string.IsNullOrEmpty(client.DecryptApiKey))
 101        {
 102            result.Warning = $"Клиент Ozon ID {schedule.OzonClientId} не найден";
 103            return result;
 104        }
 105
 106        var warning = await LoadOzonStocksForClientAsync(items, filter, client, result, cancellationToken);
 107        if (!string.IsNullOrEmpty(warning))
 108        {
 109            result.Warning = warning;
 110        }
 111
 112        BitrixStockIndexFilterRules.ApplyOzonMemoryFilters(items, filter);
 113
 114        var ozonStoresBeforePrune = items.Sum(i => i.OzonStores.Count);
 115        BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name);
 116        var prunedStores = ozonStoresBeforePrune - items.Sum(i => i.OzonStores.Count);
 117        if (prunedStores > 0)
 118        {
 119            result.LogLines.Add(
 120                $"Склады Ozon без товара в каталоге клиента (нет product_id): исключено {prunedStores}");
 121        }
 122
 123        if (string.Equals(schedule.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnoreCase))
 124        {
 125            result.ClearItems = BuildClearItems(items, maxItems);
 126        }
 127        else
 128        {
 129            var reserveMap = await LoadReserveMapForScheduleAsync(schedule, items, cancellationToken);
 130            result.PushItems = BuildPushItems(items, maxItems, reserveMap);
 131        }
 132
 133        return result;
 134    }
 135
 136    public static RemainingStockFilter DeserializeFilter(string json)
 137    {
 138        if (string.IsNullOrWhiteSpace(json))
 139        {
 140            return new RemainingStockFilter();
 141        }
 142
 143        try
 144        {
 145            return JsonSerializer.Deserialize<RemainingStockFilter>(json, new JsonSerializerOptions
 146            {
 147                PropertyNameCaseInsensitive = true
 148            }) ?? new RemainingStockFilter();
 149        }
 150        catch
 151        {
 152            return new RemainingStockFilter();
 153        }
 154    }
 155
 156    private static List<BitrixStockClearItemDto> BuildClearItems(List<RemainingStockBitrix> items, int maxItems)
 157    {
 158        var list = new List<BitrixStockClearItemDto>();
 159        foreach (var item in items)
 160        {
 161            if (!string.Equals(item.Active, "N", StringComparison.OrdinalIgnoreCase))
 162            {
 163                continue;
 164            }
 165
 166            foreach (var oz in item.OzonStores)
 167            {
 168                if (oz.Amount <= 0 ||
 169                    !BitrixOzonStockLinkage.HasValidOzonProductId(oz) ||
 170                    BitrixOzonStockLinkage.IsMissingOzonProductError(oz.LastOzonSyncError))
 171                {
 172                    continue;
 173                }
 174
 175                if (!TryParseProductId(oz.ProductId, out var productId) || productId <= 0)
 176                {
 177                    continue;
 178                }
 179
 180                list.Add(new BitrixStockClearItemDto
 181                {
 182                    Article = item.Article,
 183                    OfferId = oz.OfferId ?? item.ArticleWithKey,
 184                    ProductId = productId,
 185                    WarehouseId = oz.StoreId,
 186                    WarehouseName = oz.Title,
 187                    OzonClient = oz.ClientName ?? string.Empty,
 188                    PreviousStock = (int)oz.Amount
 189                });
 190
 191                if (list.Count >= maxItems)
 192                {
 193                    return list;
 194                }
 195            }
 196        }
 197
 198        return list;
 199    }
 200
 201    private async Task<Dictionary<string, Dictionary<string, int>>> LoadReserveMapForScheduleAsync(
 202        BitrixStockAutoSchedule schedule,
 203        List<RemainingStockBitrix> items,
 204        CancellationToken cancellationToken)
 205    {
 206        if (items.Count == 0)
 207        {
 208            return new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
 209        }
 210
 211        var presets = await _presetService.GetAllAsync(cancellationToken);
 212        var preset = presets.FirstOrDefault(p => p.GetOrderedClientIds().Contains(schedule.OzonClientId));
 213
 214        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 215        var reservedStatusIds = await BitrixStockSyncReportService.ResolveReserveStatusIdsAsync(
 216            db, preset?.StageSettings.ReserveStatusIds, cancellationToken);
 217
 218        return await BitrixStockSyncReportService.LoadOrderReservesAsync(
 219            db, reservedStatusIds, cancellationToken);
 220    }
 221
 222    private static List<BitrixStockPushItemDto> BuildPushItems(
 223        List<RemainingStockBitrix> items,
 224        int maxItems,
 225        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap)
 226    {
 227        var list = new List<BitrixStockPushItemDto>();
 228        foreach (var item in items)
 229        {
 230            if (BitrixOzonStockLinkage.IsInactiveProduct(item))
 231            {
 232                continue;
 233            }
 234
 235            foreach (var oz in item.OzonStores)
 236            {
 237                if (!BitrixOzonStockLinkage.HasValidOzonProductId(oz) ||
 238                    BitrixOzonStockLinkage.IsMissingOzonProductError(oz.LastOzonSyncError))
 239                {
 240                    continue;
 241                }
 242
 243                if (!BitrixOzonStockLinkage.HasLinkedOneCStore(item, oz.LinkStores))
 244                {
 245                    continue;
 246                }
 247
 248                var target = BitrixOzonStockLinkage.GetLinkedOneCQuantity(item, oz.LinkStores);
 249                var reserved = BitrixOzonStockLinkage.GetLinkedReservedQuantity(item.Article, oz.LinkStores, reserveMap)
 250                if (reserved > 0)
 251                {
 252                    target = Math.Max(0, target - reserved);
 253                }
 254                var linkedStoreName = BitrixOzonStockLinkage.GetLinkedOneCStoreLabel(oz.LinkStores);
 255                var previous = oz.OzonPresentOnOzon.HasValue
 256                    ? (int)Math.Round(oz.OzonPresentOnOzon.Value)
 257                    : (int)Math.Round(oz.Amount);
 258
 259                if (target == previous && !oz.PendingOzonApiSync)
 260                {
 261                    continue;
 262                }
 263
 264                var productId = 0L;
 265                if (!TryParseProductId(oz.ProductId, out productId) || productId <= 0)
 266                {
 267                    continue;
 268                }
 269
 270                list.Add(new BitrixStockPushItemDto
 271                {
 272                    Article = item.Article,
 273                    OfferId = oz.OfferId ?? item.ArticleWithKey,
 274                    ProductId = productId,
 275                    WarehouseId = oz.StoreId,
 276                    WarehouseName = oz.Title,
 277                    OzonClient = oz.ClientName ?? string.Empty,
 278                    LinkedStoreName = linkedStoreName,
 279                    PreviousStock = previous,
 280                    TargetStock = target
 281                });
 282
 283                if (list.Count >= maxItems)
 284                {
 285                    return list;
 286                }
 287            }
 288        }
 289
 290        return list;
 291    }
 292
 293    private static bool TryParseProductId(string? productId, out long id)
 294    {
 295        id = 0;
 296        return !string.IsNullOrWhiteSpace(productId) && long.TryParse(productId, out id);
 297    }
 298
 299    private async Task ApplySupplierPrefixesAsync(List<RemainingStockBitrix> items, CancellationToken cancellationToken)
 300    {
 301        var suppliers = items
 302            .Where(r => !string.IsNullOrWhiteSpace(r.Supplier))
 303            .Select(r => r.Supplier!)
 304            .Distinct()
 305            .ToList();
 306        if (suppliers.Count == 0)
 307        {
 308            return;
 309        }
 310
 311        var prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(suppliers);
 312        foreach (var item in items)
 313        {
 314            if (!string.IsNullOrWhiteSpace(item.Supplier) && prefixes.TryGetValue(item.Supplier, out var prefix))
 315            {
 316                item.MarketPrefix = prefix;
 317            }
 318        }
 319    }
 320
 321    private async Task<string?> LoadOzonStocksForClientAsync(
 322        List<RemainingStockBitrix> items,
 323        RemainingStockFilter filter,
 324        OzonClient client,
 325        BitrixStockAutoCollectResult collectResult,
 326        CancellationToken cancellationToken)
 327    {
 328        var articles = items
 329            .Select(x => x.ArticleWithKey)
 330            .Where(x => !string.IsNullOrWhiteSpace(x))
 331            .Distinct(StringComparer.OrdinalIgnoreCase)
 332            .ToArray();
 333
 334        if (articles.Length == 0)
 335        {
 336            return "Нет артикулов для загрузки Ozon";
 337        }
 338
 339        string? warning = null;
 340        if (articles.Length > 5000)
 341        {
 342            warning = $"Ozon: загрузка остатков для {articles.Length} артикулов (пакетами по API)";
 343        }
 344
 345        var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 346
 347        var warehouseList = await GetOrCreateWarehouseCacheAsync(apiManager, client.DecryptClientId ?? "");
 348        var bitrixStores = GetBitrixStoreNames(filter, items);
 349        if (bitrixStores.Count > 0)
 350        {
 351            var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync(
 352                _warehouseMappingDataServices,
 353                client,
 354                bitrixStores,
 355                warehouseList);
 356            collectResult.MappingsCreated = ensureResult.CreatedCount;
 357            collectResult.MappingsUpdated = ensureResult.UpdatedIdCount;
 358            collectResult.LogLines.AddRange(ensureResult.LogLines);
 359        }
 360
 361        var allStocks = await apiManager.GetProductStocks(articles);
 362
 363        if (allStocks?["items"] == null)
 364        {
 365            collectResult.LogLines.Add(warning ?? "Ozon не вернул остатки");
 366            return warning ?? "Ozon не вернул остатки";
 367        }
 368
 369        var clientMappings = await _warehouseMappingDataServices.GetByClientIdAsync(client.Id);
 370        var allowed = bitrixStores;
 371
 372        var tasks = items.Select(item => ProcessItemAsync(
 373            item, client, allStocks, apiManager, warehouseList, clientMappings, allowed));
 374
 375        await Task.WhenAll(tasks);
 376
 377        var syncSummary = await _ozonLinkedStockApiSyncService.SyncPendingLinkedStocksAsync(
 378            items,
 379            new List<OzonClient> { client });
 380        collectResult.OzonLinkageSync = syncSummary;
 381        AppendSyncLogLines(collectResult, syncSummary);
 382
 383        BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name);
 384
 385        return warning;
 386    }
 387
 388    private static void AppendSyncLogLines(BitrixStockAutoCollectResult result, BitrixOzonStockSyncSummary sync)
 389    {
 390        if (sync.Attempted == 0)
 391        {
 392            result.LogLines.Add("Ozon API (связки): нечего отправлять — остатки уже совпадают или нет связей с id склада
 393            return;
 394        }
 395
 396        result.LogLines.Add(
 397            $"Ozon API (связки): обновлено {sync.Succeeded} из {sync.Attempted}, ошибок {sync.Failed}" +
 398            (sync.Skipped > 0 ? $", пропущено {sync.Skipped}" : "") +
 399            (sync.LimitReached ? " (лимит 400)" : ""));
 400
 401        foreach (var entry in sync.Entries)
 402        {
 403            if (!entry.Success && BitrixOzonStockLinkage.IsMissingOzonProductError(entry.ErrorMessage))
 404            {
 405                continue;
 406            }
 407
 408            var change = entry.PreviousOzonStock.HasValue
 409                ? $"{entry.PreviousOzonStock} → {entry.NewStock}"
 410                : $"→ {entry.NewStock}";
 411            if (entry.Success)
 412            {
 413                result.LogLines.Add(
 414                    $"✓ {entry.Article} | {entry.OzonClient} | {entry.WarehouseName} ({entry.WarehouseId}) | Bitrix {ent
 415            }
 416            else
 417            {
 418                result.LogLines.Add(
 419                    $"✗ {entry.Article} | {entry.OzonClient} | {entry.WarehouseName} ({entry.WarehouseId}) | {change} | 
 420            }
 421        }
 422    }
 423
 424    private static HashSet<string> GetBitrixStoreNames(RemainingStockFilter filter, List<RemainingStockBitrix> items)
 425    {
 426        var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 427        if (filter.SelectedStoreTitles?.Count > 0)
 428        {
 429            foreach (var t in filter.SelectedStoreTitles)
 430            {
 431                if (!string.IsNullOrWhiteSpace(t))
 432                {
 433                    names.Add(t.Trim());
 434                }
 435            }
 436        }
 437        else
 438        {
 439            foreach (var t in items.SelectMany(i => i.Stores).Select(s => s.Title))
 440            {
 441                if (!string.IsNullOrWhiteSpace(t))
 442                {
 443                    names.Add(t.Trim());
 444                }
 445            }
 446        }
 447
 448        return names;
 449    }
 450
 451    private async Task ProcessItemAsync(
 452        RemainingStockBitrix item,
 453        OzonClient client,
 454        JObject allStocks,
 455        OzonApiDataManager apiManager,
 456        List<WarehouseOzon> warehouseList,
 457        List<WarehouseMapping> clientMappings,
 458        HashSet<string> allowedBitrixStores)
 459    {
 460        if (clientMappings.Count == 0)
 461        {
 462            return;
 463        }
 464
 465        JToken? warehouseInfo = null;
 466        var stocksProductId = 0L;
 467        var itemStocks = BitrixOzonStockLinkage.FindStocksForArticle(allStocks, item);
 468        if (itemStocks != null)
 469        {
 470            stocksProductId = BitrixOzonStockLinkage.ExtractProductIdFromStocks(itemStocks);
 471            warehouseInfo = await apiManager.GetWarehouseInfo(itemStocks, item.ArticleWithKey, client, warehouseList);
 472        }
 473
 474        BitrixOzonStockLinkage.ApplyMappedStocks(
 475            item,
 476            client,
 477            warehouseInfo,
 478            clientMappings,
 479            warehouseList,
 480            allowedBitrixStores,
 481            item.ArticleWithKey,
 482            stocksProductId);
 483    }
 484
 485    private async Task<List<WarehouseOzon>> GetOrCreateWarehouseCacheAsync(OzonApiDataManager apiManager, string clientI
 486    {
 487        var cacheKey = $"WarehouseList_{clientId}";
 488        if (_memoryCache.TryGetValue(cacheKey, out List<WarehouseOzon>? cached) && cached != null)
 489        {
 490            return cached;
 491        }
 492
 493        var list = await apiManager.GetWarehouseList();
 494        _memoryCache.Set(cacheKey, list, new MemoryCacheEntryOptions
 495        {
 496            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
 497        });
 498        return list;
 499    }
 500
 501}