< Summary

Information
Class: Services.BitrixStockServices.BitrixStockInventoryService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockInventoryService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 237
Coverable lines: 237
Total lines: 501
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 140
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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{
 23    public List<BitrixStockClearItemDto> ClearItems { get; set; } = new();
 24    public List<BitrixStockPushItemDto> PushItems { get; set; } = new();
 25    public int ProductsScanned { get; set; }
 26    public string? Warning { get; set; }
 27    public List<string> LogLines { get; } = new();
 28    public int MappingsCreated { get; set; }
 29    public int MappingsUpdated { get; set; }
 30    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
 053    public BitrixStockInventoryService(
 054        BitrixStockRepository repository,
 055        OzonClientServices ozonClientServices,
 056        IHttpClientFactory httpClientFactory,
 057        IEmailService? emailService,
 058        WarehouseMappingDataServices warehouseMappingDataServices,
 059        IMemoryCache memoryCache,
 060        EtProducerRepository etProducerRepository,
 061        IBitrixOzonLinkedStockApiSyncService ozonLinkedStockApiSyncService,
 062        IDbContextFactory<ApplicationDbContext> dbFactory,
 063        IBitrixStockSyncPresetService presetService)
 64    {
 065        _repository = repository;
 066        _ozonClientServices = ozonClientServices;
 067        _httpClientFactory = httpClientFactory;
 068        _emailService = emailService;
 069        _warehouseMappingDataServices = warehouseMappingDataServices;
 070        _memoryCache = memoryCache;
 071        _etProducerRepository = etProducerRepository;
 072        _ozonLinkedStockApiSyncService = ozonLinkedStockApiSyncService;
 073        _dbFactory = dbFactory;
 074        _presetService = presetService;
 075    }
 76
 077    public RemainingStockFilter ApplySchedulePreset(string actionType, RemainingStockFilter filter) => filter;
 78
 79    public async Task<BitrixStockAutoCollectResult> CollectForScheduleAsync(
 80        BitrixStockAutoSchedule schedule,
 81        CancellationToken cancellationToken = default)
 82    {
 083        var result = new BitrixStockAutoCollectResult();
 084        var activeStores = await _repository.GetActiveStoresAsync();
 085        var filter = BitrixStockAutoFilterFactory.ResolveForSchedule(schedule, activeStores);
 86
 087        var maxItems = schedule.MaxItemsPerRun > 0 ? schedule.MaxItemsPerRun : 500;
 88
 089        var paged = await _repository.GetRemainingStockAsync(filter);
 090        var items = paged.Items.ToList();
 091        result.ProductsScanned = items.Count;
 92
 093        await ApplySupplierPrefixesAsync(items, cancellationToken);
 94
 095        var client = (await _ozonClientServices.GetOzonClients())
 096            .FirstOrDefault(c => c.Id == schedule.OzonClientId && c.ClientType == ClientType.OZON);
 97
 098        if (client == null ||
 099            string.IsNullOrEmpty(client.DecryptClientId) ||
 0100            string.IsNullOrEmpty(client.DecryptApiKey))
 101        {
 0102            result.Warning = $"Клиент Ozon ID {schedule.OzonClientId} не найден";
 0103            return result;
 104        }
 105
 0106        var warning = await LoadOzonStocksForClientAsync(items, filter, client, result, cancellationToken);
 0107        if (!string.IsNullOrEmpty(warning))
 108        {
 0109            result.Warning = warning;
 110        }
 111
 0112        BitrixStockIndexFilterRules.ApplyOzonMemoryFilters(items, filter);
 113
 0114        var ozonStoresBeforePrune = items.Sum(i => i.OzonStores.Count);
 0115        BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name);
 0116        var prunedStores = ozonStoresBeforePrune - items.Sum(i => i.OzonStores.Count);
 0117        if (prunedStores > 0)
 118        {
 0119            result.LogLines.Add(
 0120                $"Склады Ozon без товара в каталоге клиента (нет product_id): исключено {prunedStores}");
 121        }
 122
 0123        if (string.Equals(schedule.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnoreCase))
 124        {
 0125            result.ClearItems = BuildClearItems(items, maxItems);
 126        }
 127        else
 128        {
 0129            var reserveMap = await LoadReserveMapForScheduleAsync(schedule, items, cancellationToken);
 0130            result.PushItems = BuildPushItems(items, maxItems, reserveMap);
 131        }
 132
 0133        return result;
 0134    }
 135
 136    public static RemainingStockFilter DeserializeFilter(string json)
 137    {
 0138        if (string.IsNullOrWhiteSpace(json))
 139        {
 0140            return new RemainingStockFilter();
 141        }
 142
 143        try
 144        {
 0145            return JsonSerializer.Deserialize<RemainingStockFilter>(json, new JsonSerializerOptions
 0146            {
 0147                PropertyNameCaseInsensitive = true
 0148            }) ?? new RemainingStockFilter();
 149        }
 0150        catch
 151        {
 0152            return new RemainingStockFilter();
 153        }
 0154    }
 155
 156    private static List<BitrixStockClearItemDto> BuildClearItems(List<RemainingStockBitrix> items, int maxItems)
 157    {
 0158        var list = new List<BitrixStockClearItemDto>();
 0159        foreach (var item in items)
 160        {
 0161            if (!string.Equals(item.Active, "N", StringComparison.OrdinalIgnoreCase))
 162            {
 163                continue;
 164            }
 165
 0166            foreach (var oz in item.OzonStores)
 167            {
 0168                if (oz.Amount <= 0 ||
 0169                    !BitrixOzonStockLinkage.HasValidOzonProductId(oz) ||
 0170                    BitrixOzonStockLinkage.IsMissingOzonProductError(oz.LastOzonSyncError))
 171                {
 172                    continue;
 173                }
 174
 0175                if (!TryParseProductId(oz.ProductId, out var productId) || productId <= 0)
 176                {
 177                    continue;
 178                }
 179
 0180                list.Add(new BitrixStockClearItemDto
 0181                {
 0182                    Article = item.Article,
 0183                    OfferId = oz.OfferId ?? item.ArticleWithKey,
 0184                    ProductId = productId,
 0185                    WarehouseId = oz.StoreId,
 0186                    WarehouseName = oz.Title,
 0187                    OzonClient = oz.ClientName ?? string.Empty,
 0188                    PreviousStock = (int)oz.Amount
 0189                });
 190
 0191                if (list.Count >= maxItems)
 192                {
 0193                    return list;
 194                }
 195            }
 196        }
 197
 0198        return list;
 0199    }
 200
 201    private async Task<Dictionary<string, Dictionary<string, int>>> LoadReserveMapForScheduleAsync(
 202        BitrixStockAutoSchedule schedule,
 203        List<RemainingStockBitrix> items,
 204        CancellationToken cancellationToken)
 205    {
 0206        if (items.Count == 0)
 207        {
 0208            return new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
 209        }
 210
 0211        var presets = await _presetService.GetAllAsync(cancellationToken);
 0212        var preset = presets.FirstOrDefault(p => p.GetOrderedClientIds().Contains(schedule.OzonClientId));
 213
 0214        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 0215        var reservedStatusIds = await BitrixStockSyncReportService.ResolveReserveStatusIdsAsync(
 0216            db, preset?.StageSettings.ReserveStatusIds, cancellationToken);
 217
 0218        return await BitrixStockSyncReportService.LoadOrderReservesAsync(
 0219            db, reservedStatusIds, cancellationToken);
 0220    }
 221
 222    private static List<BitrixStockPushItemDto> BuildPushItems(
 223        List<RemainingStockBitrix> items,
 224        int maxItems,
 225        IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap)
 226    {
 0227        var list = new List<BitrixStockPushItemDto>();
 0228        foreach (var item in items)
 229        {
 0230            if (BitrixOzonStockLinkage.IsInactiveProduct(item))
 231            {
 232                continue;
 233            }
 234
 0235            foreach (var oz in item.OzonStores)
 236            {
 0237                if (!BitrixOzonStockLinkage.HasValidOzonProductId(oz) ||
 0238                    BitrixOzonStockLinkage.IsMissingOzonProductError(oz.LastOzonSyncError))
 239                {
 240                    continue;
 241                }
 242
 0243                if (!BitrixOzonStockLinkage.HasLinkedOneCStore(item, oz.LinkStores))
 244                {
 245                    continue;
 246                }
 247
 0248                var target = BitrixOzonStockLinkage.GetLinkedOneCQuantity(item, oz.LinkStores);
 0249                var reserved = BitrixOzonStockLinkage.GetLinkedReservedQuantity(item.Article, oz.LinkStores, reserveMap)
 0250                if (reserved > 0)
 251                {
 0252                    target = Math.Max(0, target - reserved);
 253                }
 0254                var linkedStoreName = BitrixOzonStockLinkage.GetLinkedOneCStoreLabel(oz.LinkStores);
 0255                var previous = oz.OzonPresentOnOzon.HasValue
 0256                    ? (int)Math.Round(oz.OzonPresentOnOzon.Value)
 0257                    : (int)Math.Round(oz.Amount);
 258
 0259                if (target == previous && !oz.PendingOzonApiSync)
 260                {
 261                    continue;
 262                }
 263
 0264                var productId = 0L;
 0265                if (!TryParseProductId(oz.ProductId, out productId) || productId <= 0)
 266                {
 267                    continue;
 268                }
 269
 0270                list.Add(new BitrixStockPushItemDto
 0271                {
 0272                    Article = item.Article,
 0273                    OfferId = oz.OfferId ?? item.ArticleWithKey,
 0274                    ProductId = productId,
 0275                    WarehouseId = oz.StoreId,
 0276                    WarehouseName = oz.Title,
 0277                    OzonClient = oz.ClientName ?? string.Empty,
 0278                    LinkedStoreName = linkedStoreName,
 0279                    PreviousStock = previous,
 0280                    TargetStock = target
 0281                });
 282
 0283                if (list.Count >= maxItems)
 284                {
 0285                    return list;
 286                }
 287            }
 288        }
 289
 0290        return list;
 0291    }
 292
 293    private static bool TryParseProductId(string? productId, out long id)
 294    {
 0295        id = 0;
 0296        return !string.IsNullOrWhiteSpace(productId) && long.TryParse(productId, out id);
 297    }
 298
 299    private async Task ApplySupplierPrefixesAsync(List<RemainingStockBitrix> items, CancellationToken cancellationToken)
 300    {
 0301        var suppliers = items
 0302            .Where(r => !string.IsNullOrWhiteSpace(r.Supplier))
 0303            .Select(r => r.Supplier!)
 0304            .Distinct()
 0305            .ToList();
 0306        if (suppliers.Count == 0)
 307        {
 0308            return;
 309        }
 310
 0311        var prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(suppliers);
 0312        foreach (var item in items)
 313        {
 0314            if (!string.IsNullOrWhiteSpace(item.Supplier) && prefixes.TryGetValue(item.Supplier, out var prefix))
 315            {
 0316                item.MarketPrefix = prefix;
 317            }
 318        }
 0319    }
 320
 321    private async Task<string?> LoadOzonStocksForClientAsync(
 322        List<RemainingStockBitrix> items,
 323        RemainingStockFilter filter,
 324        OzonClient client,
 325        BitrixStockAutoCollectResult collectResult,
 326        CancellationToken cancellationToken)
 327    {
 0328        var articles = items
 0329            .Select(x => x.ArticleWithKey)
 0330            .Where(x => !string.IsNullOrWhiteSpace(x))
 0331            .Distinct(StringComparer.OrdinalIgnoreCase)
 0332            .ToArray();
 333
 0334        if (articles.Length == 0)
 335        {
 0336            return "Нет артикулов для загрузки Ozon";
 337        }
 338
 0339        string? warning = null;
 0340        if (articles.Length > 5000)
 341        {
 0342            warning = $"Ozon: загрузка остатков для {articles.Length} артикулов (пакетами по API)";
 343        }
 344
 0345        var apiManager = BitrixOzonApiFactory.Create(_httpClientFactory, _emailService, client);
 346
 0347        var warehouseList = await GetOrCreateWarehouseCacheAsync(apiManager, client.DecryptClientId ?? "");
 0348        var bitrixStores = GetBitrixStoreNames(filter, items);
 0349        if (bitrixStores.Count > 0)
 350        {
 0351            var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync(
 0352                _warehouseMappingDataServices,
 0353                client,
 0354                bitrixStores,
 0355                warehouseList);
 0356            collectResult.MappingsCreated = ensureResult.CreatedCount;
 0357            collectResult.MappingsUpdated = ensureResult.UpdatedIdCount;
 0358            collectResult.LogLines.AddRange(ensureResult.LogLines);
 359        }
 360
 0361        var allStocks = await apiManager.GetProductStocks(articles);
 362
 0363        if (allStocks?["items"] == null)
 364        {
 0365            collectResult.LogLines.Add(warning ?? "Ozon не вернул остатки");
 0366            return warning ?? "Ozon не вернул остатки";
 367        }
 368
 0369        var clientMappings = await _warehouseMappingDataServices.GetByClientIdAsync(client.Id);
 0370        var allowed = bitrixStores;
 371
 0372        var tasks = items.Select(item => ProcessItemAsync(
 0373            item, client, allStocks, apiManager, warehouseList, clientMappings, allowed));
 374
 0375        await Task.WhenAll(tasks);
 376
 0377        var syncSummary = await _ozonLinkedStockApiSyncService.SyncPendingLinkedStocksAsync(
 0378            items,
 0379            new List<OzonClient> { client });
 0380        collectResult.OzonLinkageSync = syncSummary;
 0381        AppendSyncLogLines(collectResult, syncSummary);
 382
 0383        BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name);
 384
 0385        return warning;
 0386    }
 387
 388    private static void AppendSyncLogLines(BitrixStockAutoCollectResult result, BitrixOzonStockSyncSummary sync)
 389    {
 0390        if (sync.Attempted == 0)
 391        {
 0392            result.LogLines.Add("Ozon API (связки): нечего отправлять — остатки уже совпадают или нет связей с id склада
 0393            return;
 394        }
 395
 0396        result.LogLines.Add(
 0397            $"Ozon API (связки): обновлено {sync.Succeeded} из {sync.Attempted}, ошибок {sync.Failed}" +
 0398            (sync.Skipped > 0 ? $", пропущено {sync.Skipped}" : "") +
 0399            (sync.LimitReached ? " (лимит 400)" : ""));
 400
 0401        foreach (var entry in sync.Entries)
 402        {
 0403            if (!entry.Success && BitrixOzonStockLinkage.IsMissingOzonProductError(entry.ErrorMessage))
 404            {
 405                continue;
 406            }
 407
 0408            var change = entry.PreviousOzonStock.HasValue
 0409                ? $"{entry.PreviousOzonStock} → {entry.NewStock}"
 0410                : $"→ {entry.NewStock}";
 0411            if (entry.Success)
 412            {
 0413                result.LogLines.Add(
 0414                    $"✓ {entry.Article} | {entry.OzonClient} | {entry.WarehouseName} ({entry.WarehouseId}) | Bitrix {ent
 415            }
 416            else
 417            {
 0418                result.LogLines.Add(
 0419                    $"✗ {entry.Article} | {entry.OzonClient} | {entry.WarehouseName} ({entry.WarehouseId}) | {change} | 
 420            }
 421        }
 0422    }
 423
 424    private static HashSet<string> GetBitrixStoreNames(RemainingStockFilter filter, List<RemainingStockBitrix> items)
 425    {
 0426        var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 0427        if (filter.SelectedStoreTitles?.Count > 0)
 428        {
 0429            foreach (var t in filter.SelectedStoreTitles)
 430            {
 0431                if (!string.IsNullOrWhiteSpace(t))
 432                {
 0433                    names.Add(t.Trim());
 434                }
 435            }
 436        }
 437        else
 438        {
 0439            foreach (var t in items.SelectMany(i => i.Stores).Select(s => s.Title))
 440            {
 0441                if (!string.IsNullOrWhiteSpace(t))
 442                {
 0443                    names.Add(t.Trim());
 444                }
 445            }
 446        }
 447
 0448        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    {
 0460        if (clientMappings.Count == 0)
 461        {
 0462            return;
 463        }
 464
 0465        JToken? warehouseInfo = null;
 0466        var stocksProductId = 0L;
 0467        var itemStocks = BitrixOzonStockLinkage.FindStocksForArticle(allStocks, item);
 0468        if (itemStocks != null)
 469        {
 0470            stocksProductId = BitrixOzonStockLinkage.ExtractProductIdFromStocks(itemStocks);
 0471            warehouseInfo = await apiManager.GetWarehouseInfo(itemStocks, item.ArticleWithKey, client, warehouseList);
 472        }
 473
 0474        BitrixOzonStockLinkage.ApplyMappedStocks(
 0475            item,
 0476            client,
 0477            warehouseInfo,
 0478            clientMappings,
 0479            warehouseList,
 0480            allowedBitrixStores,
 0481            item.ArticleWithKey,
 0482            stocksProductId);
 0483    }
 484
 485    private async Task<List<WarehouseOzon>> GetOrCreateWarehouseCacheAsync(OzonApiDataManager apiManager, string clientI
 486    {
 0487        var cacheKey = $"WarehouseList_{clientId}";
 0488        if (_memoryCache.TryGetValue(cacheKey, out List<WarehouseOzon>? cached) && cached != null)
 489        {
 0490            return cached;
 491        }
 492
 0493        var list = await apiManager.GetWarehouseList();
 0494        _memoryCache.Set(cacheKey, list, new MemoryCacheEntryOptions
 0495        {
 0496            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
 0497        });
 0498        return list;
 0499    }
 500
 501}

Methods/Properties

.ctor(OzonRepositories.Data.Bitrix.BitrixStockRepository,Services.DataServices.OzonClientServices,System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,Services.DataServices.WarehouseMappingDataServices,Microsoft.Extensions.Caching.Memory.IMemoryCache,OzonRepositories.Data.EtProducerRepository,Services.BitrixStockServices.IBitrixOzonLinkedStockApiSyncService,Microsoft.EntityFrameworkCore.IDbContextFactory`1<OzonRepositories.Context.ApplicationDbContext>,Services.BitrixStockServices.IBitrixStockSyncPresetService)
ApplySchedulePreset(System.String,OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter)
CollectForScheduleAsync()
DeserializeFilter(System.String)
BuildClearItems(System.Collections.Generic.List`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>,System.Int32)
LoadReserveMapForScheduleAsync()
BuildPushItems(System.Collections.Generic.List`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>,System.Int32,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Collections.Generic.Dictionary`2<System.String,System.Int32>>)
TryParseProductId(System.String,System.Int64&)
ApplySupplierPrefixesAsync()
LoadOzonStocksForClientAsync()
AppendSyncLogLines(Services.BitrixStockServices.BitrixStockAutoCollectResult,Services.BitrixStockServices.BitrixOzonStockSyncSummary)
GetBitrixStoreNames(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter,System.Collections.Generic.List`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>)
ProcessItemAsync()
GetOrCreateWarehouseCacheAsync()